Add conversation file upload and file_key in stream-query
POST /api/conversation/upload takes an image (via process_image) or a PDF/txt/md/csv file, checks the conversation belongs to the caller, and stores files at conversations/<id>/files/<uuid>.<ext> with the original name in S3 metadata. Anything else gets a 415. stream-query accepts file_key (must sit under this conversation's files/ prefix), extracts its text capped at 12k chars, appends it to the agent's user turn as <attached file> and records 📎 name on the saved message. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
"""Text extraction for files attached to a conversation (PDF, txt, md, csv)."""
|
||||
|
||||
import os
|
||||
|
||||
import fitz
|
||||
|
||||
# Extension -> canonical MIME type for the text-like files we accept.
|
||||
FILE_TYPES = {
|
||||
"pdf": "application/pdf",
|
||||
"txt": "text/plain",
|
||||
"md": "text/markdown",
|
||||
"csv": "text/csv",
|
||||
}
|
||||
_MIME_TO_EXT = {mime: ext for ext, mime in FILE_TYPES.items()}
|
||||
_MIME_TO_EXT["text/x-markdown"] = "md"
|
||||
|
||||
MAX_ATTACHMENT_CHARS = 12_000
|
||||
TRUNCATION_MARKER = "[truncated]"
|
||||
|
||||
|
||||
class FileExtractionError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def file_extension(filename: str | None, content_type: str | None) -> str | None:
|
||||
"""Return the supported extension for an upload, or None if unsupported.
|
||||
|
||||
Matches on the MIME type first, then falls back to the filename extension
|
||||
(browsers often send .md and .csv as application/octet-stream).
|
||||
"""
|
||||
mime = (content_type or "").split(";")[0].strip().lower()
|
||||
if mime in _MIME_TO_EXT:
|
||||
return _MIME_TO_EXT[mime]
|
||||
ext = os.path.splitext(filename or "")[1].lstrip(".").lower()
|
||||
if ext == "markdown":
|
||||
ext = "md"
|
||||
return ext if ext in FILE_TYPES else None
|
||||
|
||||
|
||||
def extract_text(data: bytes, ext: str) -> str:
|
||||
"""Extract plain text from file bytes. Text files decode as UTF-8 with replacement."""
|
||||
if ext == "pdf":
|
||||
try:
|
||||
with fitz.open(stream=data, filetype="pdf") as doc:
|
||||
return "\n".join(page.get_text() for page in doc).strip()
|
||||
except Exception as e:
|
||||
raise FileExtractionError(f"Could not read PDF: {e}") from e
|
||||
if ext in FILE_TYPES:
|
||||
return data.decode("utf-8", errors="replace").lstrip("")
|
||||
raise FileExtractionError(f"Unsupported file type: {ext}")
|
||||
|
||||
|
||||
def cap_text(text: str, limit: int = MAX_ATTACHMENT_CHARS) -> str:
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return f"{text[:limit]}\n{TRUNCATION_MARKER}"
|
||||
|
||||
|
||||
def attachment_block(name: str, text: str) -> str:
|
||||
"""Wrap (capped) file text for the user turn sent to the agent."""
|
||||
safe_name = name.replace('"', "'")
|
||||
return f'<attached file "{safe_name}">\n{cap_text(text)}\n</attached>'
|
||||
@@ -61,3 +61,28 @@ async def delete_image(key: str) -> None:
|
||||
async with _get_client() as client:
|
||||
await client.delete_object(Bucket=S3_BUCKET_NAME, Key=key)
|
||||
logging.info(f"Deleted image from S3: {key}")
|
||||
|
||||
|
||||
async def upload_file(
|
||||
file_bytes: bytes, key: str, content_type: str, metadata: dict[str, str]
|
||||
) -> str:
|
||||
"""Store a non-image object. Metadata values must be ASCII (S3 headers)."""
|
||||
async with _get_client() as client:
|
||||
await client.put_object(
|
||||
Bucket=S3_BUCKET_NAME,
|
||||
Key=key,
|
||||
Body=file_bytes,
|
||||
ContentType=content_type,
|
||||
Metadata=metadata,
|
||||
)
|
||||
logging.info(f"Uploaded file to S3: {key}")
|
||||
return key
|
||||
|
||||
|
||||
async def get_file(key: str) -> tuple[bytes, dict[str, str]]:
|
||||
"""Return an object's bytes and its user metadata."""
|
||||
async with _get_client() as client:
|
||||
response = await client.get_object(Bucket=S3_BUCKET_NAME, Key=key)
|
||||
body = await response["Body"].read()
|
||||
metadata = response.get("Metadata", {}) or {}
|
||||
return body, metadata
|
||||
|
||||
Reference in New Issue
Block a user