Skip to content

Commit e70e49d

Browse files
Merge pull request #37 from Promptly-Technologies-LLC/11-handle-message-annotations-for-rendering-citations-to-files-referenced-by-the-assistant-run
Convert file citation text deltas to download links
2 parents ca28041 + 76dd355 commit e70e49d

File tree

6 files changed

+155
-17
lines changed

6 files changed

+155
-17
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ __pycache__
99
.cursorrules
1010
.repomix-output.txt
1111
repomix-output.txt
12-
artifacts/
12+
artifacts/
13+
uploads/

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "openai-assistants-python-quickstart"
3-
version = "0.1.0"
3+
version = "1.0.0"
44
description = "A quickstart template using the OpenAI Assistants API with Python, FastAPI, Jinja2, and HTMX"
55
readme = "README.md"
66
requires-python = ">=3.12"

routers/chat.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
import re
23
from datetime import datetime
34
from typing import AsyncGenerator, Optional, Union
45
from fastapi.templating import Jinja2Templates
@@ -19,6 +20,7 @@
1920

2021
import json
2122

23+
from routers import files as files_router
2224
from utils.custom_functions import get_weather, post_tool_outputs
2325
from utils.sse import sse_format
2426
from utils.streaming import AssistantStreamMetadata
@@ -121,6 +123,28 @@ async def handle_assistant_stream(
121123
if isinstance(content, TextDeltaBlock) and content.text and content.text.value:
122124
step_id = event.data.id
123125
text_value = content.text.value
126+
annotations = content.text.annotations
127+
128+
# Check for file citation annotations
129+
if annotations:
130+
logger.debug(f"Annotation found: {annotations}")
131+
for annotation in annotations:
132+
if annotation.type == 'file_citation' and hasattr(annotation, 'file_citation') and annotation.file_citation:
133+
match = re.search(r'【.*?†(.*?)】', text_value)
134+
if match:
135+
file_name = match.group(1)
136+
# Manually construct the URL
137+
file_url = f'/assistants/{assistant_id}/files/{file_name}'
138+
text_value = f'[†]({file_url})'
139+
logger.debug(f"Replacing citation with link: {text_value}")
140+
else:
141+
# Handle error: pattern not found in the text
142+
logger.warning(f"Could not extract filename from citation text: {text_value}")
143+
file_name = None # Indicate failure
144+
145+
# Assuming one citation per delta for now
146+
break
147+
124148
sse_data = f'<span hx-swap-oob="beforeend:#step-{step_id}">{text_value}</span>'
125149
yield sse_format(
126150
"textDelta",
@@ -205,7 +229,7 @@ async def handle_assistant_stream(
205229
async def event_generator() -> AsyncGenerator[str, None]:
206230
"""
207231
Main generator for SSE events. We call our helper function to handle the assistant
208-
stream, and if the assistant requests a tool call, we do it and then re-stream the stream.
232+
stream, and if the assistant requests a tool call, we execute it and then re-stream the stream.
209233
"""
210234
step_id: str = ""
211235
stream_manager: AsyncAssistantStreamManager[AsyncAssistantEventHandler] = client.beta.threads.runs.stream(

routers/files.py

Lines changed: 63 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@
55
from fastapi import (
66
APIRouter, Request, UploadFile, File, HTTPException, Depends, Path, Form
77
)
8-
from fastapi.responses import HTMLResponse, StreamingResponse
8+
from fastapi.responses import HTMLResponse, StreamingResponse, FileResponse
99
from fastapi.templating import Jinja2Templates
1010
from openai import AsyncOpenAI
11-
from utils.files import get_or_create_vector_store, get_files_for_vector_store
11+
from utils.files import get_or_create_vector_store, get_files_for_vector_store, store_file, retrieve_file, delete_local_file
1212
from utils.streaming import stream_file_content
1313

1414
logger = logging.getLogger("uvicorn.error")
@@ -69,18 +69,38 @@ async def upload_file(
6969
)
7070

7171
error_message = None
72+
file_content = None
7273
try:
73-
# Upload the file to OpenAI
74+
# 1. Read the file content first
75+
file_content = await file.read()
76+
if not file.filename:
77+
raise ValueError("File has no filename")
78+
if not file_content:
79+
raise ValueError("File content is empty")
80+
81+
# 2. Upload the file content to OpenAI
7482
openai_file = await client.files.create(
75-
file=(file.filename, file.file),
83+
file=(file.filename, file_content),
7684
purpose=purpose
7785
)
7886

79-
# Add the uploaded file to the vector store
87+
# 3. Add the uploaded file to the vector store
8088
await client.vector_stores.files.create(
8189
vector_store_id=vector_store_id,
8290
file_id=openai_file.id
8391
)
92+
logger.info(f"File {file.filename} uploaded to OpenAI and added to vector store.")
93+
94+
# 4. Store the file locally using the same content
95+
try:
96+
store_file(file.filename, file_content)
97+
except Exception as e:
98+
logger.error(f"Error storing file {file.filename} locally: {e}")
99+
error_message = f"Error storing file locally"
100+
101+
except ValueError as ve:
102+
logger.error(f"File validation error for assistant {assistant_id}: {ve}")
103+
error_message = str(ve)
84104
except Exception as e:
85105
logger.error(f"Error uploading file for assistant {assistant_id}: {e}")
86106
error_message = f"Error uploading file for assistant"
@@ -121,6 +141,26 @@ async def delete_file(
121141
try:
122142
vector_store_id = await get_or_create_vector_store(assistant_id, client)
123143

144+
# Retrieve filename before attempting deletions
145+
file_to_delete_name = None
146+
try:
147+
retrieved_file = await client.files.retrieve(file_id)
148+
if retrieved_file and retrieved_file.filename:
149+
file_to_delete_name = retrieved_file.filename
150+
logger.info(f"Retrieved filename '{retrieved_file.filename}' for deletion.")
151+
else:
152+
logger.warning(f"Could not retrieve filename for file_id {file_id}")
153+
except Exception as retrieve_err:
154+
logger.error(f"Error retrieving file object {file_id} for filename: {retrieve_err}")
155+
156+
# Attempt to delete stored file if filename was found
157+
if file_to_delete_name:
158+
try:
159+
delete_local_file(file_to_delete_name)
160+
except Exception as local_delete_err:
161+
# Log error but continue with OpenAI/VS deletion
162+
logger.error(f"Error deleting local file {file_to_delete_name}: {local_delete_err}")
163+
124164
# 1. Delete the file association from the vector store
125165
try:
126166
deleted_vs_file = await client.vector_stores.files.delete(
@@ -175,9 +215,20 @@ async def delete_file(
175215

176216
# --- Streaming file content routes ---
177217

178-
@router.get("/{file_id}")
218+
@router.get("/{file_name}")
179219
async def download_assistant_file(
180-
file_id: str = Path(..., description="The ID of the file to retrieve"),
220+
assistant_id: str = Path(..., description="The ID of the Assistant"),
221+
file_name: str = Path(..., description="The name of the file to retrieve")
222+
) -> FileResponse:
223+
"""Serves an assistant file stored locally in the uploads directory."""
224+
return retrieve_file(file_name)
225+
226+
227+
# This endpoint retrieves files uploaded TO openai (e.g., code interpreter output)
228+
# Keep it separate for clarity
229+
@router.get("/{file_id}/openai_content")
230+
async def download_openai_file(
231+
file_id: str = Path(..., description="The ID of the file stored in OpenAI"),
181232
client: AsyncOpenAI = Depends(lambda: AsyncOpenAI())
182233
) -> StreamingResponse:
183234
try:
@@ -187,12 +238,14 @@ async def download_assistant_file(
187238
if not hasattr(file_content, 'content'):
188239
raise HTTPException(status_code=500, detail="File content not available")
189240

241+
# Use stream_file_content helper
190242
return StreamingResponse(
191-
stream_file_content(file_content.content),
192-
headers={"Content-Disposition": f'attachment; filename=\"{file.filename or "download"}\"'}
243+
stream_file_content(file_content.content), # Assuming stream_file_content handles bytes
244+
headers={"Content-Disposition": f'attachment; filename="{file.filename or file_id}"'} # Use file_id as fallback filename
193245
)
194246
except Exception as e:
195-
raise HTTPException(status_code=500, detail=str(e))
247+
logger.error(f"Error downloading file {file_id} from OpenAI: {e}")
248+
raise HTTPException(status_code=500, detail=f"Error downloading file from OpenAI: {str(e)}")
196249

197250

198251
@router.get("/{file_id}/content")

utils/files.py

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import logging
2-
from typing import List, Dict, Any
3-
from fastapi import Depends
2+
import os
3+
from typing import List, Dict
4+
from fastapi import Depends, HTTPException
5+
from fastapi.responses import FileResponse
46
from openai import AsyncOpenAI
57
from openai.types.file_object import FileObject
68
from openai.types.vector_stores.vector_store_file import VectorStoreFile
9+
710
logger = logging.getLogger("uvicorn.error")
811

912
# Helper function to get or create a vector store
@@ -60,4 +63,61 @@ async def get_files_for_vector_store(vector_store_id: str, client: AsyncOpenAI)
6063
except Exception as e:
6164
logger.error(f"Error listing files for vector store {vector_store_id}: {e}")
6265
# Return empty list or re-raise depending on desired behavior
63-
return []
66+
return []
67+
68+
69+
def store_file(file_name: str, file_content: bytes):
70+
"""Store a file in the uploads directory."""
71+
upload_dir = "uploads"
72+
os.makedirs(upload_dir, exist_ok=True) # Create directory if it doesn't exist
73+
file_path = os.path.join(upload_dir, file_name)
74+
try:
75+
with open(file_path, "wb") as f:
76+
f.write(file_content)
77+
logger.info(f"File stored successfully: {file_path}")
78+
except IOError as e:
79+
logger.error(f"Error writing file {file_path}: {e}")
80+
# Re-raise a more specific exception or handle as needed
81+
# For now, letting the caller's try/except handle it might be okay
82+
raise
83+
84+
85+
def retrieve_file(file_name: str):
86+
"""Retrieve a file from the uploads directory."""
87+
upload_dir = "uploads"
88+
file_path = os.path.join(upload_dir, file_name)
89+
90+
if not os.path.exists(file_path):
91+
logger.error(f"File not found: {file_path}")
92+
raise HTTPException(status_code=404, detail="File not found")
93+
94+
# Basic security check: prevent path traversal
95+
if not os.path.abspath(file_path).startswith(os.path.abspath(upload_dir)):
96+
logger.error(f"Attempted path traversal: {file_path}")
97+
raise HTTPException(status_code=403, detail="Forbidden")
98+
99+
try:
100+
return FileResponse(path=file_path, filename=file_name, media_type='application/octet-stream')
101+
except Exception as e:
102+
logger.error(f"Error serving file {file_path}: {e}")
103+
raise HTTPException(status_code=500, detail="Error serving file")
104+
105+
106+
def delete_local_file(file_name: str):
107+
"""Delete a file from the local uploads directory."""
108+
upload_dir = "uploads"
109+
file_path = os.path.join(upload_dir, file_name)
110+
111+
# Basic security check
112+
if not os.path.abspath(file_path).startswith(os.path.abspath(upload_dir)):
113+
logger.error(f"Attempted path traversal during delete: {file_path}")
114+
raise HTTPException(status_code=403, detail="Forbidden")
115+
116+
try:
117+
if os.path.exists(file_path):
118+
os.remove(file_path)
119+
logger.info(f"Successfully deleted local file: {file_path}")
120+
else:
121+
logger.warning(f"Attempted to delete local file, but it was not found: {file_path}")
122+
except OSError as e:
123+
logger.error(f"Error deleting local file {file_path}: {e}")

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)