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:
Xubin Ren
2026-04-14 13:00:59 +00:00
parent 2502fc616b
commit 47f5795708
4 changed files with 131 additions and 89 deletions
+13 -39
View File
@@ -147,56 +147,30 @@ class ContextBuilder:
messages.append({"role": current_role, "content": merged})
return messages
def _build_user_content(
self, text: str, media: list[str] | None
) -> str | list[dict[str, Any]]:
"""Build user message content with optional media.
Images are converted to base64 vision blocks.
Documents (PDF, Word, Excel, PPT) have their text extracted and appended.
"""
def _build_user_content(self, text: str, media: list[str] | None) -> str | list[dict[str, Any]]:
"""Build user message content with optional base64-encoded images."""
if not media:
return text
images: list[dict[str, Any]] = []
doc_texts: list[str] = []
images = []
for path in media:
p = Path(path)
if not p.is_file():
continue
raw = p.read_bytes()
mime = detect_image_mime(raw) or mimetypes.guess_type(path)[0]
if not mime or not mime.startswith("image/"):
continue
b64 = base64.b64encode(raw).decode()
images.append({
"type": "image_url",
"image_url": {"url": f"data:{mime};base64,{b64}"},
"_meta": {"path": str(p)},
})
if mime and mime.startswith("image/"):
b64 = base64.b64encode(raw).decode()
images.append({
"type": "image_url",
"image_url": {"url": f"data:{mime};base64,{b64}"},
"_meta": {"path": str(p)},
})
else:
# Try document text extraction
from nanobot.utils.document import extract_text
extracted = extract_text(p)
if extracted and not extracted.startswith("[error:"):
doc_texts.append(f"[File: {p.name}]\n{extracted}")
# Build final content
parts: list[dict[str, Any]] = []
parts.extend(images)
combined_text = text
if doc_texts:
combined_text = text + "\n\n" + "\n\n".join(doc_texts)
if images:
parts.append({"type": "text", "text": combined_text})
return parts
elif doc_texts:
return combined_text
else:
if not images:
return text
return images + [{"type": "text", "text": text}]
def add_tool_result(
self, messages: list[dict[str, Any]],
+40 -1
View File
@@ -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())