feat: S3 upload on conversion complete (save_to_s3 param)

Adds optional save_to_s3=True to convert_document. After conversion,
the text output is uploaded to chat-artifacts S3 and s3_url is returned
alongside the result. Also fixes boto3 1.35+ checksum regression
(request_checksum_calculation=when_required) and adds boto3 dep.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-05-21 09:29:09 +02:00
parent cf451ad784
commit 0cd51ddd4c
2 changed files with 67 additions and 7 deletions
+1
View File
@@ -1,2 +1,3 @@
mcp[cli]>=1.6.0 mcp[cli]>=1.6.0
httpx>=0.27 httpx>=0.27
boto3>=1.34
+66 -7
View File
@@ -18,9 +18,12 @@ import base64
import os import os
import time import time
import uuid import uuid
from datetime import datetime, timezone
from urllib.parse import urlparse from urllib.parse import urlparse
import boto3
import httpx import httpx
from botocore.config import Config
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
API_KEY = os.environ.get("SAIA_API_KEY", "") API_KEY = os.environ.get("SAIA_API_KEY", "")
@@ -30,6 +33,21 @@ API_ENDPOINT = os.environ.get(
CONVERT_DEADLINE = int(os.environ.get("CONVERT_DEADLINE", 60)) CONVERT_DEADLINE = int(os.environ.get("CONVERT_DEADLINE", 60))
_VALID_TYPES = ("markdown", "html", "json", "tokens") _VALID_TYPES = ("markdown", "html", "json", "tokens")
_S3_ENDPOINT = os.environ.get("CHAT_ARTIFACTS_ENDPOINT", "")
_S3_ACCESS = os.environ.get("CHAT_ARTIFACTS_ACCESS_KEY", "")
_S3_SECRET = os.environ.get("CHAT_ARTIFACTS_SECRET_KEY", "")
_S3_BUCKET = os.environ.get("CHAT_ARTIFACTS_BUCKET", "chat-artifacts")
_S3_PUBLIC = "https://chat-artifacts.s3.nuclide.systems"
_S3_ENABLED = bool(_S3_ENDPOINT and _S3_ACCESS and _S3_SECRET)
_EXT = {"markdown": "md", "html": "html", "json": "json", "tokens": "txt"}
_CT = {
"markdown": "text/markdown; charset=utf-8",
"html": "text/html; charset=utf-8",
"json": "application/json",
"tokens": "text/plain; charset=utf-8",
}
mcp = FastMCP("saia-docling", host="0.0.0.0", port=8000, stateless_http=True) mcp = FastMCP("saia-docling", host="0.0.0.0", port=8000, stateless_http=True)
# job_id -> {status, result, error, submitted_at, filename} # job_id -> {status, result, error, submitted_at, filename}
@@ -37,6 +55,35 @@ _jobs: dict[str, dict] = {}
_tasks: set[asyncio.Task] = set() _tasks: set[asyncio.Task] = set()
def _s3_upload(text: str, job_id: str, response_type: str) -> str | None:
if not _S3_ENABLED:
return None
ext = _EXT.get(response_type, "txt")
key = datetime.now(timezone.utc).strftime("%Y-%m-%d/") + f"docling-{job_id}.{ext}"
try:
boto3.client(
"s3",
endpoint_url=_S3_ENDPOINT,
aws_access_key_id=_S3_ACCESS,
aws_secret_access_key=_S3_SECRET,
region_name="garage",
config=Config(
signature_version="s3v4",
request_checksum_calculation="when_required",
response_checksum_validation="when_required",
),
).put_object(
Bucket=_S3_BUCKET,
Key=key,
Body=text.encode(),
ContentType=_CT.get(response_type, "text/plain"),
)
return f"{_S3_PUBLIC}/{key}"
except Exception as exc:
print(f"S3 upload failed for {job_id}: {exc}", flush=True)
return None
async def _do_convert(filename: str, content: bytes, params: dict) -> dict: async def _do_convert(filename: str, content: bytes, params: dict) -> dict:
headers = {"accept": "application/json"} headers = {"accept": "application/json"}
if API_KEY: if API_KEY:
@@ -53,18 +100,24 @@ async def _do_convert(filename: str, content: bytes, params: dict) -> dict:
return r.json() return r.json()
async def _run_job(job_id, filename, content, params, response_type): async def _run_job(job_id, filename, content, params, response_type, save_to_s3=True):
try: try:
data = await _do_convert(filename, content, params) data = await _do_convert(filename, content, params)
text = data.get(response_type, data.get("markdown", ""))
_jobs[job_id].update( _jobs[job_id].update(
status="done", status="done",
result={ result={
"response_type": data.get("response_type", response_type.upper()), "response_type": data.get("response_type", response_type.upper()),
"filename": data.get("filename", filename), "filename": data.get("filename", filename),
response_type: data.get(response_type, data.get("markdown", "")), response_type: text,
"images": data.get("images", []), "images": data.get("images", []),
}, },
) )
if save_to_s3 and text:
s3_url = await asyncio.get_event_loop().run_in_executor(
None, _s3_upload, text, job_id, response_type
)
_jobs[job_id]["s3_url"] = s3_url
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
_jobs[job_id].update(status="error", error=str(e)[:500]) _jobs[job_id].update(status="error", error=str(e)[:500])
@@ -72,7 +125,10 @@ async def _run_job(job_id, filename, content, params, response_type):
def _result(job_id: str) -> dict: def _result(job_id: str) -> dict:
j = _jobs[job_id] j = _jobs[job_id]
if j["status"] == "done": if j["status"] == "done":
return {"job_id": job_id, "status": "done", **j["result"]} out = {"job_id": job_id, "status": "done", **j["result"]}
if j.get("s3_url"):
out["s3_url"] = j["s3_url"]
return out
if j["status"] == "error": if j["status"] == "error":
return {"job_id": job_id, "status": "error", "error": j["error"]} return {"job_id": job_id, "status": "error", "error": j["error"]}
return { return {
@@ -89,6 +145,7 @@ async def convert_document(
response_type: str = "markdown", response_type: str = "markdown",
extract_tables_as_images: bool = False, extract_tables_as_images: bool = False,
image_resolution_scale: int = 2, image_resolution_scale: int = 2,
save_to_s3: bool = True,
) -> dict: ) -> dict:
"""Convert a document (PDF, DOCX, PPTX, images, …) to Markdown/HTML/JSON. """Convert a document (PDF, DOCX, PPTX, images, …) to Markdown/HTML/JSON.
@@ -103,10 +160,12 @@ async def convert_document(
extract_tables_as_images: False (default) keeps tables as structured extract_tables_as_images: False (default) keeps tables as structured
text; True returns them as images. text; True returns them as images.
image_resolution_scale: Image scaling factor 14 (default 2). image_resolution_scale: Image scaling factor 14 (default 2).
save_to_s3: Upload the converted text to chat-artifacts S3 and include
an s3_url in the response (default True). Set False to skip upload.
Returns: Returns:
dict with status "done" (incl. the converted text under its format key dict with status "done" (incl. the converted text under its format key,
plus "images"), or status "running" with a job_id to poll. "images", and optionally "s3_url"), or status "running" with a job_id.
""" """
if response_type not in _VALID_TYPES: if response_type not in _VALID_TYPES:
raise ValueError(f"response_type must be one of {_VALID_TYPES}") raise ValueError(f"response_type must be one of {_VALID_TYPES}")
@@ -136,7 +195,7 @@ async def convert_document(
job_id = f"doc-{uuid.uuid4().hex[:8]}" job_id = f"doc-{uuid.uuid4().hex[:8]}"
_jobs[job_id] = { _jobs[job_id] = {
"status": "running", "result": None, "error": None, "status": "running", "result": None, "error": None, "s3_url": None,
"submitted_at": time.time(), "filename": filename, "submitted_at": time.time(), "filename": filename,
} }
if len(_jobs) > 200: if len(_jobs) > 200:
@@ -144,7 +203,7 @@ async def convert_document(
_jobs.pop(k, None) _jobs.pop(k, None)
task = asyncio.create_task( task = asyncio.create_task(
_run_job(job_id, filename, content, params, response_type) _run_job(job_id, filename, content, params, response_type, save_to_s3)
) )
_tasks.add(task) _tasks.add(task)
task.add_done_callback(_tasks.discard) task.add_done_callback(_tasks.discard)