diff options
| author | tanaka takashi <isazaka@gmail.com> | 2026-06-23 18:37:46 +0900 |
|---|---|---|
| committer | tanaka takashi <isazaka@gmail.com> | 2026-06-23 18:37:46 +0900 |
| commit | 21b59cc1d0928dc04dcf5eac24470aaa05013b2d (patch) | |
| tree | 9888aa23aafea3907ce96eb958a06d4369dad86e /src/ollama_web/tools | |
| parent | de0513e7cb0593b1aa141e367ff4da7156d6110f (diff) | |
基本機能実装
Diffstat (limited to 'src/ollama_web/tools')
| -rw-r--r-- | src/ollama_web/tools/__init__.py | 1 | ||||
| -rw-r--r-- | src/ollama_web/tools/registry.py | 81 | ||||
| -rw-r--r-- | src/ollama_web/tools/scrape.py | 59 | ||||
| -rw-r--r-- | src/ollama_web/tools/search.py | 45 |
4 files changed, 186 insertions, 0 deletions
diff --git a/src/ollama_web/tools/__init__.py b/src/ollama_web/tools/__init__.py new file mode 100644 index 0000000..c034c94 --- /dev/null +++ b/src/ollama_web/tools/__init__.py @@ -0,0 +1 @@ +"""Tool package for ollama-web."""
\ No newline at end of file diff --git a/src/ollama_web/tools/registry.py b/src/ollama_web/tools/registry.py new file mode 100644 index 0000000..92b321c --- /dev/null +++ b/src/ollama_web/tools/registry.py @@ -0,0 +1,81 @@ +"""Registry of tools exposed to the LLM.""" + +from __future__ import annotations + +import json +from collections.abc import Callable, Sequence +from dataclasses import dataclass, field + +from .scrape import scrape_url +from .search import web_search + + +@dataclass +class ToolRegistry: + """A simple registry mapping tool names to callable implementations.""" + + _tools: dict[str, Callable[..., object]] = field(default_factory=dict) + + def register(self, func: Callable[..., object]) -> Callable[..., object]: + """Register a callable under its ``__name__``.""" + self._tools[func.__name__] = func + return func + + @property + def names(self) -> list[str]: + return list(self._tools.keys()) + + @property + def callables(self) -> list[Callable[..., object]]: + return list(self._tools.values()) + + def get(self, name: str) -> Callable[..., object] | None: + return self._tools.get(name) + + def execute(self, name: str, arguments: dict[str, object] | str) -> str: + """Execute a named tool and return a JSON-encoded string result. + + Args: + name: The tool function name. + arguments: Either a dict of keyword arguments or a JSON string that + decodes to one. + + Returns: + A JSON string representation of the tool's return value, or an error + object when the tool is unknown or raises. + """ + func = self.get(name) + if func is None: + return json.dumps({"error": f"Unknown tool: {name}"}) + + if isinstance(arguments, str): + try: + arguments = json.loads(arguments) if arguments else {} + except json.JSONDecodeError: + return json.dumps({"error": f"Invalid JSON arguments for {name}: {arguments}"}) + + try: + result = func(**arguments) + except Exception as exc: # noqa: BLE001 + return json.dumps({"error": f"{type(exc).__name__}: {exc}"}) + + if isinstance(result, str): + return result + try: + return json.dumps(result, ensure_ascii=False, default=str) + except (TypeError, ValueError): + return str(result) + + +def default_registry() -> ToolRegistry: + """Build a registry pre-loaded with the built-in tools.""" + reg = ToolRegistry() + reg.register(web_search) + reg.register(scrape_url) + return reg + + +def all_tool_callables(registry: ToolRegistry | None = None) -> Sequence[Callable[..., object]]: + """Return the list of callables suitable for ``ollama`` ``tools`` argument.""" + reg = registry or default_registry() + return reg.callables
\ No newline at end of file diff --git a/src/ollama_web/tools/scrape.py b/src/ollama_web/tools/scrape.py new file mode 100644 index 0000000..d20956d --- /dev/null +++ b/src/ollama_web/tools/scrape.py @@ -0,0 +1,59 @@ +"""URL scraping tool that fetches a page and extracts readable text.""" + +from __future__ import annotations + +import httpx +from bs4 import BeautifulSoup + +from ..config import settings + + +def scrape_url(url: str, max_chars: int | None = None) -> str: + """Fetch a web page and return its main text content. + + Scripts, styles and other non-content elements are removed before + collapsing whitespace. The returned text is truncated to ``max_chars``. + + Args: + url: The absolute URL of the page to scrape. + max_chars: Maximum number of characters to return. Defaults to the value + in settings. + + Returns: + The extracted plain text of the page, truncated to ``max_chars``. If the + request fails a short error description is returned instead. + """ + if not url or not url.strip(): + return "Invalid URL." + + limit = max_chars if max_chars is not None and max_chars > 0 else settings.scrape_max_chars + headers = {"User-Agent": "ollama-web/0.1 (+https://github.com/local)"} + + try: + with httpx.Client( + timeout=settings.scrape_timeout, + follow_redirects=True, + headers=headers, + ) as client: + resp = client.get(url) + resp.raise_for_status() + html = resp.text + 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) + + if len(cleaned) > limit: + cleaned = cleaned[:limit].rsplit(" ", 1)[0] + "\n…[truncated]" + return cleaned or "No readable text content found."
\ No newline at end of file diff --git a/src/ollama_web/tools/search.py b/src/ollama_web/tools/search.py new file mode 100644 index 0000000..0af2a3e --- /dev/null +++ b/src/ollama_web/tools/search.py @@ -0,0 +1,45 @@ +"""Web search tool backed by DuckDuckGo (ddgs).""" + +from __future__ import annotations + +from ddgs import DDGS + +from ..config import settings + + +def web_search(query: str, max_results: int | None = None) -> list[dict[str, str]]: + """Search the web using DuckDuckGo and return compact result summaries. + + Args: + query: The search query string. + max_results: Maximum number of results to return (default from settings). + + Returns: + A list of dictionaries with keys 'title', 'href' and 'body' for each + result. Returns an empty list if the search fails or no results are found. + """ + if not query or not query.strip(): + return [] + + limit = ( + max_results + if max_results is not None and max_results > 0 + else settings.search_max_results + ) + + results: list[dict[str, str]] = [] + try: + with DDGS() as ddgs: + for item in ddgs.text(query, max_results=limit): + results.append( + { + "title": str(item.get("title", "")).strip(), + "href": str(item.get("href") or item.get("url") or "").strip(), + "body": str(item.get("body") or item.get("snippet") or "").strip(), + } + ) + except Exception: # noqa: BLE001 + # Swallow network/runtime errors so the chat loop can continue. + return [] + + return results[:limit] |
