summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md9
-rw-r--r--src/ollama_web/app.py34
-rw-r--r--src/ollama_web/llm.py181
-rw-r--r--src/ollama_web/tools/search.py13
-rw-r--r--tests/test_llm.py101
-rw-r--r--tests/test_logging.py88
-rw-r--r--tests/test_settings.py25
-rw-r--r--tests/test_tools.py49
8 files changed, 248 insertions, 252 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1381129..f7d0b69 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,15 @@
## [Unreleased]
+### Changed
+
+- 使用されていない非ストリーミングのチャット処理を削除し、ツール呼び出し関連のテストを実運用のストリーミング処理へ統合しました。
+
+### Fixed
+
+- `server.log`を`OLLAMA_WEB_DATA_DIR`の親ではなくデータディレクトリ直下へ保存し、アプリケーションの起動・終了に合わせてログハンドラーを開閉するようにしました。
+- DuckDuckGo検索で発生した例外を空の検索結果として握りつぶさず、検索語を記録しない診断ログを残してツール実行層へ伝播するようにしました。
+
## [0.5.0] - 2026-08-16
### Added
diff --git a/src/ollama_web/app.py b/src/ollama_web/app.py
index eeb3bae..9792c7b 100644
--- a/src/ollama_web/app.py
+++ b/src/ollama_web/app.py
@@ -3,6 +3,8 @@
from __future__ import annotations
import logging
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
@@ -42,17 +44,29 @@ class NoCacheStaticFiles(StaticFiles):
_STATIC_DIR = Path(__file__).resolve().parent / "static"
+@asynccontextmanager
+async def _lifespan(app: Starlette) -> AsyncIterator[None]:
+ """Keep the application log handler scoped to the ASGI lifespan."""
+ log_dir = Path(app.state.settings.data_dir).resolve()
+ log_dir.mkdir(parents=True, exist_ok=True)
+ handler = logging.FileHandler(str(log_dir / "server.log"), encoding="utf-8")
+ handler.setFormatter(
+ logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
+ )
+ logger = logging.getLogger("ollama_web")
+ previous_level = logger.level
+ logger.setLevel(logging.INFO)
+ logger.addHandler(handler)
+ try:
+ yield
+ finally:
+ logger.removeHandler(handler)
+ handler.close()
+ logger.setLevel(previous_level)
+
+
def create_app() -> Starlette:
"""Build and return the configured Starlette application."""
- # Ensure application logs are written alongside uvicorn access logs.
- log_path = Path(settings.data_dir).resolve().parent / "server.log"
- log_path.parent.mkdir(parents=True, exist_ok=True)
- handler = logging.FileHandler(str(log_path), encoding="utf-8")
- handler.setFormatter(logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s"))
- root = logging.getLogger()
- root.setLevel(logging.INFO)
- root.addHandler(handler)
-
routes = [
Route("/login", pages_route.login, name="login_page"),
Route("/api/auth/login", auth_route.login, methods=["POST"], name="auth_login"),
@@ -139,7 +153,7 @@ def create_app() -> Starlette:
)
)
- app = Starlette(routes=routes, middleware=middleware)
+ app = Starlette(routes=routes, middleware=middleware, lifespan=_lifespan)
app.state.settings = settings
app.state.session_store = SessionStore(settings.data_dir)
app.state.tool_names = ["web_search", "scrape_url", "search_and_fetch", "pdf_to_text"]
diff --git a/src/ollama_web/llm.py b/src/ollama_web/llm.py
index 4951b36..187b5e7 100644
--- a/src/ollama_web/llm.py
+++ b/src/ollama_web/llm.py
@@ -132,14 +132,6 @@ def _embed_images_in_content(message: Message) -> Message:
return Message(**kwargs)
-def _tool_calls(response: Any) -> list[dict[str, Any]]:
- """Extract tool calls from an ollama response in a tolerant manner."""
- msg = getattr(response, "message", None) or (
- response.get("message") if isinstance(response, dict) else None
- )
- return _tool_calls_from_message(msg)
-
-
def _tool_calls_from_message(msg: Any) -> list[dict[str, Any]]:
"""Extract tool calls from a message object/dict."""
if msg is None:
@@ -166,18 +158,6 @@ def _tool_calls_from_message(msg: Any) -> list[dict[str, Any]]:
return out
-def _assistant_message(response: Any) -> Message:
- """Convert a non-streaming response into a Message for re-submission."""
- msg = getattr(response, "message", None) or (
- response.get("message") if isinstance(response, dict) else None
- )
- if isinstance(msg, dict):
- return Message(**msg)
- if msg is not None:
- return cast(Message, msg)
- return Message(role="assistant", content="")
-
-
def _token_counts(response: Any) -> dict[str, int] | None:
"""Extract prompt_eval_count and eval_count from an ollama response."""
if response is None:
@@ -193,161 +173,6 @@ def _token_counts(response: Any) -> dict[str, int] | None:
return {"prompt_eval_count": int(prompt_eval), "eval_count": int(eval_count)}
-def chat_with_tools(
- messages: list[Message],
- model: str,
- *,
- think: bool | str | None = None,
- registry: ToolRegistry | None = None,
- host: str | None = None,
- capabilities: set[str] | None = None,
- options: dict[str, Any] | None = None,
- language: str | None = None,
-) -> Iterator[dict[str, Any]]:
- """Run a multi-round tool-calling chat and yield progress events.
-
- Events are dicts with a ``type`` key:
-
- - ``{"type": "tool_start", "name": str, "arguments": dict}``
- - ``{"type": "tool_end", "name": str, "result": str}``
- - ``{"type": "delta", "content": str}``
- - ``{"type": "thinking", "content": str}``
- - ``{"type": "done", "prompt_eval_count": int, "eval_count": int}``
- - ``{"type": "error", "message": str}``
-
- The final assistant message is streamed token-by-token via ``delta`` events.
- """
- import logging
-
- logger = logging.getLogger("ollama_web.llm")
-
- caps = capabilities or set()
- raw_client = get_client(host)
- reg = registry or default_registry()
- tools: list[ToolCallable] = [cast(Any, t) for t in reg.ollama_tools]
- effective_tools = tools if "tools" in caps else []
- effective_think = think if "thinking" in caps else None
-
- # Non-vision models cannot use the ``images`` field; embed them as base64.
- if "vision" not in caps:
- messages = [_embed_images_in_content(m) if _is_image_message(m) else m for m in messages]
-
- cumulative_tool_chars = 0
-
- rounds = 0
- while rounds < MAX_TOOL_ROUNDS:
- rounds += 1
- logger.info(
- "tool round %d/%d start messages=%d model=%s cumulative_tool_chars=%d",
- rounds,
- MAX_TOOL_ROUNDS,
- len(messages),
- model,
- cumulative_tool_chars,
- )
- try:
- resp = cast(Any, raw_client).chat(
- model=model,
- messages=messages,
- tools=effective_tools,
- think=effective_think,
- options=options or None,
- stream=False,
- )
- except Exception as exc: # noqa: BLE001
- logger.warning("ollama chat call failed: %s", exc)
- yield {"type": "error", "message": str(exc)}
- return
-
- calls = _tool_calls(resp)
- tokens = _token_counts(resp)
- if not calls:
- # No tool calls: stream the final answer for UI effect.
- content = _assistant_content(resp)
- thinking = _assistant_thinking(resp)
- if thinking:
- yield {"type": "thinking", "content": thinking}
- if content:
- yield {"type": "delta", "content": content}
- done_event: dict[str, Any] = {"type": "done"}
- if tokens:
- done_event.update(tokens)
- yield done_event
- return
-
- # Append the assistant message that requested tool calls.
- messages.append(_assistant_message(resp))
-
- # Execute each requested tool and append tool-role messages.
- tool_results: list[tuple[str, str]] = []
- for call in calls:
- name = call.get("name", "")
- args = call.get("arguments", {})
-
- yield {"type": "tool_start", "name": name, "arguments": _safe_tool_arguments(args)}
- result = reg.execute(name, args if isinstance(args, dict) else json.dumps(args))
- result = _safe_tool_result(_trim_tool_result(result, settings.max_tool_result_chars))
- yield {"type": "tool_end", "name": name, "result": result}
- tool_results.append((name, result))
-
- if sum(len(r) for _, r in tool_results) > MAX_TOOL_CONTEXT_CHARS:
- budget = MAX_TOOL_CONTEXT_CHARS - len(tool_results) * 200
- max_per_tool = budget // max(1, len(tool_results))
- logger.warning(
- "tool results too large (%d chars), truncating to %d chars per tool",
- sum(len(r) for _, r in tool_results),
- max_per_tool,
- )
- tool_results = [(n, _trim_tool_result(r, max_per_tool)) for n, r in tool_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))
-
- # Exceeded tool rounds: ask for a final answer without tools.
- logger.info(
- (
- "max tool rounds exceeded, requesting final answer without tools "
- "(cumulative_tool_chars=%d)"
- ),
- cumulative_tool_chars,
- )
- yield {
- "type": "status",
- "message": t("status.tool_limit_reached", lang=language),
- }
- messages.append(
- Message(
- role="user",
- content=get_prompt("tool_limit_instruction", lang=language),
- )
- )
- try:
- resp = cast(Any, raw_client).chat(
- model=model,
- messages=messages,
- tools=[],
- think=effective_think,
- options=options or None,
- stream=False,
- )
- except Exception as exc: # noqa: BLE001
- logger.warning("final ollama chat call failed: %s", exc)
- yield {"type": "error", "message": str(exc)}
- return
- tokens = _token_counts(resp)
- content = _assistant_content(resp)
- thinking = _assistant_thinking(resp)
- if thinking:
- yield {"type": "thinking", "content": thinking}
- if content:
- yield {"type": "delta", "content": content}
- done_event_final: dict[str, Any] = {"type": "done"}
- if tokens:
- done_event_final.update(tokens)
- yield done_event_final
-
-
def _trim_tool_result(result: str, limit: int) -> str:
"""Truncate ``result`` to ``limit`` chars while keeping it readable.
@@ -684,12 +509,6 @@ def _assistant_thinking(response: Any) -> str:
return str(getattr(msg, "thinking", "") or "")
-def _chunk_text(text: str, size: int) -> Iterator[str]:
- """Yield successive chunks of ``text`` up to ``size`` characters."""
- for i in range(0, len(text), size):
- yield text[i : i + size]
-
-
def _assistant_message_chunk(full_message: dict[str, Any]) -> Message | None:
"""Build an assistant ``Message`` from the accumulated streamed message.
diff --git a/src/ollama_web/tools/search.py b/src/ollama_web/tools/search.py
index 761f182..773f7f3 100644
--- a/src/ollama_web/tools/search.py
+++ b/src/ollama_web/tools/search.py
@@ -2,10 +2,14 @@
from __future__ import annotations
+import logging
+
from ddgs import DDGS
from ..config import settings
+logger = logging.getLogger(__name__)
+
def web_search(query: str, max_results: int | None = None) -> list[dict[str, str]]:
"""Search the web using DuckDuckGo and return compact result summaries.
@@ -16,7 +20,8 @@ def web_search(query: str, max_results: int | None = None) -> list[dict[str, str
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.
+ result. Returns an empty list if no results are found. Provider failures
+ are logged and propagated to the tool execution layer.
"""
if not query or not query.strip():
return []
@@ -37,7 +42,9 @@ def web_search(query: str, max_results: int | None = None) -> list[dict[str, str
}
)
except Exception: # noqa: BLE001
- # Swallow network/runtime errors so the chat loop can continue.
- return []
+ # Preserve the provider exception for the tool layer while recording
+ # the traceback without logging the potentially sensitive query.
+ logger.exception("DuckDuckGo web search failed")
+ raise
return results[:limit]
diff --git a/tests/test_llm.py b/tests/test_llm.py
index edd9d61..0e1b146 100644
--- a/tests/test_llm.py
+++ b/tests/test_llm.py
@@ -9,13 +9,11 @@ from typing import Any
import pytest
from ollama import Message
from ollama._types import Image as OllamaImage
-from ollama._types import ResponseError
from ollama_web.llm import (
_embed_images_in_content,
_is_image_message,
_token_counts,
- chat_with_tools,
stream_chat_with_tools,
)
from ollama_web.tools.registry import ToolRegistry
@@ -44,46 +42,44 @@ def test_embed_images_in_content_adds_base64_markdown() -> None:
assert not getattr(embedded, "images", None)
-def test_chat_with_tools_skips_tools_for_non_tool_model() -> None:
- """Tools are not passed when the model does not advertise tool support."""
- messages = [Message(role="user", content="hi")]
- # Use an empty registry so any tool call would fail if requested.
+def test_stream_chat_with_tools_disables_unsupported_capabilities(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Unsupported tools and thinking options are not sent to Ollama."""
+ from ollama_web import llm
+
registry = ToolRegistry()
- # Without tool capability, the iterator should finish immediately with no
- # actual network request. We only verify it accepts the call and does not
- # raise due to tool-related setup.
- gen = chat_with_tools(
- messages,
- model="nonexistent-model",
- capabilities=set(),
- registry=registry,
- )
- events = list(gen)
- # We expect either an error (model not found) or a done event.
- assert any(e.get("type") in {"error", "done"} for e in events)
+ def lookup(query: str) -> str:
+ return query
+ registry.register(lookup)
-def test_chat_with_tools_forces_think_none_for_non_thinking_model() -> None:
- messages = [Message(role="user", content="hi")]
- gen = chat_with_tools(
- messages,
- model="nonexistent-model",
- capabilities=set(),
- )
- events = list(gen)
- assert any(e.get("type") in {"error", "done"} for e in events)
+ class FakeClient:
+ def __init__(self) -> None:
+ self.calls: list[dict[str, Any]] = []
+ def chat(self, **kwargs: Any) -> list[SimpleNamespace]:
+ self.calls.append(kwargs)
+ return [SimpleNamespace(message={"content": "done"})]
+
+ client = FakeClient()
+ monkeypatch.setattr(llm, "get_client", lambda _host=None: client)
-def test_stream_chat_with_tools_accepts_capability_set() -> None:
- messages = [Message(role="user", content="hi")]
- gen = stream_chat_with_tools(
- messages,
- model="nonexistent-model",
- capabilities={"tools", "thinking"},
+ events = list(
+ stream_chat_with_tools(
+ [Message(role="user", content="hi")],
+ model="test-model",
+ think=True,
+ capabilities=set(),
+ registry=registry,
+ )
)
- with pytest.raises(ResponseError):
- list(gen)
+
+ assert client.calls[0]["tools"] == []
+ assert client.calls[0]["think"] is None
+ assert client.calls[0]["stream"] is True
+ assert events[-1] == {"type": "done"}
def test_token_counts_extracts_from_dict() -> None:
@@ -109,7 +105,7 @@ def test_token_counts_returns_none_when_missing() -> None:
assert _token_counts(None) is None
-def test_chat_with_tools_disables_tools_for_final_answer_after_limit(
+def test_stream_chat_with_tools_disables_tools_for_final_answer_after_limit(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from ollama_web import llm
@@ -128,26 +124,33 @@ def test_chat_with_tools_disables_tools_for_final_answer_after_limit(
def __init__(self) -> None:
self.calls: list[dict[str, Any]] = []
- def chat(self, **kwargs: Any) -> dict[str, Any]:
+ def chat(self, **kwargs: Any) -> list[SimpleNamespace]:
self.calls.append(kwargs)
if len(self.calls) == 1:
- return {
- "message": {
- "role": "assistant",
- "content": "",
- "tool_calls": [
- {"function": {"name": "lookup", "arguments": {"query": "test"}}}
- ],
- }
- }
- return {"message": {"role": "assistant", "content": "final answer"}}
+ return [
+ SimpleNamespace(
+ message={
+ "role": "assistant",
+ "content": "",
+ "tool_calls": [
+ {
+ "function": {
+ "name": "lookup",
+ "arguments": {"query": "test"},
+ }
+ }
+ ],
+ }
+ )
+ ]
+ return [SimpleNamespace(message={"content": "final answer"})]
client = FakeClient()
monkeypatch.setattr(llm, "get_client", lambda _host=None: client)
monkeypatch.setattr(llm, "MAX_TOOL_ROUNDS", 1)
events = list(
- llm.chat_with_tools(
+ llm.stream_chat_with_tools(
[Message(role="user", content="research this")],
model="test-model",
registry=registry,
@@ -160,6 +163,8 @@ def test_chat_with_tools_disables_tools_for_final_answer_after_limit(
assert len(client.calls) == 2
assert client.calls[0]["tools"]
assert client.calls[1]["tools"] == []
+ assert client.calls[0]["stream"] is True
+ assert client.calls[1]["stream"] is True
assert any(event.get("type") == "status" for event in events)
assert any(event == {"type": "delta", "content": "final answer"} for event in events)
assert events[-1] == {"type": "done"}
diff --git a/tests/test_logging.py b/tests/test_logging.py
new file mode 100644
index 0000000..ef6fbd2
--- /dev/null
+++ b/tests/test_logging.py
@@ -0,0 +1,88 @@
+"""Tests for application file logging."""
+
+from __future__ import annotations
+
+import json
+import logging
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+from starlette.testclient import TestClient
+
+from ollama_web.app import create_app
+from ollama_web.config import settings
+from ollama_web.tools.registry import default_registry
+
+
+def test_server_log_is_scoped_to_data_dir_and_app_lifespan(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ data_dir = tmp_path / "data"
+ monkeypatch.setattr(settings, "data_dir", str(data_dir))
+ app_logger = logging.getLogger("ollama_web")
+ original_handlers = tuple(app_logger.handlers)
+ original_level = app_logger.level
+
+ app = create_app()
+ log_path = data_dir / "server.log"
+ assert not log_path.exists()
+
+ with TestClient(app):
+ assert len(app_logger.handlers) == len(original_handlers) + 1
+ logging.getLogger("ollama_web.test").info("lifespan log marker")
+
+ assert tuple(app_logger.handlers) == original_handlers
+ assert app_logger.level == original_level
+ assert log_path.read_text(encoding="utf-8").count("lifespan log marker") == 1
+
+ # Closing the lifespan must release the file on Windows as well.
+ log_path.unlink()
+ assert not log_path.exists()
+
+
+def test_repeated_app_lifespans_do_not_duplicate_log_handlers(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ data_dir = tmp_path / "data"
+ monkeypatch.setattr(settings, "data_dir", str(data_dir))
+
+ with TestClient(create_app()):
+ logging.getLogger("ollama_web.test").info("first app marker")
+ with TestClient(create_app()):
+ logging.getLogger("ollama_web.test").info("second app marker")
+
+ content = (data_dir / "server.log").read_text(encoding="utf-8")
+ assert content.count("first app marker") == 1
+ assert content.count("second app marker") == 1
+
+
+def test_web_search_provider_error_is_written_to_server_log(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ data_dir = tmp_path / "data"
+ monkeypatch.setattr(settings, "data_dir", str(data_dir))
+
+ class FailingDDGS:
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *args):
+ return None
+
+ def text(self, query, max_results=5):
+ raise RuntimeError("403 Forbidden")
+
+ with patch("ollama_web.tools.search.DDGS", FailingDDGS), TestClient(create_app()):
+ result = json.loads(
+ default_registry().execute("web_search", {"query": "private search terms"})
+ )
+
+ assert result == {"error": "RuntimeError: 403 Forbidden"}
+ content = (data_dir / "server.log").read_text(encoding="utf-8")
+ assert "DuckDuckGo web search failed" in content
+ assert "RuntimeError: 403 Forbidden" in content
+ assert "private search terms" not in content
diff --git a/tests/test_settings.py b/tests/test_settings.py
index 0f334f4..d19f64d 100644
--- a/tests/test_settings.py
+++ b/tests/test_settings.py
@@ -4,6 +4,7 @@ from __future__ import annotations
import json
from pathlib import Path
+from types import SimpleNamespace
from typing import Any
import pytest
@@ -151,24 +152,28 @@ def test_options_are_forwarded_to_tool_and_final_rounds(monkeypatch: pytest.Monk
def __init__(self) -> None:
self.calls: list[dict[str, Any]] = []
- def chat(self, **kwargs: Any) -> dict[str, Any]:
+ def chat(self, **kwargs: Any) -> list[SimpleNamespace]:
self.calls.append(kwargs)
if len(self.calls) == 1:
- return {
- "message": {
- "role": "assistant",
- "content": "",
- "tool_calls": [{"function": {"name": "missing_tool", "arguments": {}}}],
- }
- }
- return {"message": {"role": "assistant", "content": "done"}}
+ return [
+ SimpleNamespace(
+ message={
+ "role": "assistant",
+ "content": "",
+ "tool_calls": [
+ {"function": {"name": "missing_tool", "arguments": {}}}
+ ],
+ }
+ )
+ ]
+ return [SimpleNamespace(message={"content": "done"})]
client = FakeClient()
monkeypatch.setattr(llm, "get_client", lambda _host=None: client)
monkeypatch.setattr(llm, "MAX_TOOL_ROUNDS", 1)
options = {"temperature": 0.8, "num_ctx": 8192, "top_p": 0.9}
events = list(
- llm.chat_with_tools(
+ llm.stream_chat_with_tools(
[Message(role="user", content="hello")],
"test-model",
capabilities={"tools"},
diff --git a/tests/test_tools.py b/tests/test_tools.py
index a94ea52..387cbe2 100644
--- a/tests/test_tools.py
+++ b/tests/test_tools.py
@@ -114,6 +114,55 @@ def test_web_search_mocked():
assert result[0]["title"] == "T"
+def test_web_search_returns_empty_list_for_no_provider_results() -> None:
+ from ollama_web.tools import search as search_mod
+
+ class EmptyDDGS:
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *args):
+ return None
+
+ def text(self, query, max_results=5):
+ return []
+
+ with patch.object(search_mod, "DDGS", EmptyDDGS):
+ assert search_mod.web_search("nothing found") == []
+
+
+def test_web_search_logs_and_propagates_provider_errors(
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ from ollama_web.tools import search as search_mod
+
+ class FailingDDGS:
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *args):
+ return None
+
+ def text(self, query, max_results=5):
+ raise RuntimeError("403 Forbidden")
+
+ with (
+ patch.object(search_mod, "DDGS", FailingDDGS),
+ caplog.at_level("ERROR", logger="ollama_web.tools.search"),
+ ):
+ with pytest.raises(RuntimeError, match="403 Forbidden"):
+ search_mod.web_search("private search terms")
+
+ assert "DuckDuckGo web search failed" in caplog.text
+ assert "private search terms" not in caplog.text
+
+ registry = ToolRegistry()
+ registry.register(search_mod.web_search)
+ with patch.object(search_mod, "DDGS", FailingDDGS):
+ result = json.loads(registry.execute("web_search", {"query": "test"}))
+ assert result == {"error": "RuntimeError: 403 Forbidden"}
+
+
# ---------------------------------------------------------------------------
# scrape_url tests (existing + pagination)
# ---------------------------------------------------------------------------