mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-04 08:28:36 +00:00
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:
parent
2502fc616b
commit
47f5795708
@ -147,56 +147,30 @@ class ContextBuilder:
|
|||||||
messages.append({"role": current_role, "content": merged})
|
messages.append({"role": current_role, "content": merged})
|
||||||
return messages
|
return messages
|
||||||
|
|
||||||
def _build_user_content(
|
def _build_user_content(self, text: str, media: list[str] | None) -> str | list[dict[str, Any]]:
|
||||||
self, text: str, media: list[str] | None
|
"""Build user message content with optional base64-encoded images."""
|
||||||
) -> 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.
|
|
||||||
"""
|
|
||||||
if not media:
|
if not media:
|
||||||
return text
|
return text
|
||||||
|
|
||||||
images: list[dict[str, Any]] = []
|
images = []
|
||||||
doc_texts: list[str] = []
|
|
||||||
|
|
||||||
for path in media:
|
for path in media:
|
||||||
p = Path(path)
|
p = Path(path)
|
||||||
if not p.is_file():
|
if not p.is_file():
|
||||||
continue
|
continue
|
||||||
raw = p.read_bytes()
|
raw = p.read_bytes()
|
||||||
mime = detect_image_mime(raw) or mimetypes.guess_type(path)[0]
|
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/"):
|
if not images:
|
||||||
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:
|
|
||||||
return text
|
return text
|
||||||
|
return images + [{"type": "text", "text": text}]
|
||||||
|
|
||||||
def add_tool_result(
|
def add_tool_result(
|
||||||
self, messages: list[dict[str, Any]],
|
self, messages: list[dict[str, Any]],
|
||||||
|
|||||||
@ -19,7 +19,8 @@ from aiohttp import web
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.config.paths import get_media_dir
|
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
|
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
|
||||||
|
|
||||||
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
|
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
|
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
|
# Route handlers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -197,6 +232,10 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
|
|||||||
logger.exception("Error parsing upload")
|
logger.exception("Error parsing upload")
|
||||||
return _error_json(413, "File too large or invalid 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_key = f"api:{session_id}" if session_id else API_SESSION_KEY
|
||||||
session_locks: dict[str, asyncio.Lock] = request.app["session_locks"]
|
session_locks: dict[str, asyncio.Lock] = request.app["session_locks"]
|
||||||
session_lock = session_locks.setdefault(session_key, asyncio.Lock())
|
session_lock = session_locks.setdefault(session_key, asyncio.Lock())
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import pytest
|
|||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
|
|
||||||
from nanobot.api.server import (
|
from nanobot.api.server import (
|
||||||
|
_extract_documents,
|
||||||
_FileSizeExceeded,
|
_FileSizeExceeded,
|
||||||
_parse_json_content,
|
_parse_json_content,
|
||||||
_save_base64_data_url,
|
_save_base64_data_url,
|
||||||
@ -184,7 +185,7 @@ def test_parse_json_content_rejects_oversized_base64_file(tmp_path) -> None:
|
|||||||
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
|
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_multipart_upload_saves_file(aiohttp_client, mock_agent, tmp_path) -> None:
|
async def test_multipart_upload_saves_file(aiohttp_client, mock_agent, tmp_path) -> None:
|
||||||
"""Multipart upload saves file to media dir and passes path to process_direct."""
|
"""Multipart upload of non-image extracts text into content (not media)."""
|
||||||
import os
|
import os
|
||||||
original_cwd = os.getcwd()
|
original_cwd = os.getcwd()
|
||||||
os.chdir(tmp_path)
|
os.chdir(tmp_path)
|
||||||
@ -202,8 +203,9 @@ async def test_multipart_upload_saves_file(aiohttp_client, mock_agent, tmp_path)
|
|||||||
)
|
)
|
||||||
assert resp.status == 200
|
assert resp.status == 200
|
||||||
call_kwargs = mock_agent.process_direct.call_args.kwargs
|
call_kwargs = mock_agent.process_direct.call_args.kwargs
|
||||||
assert call_kwargs["content"] == "analyze this"
|
assert "analyze this" in call_kwargs["content"]
|
||||||
assert len(call_kwargs.get("media", [])) == 1
|
# Non-image file text is extracted into content, not kept as media
|
||||||
|
assert not call_kwargs.get("media")
|
||||||
finally:
|
finally:
|
||||||
os.chdir(original_cwd)
|
os.chdir(original_cwd)
|
||||||
|
|
||||||
@ -371,13 +373,62 @@ async def test_json_base64_image_upload(aiohttp_client, mock_agent, tmp_path) ->
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# DOCX document extraction tests
|
# _extract_documents tests (API-layer document extraction)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_extract_documents_separates_images_from_docs(tmp_path) -> None:
|
||||||
|
"""Images stay in media; document text is appended to content."""
|
||||||
|
from docx import Document
|
||||||
|
|
||||||
|
png = tmp_path / "chart.png"
|
||||||
|
png.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)
|
||||||
|
|
||||||
|
doc = Document()
|
||||||
|
doc.add_paragraph("Quarterly revenue is $5M")
|
||||||
|
docx_path = tmp_path / "report.docx"
|
||||||
|
doc.save(docx_path)
|
||||||
|
|
||||||
|
text, image_paths = _extract_documents("summarize", [str(png), str(docx_path)])
|
||||||
|
assert len(image_paths) == 1
|
||||||
|
assert image_paths[0] == str(png)
|
||||||
|
assert "Quarterly revenue" in text
|
||||||
|
assert "summarize" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_documents_skips_extraction_errors(tmp_path, monkeypatch) -> None:
|
||||||
|
"""Document extraction errors should not leak into user text."""
|
||||||
|
bad_file = tmp_path / "broken.docx"
|
||||||
|
bad_file.write_text("not a docx", encoding="utf-8")
|
||||||
|
|
||||||
|
import nanobot.api.server as _srv
|
||||||
|
monkeypatch.setattr(
|
||||||
|
_srv, "extract_text",
|
||||||
|
lambda _path: "[error: failed to extract DOCX: boom]",
|
||||||
|
)
|
||||||
|
|
||||||
|
text, image_paths = _extract_documents("hello", [str(bad_file)])
|
||||||
|
assert text == "hello"
|
||||||
|
assert image_paths == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_documents_images_only(tmp_path) -> None:
|
||||||
|
"""When all files are images, text is unchanged and all paths kept."""
|
||||||
|
png = tmp_path / "a.png"
|
||||||
|
png.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)
|
||||||
|
text, image_paths = _extract_documents("describe", [str(png)])
|
||||||
|
assert text == "describe"
|
||||||
|
assert len(image_paths) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DOCX end-to-end upload test (API layer now extracts text)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
|
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_docx_upload_extracted_and_sent(aiohttp_client, tmp_path) -> None:
|
async def test_docx_upload_extracted_and_sent(aiohttp_client, tmp_path) -> None:
|
||||||
"""Uploaded DOCX should have its text extracted before being sent to AI."""
|
"""Uploaded DOCX text should be extracted at the API layer and
|
||||||
|
appended to the content string, not passed as media."""
|
||||||
from docx import Document
|
from docx import Document
|
||||||
|
|
||||||
agent = _make_mock_agent("This report shows $5M revenue")
|
agent = _make_mock_agent("This report shows $5M revenue")
|
||||||
@ -405,8 +456,9 @@ async def test_docx_upload_extracted_and_sent(aiohttp_client, tmp_path) -> None:
|
|||||||
resp = await client.post("/v1/chat/completions", data=data)
|
resp = await client.post("/v1/chat/completions", data=data)
|
||||||
assert resp.status == 200
|
assert resp.status == 200
|
||||||
call_kwargs = agent.process_direct.call_args.kwargs
|
call_kwargs = agent.process_direct.call_args.kwargs
|
||||||
media = call_kwargs.get("media", [])
|
# Document text should be extracted into content, not media
|
||||||
assert len(media) == 1
|
assert "Total revenue" in call_kwargs["content"]
|
||||||
assert "report.docx" in media[0]
|
# No media (docx is not an image)
|
||||||
|
assert not call_kwargs.get("media")
|
||||||
finally:
|
finally:
|
||||||
os.chdir(original_cwd)
|
os.chdir(original_cwd)
|
||||||
|
|||||||
@ -1,4 +1,8 @@
|
|||||||
"""Tests for context builder document handling."""
|
"""Tests for context builder media handling.
|
||||||
|
|
||||||
|
The ContextBuilder._build_user_content method should ONLY handle images.
|
||||||
|
Document text extraction is the responsibility of the API layer.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@ -30,52 +34,25 @@ def test_build_user_content_with_image_returns_list(tmp_path: Path) -> None:
|
|||||||
assert "text" in types
|
assert "text" in types
|
||||||
|
|
||||||
|
|
||||||
def test_build_user_content_with_docx_includes_extracted_text(tmp_path: Path) -> None:
|
def test_build_user_content_ignores_non_image_files(tmp_path: Path) -> None:
|
||||||
"""Document files should have their text extracted and included."""
|
"""Non-image files should be silently skipped — extraction is not context builder's job."""
|
||||||
from docx import Document
|
|
||||||
|
|
||||||
doc = Document()
|
|
||||||
doc.add_paragraph("Quarterly revenue is $5M")
|
|
||||||
docx_path = tmp_path / "report.docx"
|
|
||||||
doc.save(docx_path)
|
|
||||||
|
|
||||||
builder = _make_builder(tmp_path)
|
builder = _make_builder(tmp_path)
|
||||||
result = builder._build_user_content("summarize this", [str(docx_path)])
|
txt = tmp_path / "notes.txt"
|
||||||
assert isinstance(result, str)
|
txt.write_text("some text", encoding="utf-8")
|
||||||
assert "Quarterly revenue" in result
|
result = builder._build_user_content("summarize", [str(txt)])
|
||||||
|
assert result == "summarize"
|
||||||
|
|
||||||
|
|
||||||
def test_build_user_content_mixed_image_and_document(tmp_path: Path) -> None:
|
def test_build_user_content_mixed_image_and_non_image(tmp_path: Path) -> None:
|
||||||
"""Mix of images and documents: images as base64, docs as text."""
|
"""Only images should be included; non-image files are skipped."""
|
||||||
from docx import Document
|
builder = _make_builder(tmp_path)
|
||||||
|
|
||||||
png = tmp_path / "chart.png"
|
png = tmp_path / "chart.png"
|
||||||
png.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)
|
png.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)
|
||||||
|
txt = tmp_path / "report.txt"
|
||||||
|
txt.write_text("report text", encoding="utf-8")
|
||||||
|
|
||||||
doc = Document()
|
result = builder._build_user_content("analyze", [str(png), str(txt)])
|
||||||
doc.add_paragraph("Report text here")
|
|
||||||
docx = tmp_path / "report.docx"
|
|
||||||
doc.save(docx)
|
|
||||||
|
|
||||||
builder = _make_builder(tmp_path)
|
|
||||||
result = builder._build_user_content("analyze both", [str(png), str(docx)])
|
|
||||||
assert isinstance(result, list)
|
assert isinstance(result, list)
|
||||||
assert any(b["type"] == "image_url" for b in result)
|
assert any(b["type"] == "image_url" for b in result)
|
||||||
text_parts = [b.get("text", "") for b in result if b.get("type") == "text"]
|
text_parts = [b.get("text", "") for b in result if b.get("type") == "text"]
|
||||||
assert any("Report text here" in t for t in text_parts)
|
assert all("report text" not in t for t in text_parts)
|
||||||
|
|
||||||
|
|
||||||
def test_build_user_content_skips_document_extraction_errors(tmp_path: Path, monkeypatch) -> None:
|
|
||||||
"""Document extraction errors should not be embedded into the user prompt."""
|
|
||||||
docx_path = tmp_path / "broken.docx"
|
|
||||||
docx_path.write_text("not a real docx", encoding="utf-8")
|
|
||||||
|
|
||||||
builder = _make_builder(tmp_path)
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"nanobot.utils.document.extract_text",
|
|
||||||
lambda _path: "[error: failed to extract DOCX: boom]",
|
|
||||||
)
|
|
||||||
|
|
||||||
result = builder._build_user_content("summarize this", [str(docx_path)])
|
|
||||||
assert result == "summarize this"
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user