refactor: move document extraction from ContextBuilder to API layer
ContextBuilder._build_user_content now only handles images (its original responsibility). Document text extraction (PDF, DOCX, XLSX, PPTX) is performed by the new _extract_documents() helper in server.py, called before process_direct(). This keeps the core context builder free of format-specific dependencies and makes the API boundary the single place where uploaded files are pre-processed. Tests updated to reflect the new responsibility boundary. Made-with: Cursor
This commit is contained in:
+40
-1
@@ -19,7 +19,8 @@ from aiohttp import web
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.config.paths import get_media_dir
|
||||
from nanobot.utils.helpers import safe_filename
|
||||
from nanobot.utils.document import extract_text
|
||||
from nanobot.utils.helpers import detect_image_mime, safe_filename
|
||||
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
|
||||
|
||||
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
|
||||
@@ -161,6 +162,40 @@ async def _parse_multipart(request: web.Request) -> tuple[str, list[str], str |
|
||||
return text, media_paths, session_id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pre-processing: extract document text at the API boundary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _extract_documents(text: str, media_paths: list[str]) -> tuple[str, list[str]]:
|
||||
"""Separate images from documents in *media_paths*.
|
||||
|
||||
Documents (PDF, DOCX, XLSX, PPTX, …) have their text extracted and
|
||||
appended to *text*. Only image paths are kept in the returned list so
|
||||
that downstream layers (ContextBuilder) only need to handle vision
|
||||
blocks.
|
||||
"""
|
||||
image_paths: list[str] = []
|
||||
doc_texts: list[str] = []
|
||||
|
||||
for path_str in media_paths:
|
||||
p = Path(path_str)
|
||||
if not p.is_file():
|
||||
continue
|
||||
raw = p.read_bytes()
|
||||
mime = detect_image_mime(raw) or mimetypes.guess_type(path_str)[0]
|
||||
if mime and mime.startswith("image/"):
|
||||
image_paths.append(path_str)
|
||||
else:
|
||||
extracted = extract_text(p)
|
||||
if extracted and not extracted.startswith("[error:"):
|
||||
doc_texts.append(f"[File: {p.name}]\n{extracted}")
|
||||
|
||||
if doc_texts:
|
||||
text = text + "\n\n" + "\n\n".join(doc_texts)
|
||||
|
||||
return text, image_paths
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -197,6 +232,10 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
|
||||
logger.exception("Error parsing upload")
|
||||
return _error_json(413, "File too large or invalid upload")
|
||||
|
||||
# Extract document text at the API boundary; only images stay in media.
|
||||
if media_paths:
|
||||
text, media_paths = _extract_documents(text, media_paths)
|
||||
|
||||
session_key = f"api:{session_id}" if session_id else API_SESSION_KEY
|
||||
session_locks: dict[str, asyncio.Lock] = request.app["session_locks"]
|
||||
session_lock = session_locks.setdefault(session_key, asyncio.Lock())
|
||||
|
||||
Reference in New Issue
Block a user