From 33f6b4dbb69e9366e312a8b44dbed9d22e7cba5b Mon Sep 17 00:00:00 2001 From: tanaka takashi Date: Sun, 23 Aug 2026 17:28:10 +0900 Subject: fix(tools): PDFページングを損失なしカーソル方式へ再設計 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PDF本文をページ単位で抽出した後に文字数上限で切断しつつ、継続位置だけ次ページへ進めていたため、ページ末尾が復元不能になる問題を修正する。ツール側の本文上限とLLM側の結果上限の不整合、25ページの処理上限、添付経路での継続情報欠落も共通契約で解消する。 - 共通PageResultと、メタデータ込みの結果上限内で本文量を決めるフォーマッターを追加 - v2トークンにPDFのページ番号とページ内文字位置を保持し、v1トークンの読み取り互換性を維持 - pdf_to_text、scrape_url、search_and_fetchを返却済み本文だけカーソルが進む方式へ統一 - max_pdf_pagesを文書全体ではなく1呼び出しの処理上限として扱い、長い単一ページと26ページ目以降の継続取得に対応 - attachment:をセッション限定で解決し、既存添付も保存済みプレビューではなく元PDFから再解析 - Web取得ファイルをセッションとURL単位で重複排除し、HTMLおよびテキスト経路の全文ページングも維持 - 正常なページング結果の後段切断を禁止し、結果上限・コンテキスト予算・ラウンド上限では未取得状態を明示 - 日英プロンプト、UIメッセージ、ツールスキーマ、変更履歴を実装へ同期 - 実PDF、30ページ超、長い単一ページ、旧トークン、セッション分離、コンテキスト境界の回帰テストを追加 検証: - pytest: 130 passed - ruff check src tests: passed - mypy src: passed - c2144.pdf: 19ページ、抽出本文46015文字(ページ区切り込み46051文字)を8回で重複・欠落なく完全再構成 --- docs/tools-schema.md | 66 +++++++++++++++++++++++++--------------------------- 1 file changed, 32 insertions(+), 34 deletions(-) (limited to 'docs') diff --git a/docs/tools-schema.md b/docs/tools-schema.md index b9cf4b9..874640a 100644 --- a/docs/tools-schema.md +++ b/docs/tools-schema.md @@ -23,7 +23,7 @@ ollama-web は LLM(Ollama)にツール呼び出し(function calling)機 ツールの登録・実行は [`ToolRegistry`](src/ollama_web/tools/registry.py:17) が担い、チャットループ([`llm.py`](src/ollama_web/llm.py:1))経由で LLM から呼び出されます。 -`scrape_url` / `pdf_to_text` / `search_and_fetch` は**ページング(継続トークン方式)に対応**しています。`web_search` は API 呼び出し回数とキャッシュ要件の懸念からページング対象外です。 +`scrape_url` / `pdf_to_text` / `search_and_fetch` は**損失なしページング(継続トークン方式)に対応**しています。本文・メタデータ・継続トークンを含む結果全体をLLM側の上限内に収めてからカーソルを確定します。`web_search` はページング対象外です。 ## ツール一覧 @@ -114,13 +114,13 @@ next_token: {token} ## 3. pdf_to_text - **実装**: [`pdf_to_text()`](src/ollama_web/tools/pdf.py:10) -- **依存**: [`extract_pdf_text()`](src/ollama_web/tools/helper/pdf.py:12)([`pypdf`](https://pypdf.readthedocs.io/) 使用) +- **依存**: [`extract_pdf_page()`](src/ollama_web/tools/helper/pdf.py:12)([`pypdf`](https://pypdf.readthedocs.io/) 使用) ### スキーマ | パラメータ | 型 | 必須 | デフォルト | 説明 | |------------|----|------|------------|------| -| `url_or_path` | `str` | ✅ | — | PDF の URL またはローカルパス。空文字列は `"Invalid PDF source."` を返す | +| `url_or_path` | `str` | ✅ | — | HTTP(S) PDF URL。チャット内では現在のセッションの `attachment:` も利用可能。任意のローカルパスは拒否 | | `max_chars` | `int \| None` | ❌ | [`settings.fetch_max_chars`](src/ollama_web/config.py:52) (=8000) | 1ページあたりの返却文字数上限 | | `continue_from` | `str \| None` | ❌ | `None` | 前回戻り値の `next_token`。指定時は続きページを取得 | @@ -130,26 +130,26 @@ next_token: {token} ### 制約 -- **`http://` / `https://` のみサポート**。ローカルパスは `"Only http(s) PDF URLs are supported."` を返す。 -- [`extract_pdf_text()`](src/ollama_web/tools/helper/pdf.py:12) 側の制約: +- 通常は **`http://` / `https://` のみサポート**。チャット内のセッションスコープ付きラッパーだけが `attachment:` を解決する。 +- [`extract_pdf_page()`](src/ollama_web/tools/helper/pdf.py:12) 側の制約: - [`settings.max_fetch_mb`](src/ollama_web/config.py:53) (=10MB) 超過時は `"PDF exceeds the configured size limit."` - - 1回あたり [`settings.max_pdf_pages`](src/ollama_web/config.py:54) (=25) ページまで抽出 - - `page_offset` から `max_pdf_pages` 件を対象に抽出(ページング時) - - `max_chars` 超過時は単語境界で切り詰め `…[truncated]` を付与 + - 1回あたり [`settings.max_pdf_pages`](src/ollama_web/config.py:54) (=25) ページまで処理するが、26ページ目以降は継続可能 + - `(page_index, page_char_offset)` から再開し、ページ途中の未返却文字を次回返す + - `max_chars` は要求上限で、結果全体を6000文字以内に収めるため本文が短くなる場合がある ### 処理フロー -1. `continue_from` があればトークンから `(url, page_offset)` を復元 +1. `continue_from` があればトークンから `(url, page_index, page_char_offset)` を復元 2. PDF を全件取得・解析 -3. `page_offset` ページ目から順にテキスト抽出し `max_chars` に達したら停止 -4. 残りページがあれば `has_more: true` + `next_token`(`page_offset = 最後に抽出したページ + 1`)を付与 +3. カーソル位置から、結果全体の上限内で実際に返せる文字数だけ抽出 +4. 未返却文字または残りページがあれば、その直後を指す `next_token` を付与 --- ## 4. search_and_fetch - **実装**: [`search_and_fetch()`](src/ollama_web/tools/fetch.py:130) -- **依存**: [`web_search()`](src/ollama_web/tools/search.py:10)、[`fetch_public_bytes()`](src/ollama_web/tools/helper/safe_http.py:72)、[`extract_pdf_text()`](src/ollama_web/tools/helper/pdf.py:12)、[`scrape_url()`](src/ollama_web/tools/scrape.py:11) +- **依存**: [`web_search()`](src/ollama_web/tools/search.py:10)、[`fetch_public_bytes()`](src/ollama_web/tools/helper/safe_http.py:72)、[`extract_pdf_page()`](src/ollama_web/tools/helper/pdf.py:12)、[`scrape_url()`](src/ollama_web/tools/scrape.py:11) ### スキーマ @@ -209,11 +209,11 @@ URL: {url} ### helper/pdf.py -- **実装**: [`extract_pdf_text()`](src/ollama_web/tools/helper/pdf.py:12) +- **実装**: [`extract_pdf_page()`](src/ollama_web/tools/helper/pdf.py:12) - `pypdf.PdfReader` でページごとに `extract_text()`。例外ページはスキップ。 -- `page_offset` パラメータで指定ページから抽出(ページング対応)。 -- 1回あたり `max_pdf_pages` 件まで、`max_chars` で切り詰め。 -- 戻り値は `(text, has_more, next_page_offset)` のタプル。 +- ページ番号とページ内文字位置の複合カーソルで再開する。 +- `max_pdf_pages` は1回の作業量だけを制限し、文書全体は制限しない。 +- 戻り値は本文、開始・次カーソル、`has_more`、ページ警告を持つ `PageResult`。 ### helper/image.py @@ -259,27 +259,24 @@ flowchart LR ## ツール結果の切り詰め(truncation)ポリシー -ツール結果は LLM コンテキストに戻される前に**多段階**で切り詰められる。ページング対応ツールの `---page-info---` フッターは保護され、本文側が優先的に削られる。 +非ページング結果はLLMコンテキストへ戻す前に切り詰められる。ページング対応ツールは本文・メタデータ・トークンを含む結果全体を事前に上限内へ収め、LLM層では切り詰めない。境界違反時は本文と進行済みトークンを返さず、同じ呼び出しを再試行できるエラーにする。 ```mermaid flowchart TD - T[ツール実行] --> S1[ステージ1: ツール内部 max_chars/max_pdf_pages で切り詰め] - S1 --> S2[ステージ2: llm.py _trim_tool_result で max_tool_result_chars=6000 に即時切り詰め] - S2 --> S3[ステージ3: 累積が MAX_TOOL_CONTEXT_CHARS=24000 超過時、各結果をさらに削る] - S3 --> S4[ステージ4: MAX_TOOL_ROUNDS=5 で連続ツール呼び出しを打ち切り] + T[ツール実行] --> S1[ページングツールが結果全体を6000文字以内に整形] + S1 --> S2[非ページング結果だけを llm.py で切り詰め] + S2 --> S3[累積コンテキスト予算到達前に次のページ呼び出しを停止] + S3 --> S4[MAX_TOOL_ROUNDS=5 で一般ツール呼び出しを打ち切り] ``` | ステージ | 場所 | 関数/定数 | 制限値 | |----------|------|-----------|--------| -| 1 | 各ツール内部 | `max_chars` / `max_pdf_pages` | ツールごと(デフォルト 4000〜8000文字 / 25ページ) | -| 2 | [`llm.py`](src/ollama_web/llm.py:1) | [`_trim_tool_result()`](src/ollama_web/llm.py:1) / [`max_tool_result_chars`](src/ollama_web/config.py:58) | **6000文字**(各ツール結果ごとに即時適用) | -| 3 | [`llm.py`](src/ollama_web/llm.py:1) | [`MAX_TOOL_CONTEXT_CHARS`](src/ollama_web/llm.py:1) | **24000文字**(累積超過時に各結果を再削り) | +| 1 | 各ページングツール | `format_page_to_limit()` | **6000文字**(フッター込み) | +| 2 | [`llm.py`](src/ollama_web/llm.py:1) | [`_trim_tool_result()`](src/ollama_web/llm.py:1) | 非ページング結果のみ切り詰め | +| 3 | [`llm.py`](src/ollama_web/llm.py:1) | [`MAX_TOOL_CONTEXT_CHARS`](src/ollama_web/llm.py:1) | **24000文字**(次のページを実行前に停止) | | 4 | [`llm.py`](src/ollama_web/llm.py:1) | [`MAX_TOOL_ROUNDS`](src/ollama_web/llm.py:1) | **5ラウンド**(連続ツール呼び出し上限) | -`_trim_tool_result()` は: -- `---page-info---` フッターを含む結果は、フッターを保護し本文のみを切り詰める(段落境界 `\n\n` 優先、次に単語境界) -- フッター無し時は従来ロジック(段落境界優先、次に単語境界) -- 末尾に `…[tool result truncated]` を付与 +`_trim_tool_result()` はページング結果が上限を超えた場合、本文を部分返却せず再試行可能な境界エラーへ置換する。フッター無しの結果は従来どおり段落・単語境界で切り詰める。 --- @@ -296,7 +293,7 @@ flowchart TD ```json { - "v": 1, + "v": 2, "tool": "scrape_url", "url": "https://example.com/page", "offset": 4000 @@ -307,13 +304,16 @@ flowchart TD ```json { - "v": 1, + "v": 2, "tool": "search_and_fetch", "url": "https://example.com/file.pdf", "offset": 8000, "matched": [{"title": "...", "href": "..."}], "file_idx": 1, - "file_offset": 8000 + "file_offset": 8000, + "pdf_page_index": 3, + "pdf_page_char_offset": 1200, + "file_type": "pdf" } ``` @@ -328,7 +328,7 @@ next_token: {token} ``` - `has_more: false` のときは `next_token` 行を省略(フッター自体も付与しない) -- `---page-info---` は [`_trim_tool_result()`](src/ollama_web/llm.py:1) の切り詰め対象外 +- 正常なページング結果全体が上限内に収まるため、`---page-info---` を含む結果は切り詰め対象外 ### LLM誤用対策 @@ -368,5 +368,3 @@ sequenceDiagram Tool->>Tool: slice offset:offset+max_chars Tool-->>LLM: text + has_more:false ``` - -実装の詳細設計は [`docs/pagination-plan.md`](pagination-plan.md) を参照。 \ No newline at end of file -- cgit v1.2.3