diff options
Diffstat (limited to 'src/ollama_web')
| -rw-r--r-- | src/ollama_web/i18n/messages/en.json | 3 | ||||
| -rw-r--r-- | src/ollama_web/i18n/messages/ja.json | 3 | ||||
| -rw-r--r-- | src/ollama_web/llm.py | 116 | ||||
| -rw-r--r-- | src/ollama_web/prompts/en.json | 4 | ||||
| -rw-r--r-- | src/ollama_web/prompts/ja.json | 4 | ||||
| -rw-r--r-- | src/ollama_web/routes/chat.py | 86 | ||||
| -rw-r--r-- | src/ollama_web/routes/sessions.py | 17 | ||||
| -rw-r--r-- | src/ollama_web/tools/__init__.py | 3 | ||||
| -rw-r--r-- | src/ollama_web/tools/fetch.py | 290 | ||||
| -rw-r--r-- | src/ollama_web/tools/helper/__init__.py | 3 | ||||
| -rw-r--r-- | src/ollama_web/tools/helper/pagination.py | 192 | ||||
| -rw-r--r-- | src/ollama_web/tools/helper/pdf.py | 155 | ||||
| -rw-r--r-- | src/ollama_web/tools/pdf.py | 153 | ||||
| -rw-r--r-- | src/ollama_web/tools/scrape.py | 70 |
14 files changed, 714 insertions, 385 deletions
diff --git a/src/ollama_web/i18n/messages/en.json b/src/ollama_web/i18n/messages/en.json index 42bce33..74e0c00 100644 --- a/src/ollama_web/i18n/messages/en.json +++ b/src/ollama_web/i18n/messages/en.json @@ -76,6 +76,7 @@ "connecting": "Connecting…", "disconnected": "Disconnected", "tool_limit_reached": "Tool call limit reached. Summarizing findings…", + "pagination_incomplete": "The source still has unread content. The answer uses only the retrieved portion.", "thinking": "Thinking process", "executing_tool": "Executing {label}…", "running": "Running…", @@ -139,4 +140,4 @@ "invalid_number": "Enter a number." } } -}
\ No newline at end of file +} diff --git a/src/ollama_web/i18n/messages/ja.json b/src/ollama_web/i18n/messages/ja.json index a476ca2..f775ba6 100644 --- a/src/ollama_web/i18n/messages/ja.json +++ b/src/ollama_web/i18n/messages/ja.json @@ -76,6 +76,7 @@ "connecting": "接続中...", "disconnected": "切断しました", "tool_limit_reached": "ツール呼び出し上限に達しました。これまでの調査結果を整理中…", + "pagination_incomplete": "資料に未取得部分があります。取得済み範囲のみで回答します。", "thinking": "思考プロセス", "executing_tool": "{label} を実行中…", "running": "実行中…", @@ -139,4 +140,4 @@ "invalid_number": "数値を入力してください。" } } -}
\ No newline at end of file +} 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() diff --git a/src/ollama_web/prompts/en.json b/src/ollama_web/prompts/en.json index 42e2e68..1d35f52 100644 --- a/src/ollama_web/prompts/en.json +++ b/src/ollama_web/prompts/en.json @@ -2,7 +2,9 @@ "tool_system": "You can use web search, scraping, and file fetch tools. Do not run more searches than necessary in a single response; review the results before conducting the next investigation. Tool results are summarized, so grasp the important points and use them effectively. If a tool result is truncated, you can fetch the continuation using the next_token at the end of the result. Pass the next_token value verbatim to the continue_from parameter. Omit continue_from when no continuation is needed.", "current_time": "Current time: {time}", "tool_limit_instruction": "The tool call limit has been reached. Based on the search results and conversation so far, please summarize the final answer the user requested.", + "pagination_incomplete_instruction": "A paged source still has unread content. Answer only from the retrieved portion and do not claim that the full source was reviewed.", "attachment_prefix": "The following is the content of files attached by the user:", + "attachment_paging": "A PDF body is an initial preview. When has_more is true, pass the attached-file uri and next_token to pdf_to_text for the needed continuation. Do not claim to have read the full file while unread content remains.", "fetch": { "candidate": "Candidate", "title": "Title", @@ -16,4 +18,4 @@ "empty_query": "Empty query.", "invalid_type": "Invalid file type." } -}
\ No newline at end of file +} diff --git a/src/ollama_web/prompts/ja.json b/src/ollama_web/prompts/ja.json index 88af710..6bcba63 100644 --- a/src/ollama_web/prompts/ja.json +++ b/src/ollama_web/prompts/ja.json @@ -2,7 +2,9 @@ "tool_system": "あなたはWeb検索・スクレイピング・ファイル取得ツールを利用できます。一度の回答で必要以上に多くの検索を同時に行わず、結果を確認してから次の調査を行ってください。各ツール結果は要約されて提供されるため、重要なポイントを把握して活用してください。ツール結果が途切れている場合、結果末尾の next_token を使って続きを取得できます。next_token の値は解釈せず、そのまま continue_from パラメータに渡してください。続きが必要ない場合は continue_from を省略してください。", "current_time": "現在時刻: {time}", "tool_limit_instruction": "ツール呼び出しの上限に達しました。これまでの検索結果と会話を元に、ユーザーが求めた回答を最終的にまとめてください。", + "pagination_incomplete_instruction": "ページングされた資料に未取得部分があります。取得済み範囲だけを根拠に回答し、全文を確認したとは述べないでください。", "attachment_prefix": "以下はユーザーが添付したファイルの内容です:", + "attachment_paging": "PDFは先頭プレビューです。has_more: true の場合、attached-file の uri と next_token を pdf_to_text に渡し、必要な範囲を続けて取得してください。未取得部分が残る場合は全文を読んだと主張しないでください。", "fetch": { "candidate": "候補", "title": "タイトル", @@ -16,4 +18,4 @@ "empty_query": "Empty query.", "invalid_type": "Invalid file type." } -}
\ No newline at end of file +} 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 []) diff --git a/src/ollama_web/routes/sessions.py b/src/ollama_web/routes/sessions.py index e1e669c..73f79d6 100644 --- a/src/ollama_web/routes/sessions.py +++ b/src/ollama_web/routes/sessions.py @@ -11,7 +11,7 @@ from starlette.requests import Request from starlette.responses import JSONResponse from ..sessions import _IMAGE_MIMES, _IMAGE_SUFFIXES, SessionStore, is_valid_id -from ..tools.helper.pdf import extract_pdf_text +from ..tools.helper.pdf import extract_pdf_page logger = logging.getLogger("ollama_web.sessions") @@ -127,8 +127,17 @@ async def upload_file(request: Request) -> JSONResponse: # Images are passed to ollama via Message.images (base64), not as text. text = "" elif content_type == "application/pdf" or name.lower().endswith(".pdf"): - # extract_pdf_text now returns (text, has_more, next_page_offset). - text = extract_pdf_text(data)[0] + # Store only a bounded preview. The chat-scoped PDF tool reads the + # original bytes on demand and exposes the lossless continuation. + preview_limit = min( + request.app.state.settings.max_attachment_text_chars, + request.app.state.settings.max_tool_result_chars, + ) + text = extract_pdf_page( + data, + max_chars=preview_limit, + max_size_mb=request.app.state.settings.max_upload_mb, + ).text else: # Try decode as text for text-like uploads. try: @@ -172,4 +181,4 @@ async def clear_messages(request: Request) -> JSONResponse: session = store.clear_messages(session_id) if session is None: return JSONResponse({"error": "Session not found"}, status_code=404) - return JSONResponse({"cleared": True, "session": session})
\ No newline at end of file + return JSONResponse({"cleared": True, "session": session}) diff --git a/src/ollama_web/tools/__init__.py b/src/ollama_web/tools/__init__.py index f2a6b0b..05d4a04 100644 --- a/src/ollama_web/tools/__init__.py +++ b/src/ollama_web/tools/__init__.py @@ -2,7 +2,7 @@ from .fetch import search_and_fetch from .helper.image import resize_image -from .helper.pdf import extract_pdf_text +from .helper.pdf import extract_pdf_page, extract_pdf_text from .pdf import pdf_to_text from .registry import ToolRegistry, all_tool_callables, default_registry from .scrape import scrape_url @@ -12,6 +12,7 @@ __all__ = [ "search_and_fetch", "resize_image", "extract_pdf_text", + "extract_pdf_page", "pdf_to_text", "ToolRegistry", "all_tool_callables", diff --git a/src/ollama_web/tools/fetch.py b/src/ollama_web/tools/fetch.py index 65ec511..6ab17bb 100644 --- a/src/ollama_web/tools/fetch.py +++ b/src/ollama_web/tools/fetch.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import mimetypes import re from pathlib import Path @@ -9,17 +10,24 @@ from typing import Any from ..config import settings from ..prompts import get_prompt, get_prompts -from .helper.pagination import format_paged_result, make_token, parse_token -from .helper.pdf import extract_pdf_text +from .helper.pagination import ( + PageResult, + PageSizeError, + format_page_to_limit, + make_token, + parse_token, + text_page, +) +from .helper.pdf import extract_pdf_page, legacy_pdf_offset_to_cursor from .helper.safe_http import fetch_public_bytes -from .scrape import scrape_url +from .scrape import extract_readable_text from .search import web_search _FetchedFile = dict[str, Any] # Per-session temporary storage for files fetched by search_and_fetch. # The chat route drains this after the tool call finishes. -_fetched_files: dict[str, list[_FetchedFile]] = {} +_fetched_files: dict[str, dict[str, _FetchedFile]] = {} _FILETYPE_SUFFIXES: dict[str, str] = { @@ -37,6 +45,7 @@ _FILETYPE_SUFFIXES: dict[str, str] = { } _TOOL_NAME = "search_and_fetch" +logger = logging.getLogger("ollama_web.tools.fetch") def _slim_matched(matched: list[dict[str, str]]) -> list[dict[str, str]]: @@ -53,12 +62,6 @@ def _slim_matched(matched: list[dict[str, str]]) -> list[dict[str, str]]: return [{"title": m.get("title", ""), "href": m.get("href", "")} for m in matched] -def set_session_context(session_id: str | None) -> None: - """Set the active session id so fetched files can be persisted later.""" - # No-op marker kept for future hooks; storage is keyed by session_id directly. - pass - - def store_fetched_file( session_id: str, name: str, @@ -69,7 +72,8 @@ def store_fetched_file( ) -> None: """Store a fetched file so the chat route can persist it to the session.""" if session_id: - _fetched_files.setdefault(session_id, []).append( + _fetched_files.setdefault(session_id, {}).setdefault( + url, { "name": name, "data": data, @@ -82,7 +86,7 @@ def store_fetched_file( def pop_fetched_files(session_id: str) -> list[_FetchedFile]: """Return and clear any files fetched for the given session.""" - return _fetched_files.pop(session_id, []) + return list(_fetched_files.pop(session_id, {}).values()) def _normalize_file_type(file_type: str) -> str: @@ -118,44 +122,10 @@ def _fetch_bytes(url: str) -> bytes: return data -def _text_from_bytes(url: str, data: bytes, file_type: str, max_chars: int) -> str: - import httpx - - name = Path(httpx.URL(url).path or "file").name or f"file.{file_type}" - if not name or "." not in name: - name = f"file.{file_type}" - - if file_type == "pdf": - text, _, _ = extract_pdf_text(data, max_chars=max_chars, page_offset=0) - return text - - # Decode text-like formats. - try: - text = data.decode("utf-8") - except UnicodeDecodeError: - try: - text = data.decode("cp932") - except UnicodeDecodeError: - text = data.decode("utf-8", errors="replace") - - if file_type in {"html", "htm"}: - text = scrape_url(url, max_chars=max_chars) - - if len(text) > max_chars: - text = text[:max_chars].rsplit(" ", 1)[0] + "\n…[truncated]" - return text - - def _full_text_for_file(url: str, data: bytes, file_type: str) -> str: - """Return the *full* extracted text for a file (no truncation). - - Used by the pagination path so we can slice arbitrary offsets. For PDFs we - extract all pages; for text-like formats we decode the full payload. - """ + """Decode a complete text-like file for character-offset pagination.""" if file_type == "pdf": - # Extract with a very large char budget to get the full text. - text, _, _ = extract_pdf_text(data, max_chars=10_000_000, page_offset=0) - return text + raise ValueError("PDF files must use page-and-character cursor pagination.") try: text = data.decode("utf-8") @@ -166,10 +136,7 @@ def _full_text_for_file(url: str, data: bytes, file_type: str) -> str: text = data.decode("utf-8", errors="replace") if file_type in {"html", "htm"}: - # Reuse scrape_url's cleaning but request a large budget to get the - # full cleaned text. scrape_url itself now supports pagination, but - # here we want the full body for manual slicing. - text = scrape_url(url, max_chars=10_000_000) + text = extract_readable_text(data) return text @@ -223,19 +190,43 @@ def search_and_fetch( payload = parse_token(continue_from, _TOOL_NAME) except ValueError as exc: return f"Invalid continuation token: {exc}" - resumed_matched = payload.get("matched", []) - if not isinstance(resumed_matched, list) or not resumed_matched: + raw_matched = payload.get("matched", []) + if not isinstance(raw_matched, list) or not raw_matched: return ( get_prompt("fetch.no_results", fallback="") .replace("{file_type}", ft) .replace("{query}", query) ) - file_idx = int(payload.get("file_idx", 0)) - file_offset = int(payload.get("offset", 0)) + resumed_matched = [ + {"title": str(item.get("title", "")), "href": str(item.get("href", ""))} + for item in raw_matched + if isinstance(item, dict) + ] + if payload.get("v") == 2 and payload.get("file_type") != ft: + return "Invalid continuation token: file type does not match." + try: + file_idx = int(payload.get("file_idx", 0)) + file_offset = int(payload.get("file_offset", payload.get("offset", 0))) + pdf_page_index = int(payload.get("pdf_page_index", 0)) + pdf_page_char_offset = int(payload.get("pdf_page_char_offset", 0)) + except (TypeError, ValueError) as exc: + return f"Invalid continuation token: cursor is not an integer: {exc}" + legacy_pdf_offset = ( + file_offset + if payload.get("v") == 1 and "pdf_page_index" not in payload + else None + ) + if min(file_idx, file_offset, pdf_page_index, pdf_page_char_offset) < 0: + return "Invalid continuation token: cursor values must not be negative." + if not resumed_matched or file_idx >= len(resumed_matched): + return "Invalid continuation token: file index is out of range." return _render_file_page( matched=resumed_matched, file_idx=file_idx, file_offset=file_offset, + pdf_page_index=pdf_page_index, + pdf_page_char_offset=pdf_page_char_offset, + legacy_pdf_offset=legacy_pdf_offset, ft=ft, per_file_chars=per_file_chars, session_id=session_id, @@ -265,6 +256,9 @@ def search_and_fetch( matched=matched, file_idx=0, file_offset=0, + pdf_page_index=0, + pdf_page_char_offset=0, + legacy_pdf_offset=None, ft=ft, per_file_chars=per_file_chars, session_id=session_id, @@ -277,6 +271,9 @@ def _render_file_page( matched: list[dict[str, str]], file_idx: int, file_offset: int, + pdf_page_index: int, + pdf_page_char_offset: int, + legacy_pdf_offset: int | None, ft: str, per_file_chars: int, session_id: str | None, @@ -288,8 +285,7 @@ def _render_file_page( re-run the web search. """ if file_idx >= len(matched): - # All candidates exhausted. - return format_paged_result("", has_more=False, next_token=None) + return "" item = matched[file_idx] url = item.get("href", "") @@ -305,59 +301,157 @@ def _render_file_page( f"=== {p['candidate']} {file_idx + 1} ===\n" f"URL: {url}\n{p['title']}: {title}\n{p['status']}: {p['fetch_failed']} ({exc})\n" ) - next_token = make_token( - _TOOL_NAME, - url, - 0, - matched=_slim_matched(matched), - file_idx=file_idx + 1, - file_offset=0, + has_more = file_idx + 1 < len(matched) + cursor = { + "file_idx": file_idx + 1, + "file_offset": 0, + "pdf_page_index": 0, + "pdf_page_char_offset": 0, + } + page = PageResult(body, cursor, cursor, has_more) + return format_page_to_limit( + lambda _budget: page, + lambda next_cursor: _make_fetch_token(matched, next_cursor, ft), + requested_chars=per_file_chars, + result_limit=settings.max_tool_result_chars, ) - return format_paged_result(body, has_more=True, next_token=next_token) - - full_text = _full_text_for_file(url, data, ft) - page_text = full_text[file_offset : file_offset + per_file_chars] - has_more_in_file = len(full_text) > file_offset + per_file_chars import httpx mime = _guess_mime(Path(httpx.URL(url).path or f"file.{ft}").name, ft) name = Path(httpx.URL(url).path or f"file.{ft}").name - if session_id: - store_fetched_file(session_id, name, data, mime, page_text, url) - - body = ( + prefix = ( f"=== {p['candidate']} {file_idx + 1} ===\n" f"URL: {url}\n" f"{p['title']}: {title}\n" f"{p['type']}: {mime}\n" f"{p['size']}: {len(data)} bytes\n" - f"{p['chars']}: {len(page_text)}\n" - f"{p['separator']}\n{page_text}\n" + f"{p['separator']}\n" ) - if has_more_in_file: - # Continue within the same file. - next_token = make_token( - _TOOL_NAME, - url, - file_offset + per_file_chars, - matched=_slim_matched(matched), - file_idx=file_idx, - file_offset=file_offset + per_file_chars, - ) - return format_paged_result(body, has_more=True, next_token=next_token) + if ft == "pdf": + if legacy_pdf_offset is not None: + try: + pdf_page_index, pdf_page_char_offset = legacy_pdf_offset_to_cursor( + data, legacy_pdf_offset + ) + except Exception as exc: # noqa: BLE001 + return f"Invalid continuation token: {exc}" + preview = extract_pdf_page(data, max_chars=min(per_file_chars, 1000)).text + + def page_factory(body_budget: int) -> PageResult: + source_page = extract_pdf_page( + data, + max_chars=body_budget, + page_index=pdf_page_index, + page_char_offset=pdf_page_char_offset, + ) + if source_page.has_more: + next_cursor = { + "file_idx": file_idx, + "file_offset": 0, + "pdf_page_index": source_page.next_cursor["page_index"], + "pdf_page_char_offset": source_page.next_cursor["page_char_offset"], + } + has_more = True + else: + next_cursor = { + "file_idx": file_idx + 1, + "file_offset": 0, + "pdf_page_index": 0, + "pdf_page_char_offset": 0, + } + has_more = file_idx + 1 < len(matched) + return PageResult( + source_page.text, + { + "file_idx": file_idx, + "file_offset": 0, + "pdf_page_index": pdf_page_index, + "pdf_page_char_offset": pdf_page_char_offset, + }, + next_cursor, + has_more, + source_page.warnings, + ) - # Move to the next candidate file. - if file_idx + 1 < len(matched): - next_token = make_token( - _TOOL_NAME, - url, - 0, - matched=_slim_matched(matched), - file_idx=file_idx + 1, - file_offset=0, + else: + full_text = _full_text_for_file(url, data, ft) + preview = full_text[: min(per_file_chars, 1000)] + + def page_factory(body_budget: int) -> PageResult: + source_page = text_page(full_text, file_offset, body_budget) + if source_page.has_more: + next_cursor = { + "file_idx": file_idx, + "file_offset": source_page.next_cursor["offset"], + "pdf_page_index": 0, + "pdf_page_char_offset": 0, + } + has_more = True + else: + next_cursor = { + "file_idx": file_idx + 1, + "file_offset": 0, + "pdf_page_index": 0, + "pdf_page_char_offset": 0, + } + has_more = file_idx + 1 < len(matched) + return PageResult( + source_page.text, + { + "file_idx": file_idx, + "file_offset": file_offset, + "pdf_page_index": 0, + "pdf_page_char_offset": 0, + }, + next_cursor, + has_more, + ) + + if session_id: + store_fetched_file(session_id, name, data, mime, preview, url) + + try: + result = format_page_to_limit( + page_factory, + lambda cursor: _make_fetch_token(matched, cursor, ft), + requested_chars=per_file_chars, + result_limit=settings.max_tool_result_chars, + prefix=prefix, + suffix="\n", + ) + logger.info( + ( + "fetch page type=%s file_idx=%d file_offset=%d pdf_page_index=%d " + "pdf_page_char_offset=%d result_chars=%d has_more=%s" + ), + ft, + file_idx, + file_offset, + pdf_page_index, + pdf_page_char_offset, + len(result), + "---page-info---" in result, ) - return format_paged_result(body, has_more=True, next_token=next_token) + return result + except (PageSizeError, ValueError) as exc: + return f"File pagination failed: {exc}" + - return format_paged_result(body, has_more=False, next_token=None)
\ No newline at end of file +def _make_fetch_token( + matched: list[dict[str, str]], cursor: dict[str, int], file_type: str +) -> str: + file_idx = cursor["file_idx"] + url = matched[file_idx].get("href", "") if file_idx < len(matched) else "completed" + return make_token( + _TOOL_NAME, + url, + cursor["file_offset"], + matched=_slim_matched(matched), + file_idx=file_idx, + file_offset=cursor["file_offset"], + pdf_page_index=cursor["pdf_page_index"], + pdf_page_char_offset=cursor["pdf_page_char_offset"], + file_type=file_type, + ) diff --git a/src/ollama_web/tools/helper/__init__.py b/src/ollama_web/tools/helper/__init__.py index f201101..198a300 100644 --- a/src/ollama_web/tools/helper/__init__.py +++ b/src/ollama_web/tools/helper/__init__.py @@ -1,12 +1,13 @@ """Internal helper functions for tools.""" from .image import resize_image -from .pdf import extract_pdf_text +from .pdf import extract_pdf_page, extract_pdf_text from .safe_http import UnsafeURL, fetch_public_bytes, validate_public_http_url __all__ = [ "UnsafeURL", "extract_pdf_text", + "extract_pdf_page", "fetch_public_bytes", "resize_image", "validate_public_http_url", diff --git a/src/ollama_web/tools/helper/pagination.py b/src/ollama_web/tools/helper/pagination.py index 748338b..9d17823 100644 --- a/src/ollama_web/tools/helper/pagination.py +++ b/src/ollama_web/tools/helper/pagination.py @@ -1,32 +1,17 @@ -"""Pagination helpers for paged tool results. - -This module provides an opaque continuation-token mechanism so that LLM-driven -tools can return large content in pages without requiring the model to remember -numeric offsets. The token encodes the resumption state (URL + offset) as -base64url(JSON) and is meant to be passed back verbatim by the model via the -``continue_from`` parameter. - -Design rationale: -- The token is opaque: the LLM should copy the ``next_token`` value from a - previous result and pass it to ``continue_from`` without interpreting it. -- The state is encoded in the token itself, so the server stays stateless and - does not need to cache previous results. -- A ``tool`` field guards against a token issued by one tool being replayed - against a different tool. -""" +"""Lossless pagination helpers for tool results.""" from __future__ import annotations import base64 import json +from collections.abc import Callable +from dataclasses import dataclass, field from typing import Any -# Token format version. Bump when the payload structure changes in a -# backwards-incompatible way; ``parse_token`` rejects unknown versions. -_TOKEN_VERSION = 1 +_TOKEN_VERSION = 2 +_SUPPORTED_TOKEN_VERSIONS = {1, 2} +MAX_TOKEN_CHARS = 8192 -# Marker that separates the page body from the page-info footer in tool -# results. Kept in sync with ``llm._trim_tool_result``. PAGE_INFO_MARKER = "\n---page-info---" @@ -34,29 +19,32 @@ class InvalidTokenError(ValueError): """Raised when a continuation token is malformed or invalid.""" +class PageSizeError(ValueError): + """Raised when page metadata alone cannot fit in the result budget.""" + + +@dataclass(frozen=True) +class PageResult: + """One lossless source slice and the cursor immediately after that slice.""" + + text: str + start_cursor: dict[str, int] + next_cursor: dict[str, int] + has_more: bool + warnings: tuple[str, ...] = field(default_factory=tuple) + + def _b64url_encode(data: bytes) -> str: return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=") def _b64url_decode(token: str) -> bytes: - # Re-add any stripped padding before decoding. padding = "=" * (-len(token) % 4) return base64.urlsafe_b64decode(token + padding) def make_token(tool: str, url: str, offset: int, **extra: Any) -> str: - """Create an opaque continuation token. - - Args: - tool: Name of the issuing tool (e.g. ``"scrape_url"``). - url: The URL the content was fetched from. - offset: The resumption offset (character or page index, tool-specific). - **extra: Additional tool-specific fields to embed in the token payload - (e.g. ``matched`` candidate list for ``search_and_fetch``). - - Returns: - A base64url-encoded JSON string. - """ + """Create a version-2 opaque continuation token.""" payload: dict[str, Any] = { "v": _TOKEN_VERSION, "tool": tool, @@ -64,36 +52,23 @@ def make_token(tool: str, url: str, offset: int, **extra: Any) -> str: "offset": int(offset), } payload.update(extra) - # ensure_ascii=True keeps the token pure ASCII so truncation of the - # token by the model or tool-result trimming cannot split a multibyte - # UTF-8 sequence (which would raise UnicodeDecodeError on decode). raw = json.dumps(payload, ensure_ascii=True, separators=(",", ":")).encode("ascii") - return _b64url_encode(raw) + token = _b64url_encode(raw) + if len(token) > MAX_TOKEN_CHARS: + raise PageSizeError(f"Continuation token exceeds {MAX_TOKEN_CHARS} characters.") + return token def parse_token(token: str | None, expected_tool: str) -> dict[str, Any]: - """Validate and decode a continuation token. - - Args: - token: The opaque token string (typically from ``continue_from``). - ``None`` or an empty string raises :class:`InvalidTokenError`. - expected_tool: The tool name that should appear in the token's ``tool`` - field. Mismatches raise :class:`InvalidTokenError`. - - Returns: - The decoded payload dict (includes ``url``, ``offset`` and any extra - fields the issuing tool embedded). - - Raises: - InvalidTokenError: If the token is empty, not valid base64, not valid - JSON, missing required fields, has an unsupported version, or names a - different tool than ``expected_tool``. - """ + """Validate and decode a version-1 or version-2 continuation token.""" if not token or not token.strip(): raise InvalidTokenError("Empty continuation token.") + token = token.strip() + if len(token) > MAX_TOKEN_CHARS: + raise InvalidTokenError(f"Continuation token exceeds {MAX_TOKEN_CHARS} characters.") try: - raw = _b64url_decode(token.strip()) + raw = _b64url_decode(token) except (ValueError, base64.binascii.Error) as exc: # type: ignore[attr-defined] raise InvalidTokenError(f"Token is not valid base64url: {exc}") from exc @@ -104,77 +79,86 @@ def parse_token(token: str | None, expected_tool: str) -> dict[str, Any]: if not isinstance(payload, dict): raise InvalidTokenError("Token payload is not a JSON object.") - - version = payload.get("v") - if version != _TOKEN_VERSION: - raise InvalidTokenError(f"Unsupported token version: {version!r}") - - tool = payload.get("tool") - if tool != expected_tool: - raise InvalidTokenError(f"Token was issued for tool {tool!r}, expected {expected_tool!r}.") - + if payload.get("v") not in _SUPPORTED_TOKEN_VERSIONS: + raise InvalidTokenError(f"Unsupported token version: {payload.get('v')!r}") + if payload.get("tool") != expected_tool: + raise InvalidTokenError( + f"Token was issued for tool {payload.get('tool')!r}, expected {expected_tool!r}." + ) if "url" not in payload or "offset" not in payload: raise InvalidTokenError("Token payload missing 'url' or 'offset'.") - + if not isinstance(payload["url"], str) or not payload["url"]: + raise InvalidTokenError("Token 'url' is not a non-empty string.") try: payload["offset"] = int(payload["offset"]) except (TypeError, ValueError) as exc: raise InvalidTokenError(f"Token 'offset' is not an integer: {exc}") from exc - + if payload["offset"] < 0: + raise InvalidTokenError("Token 'offset' must not be negative.") return payload def format_paged_result(text: str, *, has_more: bool, next_token: str | None) -> str: - """Wrap page body with a page-info footer. - - The footer is appended after :data:`PAGE_INFO_MARKER` so that - ``llm._trim_tool_result`` can protect it from truncation. When - ``has_more`` is False the ``next_token`` line is omitted. - - Args: - text: The page body text. - has_more: Whether more content is available. - next_token: The continuation token to include when ``has_more`` is True. - - Returns: - The formatted result string with the page-info footer. - """ + """Append the protected continuation footer when unread content remains.""" if not has_more: - # No footer needed when there is nothing more to fetch. Keeping the - # body unchanged preserves backward compatibility for short results. return text - if not next_token: - # Defensive: has_more without a token is a programming error; fall back - # to no footer to avoid confusing the model. - return text - - footer = f"{PAGE_INFO_MARKER}\nhas_more: true\nnext_token: {next_token}" - return f"{text}{footer}" + raise PageSizeError("A continuation token is required when has_more is true.") + return f"{text}{PAGE_INFO_MARKER}\nhas_more: true\nnext_token: {next_token}" + + +def format_page_to_limit( + page_factory: Callable[[int], PageResult], + token_factory: Callable[[dict[str, int]], str], + *, + requested_chars: int, + result_limit: int, + prefix: str = "", + suffix: str = "", +) -> str: + """Render the largest lossless page whose complete envelope fits the limit.""" + body_budget = max(1, min(requested_chars, result_limit)) + for _ in range(8): + page = page_factory(body_budget) + warning_text = "".join(f"[warning] {warning}\n" for warning in page.warnings) + body = f"{prefix}{warning_text}{page.text}{suffix}" + token = token_factory(page.next_cursor) if page.has_more else None + rendered = format_paged_result(body, has_more=page.has_more, next_token=token) + if len(rendered) <= result_limit: + return rendered + overflow = len(rendered) - result_limit + new_budget = body_budget - overflow - 16 + if new_budget >= body_budget: + new_budget = body_budget - 1 + if new_budget <= 0: + break + body_budget = new_budget + raise PageSizeError("Paged result metadata exceeds the configured result limit.") + + +def text_page(text: str, offset: int, max_chars: int) -> PageResult: + """Return a lossless character-offset page from ``text``.""" + if offset < 0 or offset > len(text): + raise ValueError("Text pagination offset is out of range.") + end = min(offset + max_chars, len(text)) + return PageResult( + text=text[offset:end], + start_cursor={"offset": offset}, + next_cursor={"offset": end}, + has_more=end < len(text), + ) def parse_paged_result(result: str) -> tuple[str, bool, str | None]: - """Split a paged result into (body, has_more, next_token). - - This is the inverse of :func:`format_paged_result` and is mainly useful in - tests and for chaining tool results. - - Args: - result: The formatted result string from :func:`format_paged_result`. - - Returns: - A tuple of (body_text, has_more, next_token_or_None). - """ + """Split a formatted result into body, has-more flag, and token.""" if PAGE_INFO_MARKER not in result: return result, False, None - body, _, footer = result.partition(PAGE_INFO_MARKER) has_more = "has_more: true" in footer next_token: str | None = None if has_more: for line in footer.splitlines(): - line = line.strip() - if line.startswith("next_token:"): + if line.strip().startswith("next_token:"): next_token = line.split(":", 1)[1].strip() break return body, has_more, next_token diff --git a/src/ollama_web/tools/helper/pdf.py b/src/ollama_web/tools/helper/pdf.py index 7b9fdac..243a837 100644 --- a/src/ollama_web/tools/helper/pdf.py +++ b/src/ollama_web/tools/helper/pdf.py @@ -1,4 +1,4 @@ -"""PDF text extraction helpers.""" +"""Lossless PDF text extraction helpers.""" from __future__ import annotations @@ -7,78 +7,117 @@ import io from pypdf import PdfReader from ...config import settings +from .pagination import PageResult -def extract_pdf_text( +def extract_pdf_page( data: bytes, - max_chars: int | None = None, - page_offset: int = 0, -) -> tuple[str, bool, int]: - """Extract text from PDF bytes, starting at ``page_offset``. - - Args: - data: Raw PDF bytes. - max_chars: Maximum characters to return. Defaults to - ``settings.fetch_max_chars``. - page_offset: Zero-based index of the first page to extract. Pages before - this index are skipped. Used by the pagination layer to resume. - - Returns: - A tuple ``(text, has_more, next_page_offset)`` where: - - ``text`` is the extracted text for this page (truncated to - ``max_chars``). - - ``has_more`` is True when there are remaining pages after the - extracted slice. - - ``next_page_offset`` is the page index to resume from on the next - call (only meaningful when ``has_more`` is True). - """ + *, + max_chars: int, + page_index: int = 0, + page_char_offset: int = 0, + max_size_mb: int | None = None, +) -> PageResult: + """Extract a PDF slice using a page-and-character continuation cursor.""" + start = {"page_index": page_index, "page_char_offset": page_char_offset} if not data: - return "Empty PDF data.", False, 0 - - limit = max_chars if max_chars is not None and max_chars > 0 else settings.fetch_max_chars - - if len(data) > settings.max_fetch_mb * 1024 * 1024: - return "PDF exceeds the configured size limit.", False, 0 + return PageResult("Empty PDF data.", start, start, False) + size_limit_mb = settings.max_fetch_mb if max_size_mb is None else max_size_mb + if len(data) > size_limit_mb * 1024 * 1024: + return PageResult("PDF exceeds the configured size limit.", start, start, False) + if page_index < 0 or page_char_offset < 0: + return PageResult("Invalid PDF continuation cursor.", start, start, False) try: reader = PdfReader(stream=io.BytesIO(data)) except Exception as exc: # noqa: BLE001 - return f"Failed to parse PDF: {exc}", False, 0 + return PageResult(f"Failed to parse PDF: {exc}", start, start, False) total_pages = len(reader.pages) - if page_offset < 0: - page_offset = 0 - if page_offset >= total_pages: - return "No text could be extracted from the PDF.", False, 0 - - # Cap the number of pages processed per call to max_pdf_pages. - end = min(page_offset + settings.max_pdf_pages, total_pages) + if page_index > total_pages or (page_index == total_pages and page_char_offset): + return PageResult("Invalid PDF continuation cursor.", start, start, False) + if page_index == total_pages: + return PageResult("", start, start, False) parts: list[str] = [] - collected = 0 - last_extracted_page = page_offset - for page_index in range(page_offset, end): + warnings: list[str] = [] + remaining = max(1, max_chars) + current_page = page_index + current_char = page_char_offset + pages_processed = 0 + + while current_page < total_pages and pages_processed < settings.max_pdf_pages: try: - text = reader.pages[page_index].extract_text() - except Exception: # noqa: BLE001 + page_text = reader.pages[current_page].extract_text() or "" + except Exception as exc: # noqa: BLE001 + detail = str(exc).replace("\n", " ")[:120] + warnings.append(f"PDF page {current_page + 1} could not be extracted: {detail}") + if current_page + 1 < total_pages: + parts.append("\n\n") + current_page += 1 + current_char = 0 + pages_processed += 1 continue - if text: - parts.append(text) - collected += len(text) - last_extracted_page = page_index - # Stop as soon as we reach the per-call character budget so the page - # boundary for the next token is accurate. - if collected >= limit: + + if current_char > len(page_text): + return PageResult("Invalid PDF continuation cursor.", start, start, False) + + available = page_text[current_char:] + take = min(remaining, len(available)) + if take: + parts.append(available[:take]) + current_char += take + remaining -= take + + if current_char < len(page_text): + break + + if current_page + 1 < total_pages: + parts.append("\n\n") + current_page += 1 + current_char = 0 + pages_processed += 1 + if remaining <= 0: break - full = "\n\n".join(parts) - if len(full) > limit: - full = full[:limit].rsplit(" ", 1)[0] + "\n…[truncated]" + next_cursor = {"page_index": current_page, "page_char_offset": current_char} + has_more = current_page < total_pages + text = "".join(parts) + if not text and not warnings and not has_more: + text = "No text could be extracted from the PDF." + return PageResult(text, start, next_cursor, has_more, tuple(warnings)) + + +def legacy_pdf_offset_to_cursor(data: bytes, flat_offset: int) -> tuple[int, int]: + """Translate a v1 ``\n\n``-joined character offset to the v2 PDF cursor.""" + if flat_offset < 0: + raise ValueError("Legacy PDF offset must not be negative.") + reader = PdfReader(stream=io.BytesIO(data)) + remaining = flat_offset + for page_index, page in enumerate(reader.pages): + try: + page_text = page.extract_text() or "" + except Exception: # noqa: BLE001 + page_text = "" + if remaining <= len(page_text): + return page_index, remaining + remaining -= len(page_text) + if page_index + 1 < len(reader.pages): + if remaining <= 2: + return page_index + 1, 0 + remaining -= 2 + if remaining == 0: + return len(reader.pages), 0 + raise ValueError("Legacy PDF offset is out of range.") - # has_more when there are pages beyond the last one we extracted. - has_more = (last_extracted_page + 1) < total_pages - next_page_offset = last_extracted_page + 1 if has_more else 0 - if not full: - return "No text could be extracted from the PDF.", has_more, next_page_offset - return full, has_more, next_page_offset +def extract_pdf_text( + data: bytes, + max_chars: int | None = None, + page_offset: int = 0, +) -> tuple[str, bool, int]: + """Backward-compatible wrapper for page-only internal callers.""" + limit = max_chars if max_chars is not None and max_chars > 0 else settings.fetch_max_chars + page = extract_pdf_page(data, max_chars=limit, page_index=page_offset) + text = "".join(f"[warning] {warning}\n" for warning in page.warnings) + page.text + return text, page.has_more, page.next_cursor["page_index"] if page.has_more else 0 diff --git a/src/ollama_web/tools/pdf.py b/src/ollama_web/tools/pdf.py index 9fdb497..4faf8ac 100644 --- a/src/ollama_web/tools/pdf.py +++ b/src/ollama_web/tools/pdf.py @@ -2,67 +2,148 @@ from __future__ import annotations +import logging +from collections.abc import Callable +from typing import Any + from ..config import settings -from .helper.pagination import format_paged_result, make_token, parse_token -from .helper.pdf import extract_pdf_text +from .helper.pagination import ( + PageResult, + PageSizeError, + format_page_to_limit, + make_token, + parse_token, +) +from .helper.pdf import extract_pdf_page from .helper.safe_http import UnsafeURL, fetch_public_bytes _TOOL_NAME = "pdf_to_text" +_ATTACHMENT_PREFIX = "attachment:" +logger = logging.getLogger("ollama_web.tools.pdf") +AttachmentLoader = Callable[[str], bytes | None] -def pdf_to_text( - url_or_path: str, max_chars: int | None = None, continue_from: str | None = None -) -> str: - """Extract text from a local PDF path or remote URL. - The text is returned one page at a time. When more pages remain, a - ``next_token`` is appended that can be passed back via ``continue_from`` - to fetch the next batch of pages. +def _cursor_value(payload: dict[str, Any], key: str, fallback: int = 0) -> int: + raw = payload.get(key, fallback) + if isinstance(raw, bool) or not isinstance(raw, (int, str)): + raise ValueError(f"Token {key!r} is not an integer.") + try: + value = int(raw) + except (TypeError, ValueError) as exc: + raise ValueError(f"Token {key!r} is not an integer.") from exc + if value < 0: + raise ValueError(f"Token {key!r} must not be negative.") + return value - Args: - url_or_path: File path or URL pointing to a PDF. - max_chars: Maximum characters to return per page. - continue_from: Opaque continuation token returned in a previous call's - ``next_token``. When provided, extraction resumes from the encoded - page offset. - - Returns: - Extracted plain text for this page, followed by a ``---page-info---`` - footer when more pages are available, or an error message. - """ + +def _pdf_to_text( + url_or_path: str, + max_chars: int | None, + continue_from: str | None, + attachment_loader: AttachmentLoader | None, +) -> str: if not url_or_path or not url_or_path.strip(): return "Invalid PDF source." src = url_or_path.strip() - - # Resolve the resumption page offset from the continuation token, if any. - page_offset = 0 + page_index = 0 + page_char_offset = 0 if continue_from: try: payload = parse_token(continue_from, _TOOL_NAME) + src = str(payload.get("url") or src) + # Version 1 used offset as the PDF page index. + page_index = _cursor_value(payload, "page_index", int(payload["offset"])) + page_char_offset = _cursor_value(payload, "page_char_offset") except ValueError as exc: return f"Invalid continuation token: {exc}" - src = str(payload.get("url") or src) - page_offset = int(payload.get("offset", 0)) - if page_offset < 0: - page_offset = 0 - data: bytes | None = None - if src.startswith("http://") or src.startswith("https://"): + if src.startswith(_ATTACHMENT_PREFIX): + file_id = src.removeprefix(_ATTACHMENT_PREFIX) + data = attachment_loader(file_id) if attachment_loader is not None else None + if data is None: + return "Attachment PDF is unavailable in this session." + source_kind = "attachment" + elif src.startswith("http://") or src.startswith("https://"): try: - data, _, _ = fetch_public_bytes(src, timeout=settings.scrape_timeout) + data, final_url, _ = fetch_public_bytes(src, timeout=settings.scrape_timeout) + src = final_url or src except UnsafeURL as exc: return f"Blocked URL: {exc}" except Exception as exc: # noqa: BLE001 return f"Failed to fetch PDF: {exc}" + source_kind = "remote" else: return "Only http(s) PDF URLs are supported." - if data is None: - return "No PDF data loaded." + requested = max_chars if max_chars is not None and max_chars > 0 else settings.fetch_max_chars + + def page_factory(body_budget: int) -> PageResult: + return extract_pdf_page( + data, + max_chars=body_budget, + page_index=page_index, + page_char_offset=page_char_offset, + max_size_mb=(settings.max_upload_mb if source_kind == "attachment" else None), + ) + + def token_factory(cursor: dict[str, int]) -> str: + return make_token( + _TOOL_NAME, + src, + cursor["page_index"], + page_index=cursor["page_index"], + page_char_offset=cursor["page_char_offset"], + ) + + try: + result = format_page_to_limit( + page_factory, + token_factory, + requested_chars=requested, + result_limit=settings.max_tool_result_chars, + ) + except PageSizeError as exc: + return f"PDF pagination failed: {exc}" - text, has_more, next_page_offset = extract_pdf_text( - data, max_chars=max_chars, page_offset=page_offset + logger.info( + "pdf page source=%s page_index=%d page_char_offset=%d result_chars=%d has_more=%s", + source_kind, + page_index, + page_char_offset, + len(result), + "---page-info---" in result, ) - next_token = make_token(_TOOL_NAME, src, next_page_offset) if has_more else None - return format_paged_result(text, has_more=has_more, next_token=next_token) + return result + + +def pdf_to_text( + url_or_path: str, max_chars: int | None = None, continue_from: str | None = None +) -> str: + """Extract a losslessly paged PDF from an HTTP(S) URL. + + Args: + url_or_path: HTTP(S) PDF URL. In chat, a session attachment URI may also + be supplied by the attachment context. Arbitrary filesystem paths are + never accepted. + max_chars: Requested maximum source characters for this page. The tool + may return fewer characters so metadata and its token remain intact. + continue_from: Opaque continuation token from the preceding result. + """ + return _pdf_to_text(url_or_path, max_chars, continue_from, None) + + +def make_session_pdf_tool(attachment_loader: AttachmentLoader) -> Callable[..., str]: + """Create a session-scoped ``pdf_to_text`` supporting attachment IDs.""" + + def session_pdf_to_text( + url_or_path: str, + max_chars: int | None = None, + continue_from: str | None = None, + ) -> str: + return _pdf_to_text(url_or_path, max_chars, continue_from, attachment_loader) + + session_pdf_to_text.__name__ = _TOOL_NAME + session_pdf_to_text.__doc__ = pdf_to_text.__doc__ + return session_pdf_to_text diff --git a/src/ollama_web/tools/scrape.py b/src/ollama_web/tools/scrape.py index 39dab83..2f85c38 100644 --- a/src/ollama_web/tools/scrape.py +++ b/src/ollama_web/tools/scrape.py @@ -2,13 +2,37 @@ from __future__ import annotations +import logging + from bs4 import BeautifulSoup from ..config import settings -from .helper.pagination import format_paged_result, make_token, parse_token +from .helper.pagination import ( + PageSizeError, + format_page_to_limit, + make_token, + parse_token, + text_page, +) from .helper.safe_http import UnsafeURL, fetch_public_bytes _TOOL_NAME = "scrape_url" +logger = logging.getLogger("ollama_web.tools.scrape") + + +def extract_readable_text(data: bytes, content_type: str = "") -> str: + """Decode and normalize the complete readable body of an HTML payload.""" + encoding = "utf-8" + if "charset=" in content_type: + encoding = content_type.rsplit("charset=", 1)[-1].split(";", 1)[0].strip() + html = data.decode(encoding or "utf-8", errors="replace") + soup = BeautifulSoup(html, "html.parser") + for tag in soup(["script", "style", "noscript", "template", "head", "nav", "footer", "aside"]): + tag.decompose() + container = soup.find("article") or soup.find("main") or soup + text = container.get_text(separator="\n") + lines = [line.strip() for line in text.splitlines()] + return "\n".join(line for line in lines if line) def scrape_url(url: str, max_chars: int | None = None, continue_from: str | None = None) -> str: @@ -47,43 +71,33 @@ def scrape_url(url: str, max_chars: int | None = None, continue_from: str | None # The token's URL takes precedence; it identifies the page being paged. url = str(payload.get("url") or url) offset = int(payload.get("offset", 0)) - if offset < 0: - offset = 0 try: data, final_url, content_type = fetch_public_bytes(url, timeout=settings.scrape_timeout) - encoding = "utf-8" - if "charset=" in content_type: - encoding = content_type.rsplit("charset=", 1)[-1].split(";", 1)[0].strip() - html = data.decode(encoding or "utf-8", errors="replace") url = final_url except UnsafeURL as exc: return f"Blocked URL: {exc}" except Exception as exc: # noqa: BLE001 return f"Failed to fetch {url}: {exc}" - soup = BeautifulSoup(html, "html.parser") - - # Remove non-content tags. - for tag in soup(["script", "style", "noscript", "template", "head", "nav", "footer", "aside"]): - tag.decompose() - - # Prefer article/main content when available. - container = soup.find("article") or soup.find("main") or soup - text = container.get_text(separator="\n") - # Collapse blank lines and trailing whitespace per line. - lines = [ln.strip() for ln in text.splitlines()] - cleaned = "\n".join(ln for ln in lines if ln) + cleaned = extract_readable_text(data, content_type) if not cleaned: return "No readable text content found." - # Slice the requested page from the full cleaned text. - page = cleaned[offset : offset + limit] - if not page: - # Offset is past the end; nothing left to return. - return format_paged_result("", has_more=False, next_token=None) - - has_more = len(cleaned) > offset + limit - next_token = make_token(_TOOL_NAME, url, offset + limit) if has_more else None - return format_paged_result(page, has_more=has_more, next_token=next_token) + try: + result = format_page_to_limit( + lambda body_budget: text_page(cleaned, offset, body_budget), + lambda cursor: make_token(_TOOL_NAME, url, cursor["offset"]), + requested_chars=limit, + result_limit=settings.max_tool_result_chars, + ) + logger.info( + "scrape page offset=%d result_chars=%d has_more=%s", + offset, + len(result), + "---page-info---" in result, + ) + return result + except (PageSizeError, ValueError) as exc: + return f"Scrape pagination failed: {exc}" |
