summaryrefslogtreecommitdiff
path: root/src/ollama_web/tools
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-06-23 18:37:46 +0900
committertanaka takashi <isazaka@gmail.com>2026-06-23 18:37:46 +0900
commit21b59cc1d0928dc04dcf5eac24470aaa05013b2d (patch)
tree9888aa23aafea3907ce96eb958a06d4369dad86e /src/ollama_web/tools
parentde0513e7cb0593b1aa141e367ff4da7156d6110f (diff)
基本機能実装
Diffstat (limited to 'src/ollama_web/tools')
-rw-r--r--src/ollama_web/tools/__init__.py1
-rw-r--r--src/ollama_web/tools/registry.py81
-rw-r--r--src/ollama_web/tools/scrape.py59
-rw-r--r--src/ollama_web/tools/search.py45
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]