diff options
| author | tanaka takashi <isazaka@gmail.com> | 2026-08-23 17:28:10 +0900 |
|---|---|---|
| committer | tanaka takashi <isazaka@gmail.com> | 2026-08-23 17:28:39 +0900 |
| commit | 33f6b4dbb69e9366e312a8b44dbed9d22e7cba5b (patch) | |
| tree | 29a7480317af94fefe9f69a53095af2f233207ce /src/ollama_web/routes/chat.py | |
| parent | 68969bfbeea68ded7ff863d229760562072f910b (diff) | |
fix(tools): PDFページングを損失なしカーソル方式へ再設計
PDF本文をページ単位で抽出した後に文字数上限で切断しつつ、継続位置だけ次ページへ進めていたため、ページ末尾が復元不能になる問題を修正する。ツール側の本文上限とLLM側の結果上限の不整合、25ページの処理上限、添付経路での継続情報欠落も共通契約で解消する。
- 共通PageResultと、メタデータ込みの結果上限内で本文量を決めるフォーマッターを追加
- v2トークンにPDFのページ番号とページ内文字位置を保持し、v1トークンの読み取り互換性を維持
- pdf_to_text、scrape_url、search_and_fetchを返却済み本文だけカーソルが進む方式へ統一
- max_pdf_pagesを文書全体ではなく1呼び出しの処理上限として扱い、長い単一ページと26ページ目以降の継続取得に対応
- attachment:<file_id>をセッション限定で解決し、既存添付も保存済みプレビューではなく元PDFから再解析
- Web取得ファイルをセッションとURL単位で重複排除し、HTMLおよびテキスト経路の全文ページングも維持
- 正常なページング結果の後段切断を禁止し、結果上限・コンテキスト予算・ラウンド上限では未取得状態を明示
- 日英プロンプト、UIメッセージ、ツールスキーマ、変更履歴を実装へ同期
- 実PDF、30ページ超、長い単一ページ、旧トークン、セッション分離、コンテキスト境界の回帰テストを追加
検証:
- pytest: 130 passed
- ruff check src tests: passed
- mypy src: passed
- c2144.pdf: 19ページ、抽出本文46015文字(ページ区切り込み46051文字)を8回で重複・欠落なく完全再構成
Diffstat (limited to 'src/ollama_web/routes/chat.py')
| -rw-r--r-- | src/ollama_web/routes/chat.py | 86 |
1 files changed, 74 insertions, 12 deletions
diff --git a/src/ollama_web/routes/chat.py b/src/ollama_web/routes/chat.py index e5b90d3..d94b567 100644 --- a/src/ollama_web/routes/chat.py +++ b/src/ollama_web/routes/chat.py @@ -16,7 +16,6 @@ from ollama._types import Image as OllamaImage from starlette.requests import Request from starlette.responses import StreamingResponse -from ..config import settings from ..i18n import t from ..mcp import collect_mcp_tools, make_mcp_executor from ..prompts import get_prompt @@ -24,6 +23,7 @@ from ..sessions import SessionStore, is_valid_id from ..settings_store import load_app_settings from ..tools.fetch import pop_fetched_files from ..tools.helper.image import resize_image +from ..tools.pdf import make_session_pdf_tool from ..tools.registry import ToolRegistry, default_registry logger = logging.getLogger("ollama_web.chat") @@ -65,7 +65,9 @@ def _parse_messages(raw: list[dict[str, Any]]) -> list[Message]: return out -async def _build_registry(session_id: str | None) -> ToolRegistry: +async def _build_registry( + session_id: str | None, store: SessionStore | None = None +) -> ToolRegistry: """Return a registry with built-in tools and any configured MCP tools.""" reg = default_registry() @@ -81,6 +83,20 @@ async def _build_registry(session_id: str | None) -> ToolRegistry: reg._tools["search_and_fetch"] = wrapped # noqa: SLF001 + if session_id is not None and store is not None: + + def load_pdf_attachment(file_id: str) -> bytes | None: + chat_file = store.get_file(session_id, file_id) + if chat_file is None or chat_file.is_image: + return None + if chat_file.mime.lower() != "application/pdf" and not chat_file.name.lower().endswith( + ".pdf" + ): + return None + return store.get_file_data(session_id, file_id) + + reg._tools["pdf_to_text"] = make_session_pdf_tool(load_pdf_attachment) # noqa: SLF001 + try: mcp_tools = await collect_mcp_tools() except Exception as exc: # noqa: BLE001 @@ -105,20 +121,57 @@ async def _build_registry(session_id: str | None) -> ToolRegistry: return reg -def _attachment_context(store: SessionStore, session_id: str, file_ids: list[str]) -> str: - parts: list[str] = [] +def _attachment_context( + store: SessionStore, + session_id: str, + file_ids: list[str], + *, + total_limit: int, +) -> str: + if total_limit <= 0: + return "" + chat_files = [] for file_id in file_ids: chat_file = store.get_file(session_id, file_id) - if chat_file is None or chat_file.is_image: - continue - text = _limit_text(chat_file.text or "", settings.max_attachment_text_chars) - parts.append( + if chat_file is not None and not chat_file.is_image: + chat_files.append(chat_file) + if not chat_files: + return "" + + parts: list[str] = [] + prefix = get_prompt("attachment_prefix") + paging_hint = get_prompt( + "attachment_paging", + fallback=( + "PDF previews may have unread content. Use pdf_to_text with the attachment URI " + "and next_token until has_more is absent." + ), + ) + used = len(prefix) + len(paging_hint) + 4 + per_file = max(256, min(4000, (total_limit - used) // max(1, len(chat_files)))) + pdf_tool = make_session_pdf_tool( + lambda file_id: store.get_file_data(session_id, file_id) + if store.get_file(session_id, file_id) is not None + else None + ) + + for chat_file in chat_files: + source_uri = f"attachment:{chat_file.id}" + if chat_file.mime.lower() == "application/pdf" or chat_file.name.lower().endswith(".pdf"): + text = pdf_tool(source_uri, max_chars=per_file) + else: + text = _limit_text(chat_file.text or "", per_file) + block = ( f'<attached-file name="{html.escape(chat_file.name)}" id="{chat_file.id}" ' - f'source="{chat_file.source}">\n{text}\n</attached-file>' + f'source="{chat_file.source}" uri="{source_uri}">\n{text}\n</attached-file>' ) + if used + len(block) + 2 > total_limit: + break + parts.append(block) + used += len(block) + 2 if not parts: return "" - return "\n\n".join([get_prompt("attachment_prefix"), *parts]) + return "\n\n".join([prefix, paging_hint, *parts]) def _collect_images(store: SessionStore, session_id: str, file_ids: list[str]) -> list[bytes]: @@ -217,7 +270,16 @@ async def _chat_event_stream( attachment_ctx = "" if session_id and file_ids: - attachment_ctx = _attachment_context(store, session_id, file_ids) + attachment_budget = max( + 0, + request.app.state.settings.max_message_chars - len(user_content) - 256, + ) + attachment_ctx = _attachment_context( + store, + session_id, + file_ids, + total_limit=attachment_budget, + ) if attachment_ctx and user_content: # Inject attachment context into the latest user message. @@ -247,7 +309,7 @@ async def _chat_event_stream( from ollama_web import llm # local import to avoid circular at module load - registry = await _build_registry(session_id) + registry = await _build_registry(session_id, store) total_image_bytes = sum( len(img.value) for img in (getattr(messages[-1], "images", None) or []) |
