summaryrefslogtreecommitdiff
path: root/src/ollama_web/llm.py
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-08-23 17:28:10 +0900
committertanaka takashi <isazaka@gmail.com>2026-08-23 17:28:39 +0900
commit33f6b4dbb69e9366e312a8b44dbed9d22e7cba5b (patch)
tree29a7480317af94fefe9f69a53095af2f233207ce /src/ollama_web/llm.py
parent68969bfbeea68ded7ff863d229760562072f910b (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/llm.py')
-rw-r--r--src/ollama_web/llm.py116
1 files changed, 77 insertions, 39 deletions
diff --git a/src/ollama_web/llm.py b/src/ollama_web/llm.py
index 187b5e7..20485eb 100644
--- a/src/ollama_web/llm.py
+++ b/src/ollama_web/llm.py
@@ -14,6 +14,7 @@ from .config import settings
from .i18n import t
from .mcp import redact_secrets
from .prompts import get_prompt
+from .tools.helper.pagination import parse_paged_result, parse_token
from .tools.registry import ToolRegistry, default_registry
# Maximum successive tool-call rounds before we force a final answer.
@@ -176,29 +177,22 @@ def _token_counts(response: Any) -> dict[str, int] | None:
def _trim_tool_result(result: str, limit: int) -> str:
"""Truncate ``result`` to ``limit`` chars while keeping it readable.
- When the result contains a ``---page-info---`` footer (emitted by paged
- tools), the footer is preserved and only the body is truncated so the
- model can still see the ``next_token`` needed to fetch the continuation.
+ Paged tools must fit their own complete envelope before advancing a cursor.
+ An oversized paged result is therefore rejected atomically instead of
+ being truncated into an unrecoverable content gap.
"""
if len(result) <= limit:
return result
- # Protect the page-info footer from truncation. ``split`` keeps the
- # marker on the footer side so the ``---page-info---`` line is preserved.
+ # A paged tool must size its own body before advancing its cursor. Trimming
+ # it here would create an unrecoverable gap between visible text and the
+ # next token, so fail the call atomically and let the model repeat it.
if _PAGE_INFO_MARKER in result:
- body, footer = result.split(_PAGE_INFO_MARKER, 1)
- footer = _PAGE_INFO_MARKER + footer
- body_limit = limit - len(footer)
- if body_limit <= 0:
- # The footer alone exceeds the limit; keep it verbatim and drop the
- # body entirely so the model can still continue paging.
- return "…[tool result truncated]" + footer
- if len(body) <= body_limit:
- return result
- cutoff = body[:body_limit].rsplit("\n\n", 1)[0]
- if len(cutoff) < body_limit * 0.5:
- cutoff = body[:body_limit].rsplit(" ", 1)[0]
- return cutoff + "\n\n…[tool result truncated]" + footer
+ return (
+ "Paged tool result exceeded the context-safe result limit. "
+ "Repeat the same tool call with a smaller max_chars value; no part "
+ "of this page was consumed."
+ )
# No footer: legacy truncation behaviour.
cutoff = result[:limit].rsplit("\n\n", 1)[0]
@@ -208,6 +202,23 @@ def _trim_tool_result(result: str, limit: int) -> str:
return cutoff + "\n\n…[tool result truncated]"
+def _paged_source_key(name: str, arguments: Any) -> tuple[str, str]:
+ """Identify one paged source without exposing its content."""
+ if not isinstance(arguments, dict):
+ return name, "unknown"
+ if name == "search_and_fetch":
+ return name, f"{arguments.get('query', '')}|{arguments.get('file_type', '')}"
+ token = arguments.get("continue_from")
+ if isinstance(token, str) and token:
+ try:
+ payload = parse_token(token, name)
+ return name, str(payload.get("url", "unknown"))
+ except ValueError:
+ pass
+ source = arguments.get("url_or_path", arguments.get("url", "unknown"))
+ return name, str(source)
+
+
def stream_chat_with_tools(
messages: list[Message],
model: str,
@@ -242,6 +253,7 @@ def stream_chat_with_tools(
messages = [_embed_images_in_content(m) if _is_image_message(m) else m for m in messages]
cumulative_tool_chars = 0
+ outstanding_paged_sources: set[tuple[str, str]] = set()
MAX_EMPTY_RETRIES = 2
empty_retries = 0
@@ -339,6 +351,11 @@ def stream_chat_with_tools(
messages.append(msg)
if not tool_calls:
+ if outstanding_paged_sources:
+ yield {
+ "type": "warning",
+ "message": t("status.pagination_incomplete", lang=language),
+ }
if is_empty:
# Ran out of tool rounds while still empty; surface a warning.
yield {
@@ -358,14 +375,41 @@ def stream_chat_with_tools(
for call in tool_calls:
name = call.get("name", "")
args = call.get("arguments", {})
+ paged_source = _paged_source_key(name, args)
yield {"type": "tool_start", "name": name, "arguments": _safe_tool_arguments(args)}
t1 = time.time()
- try:
- result = reg.execute(name, args if isinstance(args, dict) else json.dumps(args))
- except Exception as exc: # noqa: BLE001
- logger.warning("tool %s execution failed in %.2fs: %s", name, time.time() - t1, exc)
- result = f"Tool execution failed: {exc}"
+ remaining_context = MAX_TOOL_CONTEXT_CHARS - (
+ cumulative_tool_chars + total_result_len
+ )
+ is_builtin_paged = name in {"scrape_url", "pdf_to_text", "search_and_fetch"}
+ if is_builtin_paged and remaining_context < settings.max_tool_result_chars:
+ result = (
+ "Tool context budget exhausted before this paged call was executed. "
+ "Unread content remains; do not claim that the source was read in full."
+ )
+ outstanding_paged_sources.add(paged_source)
+ logger.info("paged tool skipped name=%s reason=context_budget", name)
+ else:
+ try:
+ result = reg.execute(
+ name, args if isinstance(args, dict) else json.dumps(args)
+ )
+ except Exception as exc: # noqa: BLE001
+ logger.warning(
+ "tool %s execution failed in %.2fs: %s", name, time.time() - t1, exc
+ )
+ result = f"Tool execution failed: {exc}"
+ raw_is_paged = _PAGE_INFO_MARKER in result
+ if raw_is_paged:
+ _, _, next_token = parse_paged_result(result)
+ if next_token and isinstance(args, dict):
+ paged_source = _paged_source_key(
+ name, {**args, "continue_from": next_token}
+ )
+ outstanding_paged_sources.add(paged_source)
+ elif is_builtin_paged:
+ outstanding_paged_sources.discard(paged_source)
# Always truncate per-tool result immediately so it does not bloat
# the context even before we reach the cumulative limit.
result = _safe_tool_result(_trim_tool_result(result, settings.max_tool_result_chars))
@@ -379,21 +423,6 @@ def stream_chat_with_tools(
tool_results.append((name, result))
total_result_len += len(result)
- # If the accumulated tool results are still too large, truncate each
- # one more aggressively to keep ollama's context processing fast.
- if total_result_len > MAX_TOOL_CONTEXT_CHARS:
- budget = MAX_TOOL_CONTEXT_CHARS - len(tool_results) * 200 # reserve footer per tool
- max_per_tool = budget // max(1, len(tool_results))
- logger.warning(
- "tool results too large (%d chars), truncating to %d chars per tool",
- total_result_len,
- max_per_tool,
- )
- trimmed_results: list[tuple[str, str]] = []
- for name, result in tool_results:
- trimmed_results.append((name, _trim_tool_result(result, max_per_tool)))
- tool_results = trimmed_results
-
cumulative_tool_chars += sum(len(r) for _, r in tool_results)
for name, result in tool_results:
messages.append(Message(role="tool", tool_name=name, content=result))
@@ -410,10 +439,19 @@ def stream_chat_with_tools(
"type": "status",
"message": t("status.tool_limit_reached", lang=language),
}
+ limit_instruction = get_prompt("tool_limit_instruction", lang=language)
+ if outstanding_paged_sources:
+ limit_instruction += "\n\n" + get_prompt(
+ "pagination_incomplete_instruction", lang=language
+ )
+ yield {
+ "type": "warning",
+ "message": t("status.pagination_incomplete", lang=language),
+ }
messages.append(
Message(
role="user",
- content=get_prompt("tool_limit_instruction", lang=language),
+ content=limit_instruction,
)
)
t0 = time.time()