diff --git a/.remember/tmp/save-session.pid b/.remember/tmp/save-session.pid index 9bc105c..423d3c2 100644 --- a/.remember/tmp/save-session.pid +++ b/.remember/tmp/save-session.pid @@ -1 +1 @@ -3158517 +603480 diff --git a/Dockerfile b/Dockerfile index ea6eef5..37fcb07 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,56 +17,6 @@ COPY src/ src/ RUN --mount=type=cache,target=/root/.cache/pip \ pip install . -# Speech-to-text (faster-whisper + soundfile). faster-whisper is pure -# Python; the actual inference engine is ctranslate2 (C++) which has -# cp314 wheels as of v4.7.2 (2026-05-19). No torch needed — ctranslate2 -# does its own CPU inference. Image add: ~150 MB. -RUN --mount=type=cache,target=/root/.cache/pip \ - pip install faster-whisper soundfile - -# Text-to-speech (piper-tts). Replaces kokoro, which has been -# stale upstream since April 2025 (requires_python<3.13). Piper depends -# only on onnxruntime (already pulled in for STT via faster-whisper) and -# pathvalidate — total Python overhead is tiny. Voice models are separate -# .onnx + .onnx.json files bundled below. -RUN --mount=type=cache,target=/root/.cache/pip \ - pip install piper-tts - -# Bundle two default voices in the image so first-run TTS works offline. -# Additional voices can be downloaded at runtime into /data/voices via the -# admin UI (see services/tts.py for the voice-discovery logic). -# Voice catalog: https://huggingface.co/rhasspy/piper-voices -# -# Using Python's urllib instead of curl/wget because python:3.14-slim -# ships neither; python is obviously available. The heredoc requires -# the BuildKit Dockerfile 1.3+ frontend, which the `# syntax=...:1` -# directive at the top of this file already pulls in. -RUN <<'PYEOF' python3 -import os, urllib.request - -VOICES = ["en_US-amy-medium", "en_US-ryan-medium"] -BASE = "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US" -TARGET = "/opt/piper-voices" -os.makedirs(TARGET, exist_ok=True) -for v in VOICES: - dataset = v.split("-")[1] - for ext in ("onnx", "onnx.json"): - url = f"{BASE}/{dataset}/medium/{v}.{ext}" - path = os.path.join(TARGET, f"{v}.{ext}") - print(f"Downloading {url}", flush=True) - urllib.request.urlretrieve(url, path) - size = os.path.getsize(path) - print(f" -> {path} ({size:,} bytes)", flush=True) -PYEOF - -# Build the fable-mcp wheel so it can be served for download -COPY fable-mcp/ fable-mcp/ -RUN --mount=type=cache,target=/root/.cache/pip \ - pip install build hatchling \ - && python -m build --wheel ./fable-mcp --outdir /app/dist/ \ - && pip uninstall -y build \ - && rm -rf fable-mcp/ - COPY --from=build-frontend /build/dist/ src/fabledassistant/static/ COPY alembic.ini . COPY alembic/ alembic/ diff --git a/bench-chat.md b/bench-chat.md new file mode 100644 index 0000000..d8ba48b --- /dev/null +++ b/bench-chat.md @@ -0,0 +1,12 @@ +# Ollama benchmark + +- Server: `http://192.168.0.9:11434` +- Hardware mode: CPU only (`num_gpu=0`) +- Think: auto (chat=off, curator=on) + +| Model | Scenario | Runs | Prompt tok | TTFT p50 (ms) | Total p50 (ms) | tok/s p50 | Output tok (mean) | +|---|---|---|---|---|---|---|---| +| qwen3:8b | chat | 5 | 98 | 187 | 2126 | 13.8 | 26 | +| gemma2:9b | chat | 5 | 89 | 184 | 2084 | 11.7 | 22 | +| llama3.2:3b | chat | 5 | 102 | 185 | 1091 | 31.9 | 28 | +| mistral-nemo:12b | chat | 5 | 82 | 228 | 2070 | 10.2 | 19 | diff --git a/bench-curator-no-think.md b/bench-curator-no-think.md new file mode 100644 index 0000000..c024b9f --- /dev/null +++ b/bench-curator-no-think.md @@ -0,0 +1,12 @@ +# Ollama benchmark + +- Server: `http://192.168.0.9:11434` +- Hardware mode: CPU only (`num_gpu=0`) +- Think: forced OFF + +| Model | Scenario | Runs | Prompt tok | TTFT p50 (ms) | Total p50 (ms) | tok/s p50 | Output tok (mean) | +|---|---|---|---|---|---|---|---| +| qwen3:30b-a3b | curator | 3 | 529 | 183 | 164198 | 17.4 | 2732 | +| qwen3:32b | curator | 3 | 535 | 677 | 136519 | 3.0 | 406 | +| gemma2:27b | curator | 3 | 545 | 392 | 132739 | 3.7 | 451 | +| mistral-small:22b | curator | 3 | 562 | 293 | 105971 | 4.7 | 485 | diff --git a/bench-curator-think-on.md b/bench-curator-think-on.md new file mode 100644 index 0000000..b56e201 --- /dev/null +++ b/bench-curator-think-on.md @@ -0,0 +1,10 @@ +# Ollama benchmark + +- Server: `http://192.168.0.9:11434` +- Hardware mode: CPU only (`num_gpu=0`) +- Think: forced ON + +| Model | Scenario | Runs | Prompt tok | TTFT p50 (ms) | Total p50 (ms) | tok/s p50 | Output tok (mean) | +|---|---|---|---|---|---|---|---| +| qwen3:30b-a3b | curator | 3 | 529 | 147344 | 185694 | 15.6 | 3300 | +| qwen3:32b | curator | 3 | 529 | 98890 | 213817 | 3.1 | 651 | diff --git a/bench-gpuhost-chat-cpu.md b/bench-gpuhost-chat-cpu.md new file mode 100644 index 0000000..d9198fb --- /dev/null +++ b/bench-gpuhost-chat-cpu.md @@ -0,0 +1,12 @@ +# Ollama benchmark + +- Server: `http://192.168.0.13:11434` +- Hardware mode: CPU only (`num_gpu=0`) +- Think: auto (chat=off, curator=on) + +| Model | Scenario | Runs | Prompt tok | TTFT p50 (ms) | Total p50 (ms) | tok/s p50 | Output tok (mean) | +|---|---|---|---|---|---|---|---| +| qwen3:8b | chat | 5 | 98 | 410 | 4131 | 7.0 | 26 | +| gemma2:9b | chat | 5 | 89 | 410 | 2950 | 8.1 | 21 | +| llama3.2:3b | chat | 5 | 102 | 311 | 1627 | 21.7 | 28 | +| mistral-nemo:12b | chat | 5 | 82 | 458 | 2966 | 7.9 | 19 | diff --git a/bench-gpuhost-chat-gpu.md b/bench-gpuhost-chat-gpu.md new file mode 100644 index 0000000..c2442f4 --- /dev/null +++ b/bench-gpuhost-chat-gpu.md @@ -0,0 +1,12 @@ +# Ollama benchmark + +- Server: `http://192.168.0.13:11434` +- Hardware mode: GPU offload (99 layers) (`num_gpu=99`) +- Think: auto (chat=off, curator=on) + +| Model | Scenario | Runs | Prompt tok | TTFT p50 (ms) | Total p50 (ms) | tok/s p50 | Output tok (mean) | +|---|---|---|---|---|---|---|---| +| qwen3:8b | chat | 5 | 98 | 369 | 1301 | 23.8 | 25 | +| gemma2:9b | chat | 5 | 89 | 426 | 1681 | 22.1 | 24 | +| llama3.2:3b | chat | 5 | 102 | 400 | 1033 | 54.1 | 29 | +| mistral-nemo:12b | chat | 5 | 82 | 446 | 1451 | 19.7 | 18 | diff --git a/bench-gpuhost-curator-70b.md b/bench-gpuhost-curator-70b.md new file mode 100644 index 0000000..e5b569d --- /dev/null +++ b/bench-gpuhost-curator-70b.md @@ -0,0 +1,9 @@ +# Ollama benchmark + +- Server: `http://192.168.0.13:11434` +- Hardware mode: CPU only (`num_gpu=0`) +- Think: forced OFF + +| Model | Scenario | Runs | Prompt tok | TTFT p50 (ms) | Total p50 (ms) | tok/s p50 | Output tok (mean) | +|---|---|---|---|---|---|---|---| +| llama3.3:70b | curator | 3 | 499 | 1284 | 326662 | 1.1 | 367 | diff --git a/bench-gpuhost-curator-no-think.md b/bench-gpuhost-curator-no-think.md new file mode 100644 index 0000000..81362b5 --- /dev/null +++ b/bench-gpuhost-curator-no-think.md @@ -0,0 +1,12 @@ +# Ollama benchmark + +- Server: `http://192.168.0.13:11434` +- Hardware mode: CPU only (`num_gpu=0`) +- Think: forced OFF + +| Model | Scenario | Runs | Prompt tok | TTFT p50 (ms) | Total p50 (ms) | tok/s p50 | Output tok (mean) | +|---|---|---|---|---|---|---|---| +| qwen3:30b-a3b | curator | 3 | 529 | 355 | 324789 | 9.8 | 3195 | +| qwen3:32b | curator | 3 | 535 | 813 | 157978 | 2.6 | 404 | +| gemma2:27b | curator | 3 | 545 | 693 | 166996 | 2.7 | 453 | +| mistral-small:22b | curator | 3 | 562 | 386 | 144660 | 3.6 | 490 | diff --git a/bench-gpuhost-curator-think-on.md b/bench-gpuhost-curator-think-on.md new file mode 100644 index 0000000..af3d5bf --- /dev/null +++ b/bench-gpuhost-curator-think-on.md @@ -0,0 +1,10 @@ +# Ollama benchmark + +- Server: `http://192.168.0.13:11434` +- Hardware mode: CPU only (`num_gpu=0`) +- Think: forced ON + +| Model | Scenario | Runs | Prompt tok | TTFT p50 (ms) | Total p50 (ms) | tok/s p50 | Output tok (mean) | +|---|---|---|---|---|---|---|---| +| qwen3:30b-a3b | curator | 3 | 529 | 261474 | 319287 | 9.8 | 3310 | +| qwen3:32b | curator | 3 | 529 | 127386 | 274725 | 2.5 | 756 | diff --git a/fable-mcp/.env.example b/fable-mcp/.env.example deleted file mode 100644 index 767f8d0..0000000 --- a/fable-mcp/.env.example +++ /dev/null @@ -1,2 +0,0 @@ -FABLE_URL=http://localhost:5000 -FABLE_API_KEY=fmcp_your_key_here diff --git a/fable-mcp/fable_mcp/__init__.py b/fable-mcp/fable_mcp/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/fable-mcp/fable_mcp/client.py b/fable-mcp/fable_mcp/client.py deleted file mode 100644 index 80a562d..0000000 --- a/fable-mcp/fable_mcp/client.py +++ /dev/null @@ -1,126 +0,0 @@ -"""Async HTTP client for the Fable Assistant API.""" -from __future__ import annotations - -import os -from typing import Any, AsyncIterator - -import httpx - - -class FableAPIError(Exception): - """Raised when the Fable API returns a non-2xx response.""" - - def __init__(self, status_code: int, message: str) -> None: - self.status_code = status_code - super().__init__(f"Fable API error {status_code}: {message}") - - -class FableClient: - """Async wrapper around httpx for the Fable REST API.""" - - def __init__(self) -> None: - url = os.environ.get("FABLE_URL", "").rstrip("/") - key = os.environ.get("FABLE_API_KEY", "") - if not url: - raise ValueError("FABLE_URL environment variable is required") - if not key: - raise ValueError("FABLE_API_KEY environment variable is required") - self.base_url = url - self._headers = { - "Authorization": f"Bearer {key}", - "Content-Type": "application/json", - } - self._client: httpx.AsyncClient | None = None - - async def __aenter__(self) -> "FableClient": - self._client = httpx.AsyncClient( - base_url=self.base_url, - headers=self._headers, - timeout=httpx.Timeout(connect=10.0, read=300.0, write=30.0, pool=10.0), - ) - return self - - async def __aexit__(self, *args: Any) -> None: - if self._client: - await self._client.aclose() - self._client = None - - def _http(self) -> httpx.AsyncClient: - if self._client is None: - raise RuntimeError("FableClient must be used as an async context manager") - return self._client - - @staticmethod - def _raise_for_status(response: httpx.Response) -> None: - if response.is_error: - try: - message = response.json().get("error", response.text) - except Exception: - message = response.text - raise FableAPIError(response.status_code, message) - - async def get(self, path: str, **kwargs: Any) -> Any: - response = await self._http().get(path, **kwargs) - self._raise_for_status(response) - return response.json() - - async def post(self, path: str, **kwargs: Any) -> Any: - response = await self._http().post(path, **kwargs) - self._raise_for_status(response) - return response.json() - - async def patch(self, path: str, **kwargs: Any) -> Any: - response = await self._http().patch(path, **kwargs) - self._raise_for_status(response) - return response.json() - - async def put(self, path: str, **kwargs: Any) -> Any: - response = await self._http().put(path, **kwargs) - self._raise_for_status(response) - return response.json() - - async def delete(self, path: str, **kwargs: Any) -> Any: - response = await self._http().delete(path, **kwargs) - if response.status_code == 204: - return None - self._raise_for_status(response) - if response.content: - return response.json() - return None - - async def stream_get(self, path: str, **kwargs: Any) -> AsyncIterator[str]: - """Yield non-empty lines from a streaming GET response (SSE).""" - async with self._http().stream("GET", path, **kwargs) as response: - if response.is_error: - await response.aread() - self._raise_for_status(response) - async for line in response.aiter_lines(): - if line: - yield line - - -# --------------------------------------------------------------------------- -# Module-level singleton -# --------------------------------------------------------------------------- - -_client: FableClient | None = None - - -def init_client() -> FableClient: - """Initialise the module-level FableClient singleton (reads env vars).""" - global _client - _client = FableClient() - return _client - - -def get_client() -> FableClient: - """Return the singleton client; raises RuntimeError if not initialised.""" - if _client is None: - raise RuntimeError("Call init_client() before get_client()") - return _client - - -def _reset_client() -> None: - """Reset the singleton — used by tests only.""" - global _client - _client = None diff --git a/fable-mcp/fable_mcp/server.py b/fable-mcp/fable_mcp/server.py deleted file mode 100644 index 2b4d3a1..0000000 --- a/fable-mcp/fable_mcp/server.py +++ /dev/null @@ -1,757 +0,0 @@ -"""Fable MCP server — exposes Fable Scribe as MCP tools via stdio transport.""" -from __future__ import annotations - -from mcp.server.fastmcp import FastMCP -from dotenv import load_dotenv - -from fable_mcp.client import FableClient -from fable_mcp.tools import notes, tasks, projects, milestones, search, chat, admin, journal - -load_dotenv() - -_INSTRUCTIONS = """ -Fabled Scribe is a self-hosted second-brain and project management system with LLM integration. - -## Data model - -The hierarchy is: Project → Milestone → Task/Note. - -- **Notes** and **Tasks** share the same underlying model. Tasks are notes with `is_task=True`. - The note tools (fable_*_note) operate on notes; the task tools (fable_*_task) operate on tasks. - Do not use note tools to manipulate tasks or vice versa. - -- **Projects** group related work. A project has a title, description, goal, status, and an - auto-generated summary used for semantic search. Status values: `active`, `archived`. - -- **Milestones** belong to a project and group tasks within it. Status values: `active`, `done`. - -- **Tasks** belong to a project and optionally a milestone. They support sub-tasks via `parent_id`. - - Status values: `todo`, `in_progress`, `done`, `cancelled` - - Priority values: `none` (default), `low`, `medium`, `high` - -- **Notes** are free-form markdown documents. They can belong to a project or be standalone - (orphan notes). Orphan notes are included in the default RAG scope for chat conversations. - -## Tags - -Tags are plain strings — do NOT include a `#` prefix. Example: `["python", "architecture"]`. -Tags are stored as an array on the note/task. Passing `tags=[]` clears all tags; omitting `tags` -leaves existing tags unchanged on updates. - -## Integer-or-none fields - -Due to MCP type constraints, optional integer fields (project_id, milestone_id, parent_id) -use `0` to mean "not set / no association". Pass `0` to leave the field unset. - -## Search - -`fable_search` performs semantic (embedding-based) search over notes and tasks. Use it to find -relevant content by meaning rather than exact keywords. Returns results ranked by cosine -similarity with id, title, a body snippet, and tags. - -## Chat / LLM delegation - -`fable_send_message` sends a natural-language message to Fable's built-in LLM (Ollama). Fable -handles its own tool use, RAG context injection, and conversation history internally. - -Use `fable_send_message` when: -- The request is conversational or requires Fable's internal reasoning across many records -- You want Fable's RAG to surface relevant notes automatically - -Use the direct CRUD tools when: -- You know exactly what to create/read/update/delete -- You need structured data back (IDs, field values) for further processing -- You are populating Fable programmatically from another system - -## Task logs - -Use `fable_add_task_log` to append time-stamped progress notes to a task without overwriting -its main body. Suitable for recording work sessions, decisions, or status updates over time. - -## Journal - -Fable Scribe runs a per-day Journal — a conversational surface where the user narrates -their day. Each day has its own conversation. The first assistant message in a day's -conversation is the **daily prep**: an LLM-generated briefing covering today's tasks, -calendar events, weather, active projects, and recent journal context. Subsequent turns -are user/assistant journaling exchanges; the LLM may emit **Moments** (small structured -extractions) via the `record_moment` tool during the conversation. - -Use `fable_get_today_journal` to inspect today's prep + conversation. Use -`fable_get_journal_day` for past days. Use `fable_list_moments` to query the structured -journal extractions across days. Use `fable_trigger_journal_prep` to force-regenerate -today's prep prose. - -## Admin logs - -`fable_get_app_logs` requires an admin-scoped API key. Regular user keys will be rejected. -""" - -mcp = FastMCP("fable", instructions=_INSTRUCTIONS) - - -# --------------------------------------------------------------------------- -# Notes -# --------------------------------------------------------------------------- - - -@mcp.tool() -async def fable_list_notes( - limit: int = 20, - offset: int = 0, - tag: str = "", - search_text: str = "", -) -> dict: - """List notes (non-task documents) stored in Fable. - - Optionally filter by a single tag (plain string, no # prefix) or a keyword search - against title and body. Results are ordered by last-updated descending. - - Use fable_search for semantic/meaning-based lookup instead of exact keyword search. - """ - async with FableClient() as client: - return await notes.list_notes( - client, - limit=limit, - offset=offset, - tag=tag or None, - search=search_text or None, - ) - - -@mcp.tool() -async def fable_get_note(note_id: int) -> dict: - """Fetch the full content of a single Fable note by its ID. - - Returns id, title, body (markdown), tags, project_id, created_at, updated_at. - """ - async with FableClient() as client: - return await notes.get_note(client, note_id=note_id) - - -@mcp.tool() -async def fable_create_note( - title: str, - body: str = "", - tags: list[str] | None = None, - project_id: int = 0, -) -> dict: - """Create a new note in Fable. - - Args: - title: Note title (required). - body: Markdown content. Supports [[wikilinks]] to other notes by title. - tags: List of plain-string tags without # prefix, e.g. ["python", "ideas"]. - project_id: Associate with a project (use 0 for no project / orphan note). - - Returns the created note object including its assigned id. - """ - async with FableClient() as client: - return await notes.create_note( - client, - title=title, - body=body, - tags=tags, - project_id=project_id or None, - ) - - -@mcp.tool() -async def fable_update_note( - note_id: int, - title: str = "", - body: str = "", - tags: list[str] | None = None, - project_id: int = 0, -) -> dict: - """Update an existing Fable note. Only explicitly provided fields are changed. - - Args: - note_id: ID of the note to update. - title: New title, or omit to leave unchanged. - body: New markdown body, or omit to leave unchanged. - tags: Replaces the full tag list. Pass [] to clear all tags. Omit to leave unchanged. - project_id: New project association (0 = remove from project). Omit to leave unchanged. - """ - async with FableClient() as client: - return await notes.update_note( - client, - note_id=note_id, - title=title or None, - body=body or None, - tags=tags, - project_id=project_id or None, - ) - - -@mcp.tool() -async def fable_delete_note(note_id: int) -> str: - """Permanently delete a Fable note by ID. This cannot be undone.""" - async with FableClient() as client: - await notes.delete_note(client, note_id=note_id) - return f"Note {note_id} deleted." - - -# --------------------------------------------------------------------------- -# Tasks -# --------------------------------------------------------------------------- - - -@mcp.tool() -async def fable_list_tasks( - limit: int = 20, - offset: int = 0, - status: str = "", - project_id: int = 0, -) -> dict: - """List tasks in Fable. - - Args: - status: Filter by status — one of: todo, in_progress, done, cancelled. Omit for all. - project_id: Filter to a specific project. Use 0 for no filter. - - Results are ordered by last-updated descending. - """ - async with FableClient() as client: - return await tasks.list_tasks( - client, - limit=limit, - offset=offset, - status=status or None, - project_id=project_id or None, - ) - - -@mcp.tool() -async def fable_get_task(task_id: int) -> dict: - """Fetch a single Fable task by ID. - - Returns id, title, body, status, priority, tags, project_id, milestone_id, - parent_id, parent_title, due_date, created_at, updated_at. - """ - async with FableClient() as client: - return await tasks.get_task(client, task_id=task_id) - - -@mcp.tool() -async def fable_create_task( - title: str, - body: str = "", - status: str = "todo", - priority: str = "", - project_id: int = 0, - milestone_id: int = 0, - parent_id: int = 0, - tags: list[str] | None = None, -) -> dict: - """Create a new task in Fable. - - Args: - title: Task title (required). - body: Markdown description / notes for the task. - status: Initial status — one of: todo (default), in_progress, done, cancelled. - priority: One of: low, medium, high. Omit for no priority (defaults to "none"). - project_id: Associate with a project (0 = no project). - milestone_id: Place within a project milestone (0 = no milestone). - parent_id: Make this a sub-task of another task (0 = top-level). - tags: List of plain-string tags without # prefix. - - Returns the created task object including its assigned id. - """ - async with FableClient() as client: - return await tasks.create_task( - client, - title=title, - body=body, - status=status, - priority=priority or None, - project_id=project_id or None, - milestone_id=milestone_id or None, - parent_id=parent_id or None, - tags=tags, - ) - - -@mcp.tool() -async def fable_update_task( - task_id: int, - title: str = "", - body: str = "", - status: str = "", - priority: str = "", - project_id: int = 0, - milestone_id: int = 0, -) -> dict: - """Update an existing Fable task. Only explicitly provided fields are changed. - - Args: - task_id: ID of the task to update. - title: New title, or omit to leave unchanged. - body: New markdown body, or omit to leave unchanged. - status: New status — one of: todo, in_progress, done, cancelled. - priority: New priority — one of: none, low, medium, high. - project_id: New project (0 = remove from project). Omit to leave unchanged. - milestone_id: New milestone (0 = remove from milestone). Omit to leave unchanged. - """ - async with FableClient() as client: - return await tasks.update_task( - client, - task_id=task_id, - title=title or None, - body=body or None, - status=status or None, - priority=priority or None, - project_id=project_id or None, - milestone_id=milestone_id or None, - ) - - -@mcp.tool() -async def fable_add_task_log(task_id: int, content: str) -> dict: - """Append a timestamped progress log entry to a Fable task. - - Use this to record work sessions, decisions, or status updates over time without - overwriting the task's main body. Each entry is stored separately and shown - chronologically in the task view. - """ - async with FableClient() as client: - return await tasks.add_task_log(client, task_id=task_id, content=content) - - -# --------------------------------------------------------------------------- -# Projects -# --------------------------------------------------------------------------- - - -@mcp.tool() -async def fable_list_projects() -> dict: - """List all Fable projects for the current user. - - Returns id, title, description, goal, status (active/archived), color, - and a short auto-generated summary for each project. - """ - async with FableClient() as client: - return await projects.list_projects(client) - - -@mcp.tool() -async def fable_get_project(project_id: int) -> dict: - """Fetch a Fable project by ID, including its milestone summary. - - Returns full project fields plus a milestone_summary list with each milestone's - id, title, status, and task counts. - """ - async with FableClient() as client: - return await projects.get_project(client, project_id=project_id) - - -@mcp.tool() -async def fable_create_project( - title: str, - description: str = "", - goal: str = "", - status: str = "active", - color: str = "", -) -> dict: - """Create a new project in Fable. - - Args: - title: Project name (required). - description: Short summary of what the project is. - goal: The desired outcome or definition of done for the project. - status: active (default) or archived. - color: Optional hex colour for the project card (e.g. "#6366f1"). - - Returns the created project object including its assigned id. - """ - async with FableClient() as client: - return await projects.create_project( - client, - title=title, - description=description, - goal=goal or None, - status=status, - color=color or None, - ) - - -@mcp.tool() -async def fable_update_project( - project_id: int, - title: str = "", - description: str = "", - goal: str = "", - status: str = "", - color: str = "", -) -> dict: - """Update an existing Fable project. Only explicitly provided fields are changed. - - Args: - project_id: ID of the project to update. - title: New title, or omit to leave unchanged. - description: New description, or omit to leave unchanged. - goal: New goal/definition-of-done, or omit to leave unchanged. - status: New status — active or archived. - color: New hex colour, or omit to leave unchanged. - """ - async with FableClient() as client: - return await projects.update_project( - client, - project_id=project_id, - title=title or None, - description=description or None, - goal=goal or None, - status=status or None, - color=color or None, - ) - - -# --------------------------------------------------------------------------- -# Milestones -# --------------------------------------------------------------------------- - - -@mcp.tool() -async def fable_list_milestones(project_id: int) -> dict: - """List milestones for a Fable project, ordered by order_index. - - Returns id, title, description, status (active/done), order_index, and task counts. - """ - async with FableClient() as client: - return await milestones.list_milestones(client, project_id=project_id) - - -@mcp.tool() -async def fable_create_milestone( - project_id: int, - title: str, - description: str = "", - status: str = "active", -) -> dict: - """Create a milestone within a Fable project. - - Args: - project_id: The project this milestone belongs to (required). - title: Milestone name (required). - description: Optional description of what this milestone covers. - status: active (default) or done. - - Returns the created milestone including its assigned id. - """ - async with FableClient() as client: - return await milestones.create_milestone( - client, - project_id=project_id, - title=title, - description=description, - status=status, - ) - - -@mcp.tool() -async def fable_update_milestone( - project_id: int, - milestone_id: int, - title: str = "", - description: str = "", - status: str = "", - order_index: int = -1, -) -> dict: - """Update a Fable milestone. Only explicitly provided fields are changed. - - Args: - project_id: Project the milestone belongs to. - milestone_id: ID of the milestone to update. - title: New title, or omit to leave unchanged. - description: New description, or omit to leave unchanged. - status: New status — active or done. - order_index: New display position (0-based). Use -1 to leave unchanged. - """ - async with FableClient() as client: - return await milestones.update_milestone( - client, - project_id=project_id, - milestone_id=milestone_id, - title=title or None, - description=description or None, - status=status or None, - order_index=order_index if order_index >= 0 else None, - ) - - -# --------------------------------------------------------------------------- -# Search -# --------------------------------------------------------------------------- - - -@mcp.tool() -async def fable_search( - q: str, - content_type: str = "all", - limit: int = 10, -) -> dict: - """Semantic search over Fable notes and tasks using embedding similarity. - - Finds content by meaning rather than exact keywords. Use this to discover - relevant records when you don't know the exact title or tags. - - Args: - q: Natural-language query string. - content_type: "note", "task", or "all" (default). - limit: Maximum number of results (default 10). - - Returns results ordered by cosine similarity, each with id, title, body snippet, and tags. - """ - async with FableClient() as client: - return await search.search(client, q=q, content_type=content_type, limit=limit) - - -# --------------------------------------------------------------------------- -# Chat -# --------------------------------------------------------------------------- - - -@mcp.tool() -async def fable_list_conversations(limit: int = 20, offset: int = 0) -> dict: - """List chat conversations stored in Fable, ordered by last activity. - - Returns id, title, message_count, created_at, updated_at for each conversation. - Use the id with fable_send_message to continue a specific conversation. - """ - async with FableClient() as client: - return await chat.list_conversations(client, limit=limit, offset=offset) - - -@mcp.tool() -async def fable_send_message( - message: str, - conversation_id: str = "", - think: bool = False, -) -> dict: - """Send a natural-language message to Fable's built-in LLM and receive the full response. - - Fable handles tool use, RAG context injection, and conversation history internally. - The LLM can create/update notes and tasks, search, manage projects, and more — all - driven by natural language without you needing to call individual tools. - - Use this when: - - The request is conversational or exploratory - - You want Fable's RAG to automatically surface relevant notes as context - - The task is complex enough to benefit from Fable's internal reasoning - - Use the direct CRUD tools (fable_create_note, etc.) instead when you need - structured data back or are performing bulk/programmatic operations. - - Args: - message: The user message to send. - conversation_id: Continue an existing conversation by passing its id. - Omit to start a new conversation. - think: Enable extended reasoning mode for complex multi-step requests. - - Returns conversation_id (for follow-up messages), the assistant response text, - and a list of any tool_call events that fired during generation. - """ - async with FableClient() as client: - return await chat.send_message( - client, - message=message, - conversation_id=conversation_id or None, - think=think, - ) - - -# --------------------------------------------------------------------------- -# Admin / observability -# --------------------------------------------------------------------------- - - -@mcp.tool() -async def fable_get_app_logs( - category: str = "error", - limit: int = 20, - search: str = "", -) -> dict: - """Fetch Fable application logs. Requires an admin-scoped API key. - - Args: - category: Log category — "error" (default), "audit", "usage", or "generation". - limit: Maximum number of log entries to return. - search: Optional keyword filter matched against action, endpoint, username, details. - - Returns a list of log entries ordered by most recent first. - Regular user API keys will receive a 403 — only admin keys are accepted. - """ - async with FableClient() as client: - return await admin.get_app_logs( - client, - category=category, - limit=limit, - search=search or None, - ) - - -# --------------------------------------------------------------------------- -# Journal — daily prep, day payloads, moments -# --------------------------------------------------------------------------- - - -@mcp.tool() -async def fable_get_today_journal() -> dict: - """Fetch today's Journal day payload. - - Creates today's journal conversation and generates the daily prep - message if neither exists yet. Returns: - { - "day_date": "YYYY-MM-DD", - "conversation": { id, title, conversation_type, day_date, ... }, - "messages": [ ... ordered list of messages ... ] - } - - The first assistant message is the daily prep — a conversational - opener generated by the LLM from gathered tasks/events/weather/ - projects/recent moments/open threads. Its ``msg_metadata.sections`` - carries the underlying structured data for inspection. - """ - async with FableClient() as client: - return await journal.get_today_journal(client) - - -@mcp.tool() -async def fable_get_journal_day(iso_date: str) -> dict: - """Fetch a specific day's Journal payload by ISO date. - - Args: - iso_date: YYYY-MM-DD format date. - - Returns the same shape as fable_get_today_journal. If no journal - exists for that day, ``conversation`` and ``messages`` will be - null/empty respectively. - """ - async with FableClient() as client: - return await journal.get_journal_day(client, iso_date=iso_date) - - -@mcp.tool() -async def fable_list_journal_days() -> dict: - """List dates that have journal content for the current user, newest first. - - Returns ``{"days": ["YYYY-MM-DD", ...]}``. Use these dates to query - specific days via ``fable_get_journal_day``. - """ - async with FableClient() as client: - return await journal.list_journal_days(client) - - -@mcp.tool() -async def fable_trigger_journal_prep(iso_date: str = "") -> dict: - """Force-regenerate the daily prep prose for today (or a specific day). - - The prep is the first assistant message in a day's journal — a - conversational LLM-generated briefing built from tasks/events/weather/ - projects/recent moments/open threads. Use this to iterate on the prep - prompt or refresh after data changes. - - Args: - iso_date: Optional YYYY-MM-DD. If empty, regenerates today. - - Returns ``{"ok": true, "message_id": ...}``. - """ - async with FableClient() as client: - return await journal.trigger_journal_prep( - client, iso_date=iso_date or None, - ) - - -@mcp.tool() -async def fable_get_journal_config() -> dict: - """Fetch the user's journal config. - - Includes prep schedule (prep_enabled / prep_hour / prep_minute), - day rollover hour, phase boundaries, and any locations / temp_unit - used for the prep's weather section. - """ - async with FableClient() as client: - return await journal.get_journal_config(client) - - -@mcp.tool() -async def fable_list_moments( - query: str = "", - person_id: int = 0, - place_id: int = 0, - tag: str = "", - date_from: str = "", - date_to: str = "", - pinned_only: bool = False, - limit: int = 50, -) -> dict: - """Search/list journal Moments — small structured extractions the LLM emits during journaling. - - All filters are optional and combinable. Without a query, returns - moments ordered by occurred_at DESC. With a query, returns - semantically-ranked moments above the similarity threshold. - - Args: - query: Optional semantic query string. - person_id: Filter to moments mentioning this person (0 = no filter). - place_id: Filter to moments mentioning this place (0 = no filter). - tag: Filter to moments with this tag. - date_from: ISO YYYY-MM-DD lower bound (inclusive). - date_to: ISO YYYY-MM-DD upper bound (inclusive). - pinned_only: If True, only return pinned moments. - limit: Max results, default 50. - - Returns ``{"moments": [...]}`` where each moment has id, day_date, - occurred_at, content, raw_excerpt, tags, people, places, task_ids, - note_ids, pinned, and (when query set) score. - """ - async with FableClient() as client: - return await journal.list_moments( - client, - query=query or None, - person_id=person_id if person_id else None, - place_id=place_id if place_id else None, - tag=tag or None, - date_from=date_from or None, - date_to=date_to or None, - pinned_only=pinned_only, - limit=limit, - ) - - -# --------------------------------------------------------------------------- -# Generic conversation access -# --------------------------------------------------------------------------- - - -@mcp.tool() -async def fable_get_conversation(conversation_id: int) -> dict: - """Fetch any conversation (chat or journal) with its full message list. - - Returns conversation metadata plus an ordered ``messages`` array. - Each message includes role, content, tool_calls (with results), - context_note_id, and msg_metadata. Tool calls are in the stored - flat format: ``[{"function": name, "arguments": {...}, "result": {...}}]``. - - Useful for inspecting journal preps, chat history, or verifying - that a tool actually ran with the expected arguments. - """ - async with FableClient() as client: - return await journal.get_conversation( - client, conversation_id=conversation_id, - ) - - -# --------------------------------------------------------------------------- -# Entry point -# --------------------------------------------------------------------------- - - -def main() -> None: - # Validate env vars at startup — raises ValueError with a clear message if missing. - FableClient() - mcp.run(transport="stdio") - - -if __name__ == "__main__": - main() diff --git a/fable-mcp/fable_mcp/tools/__init__.py b/fable-mcp/fable_mcp/tools/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/fable-mcp/fable_mcp/tools/admin.py b/fable-mcp/fable_mcp/tools/admin.py deleted file mode 100644 index 60111c3..0000000 --- a/fable-mcp/fable_mcp/tools/admin.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Admin tools: application log access (requires admin API key).""" -from __future__ import annotations - -from fable_mcp.client import FableClient - - -async def get_app_logs( - client: FableClient, - category: str = "error", - limit: int = 20, - search: str | None = None, -) -> dict: - """Fetch application logs from Fable. Requires an admin-scoped API key. - - category: "error" | "audit" | "usage" | "generation" (default: "error") - """ - params: dict = {"category": category, "limit": limit} - if search: - params["search"] = search - return await client.get("/api/admin/logs", params=params) diff --git a/fable-mcp/fable_mcp/tools/chat.py b/fable-mcp/fable_mcp/tools/chat.py deleted file mode 100644 index 119803b..0000000 --- a/fable-mcp/fable_mcp/tools/chat.py +++ /dev/null @@ -1,95 +0,0 @@ -"""MCP tools for Fable chat — create conversations and stream responses.""" -from __future__ import annotations - -import asyncio -import json -from typing import Any - -from fable_mcp.client import FableClient, FableAPIError - - -async def list_conversations( - client: FableClient, - *, - limit: int = 20, - offset: int = 0, -) -> dict[str, Any]: - """List MCP chat conversations.""" - params: dict[str, Any] = {"limit": limit, "offset": offset, "type": "mcp"} - return await client.get("/api/chat/conversations", params=params) - - -async def send_message( - client: FableClient, - *, - message: str, - conversation_id: str | None = None, - think: bool = False, -) -> dict[str, Any]: - """Send a message to Fable and return the full assistant response. - - Creates a new MCP conversation if conversation_id is None. - Posts the user message to start generation, then streams the SSE buffer. - - SSE event format: - id: - event: # chunk | done | tool_call | status | ... - data: - - Returns: - Dict with: - - "conversation_id": str - - "response": str (full assistant message) - - "tool_calls": list of any tool_call events observed - """ - if conversation_id is None: - conv = await client.post( - "/api/chat/conversations", - json={"conversation_type": "mcp"}, - ) - conversation_id = str(conv["id"]) - - # Start generation - await client.post( - f"/api/chat/conversations/{conversation_id}/messages", - json={"content": message, "think": think}, - ) - - tokens: list[str] = [] - tool_calls: list[Any] = [] - - stream_path = f"/api/chat/conversations/{conversation_id}/generation/stream" - - # Retry connecting to the stream briefly — the background task may not have - # created the generation buffer by the time we issue the GET. - for attempt in range(10): - try: - event_type: str | None = None - async for raw_line in client.stream_get(stream_path): - if raw_line.startswith("event: "): - event_type = raw_line[len("event: "):].strip() - elif raw_line.startswith("data: "): - payload_str = raw_line[len("data: "):] - try: - data = json.loads(payload_str) - except json.JSONDecodeError: - continue - if event_type == "chunk": - tokens.append(data.get("chunk", "")) - elif event_type == "tool_call": - tool_calls.append(data.get("tool_call", data)) - elif event_type == "done": - break - event_type = None # reset after consuming data line - break # stream completed successfully - except FableAPIError as exc: - if exc.status_code == 404 and attempt < 9: - await asyncio.sleep(0.3) - continue - raise - - return { - "conversation_id": conversation_id, - "response": "".join(tokens), - "tool_calls": tool_calls, - } diff --git a/fable-mcp/fable_mcp/tools/journal.py b/fable-mcp/fable_mcp/tools/journal.py deleted file mode 100644 index 32b3544..0000000 --- a/fable-mcp/fable_mcp/tools/journal.py +++ /dev/null @@ -1,96 +0,0 @@ -"""MCP tools for inspecting and controlling the Fable Scribe Journal.""" -from __future__ import annotations - -from typing import Any - -from fable_mcp.client import FableClient - - -# ── Day payloads ───────────────────────────────────────────────────────────── - - -async def get_today_journal(client: FableClient) -> dict[str, Any]: - """Fetch today's journal day payload (creates today's conversation + prep if needed).""" - return await client.get("/api/journal/today") - - -async def get_journal_day(client: FableClient, *, iso_date: str) -> dict[str, Any]: - """Fetch a specific day's journal payload by ISO date (YYYY-MM-DD).""" - return await client.get(f"/api/journal/day/{iso_date}") - - -async def list_journal_days(client: FableClient) -> dict[str, Any]: - """List dates that have journal content for the current user.""" - return await client.get("/api/journal/days") - - -# ── Daily prep ─────────────────────────────────────────────────────────────── - - -async def trigger_journal_prep( - client: FableClient, - *, - iso_date: str | None = None, -) -> dict[str, Any]: - """Force-regenerate the daily prep for today (or a specific day).""" - payload: dict[str, Any] = {} - if iso_date: - payload["date"] = iso_date - return await client.post("/api/journal/trigger-prep", json=payload) - - -# ── Config ─────────────────────────────────────────────────────────────────── - - -async def get_journal_config(client: FableClient) -> dict[str, Any]: - """Fetch the user's journal config (prep schedule, day rollover, locations, etc.).""" - return await client.get("/api/journal/config") - - -# ── Moments ────────────────────────────────────────────────────────────────── - - -async def list_moments( - client: FableClient, - *, - query: str | None = None, - person_id: int | None = None, - place_id: int | None = None, - tag: str | None = None, - date_from: str | None = None, - date_to: str | None = None, - pinned_only: bool = False, - limit: int = 50, -) -> dict[str, Any]: - """List/search journal moments with optional filters. - - All params are optional. Returns a dict with a ``moments`` array. - """ - params: dict[str, str] = {"limit": str(limit)} - if query: - params["query"] = query - if person_id is not None: - params["person_id"] = str(person_id) - if place_id is not None: - params["place_id"] = str(place_id) - if tag: - params["tag"] = tag - if date_from: - params["date_from"] = date_from - if date_to: - params["date_to"] = date_to - if pinned_only: - params["pinned_only"] = "true" - return await client.get("/api/journal/moments", params=params) - - -# ── Generic conversation access (still works for journal conversations) ─────── - - -async def get_conversation( - client: FableClient, - *, - conversation_id: int, -) -> dict[str, Any]: - """Fetch any conversation (chat or journal) with its full message list.""" - return await client.get(f"/api/chat/conversations/{conversation_id}") diff --git a/fable-mcp/fable_mcp/tools/milestones.py b/fable-mcp/fable_mcp/tools/milestones.py deleted file mode 100644 index 3dbdc6a..0000000 --- a/fable-mcp/fable_mcp/tools/milestones.py +++ /dev/null @@ -1,53 +0,0 @@ -"""MCP tools for Fable milestones.""" -from __future__ import annotations - -from typing import Any - -from fable_mcp.client import FableClient - - -async def list_milestones(client: FableClient, *, project_id: int) -> dict[str, Any]: - """List milestones for a project.""" - return await client.get(f"/api/projects/{project_id}/milestones") - - -async def create_milestone( - client: FableClient, - *, - project_id: int, - title: str, - description: str = "", - status: str = "active", -) -> dict[str, Any]: - """Create a milestone within a project.""" - payload: dict[str, Any] = { - "title": title, - "description": description, - "status": status, - } - return await client.post(f"/api/projects/{project_id}/milestones", json=payload) - - -async def update_milestone( - client: FableClient, - *, - project_id: int, - milestone_id: int, - title: str | None = None, - description: str | None = None, - status: str | None = None, - order_index: int | None = None, -) -> dict[str, Any]: - """Update an existing milestone.""" - payload: dict[str, Any] = {} - if title is not None: - payload["title"] = title - if description is not None: - payload["description"] = description - if status is not None: - payload["status"] = status - if order_index is not None: - payload["order_index"] = order_index - return await client.patch( - f"/api/projects/{project_id}/milestones/{milestone_id}", json=payload - ) diff --git a/fable-mcp/fable_mcp/tools/notes.py b/fable-mcp/fable_mcp/tools/notes.py deleted file mode 100644 index 25cadcd..0000000 --- a/fable-mcp/fable_mcp/tools/notes.py +++ /dev/null @@ -1,72 +0,0 @@ -"""MCP tools for Fable notes.""" -from __future__ import annotations - -from typing import Any - -from fable_mcp.client import FableClient - - -async def list_notes( - client: FableClient, - *, - limit: int = 20, - offset: int = 0, - tag: str | None = None, - search: str | None = None, -) -> dict[str, Any]: - """List notes with optional filtering.""" - params: dict[str, Any] = {"limit": limit, "offset": offset} - if tag: - params["tag"] = tag - if search: - params["search"] = search - return await client.get("/api/notes", params=params) - - -async def get_note(client: FableClient, *, note_id: int) -> dict[str, Any]: - """Fetch a single note by ID.""" - return await client.get(f"/api/notes/{note_id}") - - -async def create_note( - client: FableClient, - *, - title: str, - body: str = "", - tags: list[str] | None = None, - project_id: int | None = None, -) -> dict[str, Any]: - """Create a new note.""" - payload: dict[str, Any] = {"title": title, "body": body} - if tags is not None: - payload["tags"] = tags - if project_id is not None: - payload["project_id"] = project_id - return await client.post("/api/notes", json=payload) - - -async def update_note( - client: FableClient, - *, - note_id: int, - title: str | None = None, - body: str | None = None, - tags: list[str] | None = None, - project_id: int | None = None, -) -> dict[str, Any]: - """Update an existing note.""" - payload: dict[str, Any] = {} - if title is not None: - payload["title"] = title - if body is not None: - payload["body"] = body - if tags is not None: - payload["tags"] = tags - if project_id is not None: - payload["project_id"] = project_id - return await client.patch(f"/api/notes/{note_id}", json=payload) - - -async def delete_note(client: FableClient, *, note_id: int) -> None: - """Delete a note by ID.""" - await client.delete(f"/api/notes/{note_id}") diff --git a/fable-mcp/fable_mcp/tools/projects.py b/fable-mcp/fable_mcp/tools/projects.py deleted file mode 100644 index bcbbef2..0000000 --- a/fable-mcp/fable_mcp/tools/projects.py +++ /dev/null @@ -1,59 +0,0 @@ -"""MCP tools for Fable projects.""" -from __future__ import annotations - -from typing import Any - -from fable_mcp.client import FableClient - - -async def list_projects(client: FableClient) -> dict[str, Any]: - """List all projects for the authenticated user.""" - return await client.get("/api/projects") - - -async def get_project(client: FableClient, *, project_id: int) -> dict[str, Any]: - """Fetch a project by ID (includes milestone summary).""" - return await client.get(f"/api/projects/{project_id}") - - -async def create_project( - client: FableClient, - *, - title: str, - description: str = "", - goal: str | None = None, - status: str = "active", - color: str | None = None, -) -> dict[str, Any]: - """Create a new project.""" - payload: dict[str, Any] = {"title": title, "description": description, "status": status} - if goal is not None: - payload["goal"] = goal - if color is not None: - payload["color"] = color - return await client.post("/api/projects", json=payload) - - -async def update_project( - client: FableClient, - *, - project_id: int, - title: str | None = None, - description: str | None = None, - goal: str | None = None, - status: str | None = None, - color: str | None = None, -) -> dict[str, Any]: - """Update an existing project.""" - payload: dict[str, Any] = {} - if title is not None: - payload["title"] = title - if description is not None: - payload["description"] = description - if goal is not None: - payload["goal"] = goal - if status is not None: - payload["status"] = status - if color is not None: - payload["color"] = color - return await client.patch(f"/api/projects/{project_id}", json=payload) diff --git a/fable-mcp/fable_mcp/tools/search.py b/fable-mcp/fable_mcp/tools/search.py deleted file mode 100644 index dc8f083..0000000 --- a/fable-mcp/fable_mcp/tools/search.py +++ /dev/null @@ -1,30 +0,0 @@ -"""MCP tool for semantic search across Fable notes and tasks.""" -from __future__ import annotations - -from typing import Any - -from fable_mcp.client import FableClient - - -async def search( - client: FableClient, - *, - q: str, - content_type: str = "all", - limit: int = 10, -) -> dict[str, Any]: - """Semantic search over notes and/or tasks. - - Args: - q: Search query string. - content_type: One of "note", "task", or "all" (default). - limit: Maximum number of results to return. - - Returns: - Dict with "results" list and "total" count. - Each result has: id, title, body, is_task, tags, similarity. - """ - params: dict[str, Any] = {"q": q, "limit": limit} - if content_type != "all": - params["content_type"] = content_type - return await client.get("/api/search", params=params) diff --git a/fable-mcp/fable_mcp/tools/tasks.py b/fable-mcp/fable_mcp/tools/tasks.py deleted file mode 100644 index d2f6167..0000000 --- a/fable-mcp/fable_mcp/tools/tasks.py +++ /dev/null @@ -1,93 +0,0 @@ -"""MCP tools for Fable tasks.""" -from __future__ import annotations - -from typing import Any - -from fable_mcp.client import FableClient - - -async def list_tasks( - client: FableClient, - *, - limit: int = 20, - offset: int = 0, - status: str | None = None, - project_id: int | None = None, -) -> dict[str, Any]: - """List tasks with optional filtering.""" - params: dict[str, Any] = {"limit": limit, "offset": offset} - if status: - params["status"] = status - if project_id is not None: - params["project_id"] = project_id - return await client.get("/api/tasks", params=params) - - -async def get_task(client: FableClient, *, task_id: int) -> dict[str, Any]: - """Fetch a single task by ID (includes parent_title).""" - return await client.get(f"/api/tasks/{task_id}") - - -async def create_task( - client: FableClient, - *, - title: str, - body: str = "", - status: str = "todo", - priority: str | None = None, - project_id: int | None = None, - milestone_id: int | None = None, - parent_id: int | None = None, - tags: list[str] | None = None, -) -> dict[str, Any]: - """Create a new task.""" - payload: dict[str, Any] = {"title": title, "body": body, "status": status} - if priority is not None: - payload["priority"] = priority - if project_id is not None: - payload["project_id"] = project_id - if milestone_id is not None: - payload["milestone_id"] = milestone_id - if parent_id is not None: - payload["parent_id"] = parent_id - if tags is not None: - payload["tags"] = tags - return await client.post("/api/tasks", json=payload) - - -async def update_task( - client: FableClient, - *, - task_id: int, - title: str | None = None, - body: str | None = None, - status: str | None = None, - priority: str | None = None, - project_id: int | None = None, - milestone_id: int | None = None, -) -> dict[str, Any]: - """Update an existing task.""" - payload: dict[str, Any] = {} - if title is not None: - payload["title"] = title - if body is not None: - payload["body"] = body - if status is not None: - payload["status"] = status - if priority is not None: - payload["priority"] = priority - if project_id is not None: - payload["project_id"] = project_id - if milestone_id is not None: - payload["milestone_id"] = milestone_id - return await client.patch(f"/api/tasks/{task_id}", json=payload) - - -async def add_task_log( - client: FableClient, - *, - task_id: int, - content: str, -) -> dict[str, Any]: - """Append a log entry to a task.""" - return await client.post(f"/api/tasks/{task_id}/logs", json={"content": content}) diff --git a/fable-mcp/pyproject.toml b/fable-mcp/pyproject.toml deleted file mode 100644 index ff90611..0000000 --- a/fable-mcp/pyproject.toml +++ /dev/null @@ -1,24 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "fable-mcp" -version = "0.3.0" -description = "MCP server for Fabled Scribe" -requires-python = ">=3.12" -dependencies = [ - "mcp[cli]>=1.0", - "httpx>=0.27", - "python-dotenv>=1.0", -] - -[project.scripts] -fable-mcp = "fable_mcp.server:main" - -[project.optional-dependencies] -dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "respx>=0.21"] - -[tool.pytest.ini_options] -asyncio_mode = "auto" -testpaths = ["tests"] diff --git a/fable-mcp/tests/__init__.py b/fable-mcp/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/fable-mcp/tests/conftest.py b/fable-mcp/tests/conftest.py deleted file mode 100644 index 716448c..0000000 --- a/fable-mcp/tests/conftest.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Shared pytest fixtures for fable-mcp tests.""" -from unittest.mock import AsyncMock, MagicMock -import pytest - - -@pytest.fixture -def mock_client(): - """A mock FableClient that returns empty responses by default.""" - client = AsyncMock() - client.__aenter__ = AsyncMock(return_value=client) - client.__aexit__ = AsyncMock(return_value=False) - return client diff --git a/fable-mcp/tests/test_client.py b/fable-mcp/tests/test_client.py deleted file mode 100644 index 9e5723b..0000000 --- a/fable-mcp/tests/test_client.py +++ /dev/null @@ -1,142 +0,0 @@ -"""Tests for FableClient.""" -import os -import pytest -import respx -import httpx -from unittest.mock import patch - -from fable_mcp.client import FableClient, FableAPIError, get_client, init_client, _reset_client - - -@pytest.fixture(autouse=True) -def reset_singleton(): - """Reset the module-level client singleton between tests.""" - _reset_client() - yield - _reset_client() - - -@pytest.fixture -def base_env(monkeypatch): - monkeypatch.setenv("FABLE_URL", "http://fable.test") - monkeypatch.setenv("FABLE_API_KEY", "fmcp_testkey") - - -class TestFableClientInit: - def test_missing_fable_url_raises(self, monkeypatch): - monkeypatch.delenv("FABLE_URL", raising=False) - monkeypatch.setenv("FABLE_API_KEY", "fmcp_testkey") - with pytest.raises(ValueError, match="FABLE_URL"): - FableClient() - - def test_missing_api_key_raises(self, monkeypatch): - monkeypatch.setenv("FABLE_URL", "http://fable.test") - monkeypatch.delenv("FABLE_API_KEY", raising=False) - with pytest.raises(ValueError, match="FABLE_API_KEY"): - FableClient() - - def test_trailing_slash_stripped(self, base_env): - with patch.dict(os.environ, {"FABLE_URL": "http://fable.test/"}): - client = FableClient() - assert client.base_url == "http://fable.test" - - def test_auth_header_set(self, base_env): - client = FableClient() - assert client._headers["Authorization"] == "Bearer fmcp_testkey" - - -class TestFableClientHTTP: - @respx.mock - @pytest.mark.asyncio - async def test_get_returns_json(self, base_env): - respx.get("http://fable.test/api/notes").mock( - return_value=httpx.Response(200, json={"notes": []}) - ) - async with FableClient() as client: - data = await client.get("/api/notes") - assert data == {"notes": []} - - @respx.mock - @pytest.mark.asyncio - async def test_non_2xx_raises_fable_api_error(self, base_env): - respx.get("http://fable.test/api/notes").mock( - return_value=httpx.Response(401, json={"error": "Unauthorized"}) - ) - async with FableClient() as client: - with pytest.raises(FableAPIError) as exc_info: - await client.get("/api/notes") - assert exc_info.value.status_code == 401 - - @respx.mock - @pytest.mark.asyncio - async def test_post_sends_json(self, base_env): - respx.post("http://fable.test/api/notes").mock( - return_value=httpx.Response(201, json={"id": 1, "title": "Test"}) - ) - async with FableClient() as client: - data = await client.post("/api/notes", json={"title": "Test"}) - assert data["id"] == 1 - - @respx.mock - @pytest.mark.asyncio - async def test_patch_sends_json(self, base_env): - respx.patch("http://fable.test/api/notes/1").mock( - return_value=httpx.Response(200, json={"id": 1, "title": "Updated"}) - ) - async with FableClient() as client: - data = await client.patch("/api/notes/1", json={"title": "Updated"}) - assert data["title"] == "Updated" - - @respx.mock - @pytest.mark.asyncio - async def test_delete_returns_none_on_204(self, base_env): - respx.delete("http://fable.test/api/notes/1").mock( - return_value=httpx.Response(204) - ) - async with FableClient() as client: - result = await client.delete("/api/notes/1") - assert result is None - - @respx.mock - @pytest.mark.asyncio - async def test_fable_api_error_includes_message(self, base_env): - respx.get("http://fable.test/api/notes").mock( - return_value=httpx.Response(404, json={"error": "Not found"}) - ) - async with FableClient() as client: - with pytest.raises(FableAPIError) as exc_info: - await client.get("/api/notes") - assert "Not found" in str(exc_info.value) - assert exc_info.value.status_code == 404 - - -class TestSingleton: - def test_get_client_before_init_raises(self): - with pytest.raises(RuntimeError, match="init_client"): - get_client() - - def test_init_client_returns_instance(self, base_env): - client = init_client() - assert isinstance(client, FableClient) - - def test_get_client_after_init_returns_same(self, base_env): - init_client() - c1 = get_client() - c2 = get_client() - assert c1 is c2 - - -class TestStreamGet: - @respx.mock - @pytest.mark.asyncio - async def test_stream_get_yields_lines(self, base_env): - sse_body = b"data: line1\n\ndata: line2\n\n" - respx.get("http://fable.test/api/stream").mock( - return_value=httpx.Response(200, content=sse_body) - ) - lines = [] - async with FableClient() as client: - async for line in client.stream_get("/api/stream"): - lines.append(line) - assert "data: line1" in lines - assert "data: line2" in lines diff --git a/fable-mcp/tests/test_tools_chat.py b/fable-mcp/tests/test_tools_chat.py deleted file mode 100644 index d8b527d..0000000 --- a/fable-mcp/tests/test_tools_chat.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Tests for chat MCP tools.""" -import json -import pytest -from unittest.mock import AsyncMock, MagicMock, patch - - -@pytest.fixture -def client(mock_client): - return mock_client - - -@pytest.mark.asyncio -async def test_send_message_creates_conversation_and_streams(client): - from fable_mcp.tools.chat import send_message - - client.post = AsyncMock(return_value={"id": "conv-1"}) - - async def fake_stream(path, **kwargs): - for line in [ - 'data: {"type": "token", "content": "Hello"}', - 'data: {"type": "token", "content": " world"}', - 'data: {"type": "done"}', - ]: - yield line - - client.stream_get = fake_stream - - result = await send_message(client, message="Hi", conversation_id=None) - assert result["conversation_id"] == "conv-1" - assert "Hello world" in result["response"] - - -@pytest.mark.asyncio -async def test_send_message_reuses_existing_conversation(client): - from fable_mcp.tools.chat import send_message - - async def fake_stream(path, **kwargs): - for line in [ - 'data: {"type": "token", "content": "Reply"}', - 'data: {"type": "done"}', - ]: - yield line - - client.stream_get = fake_stream - - result = await send_message(client, message="Hi", conversation_id="existing-conv") - # Should not call post to create a conversation - client.post.assert_not_called() - assert result["conversation_id"] == "existing-conv" - - -@pytest.mark.asyncio -async def test_list_conversations_calls_get(client): - from fable_mcp.tools.chat import list_conversations - client.get = AsyncMock(return_value={"conversations": []}) - await list_conversations(client) - client.get.assert_called_once() - assert "/api/chat/conversations" in client.get.call_args[0][0] diff --git a/fable-mcp/tests/test_tools_milestones.py b/fable-mcp/tests/test_tools_milestones.py deleted file mode 100644 index 13f94f3..0000000 --- a/fable-mcp/tests/test_tools_milestones.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Tests for milestones MCP tools.""" -import pytest -from unittest.mock import AsyncMock - - -@pytest.fixture -def client(mock_client): - return mock_client - - -@pytest.mark.asyncio -async def test_list_milestones_calls_correct_endpoint(client): - from fable_mcp.tools.milestones import list_milestones - client.get = AsyncMock(return_value={"milestones": []}) - await list_milestones(client, project_id=3) - client.get.assert_called_once_with("/api/projects/3/milestones") - - -@pytest.mark.asyncio -async def test_create_milestone_posts_to_project_endpoint(client): - from fable_mcp.tools.milestones import create_milestone - client.post = AsyncMock(return_value={"id": 10, "title": "M1"}) - await create_milestone(client, project_id=3, title="M1") - path = client.post.call_args[0][0] - assert path == "/api/projects/3/milestones" - assert client.post.call_args[1]["json"]["title"] == "M1" - - -@pytest.mark.asyncio -async def test_update_milestone_patches(client): - from fable_mcp.tools.milestones import update_milestone - client.patch = AsyncMock(return_value={"id": 10, "status": "completed"}) - await update_milestone(client, project_id=3, milestone_id=10, status="completed") - path = client.patch.call_args[0][0] - assert "/api/projects/3/milestones/10" in path diff --git a/fable-mcp/tests/test_tools_notes.py b/fable-mcp/tests/test_tools_notes.py deleted file mode 100644 index a7ba00d..0000000 --- a/fable-mcp/tests/test_tools_notes.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Tests for notes MCP tools.""" -import pytest -from unittest.mock import AsyncMock - - -@pytest.fixture -def client(mock_client): - return mock_client - - -@pytest.mark.asyncio -async def test_list_notes_calls_get(client): - from fable_mcp.tools.notes import list_notes - client.get = AsyncMock(return_value={"notes": [], "total": 0}) - result = await list_notes(client, limit=10) - client.get.assert_called_once() - call_args = client.get.call_args - assert "/api/notes" in call_args[0][0] - - -@pytest.mark.asyncio -async def test_get_note_calls_correct_endpoint(client): - from fable_mcp.tools.notes import get_note - client.get = AsyncMock(return_value={"id": 42, "title": "Hello"}) - result = await get_note(client, note_id=42) - client.get.assert_called_once_with("/api/notes/42") - assert result["id"] == 42 - - -@pytest.mark.asyncio -async def test_create_note_posts_payload(client): - from fable_mcp.tools.notes import create_note - client.post = AsyncMock(return_value={"id": 1, "title": "My Note"}) - result = await create_note(client, title="My Note", body="Content") - client.post.assert_called_once() - call_kwargs = client.post.call_args[1] - assert call_kwargs["json"]["title"] == "My Note" - assert call_kwargs["json"]["body"] == "Content" - - -@pytest.mark.asyncio -async def test_update_note_patches_payload(client): - from fable_mcp.tools.notes import update_note - client.patch = AsyncMock(return_value={"id": 1, "title": "Updated"}) - result = await update_note(client, note_id=1, title="Updated") - client.patch.assert_called_once() - path = client.patch.call_args[0][0] - assert "/api/notes/1" in path - - -@pytest.mark.asyncio -async def test_delete_note_calls_delete(client): - from fable_mcp.tools.notes import delete_note - client.delete = AsyncMock(return_value=None) - await delete_note(client, note_id=5) - client.delete.assert_called_once_with("/api/notes/5") diff --git a/fable-mcp/tests/test_tools_projects.py b/fable-mcp/tests/test_tools_projects.py deleted file mode 100644 index 8cd12e9..0000000 --- a/fable-mcp/tests/test_tools_projects.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Tests for projects MCP tools.""" -import pytest -from unittest.mock import AsyncMock - - -@pytest.fixture -def client(mock_client): - return mock_client - - -@pytest.mark.asyncio -async def test_list_projects_calls_get(client): - from fable_mcp.tools.projects import list_projects - client.get = AsyncMock(return_value={"projects": []}) - await list_projects(client) - client.get.assert_called_once_with("/api/projects") - - -@pytest.mark.asyncio -async def test_get_project_calls_correct_endpoint(client): - from fable_mcp.tools.projects import get_project - client.get = AsyncMock(return_value={"id": 2, "title": "Proj"}) - result = await get_project(client, project_id=2) - client.get.assert_called_once_with("/api/projects/2") - - -@pytest.mark.asyncio -async def test_create_project_posts_payload(client): - from fable_mcp.tools.projects import create_project - client.post = AsyncMock(return_value={"id": 4, "title": "New"}) - await create_project(client, title="New", description="Desc") - call_kwargs = client.post.call_args[1] - assert call_kwargs["json"]["title"] == "New" - assert call_kwargs["json"]["description"] == "Desc" - - -@pytest.mark.asyncio -async def test_update_project_patches(client): - from fable_mcp.tools.projects import update_project - client.patch = AsyncMock(return_value={"id": 4, "status": "active"}) - await update_project(client, project_id=4, status="active") - assert "/api/projects/4" in client.patch.call_args[0][0] diff --git a/fable-mcp/tests/test_tools_tasks.py b/fable-mcp/tests/test_tools_tasks.py deleted file mode 100644 index 17976b6..0000000 --- a/fable-mcp/tests/test_tools_tasks.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Tests for tasks MCP tools.""" -import pytest -from unittest.mock import AsyncMock - - -@pytest.fixture -def client(mock_client): - return mock_client - - -@pytest.mark.asyncio -async def test_list_tasks_calls_get(client): - from fable_mcp.tools.tasks import list_tasks - client.get = AsyncMock(return_value={"tasks": [], "total": 0}) - await list_tasks(client) - client.get.assert_called_once() - assert "/api/tasks" in client.get.call_args[0][0] - - -@pytest.mark.asyncio -async def test_get_task_calls_correct_endpoint(client): - from fable_mcp.tools.tasks import get_task - client.get = AsyncMock(return_value={"id": 7, "title": "Fix bug"}) - result = await get_task(client, task_id=7) - client.get.assert_called_once_with("/api/tasks/7") - - -@pytest.mark.asyncio -async def test_create_task_posts_payload(client): - from fable_mcp.tools.tasks import create_task - client.post = AsyncMock(return_value={"id": 3, "title": "New task"}) - await create_task(client, title="New task") - call_kwargs = client.post.call_args[1] - assert call_kwargs["json"]["title"] == "New task" - - -@pytest.mark.asyncio -async def test_update_task_patches(client): - from fable_mcp.tools.tasks import update_task - client.patch = AsyncMock(return_value={"id": 3, "status": "done"}) - await update_task(client, task_id=3, status="done") - client.patch.assert_called_once() - assert "/api/tasks/3" in client.patch.call_args[0][0] - - -@pytest.mark.asyncio -async def test_add_task_log_posts_to_logs_endpoint(client): - from fable_mcp.tools.tasks import add_task_log - client.post = AsyncMock(return_value={"id": 1, "content": "progress"}) - await add_task_log(client, task_id=9, content="progress") - client.post.assert_called_once() - path = client.post.call_args[0][0] - assert path == "/api/tasks/9/logs" - assert client.post.call_args[1]["json"]["content"] == "progress" diff --git a/fable-mcp/uv.lock b/fable-mcp/uv.lock deleted file mode 100644 index 046e9b5..0000000 --- a/fable-mcp/uv.lock +++ /dev/null @@ -1,771 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.12" - -[[package]] -name = "annotated-doc" -version = "0.0.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, -] - -[[package]] -name = "annotated-types" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, -] - -[[package]] -name = "anyio" -version = "4.13.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, -] - -[[package]] -name = "attrs" -version = "26.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, -] - -[[package]] -name = "certifi" -version = "2026.2.25" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, -] - -[[package]] -name = "cffi" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, - { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, - { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, - { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, - { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, - { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, - { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, - { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, - { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, - { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, - { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, - { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, - { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, - { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, - { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, - { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, - { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, - { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, - { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, - { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, - { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, - { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, - { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, - { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, - { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, - { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, - { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, - { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, - { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, - { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, - { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, - { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, - { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, -] - -[[package]] -name = "click" -version = "8.3.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, -] - -[[package]] -name = "cryptography" -version = "46.0.7" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, - { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, - { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, - { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, - { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, - { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, - { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, - { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, - { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, - { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, - { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, - { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, - { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, - { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, - { url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" }, - { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" }, - { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" }, - { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" }, - { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" }, - { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" }, - { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" }, - { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" }, - { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" }, - { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" }, - { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" }, - { url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" }, - { url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" }, - { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, - { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, - { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, - { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, - { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, - { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, - { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, - { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, - { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, - { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, - { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, - { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, - { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, - { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, -] - -[[package]] -name = "fable-mcp" -version = "0.3.0" -source = { editable = "." } -dependencies = [ - { name = "httpx" }, - { name = "mcp", extra = ["cli"] }, - { name = "python-dotenv" }, -] - -[package.optional-dependencies] -dev = [ - { name = "pytest" }, - { name = "pytest-asyncio" }, - { name = "respx" }, -] - -[package.metadata] -requires-dist = [ - { name = "httpx", specifier = ">=0.27" }, - { name = "mcp", extras = ["cli"], specifier = ">=1.0" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, - { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" }, - { name = "python-dotenv", specifier = ">=1.0" }, - { name = "respx", marker = "extra == 'dev'", specifier = ">=0.21" }, -] -provides-extras = ["dev"] - -[[package]] -name = "h11" -version = "0.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, -] - -[[package]] -name = "httpcore" -version = "1.0.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, -] - -[[package]] -name = "httpx" -version = "0.28.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, -] - -[[package]] -name = "httpx-sse" -version = "0.4.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, -] - -[[package]] -name = "idna" -version = "3.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, -] - -[[package]] -name = "iniconfig" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, -] - -[[package]] -name = "jsonschema" -version = "4.26.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "jsonschema-specifications" }, - { name = "referencing" }, - { name = "rpds-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, -] - -[[package]] -name = "jsonschema-specifications" -version = "2025.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "referencing" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, -] - -[[package]] -name = "markdown-it-py" -version = "4.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mdurl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, -] - -[[package]] -name = "mcp" -version = "1.27.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, - { name = "jsonschema" }, - { name = "pydantic" }, - { name = "pydantic-settings" }, - { name = "pyjwt", extra = ["crypto"] }, - { name = "python-multipart" }, - { name = "pywin32", marker = "sys_platform == 'win32'" }, - { name = "sse-starlette" }, - { name = "starlette" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, - { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8b/eb/c0cfc62075dc6e1ec1c64d352ae09ac051d9334311ed226f1f425312848a/mcp-1.27.0.tar.gz", hash = "sha256:d3dc35a7eec0d458c1da4976a48f982097ddaab87e278c5511d5a4a56e852b83", size = 607509, upload-time = "2026-04-02T14:48:08.88Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/46/f6b4ad632c67ef35209a66127e4bddc95759649dd595f71f13fba11bdf9a/mcp-1.27.0-py3-none-any.whl", hash = "sha256:5ce1fa81614958e267b21fb2aa34e0aea8e2c6ede60d52aba45fd47246b4d741", size = 215967, upload-time = "2026-04-02T14:48:07.24Z" }, -] - -[package.optional-dependencies] -cli = [ - { name = "python-dotenv" }, - { name = "typer" }, -] - -[[package]] -name = "mdurl" -version = "0.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, -] - -[[package]] -name = "packaging" -version = "26.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, -] - -[[package]] -name = "pluggy" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, -] - -[[package]] -name = "pycparser" -version = "3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, -] - -[[package]] -name = "pydantic" -version = "2.12.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, -] - -[[package]] -name = "pydantic-core" -version = "2.41.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, - { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, - { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, - { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, - { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, - { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, - { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, - { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, - { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, - { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, - { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, - { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, - { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, - { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, - { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, - { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, - { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, - { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, - { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, - { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, - { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, - { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, - { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, - { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, - { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, - { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, - { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, - { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, - { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, - { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, - { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, - { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, - { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, - { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, - { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, - { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, - { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, - { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, - { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, - { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, - { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, - { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, - { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, - { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, - { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, - { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, - { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, - { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, - { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, - { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, - { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, - { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, - { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, -] - -[[package]] -name = "pydantic-settings" -version = "2.13.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, -] - -[[package]] -name = "pygments" -version = "2.20.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, -] - -[[package]] -name = "pyjwt" -version = "2.12.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, -] - -[package.optional-dependencies] -crypto = [ - { name = "cryptography" }, -] - -[[package]] -name = "pytest" -version = "9.0.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, -] - -[[package]] -name = "pytest-asyncio" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pytest" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, -] - -[[package]] -name = "python-dotenv" -version = "1.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, -] - -[[package]] -name = "python-multipart" -version = "0.0.26" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/88/71/b145a380824a960ebd60e1014256dbb7d2253f2316ff2d73dfd8928ec2c3/python_multipart-0.0.26.tar.gz", hash = "sha256:08fadc45918cd615e26846437f50c5d6d23304da32c341f289a617127b081f17", size = 43501, upload-time = "2026-04-10T14:09:59.473Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/22/f1925cdda983ab66fc8ec6ec8014b959262747e58bdca26a4e3d1da29d56/python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185", size = 28847, upload-time = "2026-04-10T14:09:58.131Z" }, -] - -[[package]] -name = "pywin32" -version = "311" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, - { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, - { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, - { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, - { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, - { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, - { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, - { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, -] - -[[package]] -name = "referencing" -version = "0.37.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "rpds-py" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, -] - -[[package]] -name = "respx" -version = "0.23.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "httpx" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/43/98/4e55c9c486404ec12373708d015ebce157966965a5ebe7f28ff2c784d41b/respx-0.23.1.tar.gz", hash = "sha256:242dcc6ce6b5b9bf621f5870c82a63997e8e82bc7c947f9ffe272b8f3dd5a780", size = 29243, upload-time = "2026-04-08T14:37:16.008Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", size = 25557, upload-time = "2026-04-08T14:37:14.613Z" }, -] - -[[package]] -name = "rich" -version = "14.3.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, -] - -[[package]] -name = "rpds-py" -version = "0.30.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, - { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, - { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, - { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, - { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, - { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, - { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, - { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, - { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, - { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, - { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, - { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, - { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, - { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, - { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, - { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, - { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, - { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, - { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, - { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, - { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, - { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, - { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, - { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, - { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, - { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, - { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, - { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, - { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, - { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, - { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, - { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, - { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, - { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, - { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, - { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, - { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, - { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, - { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, - { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, - { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, - { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, - { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, - { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, - { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, - { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, - { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, - { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, - { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, - { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, - { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, - { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, - { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, - { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, - { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, - { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, - { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, - { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, - { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, - { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, - { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, - { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, - { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, - { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, - { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, - { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, - { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, - { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, -] - -[[package]] -name = "shellingham" -version = "1.5.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, -] - -[[package]] -name = "sse-starlette" -version = "3.3.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "starlette" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/26/8c/f9290339ef6d79badbc010f067cd769d6601ec11a57d78569c683fb4dd87/sse_starlette-3.3.4.tar.gz", hash = "sha256:aaf92fc067af8a5427192895ac028e947b484ac01edbc3caf00e7e7137c7bef1", size = 32427, upload-time = "2026-03-29T09:00:23.307Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/7f/3de5402f39890ac5660b86bcf5c03f9d855dad5c4ed764866d7b592b46fd/sse_starlette-3.3.4-py3-none-any.whl", hash = "sha256:84bb06e58939a8b38d8341f1bc9792f06c2b53f48c608dd207582b664fc8f3c1", size = 14330, upload-time = "2026-03-29T09:00:21.846Z" }, -] - -[[package]] -name = "starlette" -version = "1.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, -] - -[[package]] -name = "typer" -version = "0.24.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-doc" }, - { name = "click" }, - { name = "rich" }, - { name = "shellingham" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" }, -] - -[[package]] -name = "typing-extensions" -version = "4.15.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, -] - -[[package]] -name = "typing-inspection" -version = "0.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, -] - -[[package]] -name = "uvicorn" -version = "0.44.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5e/da/6eee1ff8b6cbeed47eeb5229749168e81eb4b7b999a1a15a7176e51410c9/uvicorn-0.44.0.tar.gz", hash = "sha256:6c942071b68f07e178264b9152f1f16dfac5da85880c4ce06366a96d70d4f31e", size = 86947, upload-time = "2026-04-06T09:23:22.826Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/23/a5bbd9600dd607411fa644c06ff4951bec3a4d82c4b852374024359c19c0/uvicorn-0.44.0-py3-none-any.whl", hash = "sha256:ce937c99a2cc70279556967274414c087888e8cec9f9c94644dfca11bd3ced89", size = 69425, upload-time = "2026-04-06T09:23:21.524Z" }, -] diff --git a/frontend/src/stores/push.ts b/frontend/src/stores/push.ts deleted file mode 100644 index d6e90fa..0000000 --- a/frontend/src/stores/push.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { defineStore } from 'pinia' -import { ref } from 'vue' - -function urlBase64ToUint8Array(base64String: string): Uint8Array { - const padding = '='.repeat((4 - (base64String.length % 4)) % 4) - const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/') - const rawData = window.atob(base64) - const buf = new ArrayBuffer(rawData.length) - const outputArray = new Uint8Array(buf) - for (let i = 0; i < rawData.length; ++i) { - outputArray[i] = rawData.charCodeAt(i) - } - return outputArray -} - -export const usePushStore = defineStore('push', () => { - const isSupported = ref( - 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window - ) - const permission = ref( - 'Notification' in window ? Notification.permission : 'denied' - ) - const isSubscribed = ref(false) - const loading = ref(false) - const error = ref(null) - - async function checkSubscription(): Promise { - if (!isSupported.value) return - try { - const reg = await navigator.serviceWorker.ready - const sub = await reg.pushManager.getSubscription() - isSubscribed.value = !!sub - } catch { - isSubscribed.value = false - } - } - - async function subscribe(): Promise { - if (!isSupported.value) { - error.value = 'Push notifications not supported in this browser' - return - } - loading.value = true - error.value = null - try { - // Request permission - permission.value = await Notification.requestPermission() - if (permission.value !== 'granted') { - error.value = 'Notification permission denied' - return - } - - // Fetch VAPID public key - const resp = await fetch('/api/push/vapid-public-key') - if (!resp.ok) { - error.value = 'Push notifications not configured on server' - return - } - const { publicKey } = await resp.json() - - // Register service worker - const reg = await navigator.serviceWorker.register('/sw.js') - await navigator.serviceWorker.ready - - // Subscribe - const sub = await reg.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: urlBase64ToUint8Array(publicKey), - }) - - // Save to server - const saveResp = await fetch('/api/push/subscribe', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(sub.toJSON()), - }) - if (!saveResp.ok) throw new Error('Failed to save subscription') - isSubscribed.value = true - } catch (e: unknown) { - error.value = e instanceof Error ? e.message : 'Failed to subscribe' - } finally { - loading.value = false - } - } - - async function unsubscribe(): Promise { - loading.value = true - error.value = null - try { - const reg = await navigator.serviceWorker.ready - const sub = await reg.pushManager.getSubscription() - if (sub) { - await fetch('/api/push/subscribe', { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ endpoint: sub.endpoint }), - }) - await sub.unsubscribe() - } - isSubscribed.value = false - } catch (e: unknown) { - error.value = e instanceof Error ? e.message : 'Failed to unsubscribe' - } finally { - loading.value = false - } - } - - return { isSupported, permission, isSubscribed, loading, error, checkSubscription, subscribe, unsubscribe } -}) diff --git a/frontend/src/stores/settings.ts b/frontend/src/stores/settings.ts index 4d07108..3f0c92e 100644 --- a/frontend/src/stores/settings.ts +++ b/frontend/src/stores/settings.ts @@ -1,6 +1,6 @@ -import { ref, computed } from "vue"; +import { ref } from "vue"; import { defineStore } from "pinia"; -import { apiGet, apiPut, getVoiceStatus } from "@/api/client"; +import { apiGet, apiPut } from "@/api/client"; import { useToastStore } from "@/stores/toast"; import type { AppSettings } from "@/types/settings"; @@ -8,32 +8,6 @@ export const useSettingsStore = defineStore("settings", () => { const settings = ref({}); const loading = ref(false); - const assistantName = computed( - () => settings.value.assistant_name || "Fable" - ); - - const defaultModel = computed( - () => settings.value.default_model || "" - ); - - // Voice status — checked once on login, refreshable from Settings - const voiceEnabled = ref(false); - const voiceSttReady = ref(false); - const voiceTtsReady = ref(false); - - async function checkVoiceStatus() { - try { - const s = await getVoiceStatus(); - voiceEnabled.value = s.enabled; - voiceSttReady.value = s.enabled && s.stt; - voiceTtsReady.value = s.enabled && s.tts; - } catch { - voiceEnabled.value = false; - voiceSttReady.value = false; - voiceTtsReady.value = false; - } - } - async function fetchSettings() { loading.value = true; try { @@ -60,12 +34,6 @@ export const useSettingsStore = defineStore("settings", () => { return { settings, loading, - assistantName, - defaultModel, - voiceEnabled, - voiceSttReady, - voiceTtsReady, - checkVoiceStatus, fetchSettings, updateSettings, }; diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index c659d83..0e95a1e 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -3,7 +3,7 @@ import { ref, computed, watch, onMounted } from "vue"; import { useSettingsStore } from "@/stores/settings"; import { useAuthStore } from "@/stores/auth"; import { useToastStore } from "@/stores/toast"; -import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getProfile, updateProfile, getJournalConfig, saveJournalConfig, geocodeAddress, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type UserProfile, type JournalConfig } from "@/api/client"; +import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getProfile, updateProfile, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type UserProfile } from "@/api/client"; import type { User } from "@/types/auth"; import PaginationBar from "@/components/PaginationBar.vue"; import TagInput from "@/components/TagInput.vue"; @@ -373,95 +373,6 @@ function toggleProfileWorkDay(day: string) { function emptyTagsFetch(): Promise { return Promise.resolve([]) } -// ── Journal config (locations, temp unit, prep schedule) ──────────────────── -const journalConfig = ref({ - prep_enabled: true, - prep_hour: 5, - prep_minute: 0, - day_rollover_hour: 4, - closeout_enabled: true, - locations: { home: { label: 'Home', address: '' }, work: { label: 'Work', address: '' } }, - temp_unit: 'C', -}) -const journalConfigSaving = ref(false) -const journalConfigSaved = ref(false) -const homeQuery = ref('') -const workQuery = ref('') -const homeGeoStatus = ref<'' | 'ok' | 'error' | 'pending'>('') -const workGeoStatus = ref<'' | 'ok' | 'error' | 'pending'>('') -const homeGeoMsg = ref('') -const workGeoMsg = ref('') - -async function loadJournalConfig() { - try { - const cfg = await getJournalConfig() - journalConfig.value = { - prep_enabled: cfg.prep_enabled ?? true, - prep_hour: cfg.prep_hour ?? 5, - prep_minute: cfg.prep_minute ?? 0, - day_rollover_hour: cfg.day_rollover_hour ?? 4, - closeout_enabled: cfg.closeout_enabled ?? true, - morning_end_hour: cfg.morning_end_hour, - midday_end_hour: cfg.midday_end_hour, - locations: { - home: cfg.locations?.home ?? { label: 'Home', address: '' }, - work: cfg.locations?.work ?? { label: 'Work', address: '' }, - }, - temp_unit: cfg.temp_unit ?? 'C', - } - homeQuery.value = cfg.locations?.home?.address ?? '' - workQuery.value = cfg.locations?.work?.address ?? '' - } catch { /* non-critical */ } -} - -async function geocodeFor(which: 'home' | 'work') { - const query = (which === 'home' ? homeQuery.value : workQuery.value).trim() - const statusRef = which === 'home' ? homeGeoStatus : workGeoStatus - const msgRef = which === 'home' ? homeGeoMsg : workGeoMsg - const label = which === 'home' ? 'Home' : 'Work' - if (!query) { - // Clear location - if (!journalConfig.value.locations) journalConfig.value.locations = {} - journalConfig.value.locations[which] = { label, address: '' } - statusRef.value = '' - msgRef.value = '' - return - } - statusRef.value = 'pending' - msgRef.value = 'Looking up…' - try { - const result = await geocodeAddress(query) - if (!result) { - statusRef.value = 'error' - msgRef.value = `No match for "${query}"` - return - } - if (!journalConfig.value.locations) journalConfig.value.locations = {} - journalConfig.value.locations[which] = { - label, - address: query, - lat: result.lat, - lon: result.lon, - } - statusRef.value = 'ok' - msgRef.value = `Found: ${result.display_name}` - } catch { - statusRef.value = 'error' - msgRef.value = 'Geocoding failed' - } -} - -async function saveJournalCfg() { - journalConfigSaving.value = true - journalConfigSaved.value = false - try { - await saveJournalConfig(journalConfig.value) - journalConfigSaved.value = true - setTimeout(() => { journalConfigSaved.value = false }, 2000) - } catch { toastStore.show('Failed to save journal settings', 'error') } - finally { journalConfigSaving.value = false } -} - onMounted(async () => { try { const v = await apiGet<{ version: string }>('/api/version') @@ -486,7 +397,6 @@ onMounted(async () => { await loadProfile(); // Load journal config (locations, temp unit; prep/closeout UI removed in Phase 7) - await loadJournalConfig(); // Load CalDAV settings try { @@ -1330,64 +1240,6 @@ function formatUserDate(iso: string): string { -
-

Locations

-

Home and work locations are used by the journal's daily prep to fetch local weather. Place names are geocoded once on save.

-
- -
- -
-

{{ homeGeoMsg }}

-

{{ homeGeoMsg }}

-

{{ homeGeoMsg }}

-
-
- -
- -
-

{{ workGeoMsg }}

-

{{ workGeoMsg }}

-

{{ workGeoMsg }}

-
-
- -
- - -
-
-
- - Saved! -
-
-
diff --git a/pyproject.toml b/pyproject.toml index c240a79..a5c927e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "fabledassistant" version = "0.1.0" -description = "Self-hosted note-taking and task-tracking app with LLM integration" +description = "Self-hosted note-taking, project-management, and calendar app exposed to Claude via MCP" requires-python = ">=3.14" dependencies = [ "quart>=0.19", @@ -18,11 +18,7 @@ dependencies = [ "aiosmtplib>=3.0", "caldav>=1.3", "icalendar>=5.0", - "feedparser>=6.0", - "html2text>=2024.2", - "trafilatura>=1.12", "APScheduler>=3.10,<4.0", - "pywebpush>=2.0", "mcp[cli]>=1.0", "fastembed>=0.4", ] @@ -33,11 +29,6 @@ dev = [ "pytest-asyncio>=0.23", "ruff>=0.6", ] -voice = [ - "faster-whisper>=1.0", - "kokoro>=0.9", - "soundfile>=0.12", -] [tool.setuptools.packages.find] where = ["src"] diff --git a/src/fabledassistant/app.py b/src/fabledassistant/app.py index 2ecde12..2cea950 100644 --- a/src/fabledassistant/app.py +++ b/src/fabledassistant/app.py @@ -3,25 +3,18 @@ import time import traceback as tb_module from pathlib import Path -import httpx - from quart import Quart, g, jsonify, make_response, request, send_from_directory from fabledassistant.config import Config from fabledassistant.routes.admin import admin_bp from fabledassistant.routes.api import api from fabledassistant.routes.auth import auth_bp -from fabledassistant.routes.chat import chat_bp -from fabledassistant.routes.journal import journal_bp from fabledassistant.routes.export import export_bp from fabledassistant.routes.notes import notes_bp from fabledassistant.routes.images import images_bp from fabledassistant.routes.milestones import milestones_bp from fabledassistant.routes.task_logs import task_logs_bp from fabledassistant.routes.projects import projects_bp -from fabledassistant.routes.push import push_bp -from fabledassistant.routes.fable_mcp_dist import fable_mcp_dist_bp -from fabledassistant.routes.quick_capture import quick_capture_bp from fabledassistant.routes.settings import settings_bp from fabledassistant.routes.tasks import tasks_bp from fabledassistant.routes.groups import groups_bp @@ -31,7 +24,6 @@ from fabledassistant.routes.users import users_bp from fabledassistant.routes.api_keys import api_keys_bp from fabledassistant.routes.events import events_bp from fabledassistant.routes.search import search_bp -from fabledassistant.routes.voice import voice_bp from fabledassistant.routes.profile import profile_bp from fabledassistant.routes.knowledge import knowledge_bp from fabledassistant.mcp import mount_mcp @@ -76,16 +68,11 @@ def create_app() -> Quart: app.register_blueprint(admin_bp) app.register_blueprint(api) app.register_blueprint(auth_bp) - app.register_blueprint(chat_bp) - app.register_blueprint(journal_bp) app.register_blueprint(export_bp) app.register_blueprint(images_bp) app.register_blueprint(milestones_bp) app.register_blueprint(notes_bp) - app.register_blueprint(fable_mcp_dist_bp) app.register_blueprint(projects_bp) - app.register_blueprint(push_bp) - app.register_blueprint(quick_capture_bp) app.register_blueprint(settings_bp) app.register_blueprint(task_logs_bp) app.register_blueprint(tasks_bp) @@ -96,7 +83,6 @@ def create_app() -> Quart: app.register_blueprint(api_keys_bp) app.register_blueprint(events_bp) app.register_blueprint(search_bp) - app.register_blueprint(voice_bp) app.register_blueprint(profile_bp) app.register_blueprint(knowledge_bp) @@ -161,218 +147,46 @@ def create_app() -> Quart: import asyncio from fabledassistant.services.embeddings import backfill_note_embeddings - from fabledassistant.services.generation_buffer import start_cleanup_loop - from fabledassistant.services.llm import ensure_model from fabledassistant.services.logging import start_log_retention_loop from fabledassistant.services.notifications import start_notification_loop - from fabledassistant.services.push import ensure_vapid_keys - start_cleanup_loop() start_log_retention_loop() start_notification_loop() - ensure_vapid_keys() - async def _warm_model(model: str) -> None: - """Warm an already-installed model into VRAM (no pull).""" - from fabledassistant.services.llm import keep_alive_for - try: - async with httpx.AsyncClient(timeout=300.0) as client: - await client.post( - f"{Config.OLLAMA_URL}/api/generate", - json={"model": model, "prompt": "", "keep_alive": keep_alive_for(model)}, - ) - logger.info("Warmed model '%s' into VRAM", model) - except Exception: - logger.warning("Failed to warm model '%s'", model, exc_info=True) - - async def _prime_kv_cache(user_id: int, model: str) -> None: - """Send a minimal chat request to prime Ollama's KV cache with the user's system prompt. - - This ensures the next real user message only needs to process its own tokens - rather than the full ~4,650-token system prompt, cutting TTFT from ~25s to <1s. - The num_ctx must match what real requests will use so Ollama doesn't reload. - """ - try: - from fabledassistant.services.llm import build_context, keep_alive_for, pick_num_ctx - from fabledassistant.services.tools import get_tools_for_user - messages, _ = await build_context( - user_id=user_id, - history=[], - current_note_id=None, - user_message=" ", - ) - # Include tool schemas so num_ctx matches real chat requests. - tools = await get_tools_for_user(user_id) - num_ctx = pick_num_ctx(messages, tools=tools) - async with httpx.AsyncClient(timeout=120.0) as client: - await client.post( - f"{Config.OLLAMA_URL}/api/chat", - json={ - "model": model, - "messages": messages, - "stream": False, - "options": {"num_predict": 1, "num_ctx": num_ctx}, - "keep_alive": keep_alive_for(model), - }, - ) - logger.info("Primed KV cache for user %d with model '%s' (num_ctx=%d)", user_id, model, num_ctx) - except Exception: - logger.warning("Failed to prime KV cache for user %d", user_id, exc_info=True) - - async def _warm_user_models() -> None: - """ - Pull any user-configured models that are missing from Ollama, then warm - them and prime the KV cache with each user's system prompt. - - Handles both default_model (chat) and background_model user overrides. - Falls back silently if no user preferences exist or Ollama is unreachable. - """ - from sqlalchemy import select as sa_select - - from fabledassistant.models import async_session - from fabledassistant.models.setting import Setting - - # 1. Collect all user model preferences (both chat and background). - try: - async with async_session() as session: - rows = await session.execute( - sa_select(Setting.user_id, Setting.key, Setting.value).where( - Setting.key.in_(["default_model", "background_model"]), - Setting.value.isnot(None), - Setting.value != "", - ) - ) - settings_rows: list[tuple[int, str, str]] = list(rows) - except Exception: - logger.debug("Could not read user model preferences from DB", exc_info=True) - return - - if not settings_rows: - logger.debug("No user model preferences found; skipping warm-up") - return - - # 2. Build the set of unique models to ensure, and the list of - # (user_id, chat_model) pairs for KV-cache priming. - all_models: set[str] = set() - user_chat_models: list[tuple[int, str]] = [] - for user_id_val, key, model in settings_rows: - all_models.add(model) - if key == "default_model": - user_chat_models.append((user_id_val, model)) - - # 3. Ask Ollama which models are currently installed. - try: - async with httpx.AsyncClient(timeout=10.0) as client: - resp = await client.get(f"{Config.OLLAMA_URL}/api/tags") - resp.raise_for_status() - raw_installed: set[str] = {m["name"] for m in resp.json().get("models", [])} - installed: set[str] = raw_installed | { - n.removesuffix(":latest") for n in raw_installed if n.endswith(":latest") - } - except Exception: - logger.debug("Could not reach Ollama to check installed models", exc_info=True) - return - - # 4. Pull any user-configured models that are missing. - for model in all_models: - if model not in installed: - logger.info("User-configured model '%s' not installed; pulling...", model) - await _pull_model(model) - installed.add(model) - - # 5. Warm each unique chat model, then prime KV cache per user. - warmed: set[str] = set() - for user_id_val, model in user_chat_models: - if model in installed: - if model not in warmed: - await _warm_model(model) - warmed.add(model) - await _prime_kv_cache(user_id_val, model) - - async def _pull_model(model: str, warm: bool = False) -> None: - try: - await ensure_model(model) - except Exception: - logger.warning( - "Failed to ensure model '%s'", - model, - exc_info=True, - ) - return - if warm: - await _warm_model(model) - - # Pull supporting models without warming them — embedding model and - # the configured background model load on demand without competing - # for VRAM. The chat model is warmed by _warm_user_models() which - # uses each user's *actual* default_model setting; warming - # Config.OLLAMA_MODEL unconditionally (the OLD behaviour) blasted - # the system default into VRAM ahead of the user's real preference, - # which on a single-GPU setup pushed the user's chat model out - # before they ever sent a message. - asyncio.create_task(_pull_model(Config.OLLAMA_MODEL, warm=False)) - asyncio.create_task(_pull_model(Config.EMBEDDING_MODEL, warm=False)) - asyncio.create_task(_pull_model(Config.OLLAMA_BACKGROUND_MODEL, warm=False)) - asyncio.create_task(_warm_user_models()) - - # After models are pulled, backfill embeddings for existing notes. - # Runs in the background so it never blocks the server from accepting requests. + # Backfill embeddings for any notes that don't have one. Runs in the + # background so it never blocks the server from accepting requests. async def _delayed_backfill() -> None: - await asyncio.sleep(30) # Give Ollama time to load the embedding model try: await backfill_note_embeddings() except Exception: logger.warning("Embedding backfill failed", exc_info=True) - try: - from fabledassistant.services.projects import backfill_project_summaries - await backfill_project_summaries() - except Exception: - logger.warning("Project summary backfill failed", exc_info=True) asyncio.create_task(_delayed_backfill()) - # Start journal scheduler (per-user daily prep generation) - from fabledassistant.services.journal_scheduler import start_journal_scheduler - start_journal_scheduler(asyncio.get_running_loop()) - - # Start event scheduler (reminders + CalDAV pull sync) + # Event scheduler (reminders + CalDAV pull sync) from fabledassistant.services.event_scheduler import start_event_scheduler start_event_scheduler(asyncio.get_running_loop()) - # Start version-pinning scheduler (daily auto-pin scan at 03:00 UTC) + # Version-pinning scheduler (daily auto-pin scan at 03:00 UTC) from fabledassistant.services.version_pinning_scheduler import ( start_version_pinning_scheduler, ) start_version_pinning_scheduler(asyncio.get_running_loop()) - # Start curator scheduler (15-min sweep of journal conversations) - from fabledassistant.services.curator_scheduler import start_curator_scheduler - start_curator_scheduler(asyncio.get_running_loop()) - # Diagnostic instrumentation — heartbeat, signal handlers, asyncio # exception hook. Cheap (~1 log line/min), high diagnostic value when # the app crashes mysteriously. See services/diagnostics.py. from fabledassistant.services.diagnostics import start_diagnostics start_diagnostics(asyncio.get_running_loop()) - # Voice model loading (enabled via Admin → Config in the UI, or VOICE_ENABLED env var) - from fabledassistant.services.stt import load_stt_model - from fabledassistant.services.tts import load_tts_model - asyncio.create_task(load_stt_model()) - asyncio.create_task(load_tts_model()) - @app.after_serving async def shutdown(): - from fabledassistant.services.journal_scheduler import stop_journal_scheduler - stop_journal_scheduler() from fabledassistant.services.event_scheduler import stop_event_scheduler stop_event_scheduler() from fabledassistant.services.version_pinning_scheduler import ( stop_version_pinning_scheduler, ) stop_version_pinning_scheduler() - from fabledassistant.services.curator_scheduler import stop_curator_scheduler - stop_curator_scheduler() from fabledassistant.services.diagnostics import stop_diagnostics stop_diagnostics() diff --git a/src/fabledassistant/models/__init__.py b/src/fabledassistant/models/__init__.py index 45248e9..1a29265 100644 --- a/src/fabledassistant/models/__init__.py +++ b/src/fabledassistant/models/__init__.py @@ -20,7 +20,6 @@ from fabledassistant.models.base import CreatedAtMixin, TimestampMixin # noqa: from fabledassistant.models.note import Note, TaskPriority, TaskStatus # noqa: E402, F401 -from fabledassistant.models.conversation import Conversation, Message # noqa: E402, F401 from fabledassistant.models.setting import Setting # noqa: E402, F401 from fabledassistant.models.user import User # noqa: E402, F401 from fabledassistant.models.app_log import AppLog # noqa: E402, F401 @@ -29,7 +28,6 @@ from fabledassistant.models.invitation import InvitationToken # noqa: E402, F40 from fabledassistant.models.embedding import NoteEmbedding # noqa: E402, F401 from fabledassistant.models.image_cache import ImageCache # noqa: E402, F401 from fabledassistant.models.project import Project # noqa: E402, F401 -from fabledassistant.models.push_subscription import PushSubscription # noqa: E402, F401 from fabledassistant.models.event import Event # noqa: E402, F401 from fabledassistant.models.milestone import Milestone # noqa: E402, F401 from fabledassistant.models.task_log import TaskLog # noqa: E402, F401 @@ -38,16 +36,5 @@ from fabledassistant.models.note_version import NoteVersion # noqa: E402, F401 from fabledassistant.models.group import Group, GroupMembership # noqa: E402, F401 from fabledassistant.models.share import NoteShare, ProjectShare # noqa: E402, F401 from fabledassistant.models.notification import Notification # noqa: E402, F401 -from fabledassistant.models.weather_cache import WeatherCache # noqa: E402, F401 from fabledassistant.models.api_key import ApiKey # noqa: E402, F401 from fabledassistant.models.user_profile import UserProfile # noqa: E402, F401 -from fabledassistant.models.moment import ( # noqa: E402, F401 - Moment, - MomentEmbedding, - moment_people, - moment_places, - moment_tasks, - moment_notes, -) -from fabledassistant.models.generation_tool_log import GenerationToolLog # noqa: E402, F401 -from fabledassistant.models.pending_curator_action import PendingCuratorAction # noqa: E402, F401 diff --git a/src/fabledassistant/models/conversation.py b/src/fabledassistant/models/conversation.py deleted file mode 100644 index 3227811..0000000 --- a/src/fabledassistant/models/conversation.py +++ /dev/null @@ -1,106 +0,0 @@ -import datetime - -from sqlalchemy import Date, DateTime, ForeignKey, Index, Integer, Text -from sqlalchemy import inspect -from sqlalchemy.dialects.postgresql import JSONB -from sqlalchemy.orm import Mapped, mapped_column, relationship - -from fabledassistant.models import Base -from fabledassistant.models.base import CreatedAtMixin, TimestampMixin - - -class Conversation(Base, TimestampMixin): - __tablename__ = "conversations" - - id: Mapped[int] = mapped_column(primary_key=True) - user_id: Mapped[int | None] = mapped_column( - Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True - ) - title: Mapped[str] = mapped_column(Text, default="") - model: Mapped[str] = mapped_column(Text, default="") - # 'chat' (default) or 'journal'. Journal conversations are day-anchored - # and rendered by the /journal view; they are hidden from the main chat list. - conversation_type: Mapped[str] = mapped_column(Text, default="chat", server_default="chat") - # For journal conversations only: the calendar date this conversation covers. - day_date: Mapped[datetime.date | None] = mapped_column(Date, nullable=True) - # NULL = orphan notes only; -1 = all notes; positive int = specific project - rag_project_id: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None) - # Curator scheduler bookkeeping (Phase 2, Fable #172). NULL = never run; - # scheduler processes journal conversations where this is NULL OR older - # than the most recent user message. See services/curator_scheduler.py. - last_curator_run_at: Mapped[datetime.datetime | None] = mapped_column( - DateTime(timezone=True), nullable=True, default=None - ) - # Curator's most recent one-line summary of what was captured (Phase 3, - # Fable #172). Injected into the next chat turn's system prompt so the - # chat model stays aware of recent topics without needing tools to - # retrieve. ≤ 240 chars enforced by curator service. Last-write-wins; - # we don't keep history of prior summaries. - curator_summary: Mapped[str | None] = mapped_column(Text, nullable=True, default=None) - - messages: Mapped[list["Message"]] = relationship( - back_populates="conversation", - cascade="all, delete-orphan", - order_by="Message.created_at", - ) - - __table_args__ = ( - Index("ix_conversations_updated_at", "updated_at"), - Index("ix_conversations_user_id", "user_id"), - ) - - def to_dict(self) -> dict: - # Use loaded messages if available, otherwise 0 - if "messages" in inspect(self).dict: - msg_count = len(self.messages) if self.messages else 0 - else: - msg_count = 0 - return { - "id": self.id, - "title": self.title, - "model": self.model, - "conversation_type": self.conversation_type, - "day_date": self.day_date.isoformat() if self.day_date else None, - "rag_project_id": self.rag_project_id, - "message_count": msg_count, - "created_at": self.created_at.isoformat(), - "updated_at": self.updated_at.isoformat(), - } - - -class Message(Base, CreatedAtMixin): - __tablename__ = "messages" - - id: Mapped[int] = mapped_column(primary_key=True) - conversation_id: Mapped[int] = mapped_column( - Integer, ForeignKey("conversations.id", ondelete="CASCADE") - ) - role: Mapped[str] = mapped_column(Text) - content: Mapped[str] = mapped_column(Text, default="") - status: Mapped[str] = mapped_column(Text, default="complete") - context_note_id: Mapped[int | None] = mapped_column( - Integer, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True - ) - tool_calls: Mapped[list | None] = mapped_column(JSONB, nullable=True) - # 'metadata' is reserved by SQLAlchemy Declarative — use msg_metadata as the - # Python attribute name, mapped to the 'metadata' DB column. - msg_metadata: Mapped[dict | None] = mapped_column("metadata", JSONB, nullable=True) - - conversation: Mapped["Conversation"] = relationship(back_populates="messages") - - __table_args__ = ( - Index("ix_messages_conversation_id", "conversation_id"), - ) - - def to_dict(self) -> dict: - return { - "id": self.id, - "conversation_id": self.conversation_id, - "role": self.role, - "content": self.content, - "status": self.status, - "context_note_id": self.context_note_id, - "tool_calls": self.tool_calls, - "metadata": self.msg_metadata, - "created_at": self.created_at.isoformat(), - } diff --git a/src/fabledassistant/models/generation_tool_log.py b/src/fabledassistant/models/generation_tool_log.py deleted file mode 100644 index 1322143..0000000 --- a/src/fabledassistant/models/generation_tool_log.py +++ /dev/null @@ -1,75 +0,0 @@ -from datetime import datetime, timezone - -from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, Text -from sqlalchemy.dialects.postgresql import ARRAY, JSONB -from sqlalchemy.orm import Mapped, mapped_column - -from fabledassistant.models import Base - - -class GenerationToolLog(Base): - """One row per assistant turn — what tools the model could/did use. - - Lets the operator answer "does model X actually fire record_moment?" - empirically across model swaps without relying on anecdote. - """ - - __tablename__ = "generation_tool_log" - - id: Mapped[int] = mapped_column(primary_key=True) - user_id: Mapped[int] = mapped_column( - Integer, - ForeignKey("users.id", ondelete="CASCADE"), - nullable=False, - ) - conv_id: Mapped[int] = mapped_column( - Integer, - ForeignKey("conversations.id", ondelete="CASCADE"), - nullable=False, - ) - # SET NULL (migration matches) so the telemetry row survives if the - # underlying assistant message is later deleted. - assistant_message_id: Mapped[int | None] = mapped_column( - Integer, - ForeignKey("messages.id", ondelete="SET NULL"), - nullable=True, - ) - model: Mapped[str] = mapped_column(Text, nullable=False) - think_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False) - tools_available: Mapped[list[str]] = mapped_column( - ARRAY(Text), nullable=False, default=list - ) - tools_attempted: Mapped[list[str]] = mapped_column( - ARRAY(Text), nullable=False, default=list - ) - tools_succeeded: Mapped[list[str]] = mapped_column( - ARRAY(Text), nullable=False, default=list - ) - # JSONB list of {name, error} dicts. - tools_failed: Mapped[list[dict]] = mapped_column( - JSONB, nullable=False, default=list - ) - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), - default=lambda: datetime.now(timezone.utc), - ) - - __table_args__ = ( - Index("ix_generation_tool_log_user_created", "user_id", created_at.desc()), - Index("ix_generation_tool_log_conv", "conv_id"), - ) - - def to_dict(self) -> dict: - return { - "id": self.id, - "user_id": self.user_id, - "conv_id": self.conv_id, - "assistant_message_id": self.assistant_message_id, - "model": self.model, - "think_enabled": self.think_enabled, - "tools_available": list(self.tools_available or []), - "tools_attempted": list(self.tools_attempted or []), - "tools_succeeded": list(self.tools_succeeded or []), - "tools_failed": list(self.tools_failed or []), - "created_at": self.created_at.isoformat() if self.created_at else None, - } diff --git a/src/fabledassistant/models/moment.py b/src/fabledassistant/models/moment.py deleted file mode 100644 index e2d8cb9..0000000 --- a/src/fabledassistant/models/moment.py +++ /dev/null @@ -1,130 +0,0 @@ -import datetime - -from sqlalchemy import Boolean, Column, Date, DateTime, ForeignKey, Index, Integer, Table, Text -from sqlalchemy.dialects.postgresql import ARRAY, JSONB -from sqlalchemy.orm import Mapped, mapped_column, relationship - -from fabledassistant.models import Base - - -# Junction tables. People, Places, Tasks, and (regular) Notes all live in -# the `notes` table — the four junctions are kept separate (rather than one -# merged table with a discriminator) so per-kind queries don't require a -# filter, and so the schema is explicit about which links are which. -moment_people = Table( - "moment_people", - Base.metadata, - Column("moment_id", Integer, ForeignKey("moments.id", ondelete="CASCADE"), primary_key=True), - Column("person_id", Integer, ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True), -) - -moment_places = Table( - "moment_places", - Base.metadata, - Column("moment_id", Integer, ForeignKey("moments.id", ondelete="CASCADE"), primary_key=True), - Column("place_id", Integer, ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True), -) - -moment_tasks = Table( - "moment_tasks", - Base.metadata, - Column("moment_id", Integer, ForeignKey("moments.id", ondelete="CASCADE"), primary_key=True), - Column("task_id", Integer, ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True), -) - -moment_notes = Table( - "moment_notes", - Base.metadata, - Column("moment_id", Integer, ForeignKey("moments.id", ondelete="CASCADE"), primary_key=True), - Column("note_id", Integer, ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True), -) - - -class Moment(Base): - """A small structured extraction from a journal conversation. - - Many per day. Emitted by the LLM via the `record_moment` tool when it - notices a meaningful beat. Stored separately from Notes — they are - different in kind: Notes are curated artifacts; Moments are ambient - trace data with their own embedding index, so notes-RAG and journal-RAG - cannot cross-contaminate. - """ - - __tablename__ = "moments" - - id: Mapped[int] = mapped_column(Integer, primary_key=True) - user_id: Mapped[int] = mapped_column( - Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False - ) - conversation_id: Mapped[int | None] = mapped_column( - Integer, ForeignKey("conversations.id", ondelete="SET NULL"), nullable=True - ) - source_message_id: Mapped[int | None] = mapped_column( - Integer, ForeignKey("messages.id", ondelete="SET NULL"), nullable=True - ) - day_date: Mapped[datetime.date] = mapped_column(Date, nullable=False) - occurred_at: Mapped[datetime.datetime] = mapped_column( - DateTime(timezone=True), nullable=False - ) - recorded_at: Mapped[datetime.datetime] = mapped_column( - DateTime(timezone=True), - nullable=False, - default=lambda: datetime.datetime.now(datetime.timezone.utc), - ) - content: Mapped[str] = mapped_column(Text, nullable=False) - raw_excerpt: Mapped[str | None] = mapped_column(Text, nullable=True) - tags: Mapped[list[str]] = mapped_column(ARRAY(Text), nullable=False, default=list) - pinned: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) - - people = relationship("Note", secondary=moment_people, lazy="selectin", viewonly=True) - places = relationship("Note", secondary=moment_places, lazy="selectin", viewonly=True) - tasks = relationship("Note", secondary=moment_tasks, lazy="selectin", viewonly=True) - notes = relationship("Note", secondary=moment_notes, lazy="selectin", viewonly=True) - - __table_args__ = ( - Index("ix_moments_user_day", "user_id", "day_date"), - Index("ix_moments_user_occurred", "user_id", "occurred_at"), - ) - - def to_dict(self, *, include_links: bool = True) -> dict: - result: dict = { - "id": self.id, - "user_id": self.user_id, - "conversation_id": self.conversation_id, - "source_message_id": self.source_message_id, - "day_date": self.day_date.isoformat(), - "occurred_at": self.occurred_at.isoformat(), - "recorded_at": self.recorded_at.isoformat(), - "content": self.content, - "raw_excerpt": self.raw_excerpt, - "tags": list(self.tags or []), - "pinned": self.pinned, - } - if include_links: - result["people"] = [{"id": p.id, "title": p.title} for p in (self.people or [])] - result["places"] = [{"id": p.id, "title": p.title} for p in (self.places or [])] - result["task_ids"] = [t.id for t in (self.tasks or [])] - result["note_ids"] = [n.id for n in (self.notes or [])] - return result - - -class MomentEmbedding(Base): - """Embedding vector for a Moment — used by `search_journal` semantic mode. - - Stored separately from `note_embeddings` so notes-RAG and journal-RAG - cannot cross-contaminate. This is a hard invariant of the journal design. - """ - - __tablename__ = "moment_embeddings" - - moment_id: Mapped[int] = mapped_column( - Integer, - ForeignKey("moments.id", ondelete="CASCADE"), - primary_key=True, - ) - user_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True) - embedding: Mapped[list] = mapped_column(JSONB, nullable=False) - updated_at: Mapped[datetime.datetime] = mapped_column( - DateTime(timezone=True), - default=lambda: datetime.datetime.now(datetime.timezone.utc), - ) diff --git a/src/fabledassistant/models/pending_curator_action.py b/src/fabledassistant/models/pending_curator_action.py deleted file mode 100644 index 5784877..0000000 --- a/src/fabledassistant/models/pending_curator_action.py +++ /dev/null @@ -1,81 +0,0 @@ -from datetime import datetime, timezone - -from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, Text -from sqlalchemy.dialects.postgresql import JSONB -from sqlalchemy.orm import Mapped, mapped_column - -from fabledassistant.models import Base - - -class PendingCuratorAction(Base): - """Curator-proposed mutation awaiting user approval. - - The curator can be confidently wrong, and the user is not in the loop - when it runs. Additive operations land directly; mutating operations - (update_*, delete_*) write a row here instead. The user reviews each - one from the journal's Needs Review panel. - - On approval, the original tool call is replayed via execute_tool with - authority="user" — bypassing the curator interceptor so the request - just runs. - """ - - __tablename__ = "pending_curator_actions" - - id: Mapped[int] = mapped_column(primary_key=True) - user_id: Mapped[int] = mapped_column( - Integer, - ForeignKey("users.id", ondelete="CASCADE"), - nullable=False, - ) - # SET NULL so a curator-proposed action survives if its source - # conversation is later deleted — the user might still want to - # review and approve it. - conv_id: Mapped[int | None] = mapped_column( - Integer, - ForeignKey("conversations.id", ondelete="SET NULL"), - nullable=True, - ) - # The tool name the curator wanted to call (`update_task`, `delete_note`, - # etc.). Used at approval-replay time to dispatch through execute_tool. - action_type: Mapped[str] = mapped_column(Text, nullable=False) - # Display hints — what the UI shows in the card header. - target_type: Mapped[str | None] = mapped_column(Text, nullable=True) - target_id: Mapped[int | None] = mapped_column(Integer, nullable=True) - target_label: Mapped[str | None] = mapped_column(Text, nullable=True) - # The curator's proposed arguments — replayed verbatim on approval. - payload: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) - # State of the target at proposal time. Lets the UI render an honest - # diff (current → proposed) even if other work modifies the entity - # between proposal and review. - current_snapshot: Mapped[dict] = mapped_column( - JSONB, nullable=False, default=dict - ) - status: Mapped[str] = mapped_column( - Text, nullable=False, default="pending", server_default="pending" - ) - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), - default=lambda: datetime.now(timezone.utc), - ) - reviewed_at: Mapped[datetime | None] = mapped_column( - DateTime(timezone=True), nullable=True, default=None - ) - - def to_dict(self) -> dict: - return { - "id": self.id, - "user_id": self.user_id, - "conv_id": self.conv_id, - "action_type": self.action_type, - "target_type": self.target_type, - "target_id": self.target_id, - "target_label": self.target_label, - "payload": dict(self.payload or {}), - "current_snapshot": dict(self.current_snapshot or {}), - "status": self.status, - "created_at": self.created_at.isoformat() if self.created_at else None, - "reviewed_at": ( - self.reviewed_at.isoformat() if self.reviewed_at else None - ), - } diff --git a/src/fabledassistant/models/push_subscription.py b/src/fabledassistant/models/push_subscription.py deleted file mode 100644 index 072afaf..0000000 --- a/src/fabledassistant/models/push_subscription.py +++ /dev/null @@ -1,20 +0,0 @@ -from datetime import datetime -from sqlalchemy import DateTime, ForeignKey, Integer, Text, UniqueConstraint -from sqlalchemy.orm import Mapped, mapped_column -from fabledassistant.models import Base -from fabledassistant.models.base import CreatedAtMixin - - -class PushSubscription(Base, CreatedAtMixin): - __tablename__ = "push_subscriptions" - id: Mapped[int] = mapped_column(primary_key=True) - user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE")) - endpoint: Mapped[str] = mapped_column(Text) - p256dh: Mapped[str] = mapped_column(Text) - auth: Mapped[str] = mapped_column(Text) - user_agent: Mapped[str | None] = mapped_column(Text, nullable=True) - last_used: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) - - __table_args__ = ( - UniqueConstraint("user_id", "endpoint", name="uq_push_subscription_user_endpoint"), - ) diff --git a/src/fabledassistant/models/weather_cache.py b/src/fabledassistant/models/weather_cache.py deleted file mode 100644 index e6c076e..0000000 --- a/src/fabledassistant/models/weather_cache.py +++ /dev/null @@ -1,38 +0,0 @@ -from datetime import datetime, timezone - -from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text, UniqueConstraint -from sqlalchemy.dialects.postgresql import JSONB -from sqlalchemy.orm import Mapped, mapped_column - -from fabledassistant.models import Base - - -class WeatherCache(Base): - __tablename__ = "weather_cache" - - id: Mapped[int] = mapped_column(primary_key=True) - user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE")) - # Unique key per location, e.g. "home", "work", or "event:" - location_key: Mapped[str] = mapped_column(Text) - location_label: Mapped[str] = mapped_column(Text, default="") - # Current 7-day forecast from Open-Meteo (raw JSON) - forecast_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True) - # Previous forecast — used to detect changes between fetches - previous_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True) - fetched_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) - ) - - __table_args__ = ( - UniqueConstraint("user_id", "location_key", name="uq_weather_cache_user_location"), - Index("ix_weather_cache_user_id", "user_id"), - ) - - def to_dict(self) -> dict: - return { - "id": self.id, - "location_key": self.location_key, - "location_label": self.location_label, - "forecast_json": self.forecast_json, - "fetched_at": self.fetched_at.isoformat(), - } diff --git a/src/fabledassistant/routes/admin.py b/src/fabledassistant/routes/admin.py index 0b2df86..115f35d 100644 --- a/src/fabledassistant/routes/admin.py +++ b/src/fabledassistant/routes/admin.py @@ -19,7 +19,6 @@ from fabledassistant.services.backup import ( restore_full_backup, ) from fabledassistant.services.email import SMTP_SETTING_KEYS, get_base_url, get_smtp_config, is_smtp_configured, send_test_email -from fabledassistant.services.voice_config import get_voice_config from fabledassistant.services.logging import get_logs, get_log_stats, log_audit from fabledassistant.services.notifications import send_invitation_email from fabledassistant.services.settings import set_setting, set_settings_batch @@ -205,44 +204,6 @@ async def update_base_url(): return jsonify({"status": "ok"}) -@admin_bp.route("/voice", methods=["GET"]) -@admin_required -async def get_voice_config_route(): - config = await get_voice_config() - return jsonify(config) - - -@admin_bp.route("/voice", methods=["PUT"]) -@admin_required -async def update_voice_config(): - data = await request.get_json() - uid = get_current_user_id() - valid_models = {"tiny.en", "base.en", "small.en", "medium.en"} - settings: dict[str, str] = {} - if "voice_enabled" in data: - settings["voice_enabled"] = "true" if data["voice_enabled"] else "false" - if "voice_stt_model" in data: - model = str(data["voice_stt_model"]) - if model not in valid_models: - return jsonify({"error": f"Invalid STT model. Choose from: {', '.join(sorted(valid_models))}"}), 400 - settings["voice_stt_model"] = model - if settings: - await set_settings_batch(uid, settings) - await log_audit("voice_config", user_id=uid, username=g.user.username, ip_address=request.remote_addr, details=settings) - return jsonify({"status": "ok"}) - - -@admin_bp.route("/voice/reload", methods=["POST"]) -@admin_required -async def reload_voice_models(): - """Reload STT and TTS models in the background without a server restart.""" - from fabledassistant.services.stt import reload_stt_model - from fabledassistant.services.tts import reload_tts_model - asyncio.create_task(reload_stt_model()) - asyncio.create_task(reload_tts_model()) - return jsonify({"status": "loading"}) - - @admin_bp.route("/invitations", methods=["POST"]) @admin_required async def create_invite(): diff --git a/src/fabledassistant/routes/chat.py b/src/fabledassistant/routes/chat.py deleted file mode 100644 index e0cdeb0..0000000 --- a/src/fabledassistant/routes/chat.py +++ /dev/null @@ -1,548 +0,0 @@ -import asyncio -import json -import logging - -import httpx -from quart import Blueprint, Response, jsonify, request - -from fabledassistant.auth import admin_required, login_required, get_current_user_id -from fabledassistant.routes.utils import not_found, parse_pagination -from fabledassistant.config import Config -from fabledassistant.services.chat import ( - add_message, - bulk_delete_conversations, - create_conversation, - delete_conversation, - get_conversation, - list_conversations, - save_response_as_note, - summarize_conversation_as_note, - update_conversation, -) -from fabledassistant.services.generation_buffer import ( - GenerationState, - create_buffer, - get_buffer, -) -from fabledassistant.services.generation_task import run_generation -from fabledassistant.services.notes import get_notes_by_ids -from fabledassistant.services.settings import get_setting - -logger = logging.getLogger(__name__) - -chat_bp = Blueprint("chat", __name__, url_prefix="/api/chat") - - -@chat_bp.route("/conversations", methods=["GET"]) -@login_required -async def list_conversations_route(): - uid = get_current_user_id() - limit, offset = parse_pagination() - conv_type = request.args.get("type", "chat") - conversations, total = await list_conversations(uid, limit=limit, offset=offset, conv_type=conv_type) - return jsonify({ - "conversations": conversations, - "total": total, - }) - - -@chat_bp.route("/conversations/bulk-delete", methods=["POST"]) -@login_required -async def bulk_delete_conversations_route(): - uid = get_current_user_id() - data = await request.get_json() - ids = data.get("ids", []) if isinstance(data, dict) else [] - if not isinstance(ids, list) or not all(isinstance(i, int) for i in ids): - return jsonify({"error": "ids must be a list of integers"}), 400 - count = await bulk_delete_conversations(uid, ids) - return jsonify({"deleted": count}) - - -@chat_bp.route("/conversations", methods=["POST"]) -@login_required -async def create_conversation_route(): - uid = get_current_user_id() - data = await request.get_json(force=True, silent=True) or {} - title = data.get("title", "") - model = data.get("model", Config.OLLAMA_MODEL) - conversation_type = data.get("conversation_type", "chat") - # Only allow known types to prevent accidental misuse - if conversation_type not in ("chat", "mcp", "voice"): - conversation_type = "chat" - conv = await create_conversation(uid, title=title, model=model, conversation_type=conversation_type) - return jsonify(conv.to_dict()), 201 - - -@chat_bp.route("/conversations/", methods=["GET"]) -@login_required -async def get_conversation_route(conv_id: int): - uid = get_current_user_id() - conv = await get_conversation(uid, conv_id) - if conv is None: - return not_found("Conversation") - result = conv.to_dict() - note_ids = [m.context_note_id for m in conv.messages if m.context_note_id] - note_map = await get_notes_by_ids(uid, note_ids) if note_ids else {} - result["messages"] = [] - for m in conv.messages: - msg_dict = m.to_dict() - if m.context_note_id and m.context_note_id in note_map: - msg_dict["context_note_title"] = note_map[m.context_note_id].title - result["messages"].append(msg_dict) - return jsonify(result) - - -@chat_bp.route("/conversations/", methods=["DELETE"]) -@login_required -async def delete_conversation_route(conv_id: int): - uid = get_current_user_id() - deleted = await delete_conversation(uid, conv_id) - if not deleted: - return not_found("Conversation") - return "", 204 - - -@chat_bp.route("/conversations/", methods=["PATCH"]) -@login_required -async def update_conversation_route(conv_id: int): - from fabledassistant.services.chat import _UNSET - uid = get_current_user_id() - data = await request.get_json() - title = data.get("title") - model = data.get("model") - rag_project_id = data.get("rag_project_id", _UNSET) - if title is None and model is None and rag_project_id is _UNSET: - return jsonify({"error": "title, model, or rag_project_id is required"}), 400 - conv = await update_conversation(uid, conv_id, title=title, model=model, rag_project_id=rag_project_id) - if conv is None: - return not_found("Conversation") - return jsonify(conv.to_dict()) - - -@chat_bp.route("/conversations//messages", methods=["POST"]) -@login_required -async def send_message_route(conv_id: int): - """Start generation: save user message, launch background task, return 202.""" - uid = get_current_user_id() - conv = await get_conversation(uid, conv_id) - if conv is None: - return not_found("Conversation") - - data = await request.get_json() - content = data.get("content", "").strip() - if not content: - return jsonify({"error": "content is required"}), 400 - context_note_id = data.get("context_note_id") - include_note_ids = data.get("include_note_ids") or [] - excluded_note_ids = data.get("excluded_note_ids") or [] - think = bool(data.get("think", False)) - rag_project_id = data.get("rag_project_id") or None - workspace_project_id = data.get("workspace_project_id") or None - user_timezone = data.get("user_timezone") or None - if not user_timezone: - user_timezone = await get_setting(uid, "user_timezone") or None - effective_rag_project_id = workspace_project_id or rag_project_id - - # Reject if generation already running for this conversation - existing = get_buffer(conv_id) - if existing and existing.state == GenerationState.RUNNING: - return jsonify({"error": "Generation already in progress"}), 409 - - # Save user message - await add_message(conv_id, "user", content, context_note_id=context_note_id) - - # Create placeholder assistant message - assistant_msg = await add_message(conv_id, "assistant", "", status="generating") - - try: - buf = create_buffer(conv_id, assistant_msg.id) - except RuntimeError: - return jsonify({"error": "Generation already in progress"}), 409 - - # Build history from existing messages (excluding system and the placeholder) - history = [] - for msg in conv.messages: - if msg.role == "system": - continue - msg_dict = {"role": msg.role, "content": msg.content or ""} - if msg.tool_calls: - msg_dict["tool_calls"] = [ - {"function": {"name": tc["function"], "arguments": tc["arguments"]}} - for tc in msg.tool_calls - ] - history.append(msg_dict) - for tc in msg.tool_calls: - history.append({"role": "tool", "content": json.dumps(tc.get("result", {}))}) - else: - history.append(msg_dict) - - model = await get_setting(uid, "default_model", Config.OLLAMA_MODEL) or Config.OLLAMA_MODEL - - # Launch background generation task (context building happens inside the task). - # - # Wrap in a top-level guard: `run_generation` is fire-and-forget via - # asyncio.create_task. Without a wrapper, an unhandled exception inside - # the coroutine is swallowed by the event loop, the buffer stays in - # GenerationState.RUNNING forever, and every subsequent POST to this - # conversation returns 409 — locking the user out of the chat surface - # with no log trail. Observed in production 2026-05-22 where the - # generation hung before any internal log line could fire. - async def _run_generation_guarded(): - try: - await run_generation( - buf, history, model, - uid, conv_id, conv.title, content, - context_note_id=context_note_id, - include_note_ids=include_note_ids, - excluded_note_ids=excluded_note_ids, - think=think, - rag_project_id=effective_rag_project_id, - workspace_project_id=workspace_project_id, - user_timezone=user_timezone, - voice_mode=(conv.conversation_type == "voice"), - ) - except Exception: - logger.exception( - "run_generation crashed for conv %d msg %d (model=%s); " - "transitioning buffer to FAILED so the route can be used again", - conv_id, assistant_msg.id, model, - ) - try: - buf.state = GenerationState.ERRORED - buf.append_event( - "done", - {"done": True, "error": "Generation crashed; see server logs"}, - ) - except Exception: - logger.exception("Failed to mark buffer ERRORED for conv %d", conv_id) - try: - from fabledassistant.services.generation_task import _update_message - await _update_message( - assistant_msg.id, - "", - "error", - tool_calls=None, - ) - except Exception: - logger.exception( - "Failed to mark assistant message %d as error after crash", - assistant_msg.id, - ) - - asyncio.create_task(_run_generation_guarded()) - - return jsonify({ - "assistant_message_id": assistant_msg.id, - "status": "generating", - }), 202 - - -@chat_bp.route("/conversations//generation/stream", methods=["GET"]) -@login_required -async def generation_stream_route(conv_id: int): - """SSE endpoint that tails the generation buffer for a conversation.""" - uid = get_current_user_id() - conv = await get_conversation(uid, conv_id) - if conv is None: - return not_found("Conversation") - - buf = get_buffer(conv_id) - if buf is None: - return jsonify({"error": "No active generation"}), 404 - - # Determine starting point from Last-Event-ID header or query param - last_id_str = request.headers.get("Last-Event-ID") or request.args.get("last_event_id") - try: - last_id = int(last_id_str) if last_id_str is not None else -1 - except (ValueError, TypeError): - last_id = -1 - - async def stream(): - cursor = last_id - while True: - # Replay any buffered events past the cursor - pending = buf.events_after(cursor) - for event in pending: - yield buf.format_sse(event) - cursor = event.index - - # If generation is done and all events delivered, close stream - if buf.state != GenerationState.RUNNING: - break - - # Wait for new events or send keepalive on timeout - got_new = await buf.wait_for_event(cursor, timeout=15.0) - if not got_new: - yield ": keepalive\n\n" - - # Final drain: deliver any events appended between the last yield - # and the state check above (e.g. the 'done' event). - for event in buf.events_after(cursor): - yield buf.format_sse(event) - - return Response( - stream(), - content_type="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "X-Accel-Buffering": "no", - }, - ) - - -@chat_bp.route("/conversations//generation/confirm", methods=["POST"]) -@login_required -async def confirm_generation_route(conv_id: int): - """Resolve a pending tool confirmation (accept or decline).""" - uid = get_current_user_id() - conv = await get_conversation(uid, conv_id) - if conv is None: - return not_found("Conversation") - - buf = get_buffer(conv_id) - if buf is None or buf.state != GenerationState.RUNNING: - return jsonify({"error": "No active generation"}), 404 - - if buf.confirmation_future is None or buf.confirmation_future.done(): - return jsonify({"error": "No pending tool confirmation"}), 409 - - data = await request.get_json(force=True, silent=True) or {} - decision = bool(data.get("confirmed", False)) - buf.confirmation_future.set_result(decision) - return jsonify({"status": "ok", "confirmed": decision}) - - -@chat_bp.route("/conversations//generation/cancel", methods=["POST"]) -@login_required -async def cancel_generation_route(conv_id: int): - """Cancel an active generation for a conversation.""" - uid = get_current_user_id() - conv = await get_conversation(uid, conv_id) - if conv is None: - return not_found("Conversation") - - buf = get_buffer(conv_id) - if buf is None or buf.state != GenerationState.RUNNING: - return jsonify({"error": "No active generation"}), 404 - - buf.cancel_event.set() - return jsonify({"status": "cancelled"}) - - -@chat_bp.route("/messages//save-as-note", methods=["POST"]) -@login_required -async def save_message_as_note_route(message_id: int): - uid = get_current_user_id() - try: - note = await save_response_as_note(uid, message_id) - return jsonify(note), 201 - except ValueError as e: - return jsonify({"error": str(e)}), 400 - - -@chat_bp.route("/conversations//summarize", methods=["POST"]) -@login_required -async def summarize_conversation_route(conv_id: int): - uid = get_current_user_id() - conv = await get_conversation(uid, conv_id) - if conv is None: - return not_found("Conversation") - model = await get_setting(uid, "default_model", Config.OLLAMA_MODEL) or Config.OLLAMA_MODEL - try: - note = await summarize_conversation_as_note(uid, conv_id, model) - return jsonify(note), 201 - except ValueError as e: - return jsonify({"error": str(e)}), 400 - - -@chat_bp.route("/ps", methods=["GET"]) -@login_required -async def running_models_route(): - """Return currently loaded (hot) models from Ollama.""" - try: - async with httpx.AsyncClient(timeout=5.0) as client: - resp = await client.get(f"{Config.OLLAMA_URL}/api/ps") - resp.raise_for_status() - data = resp.json() - models = [ - { - "name": m["name"], - "size": m.get("size", 0), - "size_vram": m.get("size_vram", 0), - "expires_at": m.get("expires_at", ""), - } - for m in data.get("models", []) - ] - return jsonify({"models": models}) - except Exception as e: - logger.debug("Failed to query Ollama /api/ps: %s", e) - return jsonify({"models": []}) - - -@chat_bp.route("/warm", methods=["POST"]) -@login_required -async def warm_model_route(): - """Pre-load a model into Ollama memory.""" - data = await request.get_json(force=True, silent=True) or {} - model = data.get("model", "").strip() - if not model: - return jsonify({"error": "model is required"}), 400 - - async def _warm(): - from fabledassistant.services.llm import keep_alive_for - try: - async with httpx.AsyncClient(timeout=300.0) as client: - await client.post( - f"{Config.OLLAMA_URL}/api/generate", - json={"model": model, "prompt": "", "keep_alive": keep_alive_for(model)}, - ) - logger.info("Warmed model %s", model) - except Exception as e: - logger.warning("Failed to warm model %s: %s", model, e) - - asyncio.create_task(_warm()) - return jsonify({"status": "warming"}), 202 - - -@chat_bp.route("/status", methods=["GET"]) -@login_required -async def chat_status_route(): - """Check Ollama availability, model installation, and model load state.""" - uid = get_current_user_id() - default_model = await get_setting(uid, "default_model", Config.OLLAMA_MODEL) - # Guard against empty-string rows written by older code when user selected "default" - if not default_model: - default_model = Config.OLLAMA_MODEL - result = { - "ollama": "unavailable", - "model": "not_found", - "default_model": default_model, - } - try: - async with httpx.AsyncClient(timeout=10.0) as client: - # Query installed models and loaded models in parallel - tags_task = asyncio.create_task(client.get(f"{Config.OLLAMA_URL}/api/tags")) - ps_task = asyncio.create_task(client.get(f"{Config.OLLAMA_URL}/api/ps")) - tags_resp, ps_resp = await asyncio.gather(tags_task, ps_task, return_exceptions=True) - - if isinstance(tags_resp, Exception): - logger.debug("Ollama /api/tags failed: %s", tags_resp) - else: - tags_resp.raise_for_status() - result["ollama"] = "available" - model_names = {m["name"] for m in tags_resp.json().get("models", [])} - base = default_model.removesuffix(":latest") - if default_model in model_names or f"{base}:latest" in model_names or base in model_names: - # Installed — now check if currently loaded in memory - result["model"] = "cold" - if not isinstance(ps_resp, Exception): - try: - ps_resp.raise_for_status() - loaded_names = {m["name"] for m in ps_resp.json().get("models", [])} - if default_model in loaded_names or f"{base}:latest" in loaded_names or base in loaded_names: - result["model"] = "loaded" - except Exception: - logger.debug("Ollama /api/ps check failed", exc_info=True) - except Exception: - logger.debug("Ollama status check failed", exc_info=True) - return jsonify(result) - - -@chat_bp.route("/models", methods=["GET"]) -@login_required -async def list_models_route(): - try: - async with httpx.AsyncClient(timeout=10.0) as client: - tags_task = asyncio.create_task(client.get(f"{Config.OLLAMA_URL}/api/tags")) - ps_task = asyncio.create_task(client.get(f"{Config.OLLAMA_URL}/api/ps")) - tags_resp, ps_resp = await asyncio.gather(tags_task, ps_task, return_exceptions=True) - - loaded_names: set[str] = set() - if not isinstance(ps_resp, Exception): - try: - ps_resp.raise_for_status() - loaded_names = {m["name"] for m in ps_resp.json().get("models", [])} - except Exception: - pass - - models = [] - if not isinstance(tags_resp, Exception): - tags_resp.raise_for_status() - for m in tags_resp.json().get("models", []): - models.append({ - "name": m["name"], - "size": m.get("size", 0), - "modified_at": m.get("modified_at", ""), - "loaded": m["name"] in loaded_names, - }) - return jsonify({"models": models}) - except Exception as e: - logger.warning("Failed to list Ollama models: %s", e) - return jsonify({"models": [], "error": str(e)}), 200 - - -@chat_bp.route("/models/pull", methods=["POST"]) -@admin_required -async def pull_model_route(): - """Pull a model from Ollama, streaming progress via SSE.""" - data = await request.get_json() - model_name = data.get("model", "").strip() - if not model_name: - return jsonify({"error": "model is required"}), 400 - - async def generate(): - try: - async with httpx.AsyncClient(timeout=1800.0) as client: - async with client.stream( - "POST", - f"{Config.OLLAMA_URL}/api/pull", - json={"name": model_name}, - ) as resp: - resp.raise_for_status() - async for line in resp.aiter_lines(): - if not line.strip(): - continue - progress = json.loads(line) - event_data = json.dumps(progress) - yield f"data: {event_data}\n\n" - yield f"data: {json.dumps({'status': 'success'})}\n\n" - except Exception as e: - logger.exception("Error pulling model %s", model_name) - yield f"data: {json.dumps({'error': str(e)})}\n\n" - - return Response( - generate(), - content_type="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "X-Accel-Buffering": "no", - }, - ) - - -@chat_bp.route("/models/delete", methods=["POST"]) -@admin_required -async def delete_model_route(): - """Delete a model from Ollama.""" - data = await request.get_json() - model_name = data.get("model", "").strip() - if not model_name: - return jsonify({"error": "model is required"}), 400 - - try: - async with httpx.AsyncClient(timeout=30.0) as client: - resp = await client.request( - "DELETE", - f"{Config.OLLAMA_URL}/api/delete", - json={"name": model_name}, - ) - resp.raise_for_status() - return jsonify({"status": "deleted", "model": model_name}) - except httpx.HTTPStatusError as e: - logger.warning("Failed to delete model %s: %s", model_name, e) - return jsonify({"error": f"Failed to delete model: {e.response.status_code}"}), 400 - except Exception as e: - logger.warning("Failed to delete model %s: %s", model_name, e) - return jsonify({"error": str(e)}), 500 - - diff --git a/src/fabledassistant/routes/fable_mcp_dist.py b/src/fabledassistant/routes/fable_mcp_dist.py deleted file mode 100644 index 0136c89..0000000 --- a/src/fabledassistant/routes/fable_mcp_dist.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Serve the fable-mcp distribution wheel built into the Docker image.""" -import logging -import os -from pathlib import Path - -from quart import Blueprint, jsonify, send_file - -from fabledassistant.auth import login_required - -logger = logging.getLogger(__name__) - -fable_mcp_dist_bp = Blueprint("fable_mcp_dist", __name__, url_prefix="/api/fable-mcp") - -# Wheel is built into the image at this path (see Dockerfile) -_DIST_DIR = Path(os.environ.get("FABLE_MCP_DIST_DIR", "/app/dist")) - - -def _find_wheel() -> Path | None: - """Return the newest fable_mcp wheel in the dist dir, or None.""" - wheels = sorted(_DIST_DIR.glob("fable_mcp-*.whl"), reverse=True) - return wheels[0] if wheels else None - - -@fable_mcp_dist_bp.route("/info", methods=["GET"]) -@login_required -async def fable_mcp_info(): - """Return availability and filename of the bundled fable-mcp wheel.""" - wheel = _find_wheel() - return jsonify({ - "available": wheel is not None, - "filename": wheel.name if wheel else None, - }) - - -@fable_mcp_dist_bp.route("/download", methods=["GET"]) -@login_required -async def download_fable_mcp(): - """Serve the fable-mcp wheel file as a download.""" - wheel = _find_wheel() - if wheel is None: - return jsonify({"error": "Package not built into this image"}), 404 - return await send_file(wheel, as_attachment=True) diff --git a/src/fabledassistant/routes/journal.py b/src/fabledassistant/routes/journal.py deleted file mode 100644 index 119b03e..0000000 --- a/src/fabledassistant/routes/journal.py +++ /dev/null @@ -1,519 +0,0 @@ -"""HTTP endpoints for the Journal feature. - -Includes the conversational journal endpoints (config / today / day / days / -trigger-prep / moments) plus the ambient-context surface lifted from the -old Briefing routes (RSS feeds, weather, news, RSS reactions, article-discuss). -The ambient endpoints read locations + temp_unit + topic preferences from the -``journal_config`` user setting. -""" -from __future__ import annotations - -import asyncio -import datetime -import json -import logging -from zoneinfo import ZoneInfo - -from quart import Blueprint, jsonify, request -from sqlalchemy import select - -from fabledassistant.auth import get_current_user_id, login_required -from fabledassistant.models import Conversation, Message, async_session -from fabledassistant.services import weather as weather_svc -from fabledassistant.services.journal_prep import ensure_daily_prep_message -from fabledassistant.services.journal_scheduler import ( - DEFAULT_CONFIG as DEFAULT_JOURNAL_CONFIG, - update_user_schedule, -) -from fabledassistant.services.journal_search import search_journal -from fabledassistant.services.moments import delete_moment, update_moment -from fabledassistant.services.settings import get_setting, set_setting - -logger = logging.getLogger(__name__) - -journal_bp = Blueprint("journal", __name__, url_prefix="/api/journal") - - -def _resolve_tz(tz_str: str) -> ZoneInfo: - try: - return ZoneInfo(tz_str) - except Exception: - return ZoneInfo("UTC") - - -def _today_in_tz(tz_str: str, *, day_rollover_hour: int) -> datetime.date: - tz = _resolve_tz(tz_str) - now = datetime.datetime.now(tz) - if now.hour < day_rollover_hour: - return (now - datetime.timedelta(days=1)).date() - return now.date() - - -async def _user_timezone(user_id: int) -> str: - return await get_setting(user_id, "user_timezone", "UTC") or "UTC" - - -async def _resolve_config(user_id: int) -> dict: - raw = await get_setting(user_id, "journal_config", "") - config: dict = {} - if raw: - try: - parsed = json.loads(raw) if isinstance(raw, str) else raw - if isinstance(parsed, dict): - config = parsed - except Exception: - logger.warning("Invalid journal_config for user %d", user_id) - return {**DEFAULT_JOURNAL_CONFIG, **config} - - -def _valid_location_keys(cfg: dict) -> set[str]: - """Keys in ``cfg.locations`` that have a usable lat/lon. Anything else - (orphaned cache rows, locations the user typed but didn't geocode) is - excluded so it can't render as a fake site in the UI.""" - locations = cfg.get("locations") or {} - return { - key for key, loc in locations.items() - if isinstance(loc, dict) and loc.get("lat") is not None and loc.get("lon") is not None - } - - -@journal_bp.get("/config") -@login_required -async def get_config(): - user_id = get_current_user_id() - return jsonify(await _resolve_config(user_id)) - - -@journal_bp.put("/config") -@login_required -async def put_config(): - user_id = get_current_user_id() - body = await request.get_json() - if not isinstance(body, dict): - return jsonify({"error": "config must be an object"}), 400 - await set_setting(user_id, "journal_config", json.dumps(body)) - await update_user_schedule(user_id) - - # Trigger a background weather refresh for any newly-saved location with - # valid lat/lon. Without this, the cache row for the location doesn't - # exist (or stays stale) until the user clicks the manual refresh button, - # so the journal weather panel renders empty for newly-entered sites. - valid_locs = [ - (key, loc) - for key, loc in (body.get("locations") or {}).items() - if isinstance(loc, dict) and loc.get("lat") is not None and loc.get("lon") is not None - ] - if valid_locs: - asyncio.create_task(_refresh_locations_in_background(user_id, valid_locs)) - - return jsonify({"ok": True}) - - -async def _refresh_locations_in_background( - user_id: int, locations: list[tuple[str, dict]] -) -> None: - for key, loc in locations: - try: - await weather_svc.refresh_location_cache( - user_id=user_id, - location_key=key, - location_label=loc.get("label", key), - lat=loc["lat"], - lon=loc["lon"], - ) - except Exception: - logger.warning( - "Post-save weather refresh failed for user %d / %s", - user_id, key, exc_info=True, - ) - - -@journal_bp.get("/today") -@login_required -async def get_today(): - user_id = get_current_user_id() - config = await _resolve_config(user_id) - tz_str = await _user_timezone(user_id) - today = _today_in_tz(tz_str, day_rollover_hour=int(config.get("day_rollover_hour", 4))) - - await ensure_daily_prep_message( - user_id=user_id, day_date=today, user_timezone=tz_str - ) - return await _day_payload(user_id=user_id, day_date=today) - - -@journal_bp.get("/day/") -@login_required -async def get_day(iso_date: str): - user_id = get_current_user_id() - try: - day = datetime.date.fromisoformat(iso_date) - except ValueError: - return jsonify({"error": "invalid date"}), 400 - return await _day_payload(user_id=user_id, day_date=day) - - -@journal_bp.get("/days") -@login_required -async def list_days(): - user_id = get_current_user_id() - async with async_session() as session: - stmt = ( - select(Conversation.day_date) - .where( - Conversation.user_id == user_id, - Conversation.conversation_type == "journal", - Conversation.day_date.is_not(None), - ) - .order_by(Conversation.day_date.desc()) - ) - rows = (await session.execute(stmt)).scalars().all() - return jsonify({"days": [d.isoformat() for d in rows]}) - - -@journal_bp.post("/curator/run/") -@login_required -async def trigger_curator_run(conv_id: int): - """Manually run the journal curator over a conversation. - - The curator reads recent messages and fires tool calls (record_moment, - update_task, etc.) the chat model can't (chat models have tools=[]). - Returns a summary of what was captured. - - See services/curator.py for the architectural background. - """ - user_id = get_current_user_id() - - # Confirm the conversation belongs to this user (curator runs against - # arbitrary conv_ids would otherwise leak data across tenants). - from sqlalchemy import select as _select - from fabledassistant.models import async_session as _async_session - from fabledassistant.models.conversation import Conversation as _Conversation - async with _async_session() as _sess: - _res = await _sess.execute( - _select(_Conversation).where( - _Conversation.id == conv_id, - _Conversation.user_id == user_id, - ) - ) - if _res.scalar_one_or_none() is None: - return jsonify({"error": "Conversation not found"}), 404 - - from fabledassistant.services.curator import ( - is_curator_running, - run_curator_for_conversation, - ) - # The curator typically runs on a large model (30b-70b on CPU); we - # serialize runs globally via a module-level lock. Reject rather - # than block when busy — blocking would tie up an HTTP worker for - # minutes. The user can retry in a moment. - if is_curator_running(): - return jsonify({ - "error": "Curator is currently running. Please try again in a moment.", - "busy": True, - }), 409 - result = await run_curator_for_conversation(conv_id) - - # Stamp last_curator_run_at on success so the scheduler doesn't - # immediately re-process the same conversation on its next sweep. - # Errored runs intentionally leave the timestamp alone so the - # scheduler retries them. Persist the curator's summary too when - # non-empty (Phase 3 feedback loop) — empty summary keeps the - # existing one rather than clobbering useful context. - if not result.error: - import datetime as _dt - from sqlalchemy import update as _update - _values: dict = {"last_curator_run_at": _dt.datetime.now(_dt.timezone.utc)} - if result.summary: - _values["curator_summary"] = result.summary.strip()[:240] - async with _async_session() as _sess: - await _sess.execute( - _update(_Conversation).where(_Conversation.id == conv_id).values(**_values) - ) - await _sess.commit() - - return jsonify(result.to_dict()) - - -@journal_bp.post("/trigger-prep") -@login_required -async def trigger_prep(): - user_id = get_current_user_id() - body = await request.get_json(silent=True) or {} - iso_date = body.get("date") - config = await _resolve_config(user_id) - tz_str = await _user_timezone(user_id) - day = ( - datetime.date.fromisoformat(iso_date) - if iso_date - else _today_in_tz(tz_str, day_rollover_hour=int(config.get("day_rollover_hour", 4))) - ) - msg = await ensure_daily_prep_message( - user_id=user_id, day_date=day, user_timezone=tz_str, force=True - ) - return jsonify({"ok": True, "message_id": msg.id}) - - -@journal_bp.get("/moments") -@login_required -async def list_moments(): - user_id = get_current_user_id() - args = request.args - df = args.get("date_from") - dt = args.get("date_to") - person_id = args.get("person_id", type=int) - place_id = args.get("place_id", type=int) - tag = args.get("tag") - query = args.get("query") - pinned_only = args.get("pinned_only", "false").lower() == "true" - limit = args.get("limit", default=50, type=int) - - results = await search_journal( - user_id=user_id, - query=query, - person_id=person_id, - place_id=place_id, - tag=tag, - date_from=datetime.date.fromisoformat(df) if df else None, - date_to=datetime.date.fromisoformat(dt) if dt else None, - limit=limit, - ) - if pinned_only: - results = [r for r in results if r.get("pinned")] - return jsonify({"moments": results}) - - -@journal_bp.get("/pending") -@login_required -async def list_pending_actions(): - """List curator-proposed mutations awaiting the user's review.""" - user_id = get_current_user_id() - from fabledassistant.services.pending_actions import list_pending - pending = await list_pending(user_id) - return jsonify({"pending": pending, "count": len(pending)}) - - -@journal_bp.post("/pending//approve") -@login_required -async def approve_pending_action(action_id: int): - """Approve a proposed action — replays the underlying tool call. - - Returns the tool result on success. If the replay errors (e.g., the - target was deleted in the meantime), the action stays pending so the - user can re-try or reject explicitly. - """ - user_id = get_current_user_id() - from fabledassistant.services.pending_actions import approve - result = await approve(action_id, user_id) - return jsonify(result) - - -@journal_bp.post("/pending//reject") -@login_required -async def reject_pending_action(action_id: int): - """Reject a proposed action — marks rejected without executing anything.""" - user_id = get_current_user_id() - from fabledassistant.services.pending_actions import reject - result = await reject(action_id, user_id) - return jsonify(result) - - -@journal_bp.patch("/moments/") -@login_required -async def patch_moment(moment_id: int): - user_id = get_current_user_id() - body = await request.get_json() - if not isinstance(body, dict): - return jsonify({"error": "body must be an object"}), 400 - moment = await update_moment( - user_id=user_id, - moment_id=moment_id, - content=body.get("content"), - tags=body.get("tags"), - pinned=body.get("pinned"), - person_ids=body.get("person_ids"), - place_ids=body.get("place_ids"), - task_ids=body.get("task_ids"), - note_ids=body.get("note_ids"), - ) - if moment is None: - return jsonify({"error": "not found"}), 404 - return jsonify(moment.to_dict()) - - -@journal_bp.delete("/moments/") -@login_required -async def remove_moment(moment_id: int): - user_id = get_current_user_id() - deleted = await delete_moment(user_id=user_id, moment_id=moment_id) - if not deleted: - return jsonify({"error": "not found"}), 404 - return jsonify({"ok": True}) - - -# ─────────────────────────────────────────────────────────────────────────────── -# Ambient endpoints (lifted from the old briefing surface). -# ─────────────────────────────────────────────────────────────────────────────── - - -async def _journal_temp_unit(user_id: int) -> str: - cfg = await _resolve_config(user_id) - unit = cfg.get("temp_unit", "C") - return unit if unit in ("C", "F") else "C" - - -# ── Weather ─────────────────────────────────────────────────────────────────── - - -_STALE_THRESHOLD_SECONDS = 4 * 3600 # 4 hours — start refreshing well before the 7-day forecast window slides past today - - -def _is_stale(cache_row) -> bool: - if cache_row is None or cache_row.fetched_at is None: - return True - age = (datetime.datetime.now(datetime.timezone.utc) - cache_row.fetched_at).total_seconds() - return age > _STALE_THRESHOLD_SECONDS - - -async def _refresh_stale_in_background(user_id: int, stale_keys: set[str]) -> None: - """Best-effort refresh of stale cache rows. Silently no-ops if the user's - config has no usable lat/lon for a given location_key.""" - cfg = await _resolve_config(user_id) - locations = cfg.get("locations") or {} - for key in stale_keys: - loc = locations.get(key) - if not loc or not loc.get("lat") or not loc.get("lon"): - continue - try: - await weather_svc.refresh_location_cache( - user_id=user_id, - location_key=key, - location_label=loc.get("label", key), - lat=loc["lat"], - lon=loc["lon"], - ) - except Exception: - logger.warning("Background weather refresh failed for user %d / %s", user_id, key, exc_info=True) - - -@journal_bp.get("/weather") -@login_required -async def get_weather(): - user_id = get_current_user_id() - cfg = await _resolve_config(user_id) - valid_keys = _valid_location_keys(cfg) - rows = await weather_svc.get_cached_weather_rows(user_id, valid_keys) - temp_unit = await _journal_temp_unit(user_id) - - # Kick off a best-effort background refresh for stale rows so the next page - # load gets fresh data; we still serve whatever's currently cached now. - stale_keys = {row.location_key for row in rows if _is_stale(row)} - if stale_keys: - asyncio.create_task(_refresh_stale_in_background(user_id, stale_keys)) - - cards = [ - card for row in rows - if (card := weather_svc.parse_weather_card_data(row, temp_unit)) is not None - ] - return jsonify({"locations": cards, "temp_unit": temp_unit}) - - -@journal_bp.get("/weather/current") -@login_required -async def get_current_weather(): - """Live current temperature + conditions for the user's primary location.""" - user_id = get_current_user_id() - cfg = await _resolve_config(user_id) - temp_unit = await _journal_temp_unit(user_id) - locations = cfg.get("locations") or {} - loc = locations.get("home") or locations.get("work") - if not loc or not loc.get("lat") or not loc.get("lon"): - return jsonify({"error": "No location configured"}), 404 - - current = await weather_svc.fetch_current_conditions(loc["lat"], loc["lon"]) - if current is None: - return jsonify({"error": "Failed to fetch current conditions"}), 502 - - temp = current["temperature"] - if temp is not None and temp_unit == "F": - temp = temp * 9 / 5 + 32 - current["temperature"] = round(temp) if temp is not None else None - current["temp_unit"] = temp_unit - current["location"] = loc.get("label") or "Home" - return jsonify(current) - - -@journal_bp.post("/weather/refresh") -@login_required -async def refresh_weather(): - user_id = get_current_user_id() - cfg = await _resolve_config(user_id) - temp_unit = await _journal_temp_unit(user_id) - for key, loc in (cfg.get("locations") or {}).items(): - if not loc.get("lat") or not loc.get("lon"): - continue - try: - await weather_svc.refresh_location_cache( - user_id=user_id, - location_key=key, - location_label=loc.get("label", key), - lat=loc["lat"], - lon=loc["lon"], - ) - except Exception: - logger.warning("Failed to refresh weather for %s", key, exc_info=True) - valid_keys = _valid_location_keys(cfg) - rows = await weather_svc.get_cached_weather_rows(user_id, valid_keys) - cards = [ - card for row in rows - if (card := weather_svc.parse_weather_card_data(row, temp_unit)) is not None - ] - return jsonify({"locations": cards, "temp_unit": temp_unit}) - - -@journal_bp.post("/weather/geocode") -@login_required -async def geocode_location(): - data = await request.get_json() - query = (data.get("query") or "").strip() - if not query: - return jsonify({"error": "query required"}), 400 - try: - lat, lon, label = await weather_svc.geocode(query) - return jsonify({"lat": lat, "lon": lon, "label": label}) - except ValueError as e: - return jsonify({"error": str(e)}), 404 - - -async def _day_payload(*, user_id: int, day_date: datetime.date): - async with async_session() as session: - conv_stmt = select(Conversation).where( - Conversation.user_id == user_id, - Conversation.conversation_type == "journal", - Conversation.day_date == day_date, - ) - conv = (await session.execute(conv_stmt)).scalar_one_or_none() - if conv is None: - return jsonify({ - "day_date": day_date.isoformat(), - "conversation": None, - "messages": [], - }) - - msgs_stmt = ( - select(Message) - .where(Message.conversation_id == conv.id) - .order_by(Message.created_at) - ) - messages = (await session.execute(msgs_stmt)).scalars().all() - # conv.to_dict() recomputes message_count from the `messages` - # relationship, which isn't eager-loaded here, so it would report 0. - # We already have the real list — override with the known count, same - # convention the chat-list path uses (services/chat.py). - conv_dict = conv.to_dict() - conv_dict["message_count"] = len(messages) - return jsonify({ - "day_date": day_date.isoformat(), - "conversation": conv_dict, - "messages": [m.to_dict() for m in messages], - }) diff --git a/src/fabledassistant/routes/notes.py b/src/fabledassistant/routes/notes.py index d59e218..775f146 100644 --- a/src/fabledassistant/routes/notes.py +++ b/src/fabledassistant/routes/notes.py @@ -4,18 +4,10 @@ import re from fabledassistant.services.embeddings import upsert_note_embedding -from quart import Blueprint, Response, jsonify, request +from quart import Blueprint, jsonify, request from fabledassistant.auth import login_required, get_current_user_id from fabledassistant.routes.utils import not_found, parse_iso_date, parse_pagination -from fabledassistant.config import Config -from fabledassistant.services.assist import build_assist_messages -from fabledassistant.services.generation_buffer import ( - GenerationState, - create_assist_buffer, - get_assist_buffer, -) -from fabledassistant.services.generation_task import run_assist_generation from fabledassistant.services.notes import ( build_note_graph, convert_note_to_task, @@ -31,8 +23,6 @@ from fabledassistant.services.notes import ( list_notes, update_note, ) -from fabledassistant.services.settings import get_setting -from fabledassistant.services.tag_suggestions import suggest_tags from fabledassistant.services.note_drafts import upsert_draft, get_draft, delete_draft from fabledassistant.services.note_versions import list_versions, get_version @@ -140,18 +130,6 @@ async def list_tags_route(): return jsonify({"tags": tags}) -@notes_bp.route("/suggest-tags", methods=["POST"]) -@login_required -async def suggest_tags_route(): - uid = get_current_user_id() - data = await request.get_json() - title = data.get("title", "") - body = data.get("body", "") - current_tags = data.get("current_tags", []) - tags = await suggest_tags(uid, title, body, current_tags=current_tags) - return jsonify({"suggested_tags": tags}) - - @notes_bp.route("//append-tag", methods=["POST"]) @login_required async def append_tag_route(note_id: int): @@ -318,124 +296,6 @@ async def get_backlinks_route(note_id: int): return jsonify({"backlinks": links}) -@notes_bp.route("/assist", methods=["POST"]) -@login_required -async def assist_route(): - """Launch a background assist generation task.""" - uid = get_current_user_id() - data = await request.get_json() - - body = data.get("body", "") - target_section = data.get("target_section", "") - instruction = data.get("instruction", "") - whole_doc = bool(data.get("whole_doc", False)) - project_id = data.get("project_id") - note_id = data.get("note_id") - - if not whole_doc and not target_section: - return jsonify({"error": "target_section is required for section mode"}), 400 - if not instruction: - return jsonify({"error": "instruction is required"}), 400 - - # Fetch related project notes for context (up to 5, excluding current note). - # Glossary-tagged notes are prioritised so the model knows canonical definitions. - context_notes: list[dict] = [] - if project_id: - try: - pid = int(project_id) - # 1) Glossary notes first (up to 3) - glossary_notes, _ = await list_notes( - uid, project_id=pid, tags=["definition"], sort="updated_at", order="desc", limit=3 - ) - seen_ids: set[int] = set() - for n in glossary_notes: - if n.id == note_id: - continue - seen_ids.add(n.id) - context_notes.append({ - "title": n.title or "Untitled", - "tags": n.tags or [], - "body": n.body or "", - }) - # 2) Fill remaining slots with recently-updated notes - if len(context_notes) < 5: - recent_notes, _ = await list_notes( - uid, project_id=pid, sort="updated_at", order="desc", - limit=5 - len(context_notes) + len(seen_ids) + 1, - ) - for n in recent_notes: - if n.id == note_id or n.id in seen_ids: - continue - if len(context_notes) >= 5: - break - seen_ids.add(n.id) - context_notes.append({ - "title": n.title or "Untitled", - "tags": n.tags or [], - "body": n.body or "", - }) - except Exception: - logger.warning("Failed to fetch project context notes for assist", exc_info=True) - - model = await get_setting(uid, "default_model", Config.OLLAMA_MODEL) or Config.OLLAMA_MODEL - messages = build_assist_messages( - body, target_section, instruction, - whole_doc=whole_doc, - context_notes=context_notes or None, - ) - - buf = create_assist_buffer(uid) - asyncio.create_task(run_assist_generation(buf, messages, model)) - - return jsonify({"status": "started"}), 202 - - -@notes_bp.route("/assist/stream", methods=["GET"]) -@login_required -async def assist_stream_route(): - """SSE endpoint that tails the assist generation buffer.""" - uid = get_current_user_id() - - buf = get_assist_buffer(uid) - if buf is None: - return jsonify({"error": "No active assist generation"}), 404 - - last_id_str = request.headers.get("Last-Event-ID") or request.args.get("last_event_id") - try: - last_id = int(last_id_str) if last_id_str is not None else -1 - except (ValueError, TypeError): - last_id = -1 - - async def stream(): - cursor = last_id - while True: - pending = buf.events_after(cursor) - for event in pending: - yield buf.format_sse(event) - cursor = event.index - - if buf.state != GenerationState.RUNNING: - break - - got_new = await buf.wait_for_event(cursor, timeout=15.0) - if not got_new: - yield ": keepalive\n\n" - - # Final drain: deliver any events appended between the last yield - # and the state check above (e.g. the 'done' event). - for event in buf.events_after(cursor): - yield buf.format_sse(event) - - return Response( - stream(), - content_type="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "X-Accel-Buffering": "no", - }, - ) - - # ── Link suggestions ───────────────────────────────────────────────────────── _WIKILINK_RE = re.compile(r'\[\[[^\]]+\]\]') diff --git a/src/fabledassistant/routes/profile.py b/src/fabledassistant/routes/profile.py index 56ea9d2..88f1328 100644 --- a/src/fabledassistant/routes/profile.py +++ b/src/fabledassistant/routes/profile.py @@ -5,8 +5,6 @@ from fabledassistant.services.user_profile import ( VALID_EXPERTISE, VALID_STYLES, VALID_TONES, - clear_learned_data, - consolidate_observations, get_profile, update_profile, ) @@ -43,29 +41,3 @@ async def update_profile_route(): profile = await update_profile(uid, data) return jsonify(profile.to_dict()) - - -@profile_bp.route("/consolidate", methods=["POST"]) -@login_required -async def trigger_consolidate(): - uid = get_current_user_id() - summary = await consolidate_observations(uid) - return jsonify({"status": "ok", "learned_summary": summary}) - - -@profile_bp.route("/observations", methods=["DELETE"]) -@login_required -async def clear_observations(): - uid = get_current_user_id() - await clear_learned_data(uid) - return jsonify({"status": "ok"}) - - -@profile_bp.route("/observations", methods=["GET"]) -@login_required -async def list_observations(): - uid = get_current_user_id() - profile = await get_profile(uid) - raw = list(profile.observations_raw or []) - # Newest first, last 14 entries - return jsonify({"observations": list(reversed(raw[-14:]))}) diff --git a/src/fabledassistant/routes/push.py b/src/fabledassistant/routes/push.py deleted file mode 100644 index 6179518..0000000 --- a/src/fabledassistant/routes/push.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Push notification subscription routes.""" -import logging - -from quart import Blueprint, jsonify, request - -from fabledassistant.auth import login_required, get_current_user_id, admin_required -from fabledassistant.config import Config -from fabledassistant.services.push import delete_subscription, regenerate_vapid_keys, save_subscription, vapid_enabled - -logger = logging.getLogger(__name__) - -push_bp = Blueprint("push", __name__, url_prefix="/api/push") - - -@push_bp.route("/vapid-public-key", methods=["GET"]) -@login_required -async def get_vapid_key(): - if not vapid_enabled(): - return jsonify({"error": "Push notifications not configured"}), 503 - return jsonify({"publicKey": Config.VAPID_PUBLIC_KEY}) - - -@push_bp.route("/subscribe", methods=["POST"]) -@login_required -async def subscribe(): - uid = get_current_user_id() - data = await request.get_json() - if not data or not data.get("endpoint"): - return jsonify({"error": "Invalid subscription data"}), 400 - user_agent = request.headers.get("User-Agent") - sub = await save_subscription(uid, data, user_agent=user_agent) - return jsonify({"id": sub.id}), 201 - - -@push_bp.route("/subscribe", methods=["DELETE"]) -@login_required -async def unsubscribe(): - uid = get_current_user_id() - data = await request.get_json() - endpoint = (data or {}).get("endpoint", "") - if not endpoint: - return jsonify({"error": "endpoint is required"}), 400 - await delete_subscription(uid, endpoint) - return "", 204 - - -@push_bp.route("/reset-vapid", methods=["POST"]) -@admin_required -async def reset_vapid(): - """Regenerate VAPID keys and clear all push subscriptions.""" - ok = await regenerate_vapid_keys() - if ok: - return jsonify({"publicKey": Config.VAPID_PUBLIC_KEY}), 200 - return jsonify({"error": "Key regeneration failed"}), 500 diff --git a/src/fabledassistant/routes/quick_capture.py b/src/fabledassistant/routes/quick_capture.py deleted file mode 100644 index de029e1..0000000 --- a/src/fabledassistant/routes/quick_capture.py +++ /dev/null @@ -1,111 +0,0 @@ -"""Quick-capture endpoint for mobile/external clients. - -POST /api/quick-capture — sends text through the main LLM tool-calling pipeline -and returns a single synchronous JSON response. No SSE, no conversation ID. -""" - -import logging -from datetime import date - -from quart import Blueprint, jsonify, request - -from fabledassistant.auth import get_current_user_id, login_required -from fabledassistant.config import Config -from fabledassistant.services.llm import stream_chat_with_tools -from fabledassistant.services.tools import execute_tool, get_tools_for_user - -logger = logging.getLogger(__name__) - -quick_capture_bp = Blueprint("quick_capture", __name__, url_prefix="/api/quick-capture") - -# Tools offered to the quick-capture endpoint. Excludes destructive ops, -# read-only queries, and conversational-only tools. -_CAPTURE_TOOL_NAMES = {"create_note", "create_event", "update_note", "research_topic"} - -_SYSTEM_PROMPT = """\ -Today is {today}. You are a quick-capture assistant. The user has sent a short \ -snippet from their mobile device. Create the appropriate item — note, task, or \ -calendar event — using the available tools. Always call a tool; never reply \ -conversationally.""" - - -@quick_capture_bp.route("", methods=["POST"]) -@login_required -async def quick_capture_route(): - """Classify text via native tool-calling and create the appropriate item.""" - uid = get_current_user_id() - data = await request.get_json(silent=True) or {} - text = data.get("text", "").strip() - if not text: - return jsonify({"error": "text is required"}), 400 - - from fabledassistant.services.settings import get_setting - model = await get_setting(uid, "default_model", Config.OLLAMA_MODEL) - - all_tools = await get_tools_for_user(uid) - capture_tools = [ - t for t in all_tools if t.get("function", {}).get("name") in _CAPTURE_TOOL_NAMES - ] - - messages = [ - {"role": "system", "content": _SYSTEM_PROMPT.format(today=date.today().isoformat())}, - {"role": "user", "content": text}, - ] - - # Quick capture is a fast classification path — never think. - tool_calls: list[dict] = [] - try: - async for chunk in stream_chat_with_tools(messages, model, tools=capture_tools, think=False, num_ctx=4096): - if chunk.type == "tool_calls" and chunk.tool_calls: - tool_calls = chunk.tool_calls - except Exception: - logger.warning("Quick-capture LLM call failed for uid=%d", uid, exc_info=True) - - if tool_calls: - tc = tool_calls[0] - tool_name = tc.get("function", {}).get("name", "") - arguments = tc.get("function", {}).get("arguments", {}) - - if tool_name == "research_topic" and Config.searxng_enabled(): - from fabledassistant.services.research import run_research_pipeline - topic = arguments.get("topic", text) - try: - note = await run_research_pipeline(topic, uid, model) - logger.info("Quick-capture uid=%d: research note id=%d '%s'", uid, note.id, note.title) - return jsonify({ - "success": True, - "type": "note", - "message": f"Research note created: {note.title}", - "data": {"id": note.id, "title": note.title}, - }) - except Exception as exc: - logger.exception("Quick-capture research failed: %s", topic) - return jsonify({"error": f"Research failed: {exc}"}), 500 - - result = await execute_tool(uid, tool_name, arguments) - if result.get("success"): - item_type = result.get("type", "note") - title = (result.get("data") or {}).get("title", "") - logger.info("Quick-capture uid=%d: %s '%s'", uid, item_type, title) - return jsonify({ - "success": True, - "type": item_type, - "message": f"{item_type.capitalize()}: {title}", - "data": result.get("data"), - }) - logger.warning("Quick-capture uid=%d: tool '%s' failed: %s", uid, tool_name, result.get("error")) - - # Fallback: create a plain note with the raw text - result = await execute_tool(uid, "create_note", {"title": text[:80], "body": text}) - if result.get("success"): - title = (result.get("data") or {}).get("title", "") - logger.info("Quick-capture uid=%d: fallback note '%s'", uid, title) - return jsonify({ - "success": True, - "type": "note", - "message": f"Note created: {title}", - "data": result.get("data"), - "fallback": True, - }) - - return jsonify({"error": "Failed to create item"}), 500 diff --git a/src/fabledassistant/routes/settings.py b/src/fabledassistant/routes/settings.py index 7f6b179..19d2be1 100644 --- a/src/fabledassistant/routes/settings.py +++ b/src/fabledassistant/routes/settings.py @@ -1,49 +1,46 @@ -import asyncio +"""User settings + integrations (CalDAV, SearXNG status). + +Chat-model picker endpoints (/models), KV-cache priming, and journal-schedule +hooks were removed in Phase 8 alongside the chat/journal subsystems. +""" +import ipaddress import logging +import socket +from urllib.parse import urlparse from quart import Blueprint, jsonify, request from fabledassistant.auth import login_required, get_current_user_id from fabledassistant.config import Config from fabledassistant.services.caldav import CALDAV_SETTING_KEYS, get_caldav_config, test_connection -from fabledassistant.services.llm import get_installed_models, _is_private_url from fabledassistant.services.settings import delete_setting, get_all_settings, get_setting, set_settings_batch logger = logging.getLogger(__name__) -async def _prime_kv_cache_bg(user_id: int, model: str) -> None: - """Fire-and-forget: prime Ollama's KV cache with the user's system prompt.""" - import httpx - from fabledassistant.services.llm import build_context, pick_num_ctx - from fabledassistant.services.tools import get_tools_for_user +def _is_private_url(url: str) -> bool: + """SSRF-blocking helper: returns True for URLs that resolve to private, + loopback, or link-local addresses. Inlined here after services/llm.py + (the original home) was removed in Phase 8.""" try: - messages, _ = await build_context( - user_id=user_id, - history=[], - current_note_id=None, - user_message=" ", - ) - # Size the prime to match what real chat requests will use, including - # tool schemas — otherwise Ollama reloads the model on the first real - # request and throws away the cache we just built. - tools = await get_tools_for_user(user_id) - num_ctx = pick_num_ctx(messages, tools=tools) - from fabledassistant.services.llm import keep_alive_for - async with httpx.AsyncClient(timeout=120.0) as client: - await client.post( - f"{Config.OLLAMA_URL}/api/chat", - json={ - "model": model, - "messages": messages, - "stream": False, - "options": {"num_predict": 1, "num_ctx": num_ctx}, - "keep_alive": keep_alive_for(model), - }, - ) - logger.info("Primed KV cache for user %d with model '%s' (num_ctx=%d)", user_id, model, num_ctx) + host = urlparse(url).hostname + if not host: + return True + # Resolve to all addresses; reject if any is private/loopback/link-local. + infos = socket.getaddrinfo(host, None) + for family, *_rest, sockaddr in infos: + ip_str = sockaddr[0] + try: + ip = ipaddress.ip_address(ip_str) + except ValueError: + continue + if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved: + return True except Exception: - logger.warning("Failed to prime KV cache for user %d", user_id, exc_info=True) + # Conservative: if we can't resolve, treat as private (reject). + return True + return False + settings_bp = Blueprint("settings", __name__, url_prefix="/api/settings") @@ -64,29 +61,10 @@ async def update_settings_route(): if not isinstance(data, dict): return jsonify({"error": "Expected a JSON object"}), 400 - # Normalize model names to lowercase before validation. Ollama's /api/tags - # preserves whatever casing was used at pull time, but /api/chat rejects - # mixed-case tags — lowercasing here keeps the two paths consistent and - # means the stored setting is always in a form Ollama will actually accept. - _MODEL_KEYS = frozenset({"default_model", "background_model"}) - for _key in _MODEL_KEYS: - if _key in data and data[_key]: - data[_key] = str(data[_key]).lower() - - if "default_model" in data: - installed = await get_installed_models() - if data["default_model"]: - model = str(data["default_model"]) - if installed and model not in installed: - return jsonify({"error": f"Model '{model}' is not installed"}), 400 - - # Empty string for model keys means "reset to system default". - # Delete the DB row so get_setting() falls back to Config defaults - # rather than returning "" and breaking model resolution everywhere. to_save = {} for k, v in data.items(): str_v = str(v) - if k in _MODEL_KEYS and not str_v: + if not str_v: await delete_setting(uid, k) else: to_save[k] = str_v @@ -94,29 +72,10 @@ async def update_settings_route(): if to_save: await set_settings_batch(uid, to_save) - # Live-reschedule the journal daily-prep job when the timezone changes. - if "user_timezone" in to_save: - from fabledassistant.services.journal_scheduler import update_user_schedule as _update_journal_schedule - await _update_journal_schedule(uid) - - if "default_model" in to_save and to_save["default_model"]: - asyncio.create_task(_prime_kv_cache_bg(uid, to_save["default_model"])) - settings = await get_all_settings(uid) return jsonify(settings) -@settings_bp.route("/models", methods=["GET"]) -@login_required -async def get_models_route(): - """Return installed Ollama models and the configured defaults.""" - models = sorted(await get_installed_models()) - return jsonify({ - "models": models, - "default_chat_model": Config.OLLAMA_MODEL, - }) - - @settings_bp.route("/caldav", methods=["GET"]) @login_required async def get_caldav(): @@ -166,12 +125,7 @@ async def test_caldav(): @settings_bp.route("/search", methods=["GET"]) @login_required async def test_search(): - """Test SearXNG connectivity and preview results for a query.""" + """Report SearXNG configuration status (used by the Integrations tab).""" if not Config.searxng_enabled(): return jsonify({"configured": False, "results": [], "searxng_url": ""}) - q = request.args.get("q", "").strip() - if not q: - return jsonify({"configured": True, "results": [], "searxng_url": Config.SEARXNG_URL}) - from fabledassistant.services.research import _search_searxng - results = await _search_searxng(q) - return jsonify({"configured": True, "results": results, "query": q, "searxng_url": Config.SEARXNG_URL}) + return jsonify({"configured": True, "results": [], "searxng_url": Config.SEARXNG_URL}) diff --git a/src/fabledassistant/routes/tasks.py b/src/fabledassistant/routes/tasks.py index c716914..91d7b2b 100644 --- a/src/fabledassistant/routes/tasks.py +++ b/src/fabledassistant/routes/tasks.py @@ -284,23 +284,3 @@ async def delete_task_route(task_id: int): return "", 204 -@tasks_bp.route("//consolidate", methods=["POST"]) -@login_required -async def consolidate_task_route(task_id: int): - """Manually trigger a consolidation pass for a task. - - Bypasses the auto_consolidate_tasks setting (the user is asking - explicitly). Returns the task's updated state including the freshly- - written body and consolidated_at timestamp. - """ - uid = get_current_user_id() - if not await can_write_note(uid, task_id): - return jsonify({"error": "Permission denied"}), 403 - - from fabledassistant.services.consolidation import consolidate_task - await consolidate_task(uid, task_id) - - note = await get_note(uid, task_id) - if note is None: - return not_found("Task") - return jsonify(note.to_dict()) diff --git a/src/fabledassistant/routes/voice.py b/src/fabledassistant/routes/voice.py deleted file mode 100644 index 39094cb..0000000 --- a/src/fabledassistant/routes/voice.py +++ /dev/null @@ -1,258 +0,0 @@ -"""Voice (Speech-to-Speech) routes at /api/voice.""" -import logging -import time - -from quart import Blueprint, jsonify, request - -from fabledassistant.auth import admin_required, login_required - -logger = logging.getLogger(__name__) - -voice_bp = Blueprint("voice", __name__, url_prefix="/api/voice") - - -@voice_bp.route("/status", methods=["GET"]) -@login_required -async def voice_status(): - """Return availability of STT and TTS services.""" - from fabledassistant.services.voice_config import get_voice_config - from fabledassistant.services.stt import stt_available - from fabledassistant.services.tts import tts_available - - config = await get_voice_config() - enabled = config.get("voice_enabled", "false").lower() in ("1", "true", "yes") - - if not enabled: - return jsonify({"enabled": False, "stt": False, "tts": False}) - - return jsonify({ - "enabled": True, - "stt": stt_available(), - "tts": tts_available(), - "stt_model": config.get("voice_stt_model", "base.en"), - "tts_backend": "piper", - }) - - -@voice_bp.route("/voices", methods=["GET"]) -@login_required -async def list_voices(): - """Return available piper voice IDs and metadata. - - Scans /opt/piper-voices (bundled) + /data/voices (admin-downloaded) - on every call so newly-downloaded voices show up without a restart. - Does NOT require tts_available() — even if the active voice failed - to load, the catalog is still useful for picking a different one. - """ - from fabledassistant.services.voice_config import is_voice_enabled - if not await is_voice_enabled(): - return jsonify({"error": "Voice feature is disabled"}), 503 - - from fabledassistant.services.tts import list_voices - - return jsonify({"voices": list_voices()}) - - -@voice_bp.route("/transcribe", methods=["POST"]) -@login_required -async def transcribe_audio(): - """Accept a multipart audio file and return the transcript. - - Request: multipart/form-data with field 'audio' (WebM/Opus blob) - Response: {"transcript": "...", "duration_ms": 123} - """ - from fabledassistant.services.voice_config import is_voice_enabled - if not await is_voice_enabled(): - return jsonify({"error": "Voice feature is disabled"}), 503 - - from fabledassistant.services.stt import stt_available, transcribe - - if not stt_available(): - return jsonify({"error": "STT not available — model may still be loading"}), 503 - - files = await request.files - audio_file = files.get("audio") - if audio_file is None: - return jsonify({"error": "No audio file provided"}), 400 - - audio_bytes = audio_file.read() - if not audio_bytes: - return jsonify({"error": "Empty audio file"}), 400 - - if len(audio_bytes) > 25 * 1024 * 1024: # 25 MB hard cap - return jsonify({"error": "Audio file too large (max 25 MB)"}), 413 - - mime_type = audio_file.content_type or "audio/webm" - form = await request.form - context = (form.get("context") or "").strip() or None - - t0 = time.monotonic() - try: - transcript = await transcribe(audio_bytes, mime_type, initial_prompt=context) - except Exception: - logger.exception("STT transcription failed") - return jsonify({"error": "Transcription failed"}), 500 - - duration_ms = round((time.monotonic() - t0) * 1000) - return jsonify({"transcript": transcript, "duration_ms": duration_ms}) - - -@voice_bp.route("/synthesise", methods=["POST"]) -@login_required -async def synthesise_speech(): - """Convert text to speech and return WAV bytes. - - Request body: {"text": "...", "voice": "af_heart", "speed": 1.0} - Response: audio/wav bytes - """ - from fabledassistant.services.voice_config import is_voice_enabled - if not await is_voice_enabled(): - return jsonify({"error": "Voice feature is disabled"}), 503 - - from fabledassistant.services.tts import synthesise, tts_available - - if not tts_available(): - return jsonify({"error": "TTS not available — model may still be loading"}), 503 - - data = await request.get_json() - if not data: - return jsonify({"error": "JSON body required"}), 400 - - text = str(data.get("text", "")).strip() - if not text: - return jsonify({"error": "text is required"}), 400 - - char_count = len(text) - if char_count > 8000: - logger.warning( - "TTS request rejected: text too long (%d chars, limit 8000). Preview: %r", - char_count, text[:120], - ) - return jsonify({"error": "text too long (max 8000 characters)"}), 400 - - # Piper voice file basename (e.g. "en_US-amy-medium"). Default is read - # from user settings if not in the request body. - voice = str(data.get("voice", "")) or "en_US-amy-medium" - try: - speed = float(data.get("speed", 1.0)) - except (TypeError, ValueError): - speed = 1.0 - - # Pull saved settings only when caller didn't override. - if "voice" not in data and "speed" not in data: - from fabledassistant.services.settings import get_setting - from fabledassistant.auth import get_current_user_id - try: - uid = get_current_user_id() - saved_voice = await get_setting(uid, "voice_tts_voice", "") - if saved_voice: - voice = saved_voice - saved_speed = await get_setting(uid, "voice_tts_speed", "") - if saved_speed: - try: - speed = float(saved_speed) - except ValueError: - pass - except Exception: - pass - - logger.info("TTS synthesis start: %d chars, voice=%s, speed=%.2f", char_count, voice, speed) - t0 = time.monotonic() - try: - wav_bytes = await synthesise(text, voice=voice, speed=speed) - except Exception: - logger.exception( - "TTS synthesis failed: %d chars, voice=%s. Preview: %r", - char_count, voice, text[:120], - ) - return jsonify({"error": "Synthesis failed"}), 500 - - duration_ms = round((time.monotonic() - t0) * 1000) - if not wav_bytes: - logger.warning( - "TTS synthesis returned empty audio: %d chars, voice=%s, %dms. Preview: %r", - char_count, voice, duration_ms, text[:120], - ) - else: - logger.info( - "TTS synthesis complete: %d chars → %d bytes in %dms (voice=%s)", - char_count, len(wav_bytes), duration_ms, voice, - ) - - from quart import Response - return Response(wav_bytes, mimetype="audio/wav") - - -# ── Voice library (admin only) ────────────────────────────────────────────── -# Browse the piper-voices catalog, download new voices into /data/voices, -# remove user-installed voices. Bundled voices in /opt/piper-voices are -# read-only and cannot be touched via these endpoints. -# These are admin-only because installs consume shared disk and affect -# every user on the instance (voices are picked per-user, but the files -# themselves are shared). - - -@voice_bp.route("/voices/library", methods=["GET"]) -@admin_required -async def voice_library(): - """Return the piper-voices catalog with install state annotations. - - Query params: - ?refresh=1 — force a fresh fetch from HuggingFace (bypass the 24h - in-memory cache). Use sparingly; HF doesn't appreciate hammering. - """ - from fabledassistant.services import voice_library as lib - - force = (request.args.get("refresh") or "").lower() in ("1", "true", "yes") - try: - catalog = await lib.fetch_catalog(force_refresh=force) - except Exception: - logger.exception("Voice catalog fetch failed") - return jsonify({"error": "Failed to fetch voice catalog"}), 502 - voices = lib.shape_catalog_for_ui(catalog) - return jsonify({"voices": voices, "count": len(voices)}) - - -@voice_bp.route("/voices/install", methods=["POST"]) -@admin_required -async def install_voice_route(): - """Download a voice into /data/voices. - - Body: {"voice_id": "en_US-amy-medium"} - Idempotent — already-installed voices return {"skipped": true} without - re-downloading. - """ - from fabledassistant.services import voice_library as lib - - data = await request.get_json() - voice_id = str((data or {}).get("voice_id") or "").strip() - if not voice_id: - return jsonify({"error": "voice_id is required"}), 400 - try: - result = await lib.install_voice(voice_id) - except ValueError as e: - return jsonify({"error": str(e)}), 400 - except KeyError as e: - return jsonify({"error": str(e)}), 404 - except Exception: - logger.exception("Voice install failed: %s", voice_id) - return jsonify({"error": "Voice install failed"}), 500 - return jsonify(result) - - -@voice_bp.route("/voices/", methods=["DELETE"]) -@admin_required -async def uninstall_voice_route(voice_id: str): - """Remove a /data/voices voice. Bundled voices return 403.""" - from fabledassistant.services import voice_library as lib - - try: - result = await lib.uninstall_voice(voice_id) - except ValueError as e: - return jsonify({"error": str(e)}), 400 - except PermissionError as e: - return jsonify({"error": str(e)}), 403 - except Exception: - logger.exception("Voice uninstall failed: %s", voice_id) - return jsonify({"error": "Voice uninstall failed"}), 500 - return jsonify(result) diff --git a/src/fabledassistant/services/article_fetcher.py b/src/fabledassistant/services/article_fetcher.py deleted file mode 100644 index 5f3f717..0000000 --- a/src/fabledassistant/services/article_fetcher.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Generic article-text fetcher. - -Fetches a URL and extracts its main body via trafilatura. The single source -of truth for article-content extraction across the codebase — used by the -``read_article`` LLM tool and the ``lookup`` tool's web-result enrichment. - -Trafilatura/lxml is NOT safe to call concurrently — running it via -``run_in_executor`` from multiple coroutines can trip a libxml2 double-free. -Callers must serialize their fetches (await one before starting the next). -""" -from __future__ import annotations - -import asyncio -import logging - -import httpx - -logger = logging.getLogger(__name__) - - -async def fetch_article_text(url: str) -> str | None: - """Return the clean article body for *url*, or None on failure. - - Returns None when the HTTP fetch fails or trafilatura yields nothing - useful. Callers should treat None as "no article content available." - """ - try: - async with httpx.AsyncClient(timeout=15.0, follow_redirects=True, headers={ - "User-Agent": "Mozilla/5.0 (compatible; FabledScribe/1.0; +https://fabledsword.com)", - }) as client: - resp = await client.get(url) - resp.raise_for_status() - raw_html = resp.text - except Exception: - logger.debug("Failed to fetch article URL %s", url) - return None - - loop = asyncio.get_event_loop() - try: - import trafilatura - text = await loop.run_in_executor( - None, - lambda: trafilatura.extract( - raw_html, - include_comments=False, - include_tables=True, - favor_recall=True, - ), - ) - return text or None - except Exception: - logger.debug("trafilatura extraction failed for %s", url, exc_info=True) - return None diff --git a/src/fabledassistant/services/assist.py b/src/fabledassistant/services/assist.py deleted file mode 100644 index c3aa2b0..0000000 --- a/src/fabledassistant/services/assist.py +++ /dev/null @@ -1,92 +0,0 @@ -MAX_BODY_CHARS = 24000 -MAX_CONTEXT_NOTE_CHARS = 1500 - - -def _build_context_block(context_notes: list[dict]) -> str: - """Format project context notes into a prompt block.""" - lines = [ - "--- Project Context ---", - "The following notes are from the same project. Notes tagged 'definition' are " - "canonical term definitions — treat them as authoritative. Use them to maintain " - "consistency in terminology, tone, style, and [[wikilinks]] to other documents.", - "", - ] - for n in context_notes: - tags_str = f" [tags: {', '.join(n['tags'])}]" if n.get("tags") else "" - body_preview = (n["body"] or "")[:MAX_CONTEXT_NOTE_CHARS] - if len(n["body"] or "") > MAX_CONTEXT_NOTE_CHARS: - body_preview += "…" - lines.append(f"**{n['title']}**{tags_str}") - if body_preview: - lines.append(body_preview) - lines.append("") - lines.append("--- End Project Context ---") - return "\n".join(lines) - - -def build_assist_messages( - body: str, - target_section: str, - instruction: str, - whole_doc: bool = False, - context_notes: list[dict] | None = None, -) -> list[dict]: - """Build Ollama messages for writing assist. - - When whole_doc=True, the model revises the entire document. - When whole_doc=False (section mode), the full body is provided as read-only - context and the model outputs only the replacement for the target section. - - context_notes: optional list of {title, tags, body} dicts from the same project, - injected so the model can maintain consistency with related documents. - """ - truncated_body = body[:MAX_BODY_CHARS] - if len(body) > MAX_BODY_CHARS: - truncated_body += "\n… (truncated)" - - context_block = (_build_context_block(context_notes) + "\n\n") if context_notes else "" - - if whole_doc: - system_content = ( - "You are a writing assistant. Revise the document per the instruction. " - "Output ONLY the complete revised document. Preserve all structure and " - "content not addressed by the instruction. " - "Preserve all markdown formatting exactly — including bullet lists (- item), " - "numbered lists (1. item), nested/indented sub-items ( - sub), bold (**text**), " - "italic (_text_), and code blocks. Never flatten nested lists into plain text. " - "When referencing concepts defined in the Project Context, use [[wikilinks]] " - "to link to the relevant note by its exact title." - ) - user_content = ( - f"{context_block}" - f"--- Document ---\n{truncated_body}\n--- End Document ---\n\n" - f"Instruction: {instruction}" - ) - else: - system_content = ( - "You are an AI writing assistant integrated into a note-taking app. " - "The user is editing a document. The full document is shown below for context.\n\n" - f"--- Full Document ---\n{truncated_body}\n--- End Document ---\n\n" - "The user will give you a specific section of the document and an instruction. " - "Output ONLY the replacement text for that section. " - "If the target section starts with a markdown heading (e.g. ## Heading), " - "your output MUST also start with a heading at the same level. " - "You may revise the heading text but do not remove it. " - "Do not include other sections, explanatory text, or markdown code fences around the output. " - "Match the document's existing tone and style. " - "IMPORTANT: Preserve all markdown formatting exactly — including bullet lists (- item), " - "numbered lists (1. item), nested/indented sub-items ( - sub), bold (**text**), " - "italic (_text_), and code blocks. Never flatten nested lists into plain text. " - "When referencing concepts defined in the Project Context, use [[wikilinks]] " - "to link to the relevant note by its exact title." - ) - user_content = ( - f"{context_block}" - f"--- Target Section ---\n{target_section}\n--- End Target Section ---\n\n" - f"Instruction: {instruction}" - ) - - return [ - {"role": "system", "content": system_content}, - {"role": "user", "content": user_content}, - ] diff --git a/src/fabledassistant/services/auth.py b/src/fabledassistant/services/auth.py index c203e83..0605186 100644 --- a/src/fabledassistant/services/auth.py +++ b/src/fabledassistant/services/auth.py @@ -7,7 +7,6 @@ import bcrypt from sqlalchemy import func, select, update from fabledassistant.models import async_session -from fabledassistant.models.conversation import Conversation from fabledassistant.models.note import Note from fabledassistant.models.invitation import InvitationToken from fabledassistant.models.password_reset import PasswordResetToken @@ -59,14 +58,6 @@ async def create_user( .where((Note.user_id.is_(None)) | (Note.user_id == user.id)) .values(user_id=user.id) ) - await session.execute( - update(Conversation) - .where( - (Conversation.user_id.is_(None)) - | (Conversation.user_id == user.id) - ) - .values(user_id=user.id) - ) await session.execute( update(Setting) .where(Setting.user_id.is_(None)) diff --git a/src/fabledassistant/services/backup.py b/src/fabledassistant/services/backup.py index 94ce7c8..47f72d6 100644 --- a/src/fabledassistant/services/backup.py +++ b/src/fabledassistant/services/backup.py @@ -2,16 +2,13 @@ import logging from datetime import date, datetime, timezone from sqlalchemy import select -from sqlalchemy.orm import selectinload from fabledassistant.models import async_session -from fabledassistant.models.conversation import Conversation, Message from fabledassistant.models.milestone import Milestone from fabledassistant.models.note import Note from fabledassistant.models.note_draft import NoteDraft from fabledassistant.models.note_version import NoteVersion from fabledassistant.models.project import Project -from fabledassistant.models.push_subscription import PushSubscription from fabledassistant.models.setting import Setting from fabledassistant.models.task_log import TaskLog from fabledassistant.models.user import User @@ -43,18 +40,14 @@ async def export_full_backup() -> dict: note_versions = (await session.execute( select(NoteVersion).order_by(NoteVersion.note_id, NoteVersion.id) )).scalars().all() - conversations = (await session.execute( - select(Conversation).options(selectinload(Conversation.messages)) - )).scalars().all() settings = (await session.execute(select(Setting))).scalars().all() - push_subs = (await session.execute(select(PushSubscription))).scalars().all() return { "version": 2, "scope": "full", "exported_at": datetime.now(timezone.utc).isoformat(), "_security_notice": ( - "This backup contains hashed passwords and push subscription keys. " + "This backup contains hashed passwords. " "Store it securely and restrict access." ), "users": [ @@ -156,44 +149,10 @@ async def export_full_backup() -> dict: } for nv in note_versions ], - "conversations": [ - { - "id": c.id, - "user_id": c.user_id, - "title": c.title, - "model": c.model, - "created_at": c.created_at.isoformat(), - "updated_at": c.updated_at.isoformat(), - "messages": [ - { - "id": m.id, - "role": m.role, - "content": m.content, - "status": m.status, - "context_note_id": m.context_note_id, - "tool_calls": m.tool_calls, - "created_at": m.created_at.isoformat(), - } - for m in c.messages - ], - } - for c in conversations - ], "settings": [ {"user_id": s.user_id, "key": s.key, "value": s.value} for s in settings ], - "push_subscriptions": [ - { - "user_id": ps.user_id, - "endpoint": ps.endpoint, - "p256dh": ps.p256dh, - "auth": ps.auth, - "user_agent": ps.user_agent, - "created_at": ps.created_at.isoformat(), - } - for ps in push_subs - ], } @@ -220,11 +179,6 @@ async def export_user_backup(user_id: int) -> dict: select(NoteVersion).where(NoteVersion.user_id == user_id) .order_by(NoteVersion.note_id, NoteVersion.id) )).scalars().all() - conversations = (await session.execute( - select(Conversation) - .options(selectinload(Conversation.messages)) - .where(Conversation.user_id == user_id) - )).scalars().all() settings = (await session.execute( select(Setting).where(Setting.user_id == user_id) )).scalars().all() @@ -322,28 +276,6 @@ async def export_user_backup(user_id: int) -> dict: } for nv in note_versions ], - "conversations": [ - { - "id": c.id, - "title": c.title, - "model": c.model, - "created_at": c.created_at.isoformat(), - "updated_at": c.updated_at.isoformat(), - "messages": [ - { - "id": m.id, - "role": m.role, - "content": m.content, - "status": m.status, - "context_note_id": m.context_note_id, - "tool_calls": m.tool_calls, - "created_at": m.created_at.isoformat(), - } - for m in c.messages - ], - } - for c in conversations - ], "settings": [ {"key": s.key, "value": s.value} for s in settings @@ -364,8 +296,12 @@ async def restore_full_backup(data: dict) -> dict: async def _restore_v1(data: dict) -> dict: - """Restore legacy v1 backup (original format).""" - stats = {"users": 0, "notes": 0, "conversations": 0, "messages": 0, "settings": 0} + """Restore legacy v1 backup (original format). + + Pre-pivot v1 backups included conversations + messages; those are + skipped during restore now that the chat subsystem is gone. + """ + stats = {"users": 0, "notes": 0, "settings": 0} async with async_session() as session: user_id_map: dict[int, int] = {} @@ -416,31 +352,6 @@ async def _restore_v1(data: dict) -> dict: if note_row: note_row.parent_id = note_id_map[old_parent] - for c_data in data.get("conversations", []): - mapped_user_id = user_id_map.get(c_data.get("user_id", 0)) - if mapped_user_id is None: - continue - conv = Conversation( - user_id=mapped_user_id, - title=c_data.get("title", ""), - model=c_data.get("model", ""), - created_at=_dt(c_data.get("created_at")), - updated_at=_dt(c_data.get("updated_at")), - ) - session.add(conv) - await session.flush() - stats["conversations"] += 1 - for m_data in c_data.get("messages", []): - msg = Message( - conversation_id=conv.id, - role=m_data["role"], - content=m_data.get("content", ""), - context_note_id=note_id_map.get(m_data["context_note_id"]) if m_data.get("context_note_id") else None, - created_at=_dt(m_data.get("created_at")), - ) - session.add(msg) - stats["messages"] += 1 - for s_data in data.get("settings", []): mapped_user_id = user_id_map.get(s_data.get("user_id", 0)) if mapped_user_id is None: @@ -455,11 +366,15 @@ async def _restore_v1(data: dict) -> dict: async def _restore_v2(data: dict) -> dict: - """Restore v2 backup with full FK re-mapping.""" + """Restore v2 backup with full FK re-mapping. + + Conversations + push subscriptions in pre-pivot backups are silently + skipped — those subsystems were removed in the MCP-first pivot. + """ stats: dict[str, int] = { "users": 0, "projects": 0, "milestones": 0, "notes": 0, "task_logs": 0, "note_drafts": 0, "note_versions": 0, - "conversations": 0, "messages": 0, "settings": 0, + "settings": 0, } async with async_session() as session: @@ -616,35 +531,7 @@ async def _restore_v2(data: dict) -> dict: session.add(nv) stats["note_versions"] += 1 - # 8. Conversations + Messages - for c_data in data.get("conversations", []): - mapped_uid = user_id_map.get(c_data.get("user_id", 0)) - if mapped_uid is None: - continue - conv = Conversation( - user_id=mapped_uid, - title=c_data.get("title", ""), - model=c_data.get("model", ""), - created_at=_dt(c_data.get("created_at")), - updated_at=_dt(c_data.get("updated_at")), - ) - session.add(conv) - await session.flush() - stats["conversations"] += 1 - for m_data in c_data.get("messages", []): - msg = Message( - conversation_id=conv.id, - role=m_data["role"], - content=m_data.get("content", ""), - status=m_data.get("status", "complete"), - context_note_id=note_id_map.get(m_data["context_note_id"]) if m_data.get("context_note_id") else None, - tool_calls=m_data.get("tool_calls"), - created_at=_dt(m_data.get("created_at")), - ) - session.add(msg) - stats["messages"] += 1 - - # 9. Settings + # 8. Settings for s_data in data.get("settings", []): mapped_uid = user_id_map.get(s_data.get("user_id", 0)) if mapped_uid is None: diff --git a/src/fabledassistant/services/chat.py b/src/fabledassistant/services/chat.py deleted file mode 100644 index e6df2bc..0000000 --- a/src/fabledassistant/services/chat.py +++ /dev/null @@ -1,302 +0,0 @@ -import logging -from datetime import datetime, timedelta, timezone - -from sqlalchemy import func, select, delete as sa_delete -from sqlalchemy.orm import selectinload - -from fabledassistant.models import async_session -from fabledassistant.models.conversation import Conversation, Message -from fabledassistant.config import Config -from fabledassistant.services.llm import generate_completion -from fabledassistant.services.notes import create_note -from fabledassistant.services.settings import get_setting - -logger = logging.getLogger(__name__) - - -async def create_conversation( - user_id: int, title: str = "", model: str = "", conversation_type: str = "chat" -) -> Conversation: - async with async_session() as session: - conv = Conversation(user_id=user_id, title=title, model=model, conversation_type=conversation_type) - session.add(conv) - await session.commit() - # Re-fetch with messages eagerly loaded to avoid lazy-load in async context - result = await session.execute( - select(Conversation) - .options(selectinload(Conversation.messages)) - .where(Conversation.id == conv.id) - ) - return result.scalars().first() - - -async def get_conversation( - user_id: int, conversation_id: int -) -> Conversation | None: - async with async_session() as session: - result = await session.execute( - select(Conversation) - .options(selectinload(Conversation.messages)) - .where( - Conversation.id == conversation_id, - Conversation.user_id == user_id, - ) - ) - return result.scalars().first() - - -async def list_conversations( - user_id: int, limit: int = 50, offset: int = 0, conv_type: str = "chat" -) -> tuple[list[dict], int]: - async with async_session() as session: - total = await session.scalar( - select(func.count(Conversation.id)).where( - Conversation.user_id == user_id, - Conversation.conversation_type == conv_type, - ) - ) or 0 - - # Subquery for message count — avoids loading all messages - msg_count = ( - select(func.count(Message.id)) - .where(Message.conversation_id == Conversation.id) - .correlate(Conversation) - .scalar_subquery() - ) - - result = await session.execute( - select(Conversation, msg_count.label("message_count")) - .where(Conversation.user_id == user_id, Conversation.conversation_type == conv_type) - .order_by(Conversation.updated_at.desc()) - .limit(limit) - .offset(offset) - ) - - conversations = [] - for row in result.all(): - conv = row[0] - d = { - "id": conv.id, - "title": conv.title, - "model": conv.model, - "conversation_type": conv.conversation_type, - "day_date": conv.day_date.isoformat() if conv.day_date else None, - "rag_project_id": conv.rag_project_id, - "message_count": row[1], - "created_at": conv.created_at.isoformat(), - "updated_at": conv.updated_at.isoformat(), - } - conversations.append(d) - - return conversations, total - - -async def delete_conversation(user_id: int, conversation_id: int) -> bool: - async with async_session() as session: - result = await session.execute( - select(Conversation).where( - Conversation.id == conversation_id, - Conversation.user_id == user_id, - ) - ) - conv = result.scalars().first() - if conv is None: - return False - await session.delete(conv) - await session.commit() - return True - - -async def bulk_delete_conversations(user_id: int, ids: list[int]) -> int: - """Delete multiple conversations by ID for a user. Returns count deleted.""" - if not ids: - return 0 - async with async_session() as session: - result = await session.execute( - sa_delete(Conversation) - .where(Conversation.user_id == user_id, Conversation.id.in_(ids)) - .returning(Conversation.id) - ) - await session.commit() - return len(result.fetchall()) - - -async def cleanup_old_conversations(user_id: int, days: int) -> int: - """Delete conversations older than `days` days. Returns count deleted.""" - if days <= 0: - return 0 - cutoff = datetime.now(timezone.utc) - timedelta(days=days) - async with async_session() as session: - result = await session.execute( - sa_delete(Conversation) - .where( - Conversation.user_id == user_id, - Conversation.updated_at < cutoff, - Conversation.conversation_type != "mcp", # preserve MCP audit trail - Conversation.conversation_type != "voice", # voice convs managed separately - Conversation.conversation_type != "briefing", # briefing history managed by briefing system - ) - .returning(Conversation.id) - ) - await session.commit() - return len(result.fetchall()) - - -_UNSET = object() - - -async def update_conversation( - user_id: int, - conversation_id: int, - title: str | None = None, - model: str | None = None, - rag_project_id: object = _UNSET, -) -> Conversation | None: - async with async_session() as session: - result = await session.execute( - select(Conversation).where( - Conversation.id == conversation_id, - Conversation.user_id == user_id, - ) - ) - conv = result.scalars().first() - if conv is None: - return None - if title is not None: - conv.title = title - if model is not None: - conv.model = model - if rag_project_id is not _UNSET: - conv.rag_project_id = rag_project_id # type: ignore[assignment] - conv.updated_at = datetime.now(timezone.utc) - await session.commit() - await session.refresh(conv) - return conv - - -async def update_conversation_title( - user_id: int, conversation_id: int, title: str -) -> Conversation | None: - return await update_conversation(user_id, conversation_id, title=title) - - -async def add_message( - conversation_id: int, - role: str, - content: str, - context_note_id: int | None = None, - status: str | None = None, - tool_calls: list | None = None, - msg_metadata: dict | None = None, -) -> Message: - async with async_session() as session: - kwargs: dict = dict( - conversation_id=conversation_id, - role=role, - content=content, - context_note_id=context_note_id, - ) - if status is not None: - kwargs["status"] = status - if tool_calls is not None: - kwargs["tool_calls"] = tool_calls - if msg_metadata is not None: - kwargs["msg_metadata"] = msg_metadata - msg = Message(**kwargs) - session.add(msg) - # Touch conversation updated_at - conv = await session.get(Conversation, conversation_id) - if conv: - conv.updated_at = datetime.now(timezone.utc) - await session.commit() - await session.refresh(msg) - return msg - - -async def get_message(message_id: int) -> Message | None: - async with async_session() as session: - return await session.get(Message, message_id) - - -async def save_response_as_note(user_id: int, message_id: int) -> dict: - """Create a note from an assistant message. Returns the new note dict.""" - msg = await get_message(message_id) - if msg is None: - raise ValueError("Message not found") - if msg.role != "assistant": - raise ValueError("Can only save assistant messages as notes") - - conv = await get_conversation(user_id, msg.conversation_id) - - # Generate title via LLM using the assistant message content - title = "" - if conv: - try: - prompt_messages = [ - { - "role": "system", - "content": ( - "Generate a concise 3-8 word title for a note based on " - "this content. Reply with ONLY the title, no quotes or " - "punctuation." - ), - }, - {"role": "user", "content": msg.content[:2000]}, - ] - # 3-8 word title generation is the kind of trivial small-input - # / small-output task the chat model (default_model) handles in - # ~1s. Routing here so the worker (background_model) isn't - # interrupted from heavier curator / prep / closeout passes. - chat_model = ( - await get_setting(user_id, "default_model", "") - or Config.OLLAMA_MODEL - ) - title = await generate_completion(prompt_messages, chat_model) - title = title.strip().strip('"\'').strip()[:100] - except Exception: - logger.warning("Failed to generate note title, using fallback", exc_info=True) - - if not title: - lines = msg.content.strip().split("\n", 1) - title = lines[0].strip().lstrip("# ")[:100] - - note = await create_note(user_id, title=title, body=msg.content, tags=["chat"]) - return note.to_dict() - - -async def summarize_conversation_as_note( - user_id: int, conversation_id: int, model: str -) -> dict: - """Summarize a conversation using the LLM and save as a note.""" - conv = await get_conversation(user_id, conversation_id) - if conv is None: - raise ValueError("Conversation not found") - - # Build the conversation text - conv_text = [] - for msg in conv.messages: - if msg.role == "system": - continue - label = "User" if msg.role == "user" else "Assistant" - conv_text.append(f"{label}: {msg.content}") - - prompt_messages = [ - { - "role": "system", - "content": ( - "Summarize the following conversation into a concise note. " - "Include key points, decisions, and any action items. " - "Format the summary in markdown." - ), - }, - {"role": "user", "content": "\n\n".join(conv_text)}, - ] - - logger.info("Summarizing conversation %d with model %s", conversation_id, model) - summary = await generate_completion(prompt_messages, model) - - title = conv.title or "Conversation Summary" - title = f"Summary: {title}" - - note = await create_note(user_id, title=title, body=summary, tags=["chat"]) - return note.to_dict() diff --git a/src/fabledassistant/services/consolidation.py b/src/fabledassistant/services/consolidation.py deleted file mode 100644 index ee77b5e..0000000 --- a/src/fabledassistant/services/consolidation.py +++ /dev/null @@ -1,206 +0,0 @@ -"""Background task-body consolidation pipeline. - -Reads a task's description (user goal) + work logs (chronological) and writes -a 1-3 paragraph summary into Note.body via the background model. Triggered by -log accumulation (debounced), status transitions to terminal states, and a -manual API endpoint. - -Design: docs/superpowers/specs/2026-05-13-task-as-durable-record-design.md -""" -from __future__ import annotations - -import asyncio -import logging -from collections import defaultdict -from datetime import datetime, timezone - -from sqlalchemy import func, select - -from fabledassistant.models import async_session -from fabledassistant.models.note import Note -from fabledassistant.models.task_log import TaskLog - -logger = logging.getLogger(__name__) - -# Trigger thresholds. Tunable as constants; could be promoted to env vars if -# the defaults prove wrong in practice. -DEFAULT_LOG_THRESHOLD = 3 -MAX_LOGS_FOR_PROMPT = 50 -MAX_PROMPT_INPUT_CHARS = 8000 - -# Per-task asyncio locks to prevent two simultaneous consolidations of the -# same task. Single-process; no cross-process coordination needed. -_locks: dict[int, asyncio.Lock] = defaultdict(asyncio.Lock) - - -async def _logs_since_last_consolidation(user_id: int, task_id: int) -> int: - """Count work logs that arrived after the most recent consolidation pass. - - Returns 0 if the task doesn't exist. Returns the total log count when - consolidated_at is NULL (i.e. never consolidated). - """ - async with async_session() as session: - task = ( - await session.execute( - select(Note).where(Note.id == task_id, Note.user_id == user_id) - ) - ).scalars().first() - if task is None: - return 0 - stmt = select(func.count(TaskLog.id)).where( - TaskLog.task_id == task_id, TaskLog.user_id == user_id, - ) - if task.consolidated_at is not None: - stmt = stmt.where(TaskLog.created_at > task.consolidated_at) - return int((await session.execute(stmt)).scalar() or 0) - - -async def _auto_consolidate_enabled(user_id: int) -> bool: - """User-level setting; default true. Manual endpoint bypasses this.""" - from fabledassistant.services.settings import get_setting - val = await get_setting(user_id, "auto_consolidate_tasks", "true") - return str(val).lower() in ("true", "1", "yes") - - -async def maybe_consolidate(user_id: int, task_id: int, *, reason: str) -> None: - """Debounced gate. Decides whether to schedule a consolidate_task pass. - - reason='log_added' — gated by log count >= DEFAULT_LOG_THRESHOLD - reason='task_closed' — proceeds unconditionally (subject to setting) - """ - if not await _auto_consolidate_enabled(user_id): - return - if reason == "log_added": - n = await _logs_since_last_consolidation(user_id, task_id) - if n < DEFAULT_LOG_THRESHOLD: - return - elif reason != "task_closed": - logger.warning("maybe_consolidate: unknown reason %r", reason) - return - # Fire-and-forget; consolidate_task handles its own errors. - asyncio.create_task(consolidate_task(user_id, task_id)) - - -def _build_consolidation_prompt( - *, title: str, description: str | None, logs: list, -) -> str: - """Build the LLM prompt for one consolidation pass. - - Caps total log content at MAX_PROMPT_INPUT_CHARS; logs that don't fit - are dropped. Caller is expected to slice to the most-recent window - before calling. - """ - log_lines: list[str] = [] - chars = 0 - for log in logs: - ts = log.created_at.isoformat() if getattr(log, "created_at", None) else "?" - line = f"- [{ts}] {log.content}" - if chars + len(line) > MAX_PROMPT_INPUT_CHARS: - break - log_lines.append(line) - chars += len(line) - - return ( - "You are summarizing the work done on a task. The user wrote the goal " - "below; do not restate it. Read the chronological work-log entries and " - "produce a 1-3 paragraph summary of: what was attempted, what worked, " - "what failed, what's current state. Use the user's voice; cite specific " - "commands/decisions; favor brevity over completeness. Output plain " - "markdown body content only — no preamble.\n\n" - f"TITLE: {title}\n" - f"GOAL (read-only context): {description or '(no goal recorded)'}\n" - f"WORK LOG (chronological):\n" + "\n".join(log_lines) - ) - - -async def consolidate_task(user_id: int, task_id: int) -> None: - """Run a consolidation pass: read description + logs, write summary to body. - - Errors are logged and swallowed so the fire-and-forget caller is never - interrupted; on LLM failure the body and consolidated_at are left - untouched and the next trigger retries. - """ - lock = _locks[task_id] - if lock.locked(): - logger.debug( - "consolidate_task: skipping — already running for task %d", task_id - ) - return - async with lock: - try: - async with async_session() as session: - task = ( - await session.execute( - select(Note).where( - Note.id == task_id, Note.user_id == user_id, - ) - ) - ).scalars().first() - if task is None or not task.status: - return # not a task, or missing - logs = ( - await session.execute( - select(TaskLog) - .where( - TaskLog.task_id == task_id, - TaskLog.user_id == user_id, - ) - .order_by(TaskLog.created_at.asc()) - ) - ).scalars().all() - - if not logs: - return # nothing to summarize yet - - title = task.title or "" - description = task.description - window = logs[-MAX_LOGS_FOR_PROMPT:] - - prompt = _build_consolidation_prompt( - title=title, description=description, logs=window, - ) - - from fabledassistant.services.llm import generate_completion - from fabledassistant.services.settings import get_setting - from fabledassistant.config import Config - - bg_model = await get_setting( - user_id, "background_model", Config.OLLAMA_BACKGROUND_MODEL, - ) - summary = await generate_completion( - [{"role": "user", "content": prompt}], - model=bg_model, - max_tokens=800, - num_ctx=4096, - ) - if not summary or not summary.strip(): - return - - now = datetime.now(timezone.utc) - async with async_session() as session: - task = ( - await session.execute( - select(Note).where( - Note.id == task_id, Note.user_id == user_id, - ) - ) - ).scalars().first() - if task is None: - return - task.body = summary.strip() - task.consolidated_at = now - task.updated_at = now - await session.commit() - - from fabledassistant.services.embeddings import upsert_note_embedding - await upsert_note_embedding( - task_id, user_id, f"{title}\n{summary.strip()}".strip(), - ) - logger.info( - "consolidate_task: refreshed task %d body (%d chars)", - task_id, len(summary), - ) - except Exception: - logger.exception( - "consolidate_task failed for task %d", task_id, - ) diff --git a/src/fabledassistant/services/curator.py b/src/fabledassistant/services/curator.py deleted file mode 100644 index 2fcc6ac..0000000 --- a/src/fabledassistant/services/curator.py +++ /dev/null @@ -1,465 +0,0 @@ -"""Journal curator: async LLM pass that extracts captures from a chat. - -Architecture (2026-05-22 brainstorm, Fable note #172): - -The journal chat model has no tools — it just talks. This curator is the -second LLM pass that reads recent journal messages and fires the tool -calls (record_moment, update_task, etc.) the chat model can't. - -Runs against the user's `background_model` so the chat model's KV cache -isn't disturbed. Can be triggered manually via the journal route or by -the scheduler (phase 2). The chat model is unaffected during a curator -run; this is intentionally fire-and-go. - -The curator does NOT add its own messages to the conversation. Only the -side-effects of its tool calls land in the database (moments table, -notes table, tasks, etc.). The user sees those side-effects via the -existing journal data surfaces, not via a chat-stream injection — see -the brainstorm doc's surfacing decision (right-rail captures panel, not -inline observer voice). -""" -from __future__ import annotations - -import asyncio -import json -import logging -import time -from dataclasses import dataclass, field -from datetime import datetime, timedelta, timezone - -from sqlalchemy import select - -from fabledassistant.config import Config -from fabledassistant.models import async_session -from fabledassistant.models.conversation import Conversation, Message -from fabledassistant.services.llm import pick_num_ctx, stream_chat_with_tools -from fabledassistant.services.settings import get_setting -from fabledassistant.services.tools import execute_tool, get_tools_for_user - -logger = logging.getLogger(__name__) - -# Tool-call iteration cap. The chat path uses 6; the curator should -# converge faster because its task is bounded (extract beats from a -# fixed transcript, not respond to evolving conversation). -_MAX_TOOL_ROUNDS = 4 - -# Module-level serial lock for `run_curator_for_conversation`. The curator -# model is typically large (30b-70b) and runs on CPU+RAM; loading more -# than one curator request at a time wastes memory on a KV cache slot -# that's never going to be used in parallel, and can swap the worker -# model into thrashing. Every entry point — scheduler sweep, manual -# trigger from the journal route, future hooks — must acquire this lock -# so the system guarantees at most one curator pass runs at a time -# globally. Manual-trigger route checks `is_curator_running()` first -# and returns 409 rather than blocking the HTTP request. -_CURATOR_RUN_LOCK = asyncio.Lock() - - -def is_curator_running() -> bool: - """True iff a curator pass is currently executing. - - Used by the manual-trigger route to decide between 'queue' (block on - the lock) and 'reject' (return 409). Avoids tying up an HTTP request - for minutes when a curator pass on a 70b CPU model is already in - flight. - """ - return _CURATOR_RUN_LOCK.locked() - -# Curator tool allowlist. Additive operations only — no updates, no -# deletes. Risk model: the curator can be confidently wrong, and the -# user is not in the loop when it runs. Adds are easily undone by the -# user (delete the moment, delete the task). Updates and deletes are -# not — a curator that confidently overwrites a task title with the -# wrong value is worse than a curator that creates a duplicate task. -# -# Plus a small set of read-only helpers the curator needs for entity -# resolution before it can safely link names → ids on the additive -# calls. -_CURATOR_ALLOWED_TOOLS: frozenset[str] = frozenset({ - # Additive — primary curator work; run directly. - "record_moment", - "create_note", # also creates tasks (status='todo') - "log_work", # appends to a task's work-log timeline - "save_person", - "save_place", - "create_project", - "create_milestone", - # Mutating — intercepted by execute_tool's authority="curator" path - # and routed to the pending-actions queue for user review. - # See services/tools/_registry.py:_CURATOR_MUTATING_TOOLS. - "update_note", # covers tasks + notes; routed to pending - "update_milestone", # routed to pending - "update_project", # routed to pending - "update_profile", # routed to pending - "delete_note", # routed to pending - # Read-only — supporting lookups for entity resolution and cross-ref. - "search_notes", - "search_projects", - "search_journal", - "list_tasks", - "list_projects", - "list_milestones", - "read_note", - "get_project", - "get_profile", -}) - -_CURATOR_SYSTEM_PROMPT = """You are a curator reading a fragment of the user's journal conversation. Your job is to capture meaningful beats as structured records using the tools provided. You do NOT respond to the user — your only output is tool calls. - -TRANSCRIPT FORMAT: -The transcript shows lines prefixed with either `User:` or `Assistant:`. Only the `User:` lines are journal entries. The `Assistant:` lines are context — they tell you what was said back, so you can disambiguate references ("which Sarah?") — but they are NEVER journal beats themselves. NEVER create a moment or any other record based on content that appears only in an `Assistant:` line. If a user line is short and the meaning depends on the assistant's question, capture the user's intent in your own concise phrasing, not by quoting the assistant. - -WHAT TO CAPTURE (from `User:` lines only): -- Events that happened ("I went grocery shopping", "finished the network restage") -- Encounters with people ("had coffee with Sarah", "called Mom") -- Decisions ("going to switch jobs", "won't pursue the contract") -- Observations about the user's state or world ("the new place is loud", "feeling tired") -- Plans and commitments ("watching a show tonight", "dentist Thursday") -- Small accomplishments or changes the user made ("installed the new AP", "shipped the migration") -- New tasks or todos the user wants to track ("I need to call mom tomorrow", "remind me to renew the domain") -- Knowledge worth saving as a note ("the dhcp issue at Bedford was caused by stale leases") - -TOOL USE — two categories. - -**Additive (run immediately).** These tools persist to the user's data on the spot. Use them freely when warranted: -- `record_moment` — for journal beats. **EXACTLY ONE tool call per distinct beat.** If the user mentioned three things, call record_moment three times — once per thing. NEVER call record_moment more than once for the same beat with different phrasings. Before each call, mentally check: "have I already recorded this beat in a previous tool call this turn?" If yes, skip — duplicates are worse than misses. Write content in the user's voice (first-person or imperative); NEVER "the user mentioned…" framing. -- `create_note` — create a task (set status='todo' plus due_date/priority if mentioned) OR a knowledge note (omit status). Use this when the user states a new commitment with a clear scope, or shares a chunk of reusable knowledge. -- `log_work` — append a progress entry to an existing task. Use this when the user describes work they did on a task that already exists. ALWAYS call search_notes first to confirm the task exists by title or keyword; if no match, prefer create_note with status='todo' instead, or skip. -- `save_person` / `save_place` — **call this when the user explicitly introduces a named person or place you don't already have an entry for.** Explicit introductions look like "my father's name is Dale", "my friend Sarah works at Famous Supply", "we went to Olive Garden", "I work out of the Bedford office". When the user gives a name AND signal (relation, role, or context), create the entry — don't skip. The conservative "skip if unsure" rule applies to ambiguous mentions ("a friend told me…", "someone at work said…"), not to clearly-introduced names. Pass the name, and any relation/role/notes the user provided, as the entry's content. -- `create_project` / `create_milestone` — only when the user is clearly starting a NEW project or milestone they intend to track. Don't infer projects from passing mentions. - -**Proposing (queued for user review).** These tools mutate existing data; calls are intercepted and shown to the user for approval in the Needs Review panel. Use them when the user's intent is clear, but expect each one to wait for explicit user OK: -- `update_note` — mark a task done ("I finished the network restage" → update_note query='network restage', status='done'), change priority, edit a note's body, etc. ALWAYS search_notes first to verify the target exists. -- `update_milestone` — close a milestone the user explicitly says is finished, or rename one. -- `update_project` — adjust a project's status/description when the user explicitly states a change. -- `update_profile` — record a profile fact the user explicitly stated about themselves ("I'm a backend engineer now"). Don't infer profile updates from passing context. -- `delete_note` — propose removing a note or task the user explicitly says they no longer need. - -For proposing calls, the user sees a "before / proposed" diff and approves or rejects. You don't need to confirm with the user yourself — proposing IS the asking. If the proposal returns `{success: true, pending: true}`, that means it's queued; carry on with other captures. - -ENTITY LINKING (on record_moment): -- Use the *_names parameters (person_names, place_names, task_titles, note_titles). The server resolves names → ids. -- Before passing task_titles or note_titles, call search_notes to confirm the title exists. Don't invent titles. - -CROSS-REFERENCE — surface relevant past work: -When the user mentions a project, task, person, place, or topic that you can search for, call search_notes / search_journal / search_projects to find what they've already written or done about it. This is NOT about deciding what to capture — it's about giving the chat model context about past work next turn. - -Examples of when to search: -- User mentions "Famous Supply" → search_projects("Famous Supply") to confirm it exists; search_journal(query="Famous Supply") to find recent related moments. -- User describes ongoing work on a topic → search_journal to find what they noted before. -- User mentions a person or place by name → search_notes to find related context, especially for recent mentions. - -In your final summary line, weave 1-2 short references to relevant past entries when they connect meaningfully to today's beats. Example: "Captured the dhcp fix at Bedford; related to the network restage note from May 13." Avoid: enumeration ("found 5 related notes"), or invented references — only mention what you actually retrieved. - -If nothing relevant turns up, don't force a reference. The cross-ref is helpful when natural, dead weight when forced. - -WHAT NOT TO DO: -- Don't capture content from `Assistant:` lines. -- Don't fire tool calls for purely meta-conversational fragments ("ok", "thanks", "got it"). -- Don't try to call create_event, delete_event, or anything calendar-related (curator scope excludes calendar entirely). - -After the tool calls, you may emit one short summary sentence (≤ 20 words) describing what you captured. The summary is shown back to the chat model in subsequent turns so it stays aware of recent topics; it is NOT shown to the user directly. Examples: -- "Captured network restage progress and a coffee mention with Sarah." -- "Logged work on the Famous Supply task; recorded a tired but accomplished feeling." -- "Created a task for the domain renewal next month." -- "" (empty if nothing was captured — perfectly fine). -""" - - -@dataclass -class CuratorToolCall: - """One tool call attempted by the curator.""" - - name: str - arguments: dict - result: dict | None = None - error: str | None = None - status: str = "pending" # success | error | pending - - -@dataclass -class CuratorRunResult: - """What the curator did in a single pass over a conversation.""" - - conv_id: int - user_id: int - model: str - messages_examined: int - tool_calls: list[CuratorToolCall] = field(default_factory=list) - summary: str = "" - duration_ms: int = 0 - error: str | None = None - - @property - def tools_attempted(self) -> int: - return len(self.tool_calls) - - @property - def tools_succeeded(self) -> int: - return sum(1 for tc in self.tool_calls if tc.status == "success") - - def to_dict(self) -> dict: - return { - "conv_id": self.conv_id, - "user_id": self.user_id, - "model": self.model, - "messages_examined": self.messages_examined, - "tool_calls": [ - { - "name": tc.name, - "arguments": tc.arguments, - "status": tc.status, - "error": tc.error, - } - for tc in self.tool_calls - ], - "tools_attempted": self.tools_attempted, - "tools_succeeded": self.tools_succeeded, - "summary": self.summary, - "duration_ms": self.duration_ms, - "error": self.error, - } - - -def _format_transcript(messages: list[Message]) -> str: - """Render a list of Message rows as a plain transcript the curator can read. - - Only user + assistant messages are included. System messages (instructions - the chat model received) and tool-result messages (JSON noise from prior - tool calls) are noise for the curator's task; including them risked the - curator extracting from system text. The system prompt explicitly tells - the curator to extract ONLY from `User:` lines and to treat `Assistant:` - lines as context, so the format mirrors that contract. - """ - lines: list[str] = [] - for m in messages: - if not m.content: - continue - role = (m.role or "").lower() - if role not in ("user", "assistant"): - continue - ts = m.created_at.strftime("%H:%M") if m.created_at else "??:??" - role_label = role.capitalize() - lines.append(f"[{ts}] {role_label}: {m.content.strip()}") - return "\n".join(lines) - - -async def _load_messages_since( - conv_id: int, since: datetime | None -) -> list[Message]: - """Load conversation messages since the cutoff (or all of today).""" - async with async_session() as session: - stmt = select(Message).where(Message.conversation_id == conv_id) - if since is not None: - stmt = stmt.where(Message.created_at > since) - stmt = stmt.order_by(Message.created_at.asc()) - result = await session.execute(stmt) - return list(result.scalars().all()) - - -async def run_curator_for_conversation( - conv_id: int, - *, - since: datetime | None = None, - user_id_override: int | None = None, -) -> CuratorRunResult: - """Run a single curator pass over the given journal conversation. - - Args: - conv_id: target conversation. - since: only consider messages after this timestamp. Defaults to - the last 24 hours so a first manual trigger gets the day's - worth of context without going back forever. - user_id_override: optional — used by the scheduler to attribute - the run to the conversation's owner without re-fetching. - Manual triggers from the route pass None and we read from - the conversation row. - - Returns a CuratorRunResult; never raises (errors land in result.error). - - Guarded by `_CURATOR_RUN_LOCK` so at most one curator pass runs at - once globally. The scheduler processes candidates serially within - a sweep anyway; the lock matters when the manual-trigger route - fires concurrently with a sweep (or vice versa). Manual-trigger - callers should `is_curator_running()` first and reject rather than - block, since acquiring this lock can take minutes for a large model. - """ - async with _CURATOR_RUN_LOCK: - return await _run_curator_inner( - conv_id, since=since, user_id_override=user_id_override, - ) - - -async def _run_curator_inner( - conv_id: int, - *, - since: datetime | None = None, - user_id_override: int | None = None, -) -> CuratorRunResult: - """Curator-pass body. Always invoked under `_CURATOR_RUN_LOCK`.""" - started_at = time.monotonic() - - async with async_session() as session: - conv = await session.get(Conversation, conv_id) - if conv is None: - return CuratorRunResult( - conv_id=conv_id, user_id=0, model="", - messages_examined=0, error=f"Conversation {conv_id} not found", - ) - if conv.conversation_type != "journal": - return CuratorRunResult( - conv_id=conv_id, user_id=conv.user_id, model="", - messages_examined=0, - error=f"Curator only runs on journal conversations (got {conv.conversation_type!r})", - ) - - user_id = user_id_override or conv.user_id - # Default lookback: last 24h. Phase 2's scheduler will narrow this - # by passing the conversation's last_curator_run_at as `since`. - if since is None: - since = datetime.now(timezone.utc) - timedelta(hours=24) - - messages = await _load_messages_since(conv_id, since) - if not messages: - logger.info( - "Curator skipped conv %d: no new messages since %s", - conv_id, since.isoformat(), - ) - return CuratorRunResult( - conv_id=conv_id, user_id=user_id, - model="", messages_examined=0, - duration_ms=int((time.monotonic() - started_at) * 1000), - ) - - # Use the background model so the chat model's KV cache survives. - # Falls back to OLLAMA_MODEL if no background model is configured. - model = await get_setting(user_id, "background_model", "") or Config.OLLAMA_MODEL - - # Filter the journal tool set down to the curator's additive-only - # allowlist (see _CURATOR_ALLOWED_TOOLS comment). Belt-and-suspenders - # with the system prompt: if the prompt fails to dissuade the model - # from update/delete, the tool list literally doesn't include them - # so the call can't fire even if hallucinated. execute_tool also - # treats unknown names as errors, so the filter is the canonical - # boundary for what the curator can actually do. - all_tools = await get_tools_for_user(user_id, conversation_type="journal") - tools = [ - t for t in all_tools - if (t.get("function") or {}).get("name") in _CURATOR_ALLOWED_TOOLS - ] - transcript = _format_transcript(messages) - - user_prompt = ( - "Below is a fragment of the user's journal conversation. Extract " - "the captureable beats using the tools provided, then emit one " - "short summary line (or empty).\n\n" - "TRANSCRIPT:\n" - f"{transcript}\n" - ) - - llm_messages: list[dict] = [ - {"role": "system", "content": _CURATOR_SYSTEM_PROMPT}, - {"role": "user", "content": user_prompt}, - ] - - result = CuratorRunResult( - conv_id=conv_id, user_id=user_id, model=model, - messages_examined=len(messages), - ) - - try: - num_ctx = pick_num_ctx(llm_messages, tools=tools) - summary_chunks: list[str] = [] - - # Tool-call iteration loop — same shape as run_generation, but - # without SSE streaming since nothing is watching live. - for round_idx in range(_MAX_TOOL_ROUNDS): - tool_calls_this_round: list[dict] = [] - content_this_round: list[str] = [] - - async for chunk in stream_chat_with_tools( - llm_messages, model, tools=tools, think=False, num_ctx=num_ctx, - ): - if chunk.type == "content": - content_this_round.append(chunk.content) - elif chunk.type == "tool_calls": - tool_calls_this_round = chunk.tool_calls or [] - elif chunk.type == "done": - break - - # The model's content this round contributes to the summary - # only on the final round (after no more tool calls fire). - if not tool_calls_this_round: - summary_chunks.append("".join(content_this_round).strip()) - break - - # Execute each tool call, capture result, append back into the - # message list as a tool-role message so the model sees what - # happened on the next round. - llm_messages.append({ - "role": "assistant", - "content": "".join(content_this_round), - "tool_calls": tool_calls_this_round, - }) - - for tc in tool_calls_this_round: - fn = tc.get("function", {}) if isinstance(tc, dict) else {} - name = fn.get("name") or "" - args = fn.get("arguments") or {} - if isinstance(args, str): - try: - args = json.loads(args) - except Exception: - args = {} - call = CuratorToolCall(name=name, arguments=args) - try: - # authority="curator" routes mutating tools - # (_CURATOR_MUTATING_TOOLS in tools/_registry.py) to - # the pending-actions queue instead of executing them. - # Additive tools run normally; the curator can never - # silently mutate user data. - tool_result = await execute_tool( - user_id, name, args, - conv_id=conv_id, - authority="curator", - ) - call.result = tool_result - call.status = ( - "success" if (tool_result or {}).get("success", True) - and not (tool_result or {}).get("error") - else "error" - ) - if call.status == "error": - call.error = str((tool_result or {}).get("error", ""))[:500] - except Exception as e: - call.status = "error" - call.error = f"{type(e).__name__}: {e}"[:500] - tool_result = {"success": False, "error": call.error} - logger.exception("Curator tool %r failed for conv %d", name, conv_id) - result.tool_calls.append(call) - llm_messages.append({ - "role": "tool", - "content": json.dumps(tool_result)[:4000], - }) - else: - logger.warning( - "Curator hit _MAX_TOOL_ROUNDS=%d for conv %d (still emitting tool calls)", - _MAX_TOOL_ROUNDS, conv_id, - ) - - # Trim summary: at most one sentence, cap length aggressively. - summary = " ".join(summary_chunks).strip().splitlines() - result.summary = (summary[0][:240] if summary and summary[0] else "") - except Exception as e: - result.error = f"{type(e).__name__}: {e}" - logger.exception("Curator run failed for conv %d", conv_id) - - result.duration_ms = int((time.monotonic() - started_at) * 1000) - logger.info( - "Curator pass complete: conv=%d model=%s messages=%d " - "tool_calls=%d (ok=%d) duration=%dms summary=%r", - conv_id, model, result.messages_examined, - result.tools_attempted, result.tools_succeeded, - result.duration_ms, result.summary[:60], - ) - return result diff --git a/src/fabledassistant/services/curator_scheduler.py b/src/fabledassistant/services/curator_scheduler.py deleted file mode 100644 index 7797d85..0000000 --- a/src/fabledassistant/services/curator_scheduler.py +++ /dev/null @@ -1,197 +0,0 @@ -"""APScheduler entry that runs the journal curator periodically. - -Phase 2 of the conversation+curator architecture (Fable #172). Every -`CURATOR_INTERVAL_MIN` minutes, scans every journal conversation that -has user messages newer than its `last_curator_run_at` timestamp and -runs `curator.run_curator_for_conversation` against it. - -Design notes: -- One global job, not per-user. The scheduler is system-wide because - the curator runs are bounded (one short Ollama call per conversation) - and the cost of "scan all journal conversations" is tiny next to the - cost of an LLM call. -- Idempotent. If no journal conversation has new messages, the job - does nothing. If a curator run fails, the timestamp is NOT advanced - so the next sweep retries. -- Bounded concurrency. The job processes conversations sequentially — - the Ollama server is a single bottleneck and parallelism would - contend on KV cache slots. With OLLAMA_MAX_LOADED_MODELS=2 (chat + - curator separate models), the curator can run undisturbed by chat. -- Skipped when the curator has nothing meaningful to do: only - considers conversations whose newest user message is newer than - `last_curator_run_at`. Read-only sweeps are common and cheap. - -The scheduler is started in app.py alongside the existing journal_scheduler. -""" -from __future__ import annotations - -import asyncio -import datetime -import logging -from datetime import timezone - -from apscheduler.schedulers.background import BackgroundScheduler -from apscheduler.triggers.interval import IntervalTrigger -from sqlalchemy import func, select, update - -from fabledassistant.models import async_session -from fabledassistant.models.conversation import Conversation, Message -from fabledassistant.services.curator import run_curator_for_conversation - -logger = logging.getLogger(__name__) - -# 15 minutes between sweeps. Adjustable later (settings or env var) if -# the cadence turns out wrong in practice. Tested in conversation: long -# enough that small-talk doesn't generate constant curator runs, short -# enough that captures appear before the user forgets what they said. -CURATOR_INTERVAL_MIN = 15 - -# Hard cap on conversations processed per sweep — if there's a backlog -# (e.g. service was down for a day), we don't want one sweep to take an -# hour. Anything past this cap rolls to the next sweep. -_MAX_CONVERSATIONS_PER_SWEEP = 20 - -_scheduler: BackgroundScheduler | None = None -_loop: asyncio.AbstractEventLoop | None = None -_running_lock = asyncio.Lock() # prevents overlapping sweeps if one runs long - - -async def _candidate_conversations() -> list[tuple[int, datetime.datetime | None]]: - """Return (conv_id, last_curator_run_at) for journal conversations that - have user messages newer than the last curator run. Capped at the - sweep limit. - - Returning the timestamp alongside the id lets `_sweep` pass it to the - curator as the `since` cutoff, so each sweep only sees messages added - since the previous successful pass — no re-extracting already-captured - beats. - """ - async with async_session() as session: - # Per-conversation: newest USER message timestamp, vs last_curator_run_at. - latest_user_msg = ( - select( - Message.conversation_id.label("conv_id"), - func.max(Message.created_at).label("latest_at"), - ) - .where(Message.role == "user") - .group_by(Message.conversation_id) - .subquery() - ) - stmt = ( - select(Conversation.id, Conversation.last_curator_run_at) - .join(latest_user_msg, latest_user_msg.c.conv_id == Conversation.id) - .where(Conversation.conversation_type == "journal") - .where( - (Conversation.last_curator_run_at.is_(None)) - | (latest_user_msg.c.latest_at > Conversation.last_curator_run_at) - ) - .order_by(latest_user_msg.c.latest_at.asc()) - .limit(_MAX_CONVERSATIONS_PER_SWEEP) - ) - rows = await session.execute(stmt) - return [(row[0], row[1]) for row in rows.all()] - - -async def _stamp_last_run(conv_id: int, summary: str | None = None) -> None: - """Bump last_curator_run_at to now() after a successful pass. - - When `summary` is non-empty, also persists it to `curator_summary` - so the chat model picks it up on subsequent turns (Phase 3 feedback - loop). An empty summary means the curator had nothing meaningful - to capture — we still bump the timestamp (the run succeeded), but - leave the existing summary in place rather than clobbering useful - context with "". - """ - values: dict = {"last_curator_run_at": datetime.datetime.now(timezone.utc)} - if summary: - values["curator_summary"] = summary.strip()[:240] - async with async_session() as session: - await session.execute( - update(Conversation).where(Conversation.id == conv_id).values(**values) - ) - await session.commit() - - -async def _sweep() -> None: - """Process all candidate journal conversations. - - Wrapped in a lock so that if a sweep is still running when the next - interval fires, the new one waits / is skipped rather than running - concurrently. Ollama doesn't love parallel requests on the same model. - """ - if _running_lock.locked(): - logger.info("Curator sweep already in progress; skipping this tick") - return - async with _running_lock: - try: - candidates = await _candidate_conversations() - except Exception: - logger.exception("Curator candidate query failed") - return - if not candidates: - logger.debug("Curator sweep: no journal conversations need processing") - return - logger.info( - "Curator sweep: %d journal conversation(s) to process", - len(candidates), - ) - for conv_id, last_run_at in candidates: - try: - # Pass last_run_at as the `since` cutoff so the curator only - # examines messages added since the previous successful pass. - # Without this, every sweep re-loads the curator's default - # 24h window — and the LLM re-extracts beats from messages - # that were already captured, producing duplicate moments. - result = await run_curator_for_conversation( - conv_id, since=last_run_at, - ) - if result.error: - logger.warning( - "Curator run errored for conv %d (will retry next sweep): %s", - conv_id, result.error, - ) - continue - await _stamp_last_run(conv_id, summary=result.summary) - except Exception: - logger.exception( - "Curator sweep crashed on conv %d (will retry next sweep)", - conv_id, - ) - - -def _sweep_threadsafe() -> None: - """BackgroundScheduler runs jobs on its own thread; bridge to the asyncio loop.""" - if _loop is None: - logger.warning("Curator scheduler tick but no asyncio loop registered") - return - asyncio.run_coroutine_threadsafe(_sweep(), _loop) - - -def start_curator_scheduler(loop: asyncio.AbstractEventLoop) -> None: - global _scheduler, _loop - if _scheduler is not None: - return - _loop = loop - _scheduler = BackgroundScheduler() - _scheduler.add_job( - _sweep_threadsafe, - trigger=IntervalTrigger(minutes=CURATOR_INTERVAL_MIN), - id="curator_sweep", - replace_existing=True, - # Don't fire the very first tick immediately — let the app finish - # boot, then start the cadence. APScheduler computes next_run - # from now + interval by default. - ) - _scheduler.start() - logger.info( - "Curator scheduler started (interval=%d min, max %d conversations per sweep)", - CURATOR_INTERVAL_MIN, _MAX_CONVERSATIONS_PER_SWEEP, - ) - - -def stop_curator_scheduler() -> None: - global _scheduler - if _scheduler is not None: - _scheduler.shutdown(wait=False) - _scheduler = None - logger.info("Curator scheduler stopped") diff --git a/src/fabledassistant/services/diagnostics.py b/src/fabledassistant/services/diagnostics.py index ea1fa22..ff2f684 100644 --- a/src/fabledassistant/services/diagnostics.py +++ b/src/fabledassistant/services/diagnostics.py @@ -109,11 +109,8 @@ def _asyncio_task_count() -> int | None: def _curator_busy() -> bool | None: - try: - from fabledassistant.services.curator import is_curator_running - return is_curator_running() - except Exception: - return None + # Curator was removed in Phase 8 of the MCP-first pivot. Always False. + return False def _uptime_secs() -> float | None: diff --git a/src/fabledassistant/services/event_scheduler.py b/src/fabledassistant/services/event_scheduler.py index 2a9a449..b64bea3 100644 --- a/src/fabledassistant/services/event_scheduler.py +++ b/src/fabledassistant/services/event_scheduler.py @@ -57,16 +57,9 @@ async def _fire_reminders() -> None: async with async_session() as session: for event in to_notify: - try: - from fabledassistant.services.push import send_push_notification # noqa: PLC0415 - start_local = event.start_dt.strftime("%H:%M") - await send_push_notification( - user_id=event.user_id, - title=f"Reminder: {event.title}", - body=f"Starting at {start_local} UTC", - ) - except Exception: - logger.warning("Failed to send reminder push for event %d", event.id, exc_info=True) + # Push delivery removed alongside the chat subsystem in Phase 8. + # Event reminders are still flagged via in-app notifications + # (see services/notifications.py). # Mark as sent regardless of push success to avoid re-firing result = await session.execute( @@ -98,44 +91,6 @@ def _run_caldav_sync_threadsafe(loop: asyncio.AbstractEventLoop) -> None: asyncio.run_coroutine_threadsafe(_run_caldav_sync(), loop) -# --------------------------------------------------------------------------- -# Chat retention cleanup job -# --------------------------------------------------------------------------- - -async def _run_chat_retention_cleanup() -> None: - """Delete old conversations for all users according to their retention setting.""" - from sqlalchemy import select as sa_select # noqa: PLC0415 - - from fabledassistant.models.user import User # noqa: PLC0415 - from fabledassistant.services.chat import cleanup_old_conversations # noqa: PLC0415 - from fabledassistant.services.settings import get_setting # noqa: PLC0415 - - async with async_session() as session: - result = await session.execute(sa_select(User.id)) - user_ids = [row[0] for row in result.all()] - - total_deleted = 0 - for user_id in user_ids: - try: - retention_str = await get_setting(user_id, "chat_retention_days", "90") - try: - retention_days = int(retention_str) - except (ValueError, TypeError): - retention_days = 90 - if retention_days > 0: - deleted = await cleanup_old_conversations(user_id, retention_days) - total_deleted += deleted - except Exception: - logger.warning("Chat retention cleanup failed for user %d", user_id, exc_info=True) - - if total_deleted: - logger.info("Chat retention cleanup: deleted %d conversation(s)", total_deleted) - - -def _run_chat_retention_threadsafe(loop: asyncio.AbstractEventLoop) -> None: - asyncio.run_coroutine_threadsafe(_run_chat_retention_cleanup(), loop) - - # --------------------------------------------------------------------------- # Lifecycle # --------------------------------------------------------------------------- @@ -165,17 +120,8 @@ def start_event_scheduler(loop: asyncio.AbstractEventLoop) -> None: replace_existing=True, ) - # Chat retention cleanup once per day - _scheduler.add_job( - _run_chat_retention_threadsafe, - trigger=IntervalTrigger(hours=24), - args=[loop], - id="chat_retention_cleanup", - replace_existing=True, - ) - _scheduler.start() - logger.info("Event scheduler started (reminders every 5m, CalDAV sync every 1h, chat cleanup every 24h)") + logger.info("Event scheduler started (reminders every 5m, CalDAV sync every 1h)") def stop_event_scheduler() -> None: diff --git a/src/fabledassistant/services/generation_buffer.py b/src/fabledassistant/services/generation_buffer.py deleted file mode 100644 index dc90e07..0000000 --- a/src/fabledassistant/services/generation_buffer.py +++ /dev/null @@ -1,138 +0,0 @@ -"""In-memory generation event buffer for SSE fan-out. - -Each active generation gets a GenerationBuffer keyed by conversation_id. -SSE clients tail the buffer and can reconnect at any point without data loss. -""" - -import asyncio -import json -import logging -import time -from dataclasses import dataclass, field -from enum import Enum - -logger = logging.getLogger(__name__) - - -class GenerationState(str, Enum): - RUNNING = "running" - COMPLETED = "completed" - ERRORED = "errored" - - -@dataclass -class SSEEvent: - index: int - event_type: str # "context", "chunk", "done", "error" - data: dict - - -@dataclass -class GenerationBuffer: - conversation_id: int - assistant_message_id: int - state: GenerationState = GenerationState.RUNNING - events: list[SSEEvent] = field(default_factory=list) - content_so_far: str = "" - finished_at: float | None = None - cancel_event: asyncio.Event = field(default_factory=asyncio.Event) - _notify: asyncio.Event = field(default_factory=asyncio.Event) - # Tool confirmation — set when a write tool is awaiting user approval - confirmation_future: asyncio.Future | None = None - pending_tool: dict | None = None - - def append_event(self, event_type: str, data: dict) -> SSEEvent: - event = SSEEvent(index=len(self.events), event_type=event_type, data=data) - self.events.append(event) - # Wake all waiting SSE clients, then reset for next wait - old = self._notify - self._notify = asyncio.Event() - old.set() - return event - - async def wait_for_event(self, after_index: int, timeout: float = 30.0) -> bool: - """Wait until there are events beyond after_index. Returns True if new events exist.""" - if after_index + 1 < len(self.events): - return True - try: - await asyncio.wait_for(self._notify.wait(), timeout=timeout) - return True - except asyncio.TimeoutError: - return False - - def events_after(self, index: int) -> list[SSEEvent]: - """Return events with index > given index (for replay on reconnect).""" - start = index + 1 - if start >= len(self.events): - return [] - return self.events[start:] - - @staticmethod - def format_sse(event: SSEEvent) -> str: - return f"id: {event.index}\nevent: {event.event_type}\ndata: {json.dumps(event.data)}\n\n" - - -# Module-level singleton registry -_buffers: dict[int | str, GenerationBuffer] = {} - -_cleanup_task: asyncio.Task | None = None -_GRACE_PERIOD = 60.0 # seconds to keep completed buffers - - -def create_buffer(conv_id: int, msg_id: int) -> GenerationBuffer: - existing = _buffers.get(conv_id) - if existing and existing.state == GenerationState.RUNNING: - raise RuntimeError(f"Generation already running for conversation {conv_id}") - buf = GenerationBuffer(conversation_id=conv_id, assistant_message_id=msg_id) - _buffers[conv_id] = buf - return buf - - -def get_buffer(conv_id: int) -> GenerationBuffer | None: - return _buffers.get(conv_id) - - -def remove_buffer(conv_id: int) -> None: - _buffers.pop(conv_id, None) - - -def create_assist_buffer(user_id: int) -> GenerationBuffer: - key = f"assist:{user_id}" - existing = _buffers.get(key) - if existing and existing.state == GenerationState.RUNNING: - # Orphan the old buffer — the background task holds a direct reference - # and will complete against it harmlessly. A new request always wins. - logger.warning("Assist generation still running for user %d; orphaning old buffer", user_id) - buf = GenerationBuffer(conversation_id=0, assistant_message_id=0) - _buffers[key] = buf - return buf - - -def get_assist_buffer(user_id: int) -> GenerationBuffer | None: - return _buffers.get(f"assist:{user_id}") - - -def remove_assist_buffer(user_id: int) -> None: - _buffers.pop(f"assist:{user_id}", None) - - -async def _cleanup_loop() -> None: - """Remove completed/errored buffers after grace period.""" - while True: - await asyncio.sleep(15) - now = time.monotonic() - to_remove = [ - key for key, buf in _buffers.items() - if buf.state != GenerationState.RUNNING - and buf.finished_at is not None - and now - buf.finished_at > _GRACE_PERIOD - ] - for key in to_remove: - _buffers.pop(key, None) - logger.debug("Cleaned up generation buffer for key %s", key) - - -def start_cleanup_loop() -> None: - global _cleanup_task - if _cleanup_task is None or _cleanup_task.done(): - _cleanup_task = asyncio.create_task(_cleanup_loop()) diff --git a/src/fabledassistant/services/generation_log.py b/src/fabledassistant/services/generation_log.py deleted file mode 100644 index 4264766..0000000 --- a/src/fabledassistant/services/generation_log.py +++ /dev/null @@ -1,111 +0,0 @@ -"""Per-turn tool-call telemetry for assistant generations. - -Captures the empirical surface for evaluating model swaps: -- Which tools were available to the model on this turn? -- Which did it attempt to call? -- Which succeeded? -- Which failed, and with what error? - -One row per assistant turn. Designed to answer questions like -"does mistral-small actually fire record_moment when it should?" -without relying on anecdote across model changes. -""" -from __future__ import annotations - -import logging -from typing import Iterable - -from sqlalchemy import select - -from fabledassistant.models import async_session -from fabledassistant.models.generation_tool_log import GenerationToolLog - -logger = logging.getLogger(__name__) - - -async def log_tool_outcomes( - *, - user_id: int, - conv_id: int, - assistant_message_id: int | None, - model: str, - think_enabled: bool, - tools_available: Iterable[str], - tool_calls: Iterable[dict], -) -> None: - """Persist one row capturing this turn's tool-call outcomes. - - `tool_calls` is the assembled `all_tool_calls` list from - `generation_task.run_generation`. Each entry has the shape: - {"function": , "arguments": ..., "result": ..., "status": ...} - where `status` is "success" or "error" and `result` may contain an - `error` field when status is "error". - - Fire-and-forget from the caller's perspective — failure here must not - affect the user-facing generation flow, so exceptions are caught and - logged rather than propagated. - """ - try: - available = sorted({str(t) for t in tools_available if t}) - attempted: list[str] = [] - succeeded: list[str] = [] - failed: list[dict] = [] - for call in tool_calls: - name = str(call.get("function") or call.get("name") or "").strip() - if not name: - continue - attempted.append(name) - status = call.get("status") - result = call.get("result") or {} - err = result.get("error") if isinstance(result, dict) else None - is_error = (status == "error") or bool(err) or ( - isinstance(result, dict) and result.get("success") is False - ) - if is_error: - failed.append({"name": name, "error": str(err or "unspecified")[:500]}) - else: - succeeded.append(name) - - async with async_session() as session: - session.add( - GenerationToolLog( - user_id=user_id, - conv_id=conv_id, - assistant_message_id=assistant_message_id, - model=model, - think_enabled=think_enabled, - tools_available=available, - tools_attempted=attempted, - tools_succeeded=succeeded, - tools_failed=failed, - ) - ) - await session.commit() - except Exception: - logger.warning( - "Failed to persist generation_tool_log for conv %d (msg=%s, model=%s)", - conv_id, - assistant_message_id, - model, - exc_info=True, - ) - - -async def recent_logs( - *, - user_id: int, - limit: int = 50, - model: str | None = None, -) -> list[dict]: - """Return recent generation_tool_log rows for the user, newest first. - - Optionally filter to a specific model. Returns plain dicts so callers - don't need an open session to read fields. - """ - async with async_session() as session: - stmt = select(GenerationToolLog).where(GenerationToolLog.user_id == user_id) - if model: - stmt = stmt.where(GenerationToolLog.model == model) - stmt = stmt.order_by(GenerationToolLog.created_at.desc()).limit(limit) - result = await session.execute(stmt) - return [row.to_dict() for row in result.scalars().all()] diff --git a/src/fabledassistant/services/generation_task.py b/src/fabledassistant/services/generation_task.py deleted file mode 100644 index 51b3d97..0000000 --- a/src/fabledassistant/services/generation_task.py +++ /dev/null @@ -1,653 +0,0 @@ -"""Background asyncio task for LLM generation. - -Streams from Ollama into a GenerationBuffer, periodically flushing to DB. -Runs independently of any HTTP connection. -""" - -import asyncio -import json -import logging -import re -import time -from collections.abc import AsyncGenerator - -import httpx - -from sqlalchemy import update - -from fabledassistant.config import Config -from fabledassistant.models import async_session -from fabledassistant.models.conversation import Message -from fabledassistant.services.generation_buffer import ( - GenerationBuffer, - GenerationState, -) -from fabledassistant.services.llm import ChatChunk, build_context, generate_completion, pick_num_ctx, stream_chat, stream_chat_with_tools, summarize_history_for_context -from fabledassistant.services.chat import update_conversation_title -from fabledassistant.services.settings import get_setting -from fabledassistant.services.logging import log_generation -from fabledassistant.services.tools import get_tools_for_user, execute_tool -from fabledassistant.services.research import run_research_pipeline - -logger = logging.getLogger(__name__) - -# Mistral prefixes tool-call responses with "[TOOL_CALLS]" as visible text -_TOOL_CALL_MARKER = re.compile(r"^\s*\[TOOL_CALLS\]\s*", re.IGNORECASE) - -DB_FLUSH_INTERVAL = 5.0 # seconds between partial DB flushes - - -# Human-readable labels for each tool, shown in the status indicator -_TOOL_LABELS: dict[str, str] = { - "create_note": "Creating note/task", - "update_note": "Updating note/task", - "delete_note": "Deleting note/task", - "read_note": "Reading note", - "list_notes": "Listing notes", - "list_tasks": "Searching tasks", - "search_notes": "Searching notes (semantic)", - "create_event": "Creating calendar event", - "list_events": "Searching calendar", - "search_events": "Searching calendar", - "update_event": "Updating calendar event", - "delete_event": "Removing calendar event", - "list_calendars": "Listing calendars", - "lookup": "Looking up information", - "research_topic": "Researching topic", -} - - -async def _generate_title(messages: list[dict], user_id: int) -> str: - """Ask the LLM for a concise conversation title. - - Only uses user messages to avoid feeding tool-call JSON, system prompt - fragments, or other noise into the title generator. Caps input length - to keep the task fast and focused. - """ - user_texts = [] - for m in messages: - if m["role"] == "user": - content = (m.get("content") or "").strip() - if content: - user_texts.append(content[:300]) - if not user_texts: - return "" - # First + last user messages capture intent best - if len(user_texts) > 2: - user_texts = [user_texts[0], user_texts[-1]] - - prompt_messages = [ - { - "role": "user", - "content": ( - "Generate a concise 3-8 word title for a conversation that started with:\n\n" - + "\n\n".join(user_texts) - + "\n\nReply with ONLY the title. No quotes, no punctuation, no explanation." - ), - }, - ] - bg_model = await get_setting(user_id, "background_model", Config.OLLAMA_BACKGROUND_MODEL) - title = await generate_completion(prompt_messages, bg_model, max_tokens=30, num_ctx=1024) - # Strip common LLM noise: quotes, thinking tags, role labels - title = title.strip().strip('"\'').strip() - for prefix in ("Title:", "title:", "Assistant:", "User:"): - if title.startswith(prefix): - title = title[len(prefix):].strip() - # Drop anything after a newline (model sometimes adds explanation) - if "\n" in title: - title = title.split("\n")[0].strip() - return title[:80] if title else "" - - -async def _update_message( - message_id: int, - content: str, - status: str, - tool_calls: list[dict] | None = None, -) -> None: - values: dict = {"content": content, "status": status} - if tool_calls is not None: - values["tool_calls"] = tool_calls - async with async_session() as session: - await session.execute( - update(Message) - .where(Message.id == message_id) - .values(**values) - ) - await session.commit() - - -async def _stream_with_retry( - messages: list[dict], - model: str, - tools: list[dict], - think: bool, - num_ctx: int | None = None, -) -> AsyncGenerator[ChatChunk, None]: - """stream_chat_with_tools with automatic retry on Ollama 500 errors. - - 500s occur when Ollama is still loading a model or handling a concurrent - request (e.g. tag suggestions racing with round 1). Retries up to 2 times - with a short delay — by which point the model is warm and other calls done. - """ - last_exc: BaseException | None = None - for attempt in range(3): - if attempt > 0: - delay = 3.0 * attempt - logger.warning( - "Ollama stream 500 (attempt %d/3), retrying in %.0fs", attempt, delay - ) - await asyncio.sleep(delay) - try: - async for chunk in stream_chat_with_tools(messages, model, tools=tools, think=think, num_ctx=num_ctx): - yield chunk - return - except httpx.HTTPStatusError as exc: - last_exc = exc - if exc.response.status_code != 500: - break # non-500 is not retryable - except BaseException as exc: - last_exc = exc - break - if last_exc is not None: - raise last_exc - - -async def run_generation( - buf: GenerationBuffer, - history: list[dict], - model: str, - user_id: int, - conv_id: int, - conv_title: str, - user_content: str, - context_note_id: int | None = None, - include_note_ids: list[int] | None = None, - excluded_note_ids: list[int] | None = None, - think: bool = False, - rag_project_id: int | None = None, - workspace_project_id: int | None = None, - user_timezone: str | None = None, - voice_mode: bool = False, -) -> None: - """Stream LLM response into buffer with periodic DB flushes.""" - MAX_TOOL_ROUNDS = 6 - msg_id = buf.assistant_message_id - - buf.append_event("status", {"status": "Building context..."}) - - # Phase 1: Resolve the tools list for this user, scoped to conversation type. - # - # Journal conversations get NO tools (2026-05-22 architecture pivot): - # the chat model talks, a separate background curator does tool calls - # asynchronously. See services/curator.py. Removing tools here is the - # mechanical change that makes the architecture real — the chat model - # can no longer fire record_moment / create_task / etc. and therefore - # can no longer lie about firing them. - from fabledassistant.models import async_session as _async_session - from fabledassistant.models.conversation import Conversation as _Conversation - async with _async_session() as _sess: - _conv = await _sess.get(_Conversation, conv_id) - _conversation_type = ( - _conv.conversation_type if _conv and _conv.conversation_type else "chat" - ) - if _conversation_type == "journal": - tools = [] - logger.info( - "Conv %d is journal: passing tools=[] to chat model " - "(curator handles tool calls async)", - conv_id, - ) - else: - tools = await get_tools_for_user(user_id, conversation_type=_conversation_type) - - logger.info( - "Starting generation for conv %d: model=%s, tools=%d", - conv_id, model, len(tools), - ) - - # Phase 2: Summarize long conversation history if needed. - history_to_use = history - history_summary: str | None = None - if len(history) > 30: # matches _HISTORY_SUMMARY_THRESHOLD in llm.py - buf.append_event("status", {"status": "Summarizing conversation history..."}) - history_to_use, history_summary = await summarize_history_for_context(history, model) - - # Phase 3: Build context. - # Note: Ollama lazy-loads models on the first /api/chat request, so polling - # /api/ps for model readiness only causes delay. We proceed immediately and - # let Ollama handle loading on demand. - - # Fetch voice_speech_style from user settings when voice_mode is active. - voice_speech_style = "conversational" - if voice_mode: - from fabledassistant.services.settings import get_setting - voice_speech_style = await get_setting(user_id, "voice_speech_style", "conversational") - - messages, context_meta = await build_context( - user_id, history_to_use, context_note_id, user_content, - history_summary=history_summary, - include_note_ids=include_note_ids, - excluded_note_ids=excluded_note_ids, - rag_project_id=rag_project_id, - workspace_project_id=workspace_project_id, - user_timezone=user_timezone, - conv_id=conv_id, - voice_mode=voice_mode, - voice_speech_style=voice_speech_style, - ) - - # Pick the smallest context tier that fits the current messages AND the - # tool schemas (which can be 6-10K tokens on their own with ~40 tools). - # Using the minimum needed tier reduces KV cache size and speeds up prefill. - num_ctx = pick_num_ctx(messages, tools=tools) - logger.debug("Adaptive num_ctx=%d for conv %d", num_ctx, conv_id) - - # Emit context event - buf.append_event("context", {"context": context_meta}) - - # Think mode is hardcoded off (2026-05-23). Historical context: - # originally forced on for qwen3's combined think+tools template; - # then made user-configurable when we decoupled the architecture; - # now removed entirely because in the chat+curator world there's no - # reason for the chat model to think. Chat has tools=[] — it just - # talks, and think on a no-tools conversational pass is pure - # latency cost (the May 2026 bench measured 1-2 min/turn for - # unclear quality benefit). The curator (services/curator.py) also - # already hardcodes think=False for its own reasons. - think_requested = think - think = False - - t_start = time.monotonic() - timing: dict = { - "think_requested": think_requested, - "think": think, - "num_ctx": num_ctx, - "tools": [], - "rounds": 0, - "prompt_tokens": None, - "output_tokens": None, - "first_token_ms": None, - "thinking_ms": None, - "ttft_ms": None, - "generation_ms": None, - "total_ms": None, - } - - last_flush = time.monotonic() - all_tool_calls: list[dict] = [] - new_rag_scope: object = False # sentinel; set to int|None when scope changes - new_rag_scope_label: str | None = None - - try: - cancelled = False - research_completed = False - - for _round in range(MAX_TOOL_ROUNDS): - timing["rounds"] = _round + 1 - round_tool_calls: list[dict] = [] - logger.info("Generation round %d started for conv %d (model=%s)", _round, conv_id, model) - - if cancelled: - break - - buf.append_event("status", {"status": "Generating response..." if _round == 0 else "Composing response..."}) - t_stream = time.monotonic() - - approx_msg_chars = sum(len(str(m.get("content", ""))) for m in messages) - round_content_start = len(buf.content_so_far) - round_output_tokens_start = timing.get("output_tokens") or 0 - round_prompt_tokens_start = timing.get("prompt_tokens") or 0 - logger.info( - "CTX_DIAG round_start conv=%d round=%d num_ctx=%d msgs=%d approx_chars=%d think=%s", - conv_id, _round, num_ctx, len(messages), approx_msg_chars, think, - ) - async for chunk in _stream_with_retry(messages, model, tools, think, num_ctx=num_ctx): - if buf.cancel_event.is_set(): - cancelled = True - break - - if chunk.type == "thinking": - if timing["first_token_ms"] is None: - timing["first_token_ms"] = int((time.monotonic() - t_start) * 1000) - buf.append_event("thinking_chunk", {"chunk": chunk.content}) - - elif chunk.type == "content": - if timing["ttft_ms"] is None: - now_ms = int((time.monotonic() - t_start) * 1000) - timing["ttft_ms"] = now_ms - if timing["first_token_ms"] is None: - # No thinking phase occurred — first token IS content. - timing["first_token_ms"] = now_ms - else: - # Thinking phase duration = gap between first thinking - # token and first content token. - timing["thinking_ms"] = now_ms - timing["first_token_ms"] - buf.content_so_far += chunk.content - clean = _TOOL_CALL_MARKER.sub("", chunk.content) - if clean: - buf.append_event("chunk", {"chunk": clean}) - - now = time.monotonic() - if now - last_flush >= DB_FLUSH_INTERVAL: - try: - await _update_message(msg_id, buf.content_so_far, "generating") - except Exception: - logger.warning("Failed periodic flush for message %d", msg_id, exc_info=True) - last_flush = now - - elif chunk.type == "done": - if chunk.prompt_tokens is not None: - timing["prompt_tokens"] = (timing["prompt_tokens"] or 0) + chunk.prompt_tokens - if chunk.output_tokens is not None: - timing["output_tokens"] = (timing["output_tokens"] or 0) + chunk.output_tokens - - elif chunk.type == "tool_calls" and chunk.tool_calls: - logger.info("Round %d: model returned %d tool call(s)", _round, len(chunk.tool_calls)) - for tc in chunk.tool_calls: - fn = tc.get("function", {}) - tool_name = fn.get("name", "") - arguments = fn.get("arguments", {}) - logger.info("Executing tool: %s(%s)", tool_name, json.dumps(arguments)[:200]) - buf.append_event("status", {"status": f"{_TOOL_LABELS.get(tool_name, 'Working')}..."}) - - t_tool = time.monotonic() - if tool_name == "research_topic": - topic = arguments.get("topic", "") - try: - note = await run_research_pipeline(topic, user_id, model, buf, project_id=workspace_project_id) - result = { - "success": True, - "type": "research_note", - "data": {"id": note.id, "title": note.title}, - } - done_text = ( - f"\n\n---\n\nResearch complete! I've compiled a note: " - f"**[{note.title}](/notes/{note.id})**." - ) - buf.append_event("chunk", {"chunk": done_text}) - buf.content_so_far += done_text - except Exception as e: - logger.exception("Research pipeline failed for topic: %s", topic) - err_msg = str(e) or f"{type(e).__name__}: unexpected error" - result = {"success": False, "error": err_msg} - err_text = f"\nResearch failed: {err_msg}" - buf.append_event("chunk", {"chunk": err_text}) - buf.content_so_far += err_text - research_completed = True - else: - result = await execute_tool( - user_id, tool_name, arguments, - conv_id=conv_id, - workspace_project_id=workspace_project_id, - ) - - # Capture RAG scope change for SSE done event - if result.get("type") == "rag_scope_set" and result.get("success"): - new_rag_scope = arguments.get("project_id") - new_rag_scope_label = result.get("scope_label") - - timing["tools"].append({"name": tool_name, "ms": int((time.monotonic() - t_tool) * 1000)}) - logger.info("Tool %s result: success=%s", tool_name, result.get("success")) - - tool_record = { - "function": tool_name, - "arguments": arguments, - "result": result, - "status": "success" if result.get("success") else "error", - } - round_tool_calls.append(tool_record) - all_tool_calls.append(tool_record) - buf.append_event("tool_call", {"tool_call": tool_record}) - - round_content_added = len(buf.content_so_far) - round_content_start - round_output_tokens_added = (timing.get("output_tokens") or 0) - round_output_tokens_start - round_prompt_tokens = (timing.get("prompt_tokens") or 0) - round_prompt_tokens_start - headroom = num_ctx - round_prompt_tokens if round_prompt_tokens else None - is_silent = ( - not round_tool_calls - and round_content_added == 0 - and round_output_tokens_added > 0 - ) - logger.info( - "CTX_DIAG round_end conv=%d round=%d think=%s prompt_tokens=%d output_tokens=%d headroom=%s content_added=%d tool_calls=%d silent=%s", - conv_id, _round, think, round_prompt_tokens, round_output_tokens_added, - headroom, round_content_added, len(round_tool_calls), is_silent, - ) - - timing["generation_ms"] = int((time.monotonic() - t_stream) * 1000) - - if cancelled: - logger.info("Generation cancelled for conv %d", conv_id) - break - - if research_completed: - logger.info("Research complete for conv %d, ending generation", conv_id) - break - - if not round_tool_calls: - logger.info("Round %d: no tool calls, final content length=%d", _round, len(buf.content_so_far)) - break - - logger.info("Round %d: %d tool call(s) executed, starting next round", _round, len(round_tool_calls)) - - buf.content_so_far = _TOOL_CALL_MARKER.sub("", buf.content_so_far) - - messages.append({ - "role": "assistant", - "content": buf.content_so_far, - "tool_calls": [ - {"function": {"name": tc["function"], "arguments": tc["arguments"]}} - for tc in round_tool_calls - ], - }) - for tc in round_tool_calls: - messages.append({"role": "tool", "content": json.dumps(tc["result"])}) - - buf.content_so_far = "" - - # Strip model artifacts from final content - buf.content_so_far = _TOOL_CALL_MARKER.sub("", buf.content_so_far) - - # Silent-generation safety net: the model burned output tokens but - # nothing landed in content or tool_calls (seen with qwen3:14b when - # its tool-call emission doesn't parse). Show a visible fallback so - # the user isn't staring at an empty bubble. - if ( - not cancelled - and not buf.content_so_far.strip() - and not all_tool_calls - and (timing.get("output_tokens") or 0) > 0 - ): - logger.warning( - "Silent generation for conv %d: output_tokens=%s but empty content " - "and no tool calls (model=%s)", - conv_id, timing.get("output_tokens"), model, - ) - fallback = ( - "I wasn't able to produce a usable response — the model generated " - "tokens that couldn't be parsed as content or a tool call. " - "Please try rephrasing, or try again." - ) - buf.content_so_far = fallback - buf.append_event("chunk", {"chunk": fallback}) - - # Final save - logger.info("Generation complete for conv %d: content_length=%d, tool_calls=%d", - conv_id, len(buf.content_so_far), len(all_tool_calls)) - await _update_message( - msg_id, - buf.content_so_far, - "complete", - tool_calls=all_tool_calls if all_tool_calls else None, - ) - - timing["total_ms"] = int((time.monotonic() - t_start) * 1000) - logger.info( - "Generation timing for conv %d: total=%dms think=%s(req=%s) first_token=%s " - "thinking=%s ttft=%s generation=%s tools=%s", - conv_id, timing["total_ms"], - timing["think"], timing["think_requested"], - timing["first_token_ms"], timing["thinking_ms"], timing["ttft_ms"], - timing["generation_ms"], - [(t["name"], t["ms"]) for t in timing["tools"]], - ) - try: - await log_generation(user_id, conv_id, model, timing) - except Exception: - logger.warning("Failed to persist generation timing for conv %d", conv_id, exc_info=True) - - # Per-turn tool-call telemetry. Empirical surface for evaluating - # model swaps without needing user reports — answers "did model X - # actually fire record_moment when it should have?" The helper is - # internally try/except so this never affects the user-facing flow. - from fabledassistant.services.generation_log import log_tool_outcomes - await log_tool_outcomes( - user_id=user_id, - conv_id=conv_id, - assistant_message_id=msg_id, - model=model, - think_enabled=think, - tools_available=[ - (t.get("function") or {}).get("name") for t in tools - ], - tool_calls=all_tool_calls, - ) - - buf.state = GenerationState.COMPLETED - buf.finished_at = time.monotonic() - done_payload: dict = {"done": True, "message_id": msg_id, "timing": timing} - if new_rag_scope is not False: - done_payload["new_rag_scope"] = new_rag_scope - done_payload["new_rag_scope_label"] = new_rag_scope_label - buf.append_event("done", done_payload) - - # Fire push notification when complete (non-critical, fire-and-forget) - try: - from fabledassistant.services.push import send_push_notification, vapid_enabled - if vapid_enabled(): - text = buf.content_so_far.strip() - if text: - preview = text[:120].rstrip() - if len(text) > 120: - preview += "…" - else: - # Tool-only response — summarise what was done - tool_names = [tc.get("function") for tc in all_tool_calls if tc.get("function")] - if tool_names: - preview = f"Completed: {', '.join(tool_names[:3])}" - else: - preview = "Action completed" - asyncio.create_task(send_push_notification( - user_id, - title="Response ready", - body=preview, - url=f"/chat/{conv_id}", - )) - except Exception: - logger.warning("Failed to schedule push notification", exc_info=True) - - # Title generation is non-critical — fire-and-forget so done fires immediately - non_system = [m for m in messages if m["role"] != "system"] - msg_count = len(non_system) - should_gen_title = not conv_title or (msg_count > 0 and msg_count % 10 == 0) - - if should_gen_title: - # Feed the title model the *raw* conversation turns only — never - # the post-build_context ``messages`` list. ``build_context`` - # prepends RAG snippets and URL content INTO the user message - # string itself, so filtering by role="user" downstream still - # surfaces that noise as the "user's message". That pollution - # caused wildly-wrong titles (bug #109) — the small background - # model was staring at article excerpts instead of what the user - # actually typed. Pass the original history + the raw user_content - # + the assistant reply. - title_messages: list[dict] = [ - {"role": m["role"], "content": m.get("content") or ""} - for m in history - if m.get("role") in ("user", "assistant") - ] - title_messages.append({"role": "user", "content": user_content}) - title_messages.append({"role": "assistant", "content": buf.content_so_far}) - - async def _bg_title() -> None: - try: - title = await _generate_title(title_messages, user_id) - if title: - await update_conversation_title(user_id, conv_id, title) - except Exception: - logger.warning("Failed to generate title for conversation %d", conv_id, exc_info=True) - if not conv_title: - fallback = user_content[:80] - if len(user_content) > 80: - fallback += "..." - await update_conversation_title(user_id, conv_id, fallback) - - asyncio.create_task(_bg_title()) - - except Exception as e: - logger.exception("Error in generation task for conversation %d", conv_id) - # Save partial content with error status - try: - await _update_message(msg_id, buf.content_so_far, "error") - except Exception: - logger.warning("Failed to save error state for message %d", msg_id, exc_info=True) - - buf.state = GenerationState.ERRORED - buf.finished_at = time.monotonic() - buf.append_event("error", {"error": str(e)}) - - -async def run_assist_generation( - buf: GenerationBuffer, - messages: list[dict], - model: str, -) -> None: - """Stream LLM response for assist into buffer. No DB persistence. - - Retries up to 3 times on Ollama 500 errors (model still loading). - On each retry the accumulated content is reset so the done event - always reflects only the successful generation. - """ - from fabledassistant.services.llm import pick_num_ctx - input_chars = sum(len(m.get("content", "")) for m in messages) - num_ctx = pick_num_ctx(messages) - logger.info("Assist generation started: model=%s, input_chars=%d, num_ctx=%d", model, input_chars, num_ctx) - - last_exc: BaseException | None = None - for attempt in range(3): - if attempt > 0: - delay = 3.0 * attempt - logger.warning( - "Ollama assist stream 500 (attempt %d/3), retrying in %.0fs", attempt, delay - ) - await asyncio.sleep(delay) - try: - buf.content_so_far = "" - async for chunk in stream_chat(messages, model, options={"num_predict": num_ctx}, num_ctx=num_ctx): - buf.content_so_far += chunk - buf.append_event("chunk", {"chunk": chunk}) - - output_chars = len(buf.content_so_far) - logger.info( - "Assist generation complete: output_chars=%d, events=%d", - output_chars, len(buf.events), - ) - buf.state = GenerationState.COMPLETED - buf.finished_at = time.monotonic() - buf.append_event("done", {"done": True, "full_text": buf.content_so_far}) - logger.info("Assist done event appended (event index %d)", len(buf.events) - 1) - return - - except httpx.HTTPStatusError as exc: - last_exc = exc - if exc.response.status_code != 500: - break - except Exception as exc: - last_exc = exc - break - - logger.exception("Error in assist generation task") - buf.state = GenerationState.ERRORED - buf.finished_at = time.monotonic() - buf.append_event("error", {"error": str(last_exc)}) diff --git a/src/fabledassistant/services/journal_closeout.py b/src/fabledassistant/services/journal_closeout.py deleted file mode 100644 index 467af9f..0000000 --- a/src/fabledassistant/services/journal_closeout.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Journal closeout — nightly extraction of profile observations. - -Runs once per user per day at day_rollover_hour. Reads yesterday's /journal -conversation, filters out assistant-authored auto-content (daily prep), -asks the background LLM to extract user-side patterns/habits, and appends -the bullets to user_profiles.observations_raw via append_observations. -""" -from __future__ import annotations - -import datetime -import logging - -from sqlalchemy import or_, select - -from fabledassistant.config import Config -from fabledassistant.models import async_session -from fabledassistant.models.conversation import Conversation, Message -from fabledassistant.services.llm import generate_completion -from fabledassistant.services.settings import get_setting -from fabledassistant.services.user_profile import append_observations - -logger = logging.getLogger(__name__) - -# Message kinds whose content must NEVER be sent to the closeout LLM. -# These are assistant-authored auto-blocks that would otherwise dominate -# attention and leak back into "what the assistant has learned." -EXCLUDED_KINDS: set[str] = {"daily_prep"} - - -def _filter_messages(messages): - """Drop messages whose msg_metadata.kind is in EXCLUDED_KINDS. - - Accepts any iterable of message-like objects with `role`, `content`, - and `msg_metadata` attributes (real Message rows or SimpleNamespace). - """ - kept = [] - for m in messages: - meta = getattr(m, "msg_metadata", None) or {} - if meta.get("kind") in EXCLUDED_KINDS: - continue - kept.append(m) - return kept - - -_TRANSCRIPT_WINDOW = 20 -_CONTENT_CAP = 500 - - -def _build_transcript(messages) -> str: - """Format the last 20 messages as `ROLE: content[:500]` lines.""" - tail = list(messages)[-_TRANSCRIPT_WINDOW:] - return "\n".join( - f"{m.role.upper()}: {m.content[:_CONTENT_CAP]}" for m in tail - ) - - -SYSTEM_PROMPT = ( - "You are reviewing a day's journal conversation to extract preference " - "observations the USER revealed about themselves.\n\n" - "Rules:\n" - "- Only extract patterns, habits, recurring frustrations, or contextual " - "facts the user said or demonstrated.\n" - "- DO NOT restate facts that belong in structured fields: name, job title, " - "industry, expertise level, response style, tone, interests. Those are " - "handled separately.\n" - "- DO NOT extract anything from the ASSISTANT turns about the user — only " - "what the user themselves stated or demonstrated by their choices.\n" - "- Write 2-5 short bullet points. Be specific and factual.\n" - "- If nothing notable, output only: (nothing to note)" -) - - -async def run_for_user(user_id: int, yesterday: datetime.date) -> None: - """Extract preference observations from yesterday's journal conversation. - - Skips silently when there is nothing meaningful to extract. - """ - async with async_session() as session: - conv_result = await session.execute( - select(Conversation).where( - Conversation.user_id == user_id, - Conversation.conversation_type == "journal", - Conversation.day_date == yesterday, - ) - ) - conv = conv_result.scalar_one_or_none() - if conv is None: - logger.debug("closeout: no journal conv for user %d on %s", user_id, yesterday) - return - - msg_result = await session.execute( - select(Message) - .where( - Message.conversation_id == conv.id, - Message.role.in_(("user", "assistant")), - or_( - Message.msg_metadata.is_(None), - ~Message.msg_metadata["kind"].astext.in_(EXCLUDED_KINDS), - ), - ) - .order_by(Message.created_at) - ) - messages = list(msg_result.scalars().all()) - - # Defensive second-pass filter (covers any message with metadata the - # SQL JSON path can't reach, e.g. older rows where kind nesting differs). - messages = _filter_messages(messages) - - if len(messages) < 2: - logger.debug("closeout: not enough messages for user %d (%d)", user_id, len(messages)) - return - - transcript = _build_transcript(messages) - model = await get_setting(user_id, "background_model", Config.OLLAMA_BACKGROUND_MODEL) - - try: - output = (await generate_completion( - [ - {"role": "system", "content": SYSTEM_PROMPT}, - {"role": "user", "content": transcript}, - ], - model, - )).strip() - except Exception: - logger.warning("closeout LLM failed for user %d", user_id, exc_info=True) - return - - if not output or "(nothing to note)" in output.lower(): - logger.debug("closeout: nothing to note for user %d", user_id) - return - - await append_observations(user_id, output) - logger.info("closeout: appended observations for user %d (%s)", user_id, yesterday) diff --git a/src/fabledassistant/services/journal_pipeline.py b/src/fabledassistant/services/journal_pipeline.py deleted file mode 100644 index 86b84e9..0000000 --- a/src/fabledassistant/services/journal_pipeline.py +++ /dev/null @@ -1,193 +0,0 @@ -"""System prompt + ambient context injection for journal conversations. - -Distinct from the chat pipeline (services/llm.py) in three ways: -1. New persona: warm, curious listener; not a task manager. -2. Calibration rules: ask before structural changes; record_moment freely. -3. Auto-injects last ~48h of moments for ambient continuity (no notes-RAG). - -Notes-RAG auto-injection MUST be disabled for journal sessions — the -journal's ambient context replaces it. Failing to disable would let notes -leak into journal sessions, violating the design's isolation invariant. -""" -from __future__ import annotations - -import datetime - -from fabledassistant.services.journal_search import search_journal -from fabledassistant.services.user_profile import build_profile_context - -JOURNAL_PERSONA = ( - "You are the user's journal companion. They've opened their journal — a " - "day-anchored conversation surface where they record their day. You are " - "here to listen. Talk with them, ask one short follow-up when natural, " - "and let them lead. You do NOT need to record anything; a separate " - "process (the curator) reads the conversation periodically and captures " - "structured records on its own. Trust that — focus only on being a " - "thoughtful presence in the conversation. The day's prep message at the " - "top of the conversation is your context — build on it, don't restate it." -) - -# Chat-only calibration. The conversation+curator architecture (Fable #172, -# May 2026) split tool-calling out of the chat surface: this prompt is sent -# to a model with `tools=[]` on the journal route. Older versions of this -# prompt instructed the model to CALL record_moment / search_notes / etc. -# — with no tools available that produced empty responses and silent- -# generation failures. The curator (services/curator.py) handles all -# tool work asynchronously now; the chat just talks. -JOURNAL_CALIBRATION = """\ -HOW TO TALK IN THE JOURNAL: - -- ONE question per reply, MAXIMUM. If you find yourself writing a second - question mark, delete it. Ask the single follow-up that most opens up - what the user just said — or, just as good, ask nothing and only - acknowledge. Three questions ("how was the food? what'd you order? - did your daughter enjoy it?") feels like an interview, not a journal - companion. ONE question. Often ZERO questions. -- Match the user's length. Short message → short reply. Don't pad. -- Don't apologize for the user's feelings ("I'm sorry you're feeling…"). - Engage with what they said directly. -- Don't produce multi-option menus ("1. Show your calendar 2. ..."). They - read as help-desk-bot. Ask one specific follow-up or simply acknowledge. -- Don't repeat a prior reply verbatim. If the user circles back on a theme, - pick a specific concrete detail from the new message to react to. -- Don't offer to do things, don't suggest the user share more for you to - react to (NO "do you have any pictures you can share?", NO "tell me - more about X", NO "what did Y look like?"). Their reply lives in their - head, not in something they need to fetch and paste for you. React to - what they actually said; don't fish for what they didn't. -- Don't offer troubleshooting steps, checklists, or generic process advice - for the user's work unless they explicitly ask. When the user is logging - what they're doing, they want to be heard, not coached. "I'm prepping - for an ISP migration" should be acknowledged — not met with - "Are you handling the network configuration yourself? Are there checks - you need to do first?" If a follow-up would presume they want help, drop it. -- Never claim to have done anything for the user (no "I've recorded that", - "I've added that to your tasks", "I'll note that down"). You have no - tools and cannot act on their data. The curator handles capture - separately and silently. If the user asks you to record or save - something, just acknowledge their intent in plain language — don't - claim to have done it. -- No emojis. The journal is a thinking-companion surface; emojis read as - chat-bot warmth that's out of register. -""" - -PHASE_GREETINGS = { - "morning": "Morning. What's the day looking like for you?", - "midday": "How's it going so far?", - "evening": "Wrapping up — how'd the day shake out?", -} - - -def determine_phase( - *, - now_local: datetime.datetime, - day_rollover_hour: int = 4, - morning_end_hour: int = 12, - midday_end_hour: int = 18, -) -> str: - """Return 'morning' | 'midday' | 'evening' for a local datetime.""" - h = now_local.hour - if h < day_rollover_hour: - return "evening" - if h < morning_end_hour: - return "morning" - if h < midday_end_hour: - return "midday" - return "evening" - - -def phase_greeting(phase: str) -> str: - return PHASE_GREETINGS.get(phase, PHASE_GREETINGS["morning"]) - - -async def build_journal_system_prompt( - *, - user_id: int, - day_date: datetime.date, - user_timezone: str, - conv_id: int | None = None, -) -> str: - """Static-then-dynamic system prompt. - - Static prefix (persona + calibration) is identical on every request, - preserving Ollama KV cache. Dynamic suffix changes per-day, plus a - per-conversation curator_summary if one is available (Phase 3 - feedback loop, Fable #172). - """ - static_block = f"{JOURNAL_PERSONA}\n\n{JOURNAL_CALIBRATION}" - - today_iso = day_date.isoformat() - # Include the day-of-week explicitly. LLMs are unreliable at deriving - # weekday names from ISO dates, which causes "this Friday" / "next - # Monday" to land on the wrong calendar day. - weekday = day_date.strftime("%A") - tz_block = f"Today is {weekday}, {today_iso} ({user_timezone})." - - profile_context = await build_profile_context(user_id) - profile_section = f"\n\n{profile_context}" if profile_context else "" - - ambient = await _ambient_moments_block(user_id=user_id, day_date=day_date) - ambient_section = ( - f"\n\nRECENT JOURNAL CONTEXT (last 48h):\n{ambient}" if ambient else "" - ) - - # Curator feedback (Phase 3): a one-line summary of what the curator - # extracted from this conversation on its most recent pass. Lets the - # chat model stay aware of topics it can no longer surface via tool - # calls (chat is tools=[]). - curator_section = "" - if conv_id is not None: - curator_section = await _curator_summary_block(conv_id) - - return ( - f"{static_block}\n\n{tz_block}" - f"{profile_section}{ambient_section}{curator_section}" - ) - - -async def _curator_summary_block(conv_id: int) -> str: - """Render the conversation's stored curator_summary as a system block. - - Empty when no curator pass has produced a summary yet. Importantly, - the block is positioned AFTER ambient context (and the static block) - so the chat model treats it as up-to-date awareness, not background. - """ - from sqlalchemy import select - from fabledassistant.models import async_session - from fabledassistant.models.conversation import Conversation - - async with async_session() as session: - result = await session.execute( - select(Conversation.curator_summary).where(Conversation.id == conv_id) - ) - summary = result.scalar_one_or_none() - if not summary: - return "" - return f"\n\nCURATOR NOTES (recent captures from this conversation): {summary.strip()}" - - -async def _ambient_moments_block( - *, user_id: int, day_date: datetime.date -) -> str: - """Render last 48h of moments as a compact text block. - - Capped at 20 moments / 1500 chars total. Distinct from RAG retrieval. - """ - moments = await search_journal( - user_id=user_id, - date_from=day_date - datetime.timedelta(days=2), - date_to=day_date, - limit=20, - ) - if not moments: - return "" - - lines: list[str] = [] - total_chars = 0 - for m in moments: - line = f"- [{m['occurred_at']}] {m['content']}" - if total_chars + len(line) > 1500: - break - lines.append(line) - total_chars += len(line) - return "\n".join(lines) diff --git a/src/fabledassistant/services/journal_prep.py b/src/fabledassistant/services/journal_prep.py deleted file mode 100644 index 1a5852f..0000000 --- a/src/fabledassistant/services/journal_prep.py +++ /dev/null @@ -1,615 +0,0 @@ -"""Daily prep generator for the Journal. - -Runs once per day per user (scheduled, or lazy on first journal-open of a -new day). Two phases: - -1. Gather structured data (tasks/events/weather/projects/recent moments/ - open threads) — deterministic, no LLM call. -2. Hand the structured data to the LLM and ask it for a direct, informative - conversational opener — flowing prose, briefing-style. Result is persisted - as the first *assistant* message in today's journal Conversation, so it - renders with the standard Illuminated Transcript bubble styling alongside - the rest of the conversation. - -The structured data is preserved on ``Message.msg_metadata.sections`` for -provenance and future tooling. - -Message shape: - role: 'assistant' - content: - msg_metadata: { kind: 'daily_prep', sections: { ...raw data... } } -""" -from __future__ import annotations - -import datetime -import logging -import re -from zoneinfo import ZoneInfo - -from sqlalchemy import select - -from fabledassistant.config import Config -from fabledassistant.models import Conversation, Message, async_session -from fabledassistant.services.events import list_events -from fabledassistant.services.journal_search import search_journal -from fabledassistant.services.notes import list_notes -from fabledassistant.services.projects import list_projects -from fabledassistant.services.settings import get_setting -from fabledassistant.services.weather import get_cached_weather_rows - -logger = logging.getLogger(__name__) - - -# How many days out from today an event needs to be before the prep treats -# it as too far-future to surface. Catches recurring-event canonical rows -# whose RRULE expansion missed (an `rrulestr` failure falls back to the -# canonical event in `list_events`, which leaks far-future occurrences -# into today's prep). -_EVENT_PROXIMITY_DAYS = 7 - - -def _task_to_prep_dict(task, today: datetime.date) -> dict: - """Render a Note row as a prep-payload task entry, tagging overdue - staleness when relevant so the prompt can frame it correctly.""" - d = { - "id": task.id, - "title": task.title, - "status": task.status, - "priority": task.priority, - "due_date": task.due_date.isoformat() if task.due_date else None, - } - if task.due_date and task.due_date < today: - d["days_overdue"] = (today - task.due_date).days - return d - - -def _filter_proximate_events( - events: list[dict], *, day_date: datetime.date, user_tz: ZoneInfo -) -> list[dict]: - """Drop events whose start_dt is more than ``_EVENT_PROXIMITY_DAYS`` - away from ``day_date`` in the user's local timezone. - - Belt-and-suspenders against `list_events` returning a canonical - far-future event (e.g. when RRULE expansion fails and the loop falls - back to the original event row, regardless of date). The user - observed "Birthday — 2026-09-29 (FREQ=YEARLY)" surfacing in every - daily prep 5 months out; this filter keeps the prep proximate. - """ - proximate: list[dict] = [] - for e in events: - raw = e.get("start_dt") or "" - try: - start_dt = datetime.datetime.fromisoformat( - raw.replace("Z", "+00:00") if isinstance(raw, str) else "" - ) - local_date = start_dt.astimezone(user_tz).date() - delta = abs((local_date - day_date).days) - except (ValueError, TypeError, AttributeError): - # Unparseable date — keep the event rather than suppress real data. - proximate.append(e) - continue - if delta <= _EVENT_PROXIMITY_DAYS: - proximate.append(e) - else: - logger.info( - "daily_prep: dropping non-proximate event %r start_local=%s " - "(%d days from day %s)", - e.get("title"), local_date.isoformat(), delta, day_date.isoformat(), - ) - return proximate - - -async def gather_daily_sections( - *, - user_id: int, - day_date: datetime.date, - user_timezone: str, -) -> dict: - """Gather all daily-prep sections and return them as a dict. - - Pure data fetching — no LLM call. Each section degrades to an empty - list/dict on failure so the caller always gets a complete shape. - - Tasks are returned in three explicit buckets so the prompt can frame - overdue items correctly (instead of calling them "due today" — the - pre-2026-04-29 behavior, before this rewrite). - """ - sections: dict = {} - - next_day = day_date + datetime.timedelta(days=1) - upcoming_end = day_date + datetime.timedelta(days=8) - try: - due_today_rows, _ = await list_notes( - user_id=user_id, is_task=True, status=["todo", "in_progress"], - due_after=day_date, due_before=next_day, - limit=20, sort="due_date", order="asc", - ) - upcoming_rows, _ = await list_notes( - user_id=user_id, is_task=True, status=["todo", "in_progress"], - due_after=next_day, due_before=upcoming_end, - limit=20, sort="due_date", order="asc", - ) - overdue_rows, _ = await list_notes( - user_id=user_id, is_task=True, status=["todo", "in_progress"], - due_before=day_date, - limit=20, sort="due_date", order="asc", - ) - sections["tasks_due_today"] = [_task_to_prep_dict(t, day_date) for t in due_today_rows] - sections["tasks_upcoming"] = [_task_to_prep_dict(t, day_date) for t in upcoming_rows] - sections["tasks_overdue"] = [_task_to_prep_dict(t, day_date) for t in overdue_rows] - # Backwards-compat alias for any consumers still reading sections["tasks"]. - # The combined view is more useful than the prior overdue-only behavior. - sections["tasks"] = ( - sections["tasks_due_today"] - + sections["tasks_upcoming"] - + sections["tasks_overdue"] - ) - except Exception: - logger.exception("daily_prep tasks section failed for user %d", user_id) - sections["tasks_due_today"] = [] - sections["tasks_upcoming"] = [] - sections["tasks_overdue"] = [] - sections["tasks"] = [] - - try: - try: - user_tz = ZoneInfo(user_timezone) - except Exception: - logger.warning("daily_prep: invalid user_timezone %r — defaulting to UTC", user_timezone) - user_tz = ZoneInfo("UTC") - # Build the local-day window in the user's TZ, then convert to UTC - # for the DB / RRULE expansion. A naive datetime here previously - # caused rrule.between() to throw, falling back to the canonical - # event row regardless of date — the source of stale recurring - # events polluting every daily prep. - day_start_local = datetime.datetime.combine(day_date, datetime.time.min, tzinfo=user_tz) - day_end_local = datetime.datetime.combine(day_date, datetime.time.max, tzinfo=user_tz) - day_start = day_start_local.astimezone(datetime.timezone.utc) - day_end = day_end_local.astimezone(datetime.timezone.utc) - all_events = await list_events( - user_id=user_id, - date_from=day_start, - date_to=day_end, - ) - sections["events"] = _filter_proximate_events( - all_events, day_date=day_date, user_tz=user_tz, - ) - except Exception: - logger.exception("daily_prep events section failed for user %d", user_id) - sections["events"] = [] - - try: - # Lazy import: journal_scheduler imports this module for prep generation, - # so a top-level import would cycle. - from fabledassistant.services.journal_scheduler import get_journal_config - cfg = await get_journal_config(user_id) - valid_weather_keys = { - key for key, loc in (cfg.get("locations") or {}).items() - if isinstance(loc, dict) and loc.get("lat") is not None and loc.get("lon") is not None - } - weather_rows = await get_cached_weather_rows(user_id, valid_weather_keys) - sections["weather"] = [w.to_dict() for w in weather_rows] - except Exception: - logger.exception("daily_prep weather section failed for user %d", user_id) - sections["weather"] = [] - - try: - projects = await list_projects(user_id=user_id, status="active") - sections["projects"] = [ - { - "id": p.id, - "title": p.title, - "auto_summary": p.auto_summary, - } - for p in projects[:5] - ] - except Exception: - logger.exception("daily_prep projects section failed for user %d", user_id) - sections["projects"] = [] - - try: - sections["recent_moments"] = await search_journal( - user_id=user_id, - date_from=day_date - datetime.timedelta(days=3), - date_to=day_date - datetime.timedelta(days=1), - limit=10, - ) - except Exception: - logger.exception("daily_prep recent_moments section failed for user %d", user_id) - sections["recent_moments"] = [] - - try: - sections["open_threads"] = await _open_threads(user_id=user_id, day_date=day_date) - except Exception: - logger.exception("daily_prep open_threads section failed for user %d", user_id) - sections["open_threads"] = [] - - return sections - - -async def _open_threads(*, user_id: int, day_date: datetime.date) -> list[dict]: - """Heuristic: moments from the last 7 days that look unresolved. - - Treated as 'unresolved' when they have no linked tasks/notes and aren't - pinned. Starting heuristic — refine empirically. - """ - candidates = await search_journal( - user_id=user_id, - date_from=day_date - datetime.timedelta(days=7), - date_to=day_date - datetime.timedelta(days=1), - limit=50, - ) - return [ - m for m in candidates - if not m.get("task_ids") - and not m.get("note_ids") - and not m.get("pinned") - ] - - -def _render_task_line(t: dict, *, include_due: bool, include_overdue: bool) -> str: - line = f" - {t.get('title', '?')}" - if include_overdue and t.get("days_overdue"): - n = t["days_overdue"] - unit = "day" if n == 1 else "days" - line += f" (was due {t['due_date']}, {n} {unit} overdue)" - elif include_due and t.get("due_date"): - line += f" (due {t['due_date']})" - if t.get("priority") and t["priority"] not in (None, "none"): - line += f" [{t['priority']} priority]" - if t.get("status") == "in_progress": - line += " [in progress]" - return line - - -def _render_sections_for_prompt(sections: dict) -> str: - """Render the gathered sections as a structured plain-text block for the LLM.""" - lines: list[str] = [] - - due_today = sections.get("tasks_due_today") or [] - upcoming = sections.get("tasks_upcoming") or [] - overdue = sections.get("tasks_overdue") or [] - if due_today: - lines.append("TASKS DUE TODAY:") - for t in due_today[:8]: - lines.append(_render_task_line(t, include_due=False, include_overdue=False)) - lines.append("") - if upcoming: - lines.append("UPCOMING TASKS (next 7 days):") - for t in upcoming[:8]: - lines.append(_render_task_line(t, include_due=True, include_overdue=False)) - lines.append("") - if overdue: - lines.append("OVERDUE TASKS (past their due date, still open — backlog, not today's work):") - for t in overdue[:8]: - lines.append(_render_task_line(t, include_due=False, include_overdue=True)) - lines.append("") - - events = sections.get("events") or [] - if events: - lines.append("CALENDAR EVENTS TODAY:") - for e in events[:8]: - title = e.get("title", "Untitled") - when = e.get("start_dt", "?") - location = e.get("location") or "" - line = f" - {title} at {when}" - if location: - line += f" ({location})" - lines.append(line) - lines.append("") - - weather = sections.get("weather") or [] - if weather: - lines.append("WEATHER:") - for w in weather: - label = w.get("location_label") or w.get("location_key") or "Location" - forecast_json = w.get("forecast_json") or {} - daily = forecast_json.get("daily") or {} - today_max = (daily.get("temperature_2m_max") or [None])[0] - today_min = (daily.get("temperature_2m_min") or [None])[0] - precip = (daily.get("precipitation_probability_max") or [None])[0] - bits = [label] - if today_max is not None and today_min is not None: - bits.append(f"high {today_max}° / low {today_min}°") - if precip is not None: - bits.append(f"{precip}% chance of precipitation") - lines.append(" - " + ", ".join(bits)) - lines.append("") - else: - # Explicit absent-marker. A silently-omitted weather block leaves a - # small model an unanchored void it tends to fill with plausible - # fabricated weather (observed: invented "68°F, 15% rain" with an - # empty weather section). A concrete "none" line + directive holds - # far better than relying on a negative system-prompt rule alone. - lines.append( - "WEATHER: none available — no weather, temperature, or precipitation " - "data exists for today. Do NOT mention weather in any form." - ) - lines.append("") - - projects = sections.get("projects") or [] - if projects: - lines.append("ACTIVE PROJECTS:") - for p in projects[:5]: - line = f" - {p.get('title', '?')}" - if p.get("auto_summary"): - summary = p["auto_summary"][:160] - line += f" — {summary}" - lines.append(line) - lines.append("") - - recent_moments = sections.get("recent_moments") or [] - if recent_moments: - lines.append("RECENT JOURNAL MOMENTS (last few days):") - for m in recent_moments[:8]: - day = m.get("day_date", "?") - content = (m.get("content") or "").strip() - lines.append(f" - [{day}] {content}") - lines.append("") - - open_threads = sections.get("open_threads") or [] - if open_threads: - lines.append("OPEN THREADS (mentioned recently but not resolved):") - for m in open_threads[:5]: - day = m.get("day_date", "?") - content = (m.get("content") or "").strip() - lines.append(f" - [{day}] {content}") - lines.append("") - - # The weather-none marker is always emitted, so `lines` is never empty; - # a quiet day is one with no *substantive* sections beyond that marker. - substantive = [ln for ln in lines if ln and not ln.startswith("WEATHER: none")] - if not substantive: - return ( - "(No tasks, events, or notable data for today — a quiet day. " - "Do not invent weather or any other details.)" - ) - return "\n".join(lines).rstrip() - - -_PREP_SYSTEM_PROMPT = ( - "You are briefing the user on their day. Direct and informative — tell them what's " - "actually on their plate so they can step into the day with a clear picture.\n\n" - "Rules:\n" - "- LEAD with the practical data: tasks due today, calendar events, weather.\n" - "- Be specific and concrete. Use real task titles, event times, temperatures, " - "precipitation chances. Don't paraphrase data into vague summaries.\n" - "- Write in flowing sentences — no markdown, no bullet points, no headers — but " - "keep the prose factual and useful, not sentimental.\n" - "- 4 to 7 sentences total. Tight. No padding, no flowery openings, no \"Good morning\" " - "greetings unless the actual content warrants two clauses' worth.\n" - "- TASK BUCKETS — three sections may appear: TASKS DUE TODAY, UPCOMING TASKS, " - "OVERDUE TASKS. Lead with TASKS DUE TODAY when present. Do NOT call overdue items " - "\"due today\" — they aren't. When OVERDUE TASKS appears, state the overdue " - "duration exactly as given (e.g. \"3 days overdue\") and frame it as backlog to " - "revisit, not as today's work. Do NOT echo the parenthetical section labels " - "verbatim. If the only data is overdue, lead with it but frame it as a backlog " - "reminder.\n" - "- If RECENT JOURNAL MOMENTS or OPEN THREADS are present, mention one or two BRIEFLY " - "at the end as context — not as the lead. Skip them if nothing notable.\n" - "- Close with one short invitation to journal: \"What's on your mind?\", " - "\"Anything to set down?\", \"How's the morning shaping up?\" — pick one, keep it under 8 words.\n" - "- Use ONLY the data below. A category marked 'none' (or absent) genuinely has no " - "data — do not invent it and do not mention it. In particular, NEVER state weather, " - "temperature, or precipitation unless an explicit WEATHER section with numbers " - "appears below.\n" - "- NEVER invent a task's due status. A task appears under exactly one bucket " - "(TASKS DUE TODAY, UPCOMING TASKS, or OVERDUE TASKS). Frame it as whichever " - "bucket it appears under — never call an OVERDUE TASK \"due today\", never " - "promote an UPCOMING TASK to \"due today\". If TASKS DUE TODAY is empty or " - "absent, do not say anything is due today.\n" - "- NEVER invent times of day. Task lines have a due DATE (YYYY-MM-DD) and an " - "optional status; they do NOT have a time. NEVER say \"at 1:00 PM\" or " - "similar unless an EVENTS section contains an explicit time. If a task's " - "line shows only a date, the time is unknown — do not specify one.\n" - "- A task's status is the literal value in its line (todo / in_progress / " - "done / cancelled). NEVER paraphrase it to something the data doesn't say " - "(e.g. don't call a 'todo' task \"in progress\").\n" - "- Voice is competent assistant briefing the user. Not a friend writing a letter." -) - - -def _fallback_prep_text(day_date: datetime.date) -> str: - """If the LLM call fails, return a minimal greeting so the user still sees something.""" - weekday = day_date.strftime("%A") - return f"{weekday}, {day_date.isoformat()}. What's on your mind?" - - -# Strong, low-false-positive weather signals. Deliberately NOT bare words like -# "rain"/"sunny"/"weather" (those legitimately appear in task/event titles — -# "buy rain boots"). Targets the concrete phrasings small models actually -# emit when fabricating ("partly cloudy with a high of 68°F and a 15% chance -# of rain"): temperature glyphs, "high/low of N", "chance of ", -# "(partly|mostly) (cloudy|sunny)", "overcast", "precipitation", "forecast". -_WEATHER_SIGNAL_RE = re.compile( - r""" - \d{1,3}\s?° - | \b\d{1,3}\s?°?\s?(?:degrees|fahrenheit|celsius)\b - | \b(?:high|low)\s+of\s+\d - | \bchance\s+of\s+(?:rain|showers?|precipitation|snow|sleet|storms?|thunder) - | \b(?:partly|mostly)\s+(?:cloudy|sunny)\b - | \bovercast\b - | \bprecipitation\b - | \bforecast\b - """, - re.IGNORECASE | re.VERBOSE, -) - - -def _prose_fabricated_weather(prose: str, sections: dict) -> bool: - """True when the prose talks weather but no weather data was gathered. - - The deterministic backstop for the system-prompt rule: an 8–14B model - still invents weather on quiet days even when told not to. If the - WEATHER section is genuinely empty and the prose trips a strong weather - signal, that text is fabricated. - """ - if sections.get("weather"): - return False - return bool(_WEATHER_SIGNAL_RE.search(prose or "")) - - -async def _generate_prep_prose( - *, - sections: dict, - day_date: datetime.date, - user_id: int, -) -> str: - """Ask the LLM for a direct conversational journal opener built from the sections.""" - from fabledassistant.services.llm import generate_completion - - # Daily prep is a deliberate, multi-section generation — runs once a day, - # latency-tolerant, benefits from a smarter model. Route to the worker - # (background_model) rather than the chat model. The chat model in the - # conversation+curator architecture is small/fast/no-tools; prep needs - # the heavier reasoning the worker provides. - model = ( - await get_setting(user_id, "background_model", "") - or Config.OLLAMA_BACKGROUND_MODEL - or Config.OLLAMA_MODEL - ) - if not model: - logger.warning("No LLM model configured for daily prep — using fallback text") - return _fallback_prep_text(day_date) - - rendered = _render_sections_for_prompt(sections) - user_trigger = ( - f"Today is {day_date.strftime('%A, %B %-d, %Y')} ({day_date.isoformat()}).\n\n" - f"Here is what I gathered for you:\n\n{rendered}\n\n" - f"Write the opener for today's journal." - ) - - _WEATHER_CORRECTION = ( - "\n\nIMPORTANT: there is NO weather data for today. Do not mention " - "weather, temperature, sky conditions, or precipitation in any form." - ) - - # Up to 2 attempts: if the first trips the fabricated-weather guard, retry - # once with an explicit corrective appended to the user turn. A 14B model - # almost always complies on the corrected pass; if it still doesn't we log - # and accept (surgically excising a sentence risks breaking prose flow — - # better a rare stray clause than mangled output, and the log lets us - # measure whether a model bump is actually warranted). - prose = "" - for attempt in (1, 2): - trigger = user_trigger - if attempt == 2: - trigger = user_trigger + _WEATHER_CORRECTION - messages = [ - {"role": "system", "content": _PREP_SYSTEM_PROMPT}, - {"role": "user", "content": trigger}, - ] - try: - raw = await generate_completion( - messages=messages, - model=model, - max_tokens=400, - ) - except Exception: - logger.exception("Daily prep prose generation failed for day %s", day_date) - return _fallback_prep_text(day_date) - - prose = (raw or "").strip() - if not prose: - logger.warning("LLM returned empty prep prose for day %s — using fallback", day_date) - return _fallback_prep_text(day_date) - - if not _prose_fabricated_weather(prose, sections): - return prose - - if attempt == 1: - logger.warning( - "daily_prep: fabricated weather detected for day %s (no weather " - "data gathered) — regenerating with corrective", day_date, - ) - else: - logger.error( - "daily_prep: weather still fabricated after corrective retry " - "for day %s — accepting prose as-is", day_date, - ) - return prose - - -async def ensure_daily_prep_message( - *, - user_id: int, - day_date: datetime.date, - user_timezone: str, - force: bool = False, -) -> Message: - """Get or create today's journal Conversation, then ensure the prep message exists. - - The prep message is an *assistant* role message containing the prose opener, - with the structured sections preserved on ``msg_metadata``. If a legacy - system-role prep exists from an earlier version, it gets upgraded in place - on the next call. - - With ``force=True`` the prose is regenerated even when a prep already exists. - Used by the manual /api/journal/trigger-prep endpoint. - """ - async with async_session() as session: - result = await session.execute( - select(Conversation).where( - Conversation.user_id == user_id, - Conversation.conversation_type == "journal", - Conversation.day_date == day_date, - ) - ) - conv = result.scalar_one_or_none() - if conv is None: - conv = Conversation( - user_id=user_id, - conversation_type="journal", - day_date=day_date, - title=day_date.isoformat(), - ) - session.add(conv) - await session.flush() - - # Find any existing prep (system or assistant role from any version). - prep_stmt = select(Message).where(Message.conversation_id == conv.id) - existing_prep = None - for msg in (await session.execute(prep_stmt)).scalars(): - if msg.msg_metadata and msg.msg_metadata.get("kind") == "daily_prep": - existing_prep = msg - break - - # If we already have an assistant-role prep with prose content and the - # caller didn't ask to force regeneration, we're done. - if ( - existing_prep - and not force - and existing_prep.role == "assistant" - and (existing_prep.content or "").strip() - ): - return existing_prep - - sections = await gather_daily_sections( - user_id=user_id, day_date=day_date, user_timezone=user_timezone - ) - prose = await _generate_prep_prose( - sections=sections, day_date=day_date, user_id=user_id - ) - new_metadata = {"kind": "daily_prep", "sections": sections} - - if existing_prep: - # Upgrade in place: bump role, replace content + metadata. - existing_prep.role = "assistant" - existing_prep.content = prose - existing_prep.msg_metadata = new_metadata - await session.commit() - return existing_prep - - prep_msg = Message( - conversation_id=conv.id, - role="assistant", - content=prose, - msg_metadata=new_metadata, - ) - session.add(prep_msg) - await session.commit() - return prep_msg - - -# Backwards-compat alias — older imports may use the old name. -generate_daily_prep = gather_daily_sections diff --git a/src/fabledassistant/services/journal_scheduler.py b/src/fabledassistant/services/journal_scheduler.py deleted file mode 100644 index f5bb45b..0000000 --- a/src/fabledassistant/services/journal_scheduler.py +++ /dev/null @@ -1,210 +0,0 @@ -"""APScheduler instance for the Journal — daily prep generation. - -One per-user cron job: generate today's daily prep at the configured -prep_time in the user's local timezone. Replaces briefing_scheduler. - -Mirrors the BackgroundScheduler + threadsafe-async-call pattern used by -event_scheduler.py. -""" -from __future__ import annotations - -import asyncio -import datetime -import json -import logging -from zoneinfo import ZoneInfo - -from apscheduler.schedulers.background import BackgroundScheduler -from apscheduler.triggers.cron import CronTrigger -from sqlalchemy import select - -from fabledassistant.models import User, async_session -from fabledassistant.services.journal_prep import ensure_daily_prep_message -from fabledassistant.services.settings import get_setting - -logger = logging.getLogger(__name__) - -_scheduler: BackgroundScheduler | None = None -_loop: asyncio.AbstractEventLoop | None = None - - -DEFAULT_CONFIG = { - "prep_enabled": True, - "prep_hour": 5, - "prep_minute": 0, - "day_rollover_hour": 4, - "morning_end_hour": 12, - "midday_end_hour": 18, - "closeout_enabled": True, -} - - -async def get_journal_config(user_id: int) -> dict: - """Load a user's journal_config (JSON in settings) merged with defaults.""" - raw = await get_setting(user_id, "journal_config", "") - config: dict = {} - if raw: - try: - parsed = json.loads(raw) if isinstance(raw, str) else raw - if isinstance(parsed, dict): - config = parsed - except Exception: - logger.warning("Invalid journal_config for user %d; using defaults", user_id) - return {**DEFAULT_CONFIG, **config} - - -async def get_user_timezone(user_id: int) -> str: - return await get_setting(user_id, "user_timezone", "UTC") or "UTC" - - -def _resolve_tz(tz_str: str) -> ZoneInfo: - try: - return ZoneInfo(tz_str) - except Exception: - return ZoneInfo("UTC") - - -async def _do_daily_prep(user_id: int) -> None: - try: - tz_str = await get_user_timezone(user_id) - tz = _resolve_tz(tz_str) - config = await get_journal_config(user_id) - rollover = int(config.get("day_rollover_hour", 4)) - now = datetime.datetime.now(tz) - # Today is the day after the most recent rollover. - if now.hour < rollover: - today = (now - datetime.timedelta(days=1)).date() - else: - today = now.date() - await ensure_daily_prep_message( - user_id=user_id, day_date=today, user_timezone=tz_str - ) - except Exception: - logger.exception("Daily prep failed for user %d", user_id) - - -def _run_daily_prep_threadsafe(user_id: int) -> None: - if _loop is None: - return - asyncio.run_coroutine_threadsafe(_do_daily_prep(user_id), _loop) - - -async def _do_closeout(user_id: int) -> None: - try: - tz_str = await get_user_timezone(user_id) - tz = _resolve_tz(tz_str) - now = datetime.datetime.now(tz) - # We just rolled into a new day in user-local time. The day that - # just ended is yesterday's calendar date regardless of whether - # rollover_hour is 0 or 4 — APScheduler fires precisely at the - # configured hour so no clock-skew correction is needed. - yesterday = now.date() - datetime.timedelta(days=1) - from fabledassistant.services.journal_closeout import run_for_user - await run_for_user(user_id=user_id, yesterday=yesterday) - except Exception: - logger.exception("Closeout failed for user %d", user_id) - - -def _run_closeout_threadsafe(user_id: int) -> None: - if _loop is None: - return - asyncio.run_coroutine_threadsafe(_do_closeout(user_id), _loop) - - -async def _closeout_catchup(user_id: int) -> None: - """On startup, run yesterday's closeout once if the slot already passed - and no entry for yesterday exists in observations_raw. - """ - try: - tz_str = await get_user_timezone(user_id) - tz = _resolve_tz(tz_str) - config = await get_journal_config(user_id) - if not config.get("closeout_enabled", True): - return - rollover_hour = int(config.get("day_rollover_hour", 4)) - now = datetime.datetime.now(tz) - # Slot hasn't passed yet today → wait for the cron. - if now.hour < rollover_hour: - return - yesterday = (now - datetime.timedelta(days=1)).date() - - from fabledassistant.services.user_profile import get_profile - profile = await get_profile(user_id) - existing_dates = { - (e or {}).get("date") for e in (profile.observations_raw or []) - } - if yesterday.isoformat() in existing_dates: - return - - from fabledassistant.services.journal_closeout import run_for_user - await run_for_user(user_id=user_id, yesterday=yesterday) - except Exception: - logger.exception("Closeout catch-up failed for user %d", user_id) - - -async def update_user_schedule(user_id: int) -> None: - """Add or replace this user's daily-prep + closeout jobs from current config.""" - if _scheduler is None: - return - - config = await get_journal_config(user_id) - tz_str = await get_user_timezone(user_id) - tz = _resolve_tz(tz_str) - - # ── Prep job ────────────────────────────────────────────────────────── - prep_job_id = f"journal_prep_{user_id}" - if _scheduler.get_job(prep_job_id): - _scheduler.remove_job(prep_job_id) - if config.get("prep_enabled", True): - prep_hour = int(config.get("prep_hour", 5)) - prep_minute = int(config.get("prep_minute", 0)) - _scheduler.add_job( - _run_daily_prep_threadsafe, - trigger=CronTrigger(hour=prep_hour, minute=prep_minute, timezone=tz), - args=[user_id], - id=prep_job_id, - replace_existing=True, - ) - - # ── Closeout job ────────────────────────────────────────────────────── - closeout_job_id = f"journal_closeout_{user_id}" - if _scheduler.get_job(closeout_job_id): - _scheduler.remove_job(closeout_job_id) - if config.get("closeout_enabled", True): - rollover_hour = int(config.get("day_rollover_hour", 4)) - _scheduler.add_job( - _run_closeout_threadsafe, - trigger=CronTrigger(hour=rollover_hour, minute=0, timezone=tz), - args=[user_id], - id=closeout_job_id, - replace_existing=True, - ) - - -async def _register_all_user_jobs() -> None: - async with async_session() as session: - users = (await session.execute(select(User))).scalars().all() - for user in users: - await update_user_schedule(user.id) - # Fire catch-up asynchronously so a slow LLM call doesn't block startup - if _loop is not None: - asyncio.run_coroutine_threadsafe(_closeout_catchup(user.id), _loop) - - -def start_journal_scheduler(loop: asyncio.AbstractEventLoop) -> None: - global _scheduler, _loop - if _scheduler is not None: - return - _loop = loop - _scheduler = BackgroundScheduler() - _scheduler.start() - asyncio.run_coroutine_threadsafe(_register_all_user_jobs(), loop) - logger.info("Journal scheduler started") - - -def stop_journal_scheduler() -> None: - global _scheduler - if _scheduler is not None: - _scheduler.shutdown(wait=False) - _scheduler = None - logger.info("Journal scheduler stopped") diff --git a/src/fabledassistant/services/journal_search.py b/src/fabledassistant/services/journal_search.py deleted file mode 100644 index ca12740..0000000 --- a/src/fabledassistant/services/journal_search.py +++ /dev/null @@ -1,204 +0,0 @@ -"""Search across Moments and (optionally) journal transcripts. - -Three modes, expressed through one tool surface: -1. Pure temporal: no query, just date range -> ordered by occurred_at DESC. -2. Pure entity: person_id or place_id filter -> junction lookup. -3. Semantic: query string -> embedding similarity, optionally constrained. - -This module ONLY queries Moments and (optionally) journal-conversation -messages. It MUST NOT touch notes or note_embeddings. The notes-RAG and -journal-RAG isolation is a hard invariant of the journal design. -""" -from __future__ import annotations - -import datetime -import math -from typing import Sequence - -from sqlalchemy import select - -from fabledassistant.models import ( - Conversation, - Message, - Moment, - MomentEmbedding, - async_session, - moment_people, - moment_places, -) -from fabledassistant.services.embeddings import get_embedding - -DEFAULT_LIMIT = 10 -DEFAULT_THRESHOLD = 0.55 - - -def _cosine(a: Sequence[float], b: Sequence[float]) -> float: - if not a or not b: - return 0.0 - dot = sum(x * y for x, y in zip(a, b)) - norm_a = math.sqrt(sum(x * x for x in a)) - norm_b = math.sqrt(sum(y * y for y in b)) - if norm_a == 0 or norm_b == 0: - return 0.0 - return dot / (norm_a * norm_b) - - -async def search_journal( - *, - user_id: int, - query: str | None = None, - person_id: int | None = None, - place_id: int | None = None, - tag: str | None = None, - date_from: datetime.date | None = None, - date_to: datetime.date | None = None, - include_transcripts: bool = False, - limit: int = DEFAULT_LIMIT, - threshold: float = DEFAULT_THRESHOLD, -) -> list[dict]: - """Return Moments (and optional transcript snippets) matching the filters. - - Result rows: dicts from Moment.to_dict() plus an optional `score` when - `query` is set. Transcript snippets carry `kind='transcript'` and `id=None` - to distinguish them. - """ - moment_rows = await _search_moments( - user_id=user_id, - query=query, - person_id=person_id, - place_id=place_id, - tag=tag, - date_from=date_from, - date_to=date_to, - limit=limit, - threshold=threshold, - ) - - if not include_transcripts: - return moment_rows - - transcript_rows = await _search_transcripts( - user_id=user_id, - query=query, - date_from=date_from, - date_to=date_to, - limit=limit, - ) - return moment_rows + transcript_rows - - -async def _search_moments( - *, - user_id: int, - query: str | None, - person_id: int | None, - place_id: int | None, - tag: str | None, - date_from: datetime.date | None, - date_to: datetime.date | None, - limit: int, - threshold: float, -) -> list[dict]: - async with async_session() as session: - stmt = select(Moment).where(Moment.user_id == user_id) - - if person_id is not None: - stmt = stmt.join(moment_people, moment_people.c.moment_id == Moment.id).where( - moment_people.c.person_id == person_id - ) - if place_id is not None: - stmt = stmt.join(moment_places, moment_places.c.moment_id == Moment.id).where( - moment_places.c.place_id == place_id - ) - if tag is not None: - stmt = stmt.where(Moment.tags.any(tag)) - if date_from is not None: - stmt = stmt.where(Moment.day_date >= date_from) - if date_to is not None: - stmt = stmt.where(Moment.day_date <= date_to) - - if query is None: - stmt = stmt.order_by(Moment.occurred_at.desc()).limit(limit) - moments = (await session.execute(stmt)).scalars().all() - return [m.to_dict() for m in moments] - - # Semantic mode. Pull a wider candidate set, then rank in Python. - candidate_stmt = stmt.order_by(Moment.occurred_at.desc()).limit(limit * 5) - candidates = (await session.execute(candidate_stmt)).scalars().all() - if not candidates: - return [] - - candidate_ids = [m.id for m in candidates] - emb_stmt = select(MomentEmbedding).where( - MomentEmbedding.moment_id.in_(candidate_ids) - ) - embeddings = { - e.moment_id: e.embedding - for e in (await session.execute(emb_stmt)).scalars() - } - - query_vec = await get_embedding(query) - scored = [] - for m in candidates: - vec = embeddings.get(m.id) - if vec is None: - continue - score = _cosine(query_vec, vec) - if score >= threshold: - row = m.to_dict() - row["score"] = score - scored.append(row) - - scored.sort(key=lambda r: r["score"], reverse=True) - return scored[:limit] - - -async def _search_transcripts( - *, - user_id: int, - query: str | None, - date_from: datetime.date | None, - date_to: datetime.date | None, - limit: int, -) -> list[dict]: - """Fallback substring search over raw journal Messages. - - Substring is intentional: transcripts catch what the LLM didn't extract - as Moments. Semantic search over messages would require a third embedding - index, which we deliberately don't maintain. - """ - if query is None: - return [] - async with async_session() as session: - stmt = ( - select(Message, Conversation.day_date) - .join(Conversation, Message.conversation_id == Conversation.id) - .where( - Conversation.user_id == user_id, - Conversation.conversation_type == "journal", - Message.content.ilike(f"%{query}%"), - ) - .order_by(Message.created_at.desc()) - .limit(limit) - ) - if date_from is not None: - stmt = stmt.where(Conversation.day_date >= date_from) - if date_to is not None: - stmt = stmt.where(Conversation.day_date <= date_to) - rows = (await session.execute(stmt)).all() - return [ - { - "id": None, - "kind": "transcript", - "message_id": msg.id, - "conversation_id": msg.conversation_id, - "day_date": day_date.isoformat() if day_date else None, - "occurred_at": msg.created_at.isoformat(), - "content": msg.content[:400], - "raw_excerpt": None, - "tags": [], - "people": [], - "places": [], - } - for msg, day_date in rows - ] diff --git a/src/fabledassistant/services/llm.py b/src/fabledassistant/services/llm.py deleted file mode 100644 index cd31175..0000000 --- a/src/fabledassistant/services/llm.py +++ /dev/null @@ -1,881 +0,0 @@ -import asyncio -import ipaddress -import json -import logging -import re -import socket -import time -from collections.abc import AsyncGenerator -from dataclasses import dataclass, field -from typing import Literal -from urllib.parse import urlparse - -import httpx - -from fabledassistant.config import Config -from fabledassistant.services.caldav import is_caldav_configured -from fabledassistant.services.notes import get_note, search_notes_for_context -from fabledassistant.services.settings import get_setting - -logger = logging.getLogger(__name__) - -# Context window tiers. The smallest tier that fits the current input is used -# so Ollama allocates a smaller KV cache, reducing prefill time and VRAM usage. -# Requests using the same tier hit Ollama's prefix cache; a tier upgrade causes -# a one-time model reload but then the larger cache stays warm. -_CTX_TIERS = (8192, 16384, 32768) - - -def keep_alive_for(model: str) -> str: - """Return the Ollama keep_alive duration for *model*. - - Background models get a shorter window because they're called - sporadically; the main interactive model gets a longer one so it - stays warm between user messages. - """ - if model == Config.OLLAMA_BACKGROUND_MODEL: - return Config.OLLAMA_KEEP_ALIVE_BACKGROUND - return Config.OLLAMA_KEEP_ALIVE_MAIN - - -def pick_num_ctx(messages: list[dict], tools: list[dict] | None = None) -> int: - """Return the smallest context tier that fits *messages* + *tools* with 25% headroom. - - The ``tools`` JSON schemas are a large, often-overlooked chunk of the prompt. - With ~40 tools in the registry the schemas alone can be 6-10K tokens — enough - that omitting them from the estimate causes silent prompt truncation. - - Stays at or below Config.OLLAMA_NUM_CTX (the configured ceiling). - """ - total_chars = sum(len(m.get("content") or "") for m in messages) - if tools: - # Serialize the same way Ollama will see them. json.dumps gives us a - # faithful char count for the schema payload without any guesswork. - total_chars += len(json.dumps(tools)) - estimated_tokens = int(total_chars / 3.5) - needed = int(estimated_tokens * 1.25) + 256 # 25% headroom + output buffer - cap = Config.OLLAMA_NUM_CTX - for tier in _CTX_TIERS: - if tier >= needed and tier <= cap: - return tier - return cap - - -STOP_WORDS = frozenset({ - "a", "an", "the", "is", "it", "to", "in", "for", "of", "and", "or", - "on", "at", "by", "with", "from", "as", "be", "was", "were", "been", - "are", "am", "do", "does", "did", "have", "has", "had", "will", "would", - "can", "could", "shall", "should", "may", "might", "must", "that", - "this", "these", "those", "i", "me", "my", "you", "your", "he", "she", - "we", "they", "them", "his", "her", "its", "our", "their", "what", - "which", "who", "whom", "how", "when", "where", "why", "not", "no", - "but", "if", "so", "than", "too", "very", "just", "about", "up", -}) - -RAG_AUTO_THRESHOLD = 0.60 -RAG_AUTO_LIMIT = 3 -RAG_AUTO_SNIPPET = 4000 - - -async def get_installed_models() -> set[str]: - """Return set of installed Ollama model names (with and without :latest). - - Tags are normalized to lowercase. Ollama stores whatever casing was used at - pull time (``ollama pull gemma3:12B`` keeps the ``B``), but inference calls - against the capitalized tag can 400 — the two code paths are inconsistent. - Lowercasing on read avoids exposing that mixed-case name in the UI and - keeps the validation check below from accepting a form Ollama will reject. - """ - try: - async with httpx.AsyncClient(timeout=10.0) as client: - resp = await client.get(f"{Config.OLLAMA_URL}/api/tags") - resp.raise_for_status() - data = resp.json() - names: set[str] = set() - for m in data.get("models", []): - name = m["name"].lower() - names.add(name) - if name.endswith(":latest"): - names.add(name.removesuffix(":latest")) - return names - except Exception: - logger.warning("Failed to fetch installed Ollama models") - return set() - - -async def ensure_model(model: str) -> None: - """Check if model exists in Ollama, pull if missing.""" - # Match the lowercase normalization in get_installed_models so a legacy - # mixed-case setting doesn't force a spurious re-pull at startup. - model = model.lower() - try: - installed = await get_installed_models() - if model in installed or f"{model}:latest" in installed: - logger.info("Model '%s' already available", model) - return - except Exception: - logger.warning("Failed to check Ollama models, attempting pull anyway") - - logger.info("Pulling model '%s' from Ollama...", model) - try: - async with httpx.AsyncClient(timeout=1800.0) as client: - async with client.stream( - "POST", - f"{Config.OLLAMA_URL}/api/pull", - json={"name": model}, - ) as resp: - resp.raise_for_status() - async for line in resp.aiter_lines(): - if line.strip(): - status = json.loads(line) - if "status" in status: - logger.info("Pull %s: %s", model, status["status"]) - logger.info("Model '%s' pulled successfully", model) - except Exception: - logger.warning("Failed to pull model '%s' — chat may not work", model, exc_info=True) - - -async def wait_for_model_loaded(model: str, timeout: float = 90.0) -> bool: - """Poll /api/ps every 2s until the model appears in Ollama's loaded-model list. - - Returns True when the model is loaded, False if timeout elapses first. - Used before generation to avoid streaming 500s during cold model loads. - """ - base = model.removesuffix(":latest") - deadline = time.monotonic() + timeout - while True: - try: - async with httpx.AsyncClient(timeout=5.0) as client: - resp = await client.get(f"{Config.OLLAMA_URL}/api/ps") - resp.raise_for_status() - loaded = {m["name"] for m in resp.json().get("models", [])} - if model in loaded or f"{base}:latest" in loaded or base in loaded: - return True - except Exception: - pass # Ollama may still be starting up - remaining = deadline - time.monotonic() - if remaining <= 0: - return False - await asyncio.sleep(min(2.0, remaining)) - - -async def _raise_ollama_error(resp: httpx.Response, model: str) -> None: - """On a non-2xx Ollama response, log its body before raising. - - ``resp.raise_for_status()`` alone throws away Ollama's error body, which - carries the actual reason (e.g. ``"model does not support tools"``, - ``"context length exceeded"``). Reading the body first means failures - surface a usable message instead of a bare HTTPStatusError. - """ - if resp.status_code < 400: - return - body = (await resp.aread()).decode("utf-8", errors="replace").strip() - logger.error( - "Ollama /api/chat %d for model=%s: %s", - resp.status_code, model, body[:500], - ) - resp.raise_for_status() - - -async def stream_chat( - messages: list[dict], - model: str, - options: dict | None = None, - think: bool = False, - num_ctx: int | None = None, -) -> AsyncGenerator[str, None]: - """Stream chat completion from Ollama, yielding content chunks. - - Set think=False (default) to disable chain-of-thought on qwen3+ models. - Thinking tokens are silently discarded anyway, but disabling avoids the - multi-minute delay before the first content token arrives. - """ - merged_options = {"num_ctx": num_ctx or Config.OLLAMA_NUM_CTX} - if options: - merged_options.update(options) - payload: dict = {"model": model, "messages": messages, "stream": True, "options": merged_options, "think": think, "keep_alive": keep_alive_for(model)} - # read=None: no per-chunk timeout — Ollama may pause for any duration while - # processing a large input context before the first token arrives. - async with httpx.AsyncClient(timeout=httpx.Timeout(connect=30.0, read=None, write=None, pool=30.0)) as client: - async with client.stream( - "POST", - f"{Config.OLLAMA_URL}/api/chat", - json=payload, - ) as resp: - await _raise_ollama_error(resp, model) - async for line in resp.aiter_lines(): - if not line.strip(): - continue - data = json.loads(line) - chunk = data.get("message", {}).get("content", "") - if chunk: - yield chunk - if data.get("done"): - break - - -@dataclass -class ChatChunk: - """A chunk yielded by stream_chat_with_tools.""" - type: Literal["content", "thinking", "tool_calls", "done"] - content: str = "" - tool_calls: list[dict] | None = None - # Token counts from the Ollama done event (only set on type="done") - prompt_tokens: int | None = None - output_tokens: int | None = None - - -async def stream_chat_with_tools( - messages: list[dict], - model: str, - tools: list[dict] | None = None, - think: bool = False, - num_ctx: int | None = None, -) -> AsyncGenerator[ChatChunk, None]: - """Stream chat completion from Ollama with tool support. - - Yields ChatChunk objects. If the model returns tool_calls, a - ChatChunk(type="tool_calls") is yielded. Always ends with - ChatChunk(type="done"). - - Set think=True to enable the model's chain-of-thought reasoning (qwen3+). - Thinking tokens are consumed by Ollama and not forwarded to the caller; - only the final response content is yielded. Expect higher TTFT when enabled. - """ - resolved_ctx = num_ctx or Config.OLLAMA_NUM_CTX - options: dict = {"num_ctx": resolved_ctx} - if tools: - options["num_predict"] = 8192 - payload: dict = { - "model": model, - "messages": messages, - "stream": True, - "options": options, - "think": think, - "keep_alive": keep_alive_for(model), - } - if tools: - payload["tools"] = tools - # read=None: no per-chunk timeout for the same reason as stream_chat. - async with httpx.AsyncClient(timeout=httpx.Timeout(connect=30.0, read=None, write=None, pool=30.0)) as client: - async with client.stream( - "POST", - f"{Config.OLLAMA_URL}/api/chat", - json=payload, - ) as resp: - await _raise_ollama_error(resp, model) - accumulated_tool_calls: list[dict] = [] - # Silent-generation diagnostic: if Ollama reports non-zero eval_count - # but we never yielded any thinking/content/tool_calls, something - # in the frames isn't landing in a field we read. Capture the last - # few frames so we can see what Ollama actually sent. - yielded_anything = False - recent_frames: list[str] = [] - async for line in resp.aiter_lines(): - if not line.strip(): - continue - if len(recent_frames) >= 5: - recent_frames.pop(0) - recent_frames.append(line[:500]) - data = json.loads(line) - msg = data.get("message", {}) - - # Thinking chunks (qwen3 chain-of-thought, only when think=True) - thinking = msg.get("thinking", "") - if thinking: - yielded_anything = True - yield ChatChunk(type="thinking", content=thinking) - - # Content chunks - chunk = msg.get("content", "") - if chunk: - yielded_anything = True - yield ChatChunk(type="content", content=chunk) - - # Collect tool calls from any message (some models - # emit them before the done flag) - tc = msg.get("tool_calls") - if tc: - accumulated_tool_calls.extend(tc) - - if data.get("done"): - if accumulated_tool_calls: - logger.info( - "Ollama returned %d tool call(s): %s", - len(accumulated_tool_calls), - json.dumps(accumulated_tool_calls)[:500], - ) - yielded_anything = True - yield ChatChunk(type="tool_calls", tool_calls=accumulated_tool_calls) - else: - logger.debug("Ollama done with no tool calls") - eval_count = data.get("eval_count") or 0 - if not yielded_anything and eval_count > 0: - logger.warning( - "Ollama silent generation: model=%s eval_count=%d but no " - "thinking/content/tool_calls were yielded. Last frames: %s", - model, eval_count, recent_frames, - ) - yield ChatChunk( - type="done", - prompt_tokens=data.get("prompt_eval_count"), - output_tokens=eval_count, - ) - break - - -async def generate_completion( - messages: list[dict], - model: str, - max_tokens: int = 4096, - num_ctx: int | None = None, -) -> str: - """Non-streaming chat completion, returns full response text. - - Retries up to 2 times on Ollama 500 errors (cold model loading race). - num_ctx overrides the model's context window for this call only. - """ - last_exc: Exception | None = None - # Default num_ctx to Config.OLLAMA_NUM_CTX (matching stream_chat / - # stream_chat_with_tools). Without this, Ollama silently uses the model's - # default window (~4k on qwen3) and truncates anything longer. That is - # how the research pipeline's outline step kept falling back to a single - # monolith note: its 12-source prompt is ~6k tokens and was being chopped - # before the model ever saw it. Non-streaming callers must not inherit - # that footgun — if you truly want the model default, pass num_ctx=0. - options: dict = { - "num_predict": max_tokens, - "num_ctx": num_ctx if num_ctx is not None else Config.OLLAMA_NUM_CTX, - } - for attempt in range(3): - if attempt > 0: - delay = 3.0 * attempt - logger.warning( - "generate_completion 500 (attempt %d/3), retrying in %.0fs", attempt, delay - ) - await asyncio.sleep(delay) - try: - async with httpx.AsyncClient(timeout=httpx.Timeout(1800.0, connect=30.0, read=300.0)) as client: - resp = await client.post( - f"{Config.OLLAMA_URL}/api/chat", - json={ - "model": model, - "messages": messages, - "stream": False, - "think": False, - "options": options, - "keep_alive": keep_alive_for(model), - }, - ) - resp.raise_for_status() - data = resp.json() - return data.get("message", {}).get("content", "") - except httpx.HTTPStatusError as exc: - last_exc = exc - if exc.response.status_code != 500: - break - except Exception as exc: - last_exc = exc - break - raise last_exc - - -def _is_private_url(url: str) -> bool: - """Return True if the URL resolves to a private/loopback/link-local address (SSRF guard).""" - try: - host = urlparse(url).hostname - if not host: - return True - if host.lower() in ("localhost", "::1"): - return True - addr_info = socket.getaddrinfo(host, None, proto=socket.IPPROTO_TCP) - for entry in addr_info: - ip = ipaddress.ip_address(entry[4][0]) - if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_multicast: - return True - return False - except Exception: - return True # Block on resolution failure - - -async def fetch_url_content(url: str) -> str: - """Fetch a URL and return text content (HTML tags stripped).""" - if _is_private_url(url): - logger.warning("Blocked fetch of private/internal URL: %s", url) - return "[URL blocked: internal network access not permitted]" - try: - async with httpx.AsyncClient( - timeout=15.0, follow_redirects=False, headers={"User-Agent": "FabledAssistant/1.0"} - ) as client: - resp = await client.get(url) - resp.raise_for_status() - text = resp.text - # Strip HTML tags - text = re.sub(r"]*>.*?", "", text, flags=re.DOTALL) - text = re.sub(r"]*>.*?", "", text, flags=re.DOTALL) - text = re.sub(r"<[^>]+>", " ", text) - # Collapse whitespace - text = re.sub(r"\s+", " ", text).strip() - # Truncate to reasonable size - if len(text) > 4000: - text = text[:4000] + "..." - return text - except Exception as e: - logger.warning("Failed to fetch URL %s: %s", url, e) - return f"[Failed to fetch URL: {url}]" - - -def _extract_keywords(text: str) -> list[str]: - """Extract meaningful keywords from text for note search.""" - words = re.findall(r"\b[a-zA-Z]{3,}\b", text.lower()) - keywords = [w for w in words if w not in STOP_WORDS] - # Deduplicate while preserving order - seen: set[str] = set() - unique = [] - for w in keywords: - if w not in seen: - seen.add(w) - unique.append(w) - return unique[:5] - - -def _find_urls(text: str) -> list[str]: - """Find URLs in text.""" - return re.findall(r"https?://[^\s<>\"')\]]+", text) - - -# History summarization thresholds -_HISTORY_SUMMARY_THRESHOLD = 30 # total messages before summarizing -_HISTORY_KEEP_RECENT = 8 # verbatim tail to preserve (4 exchanges) - - -async def summarize_history_for_context( - history: list[dict], - model: str, -) -> tuple[list[dict], str | None]: - """Summarize old conversation history when it exceeds the threshold. - - Returns (recent_history, summary_text | None). - recent_history is the verbatim tail passed to the model. - summary_text (when not None) should be injected into the system prompt - so the model retains the gist of earlier exchanges without the full tokens. - For short conversations, returns (history, None) immediately with no LLM call. - """ - if len(history) <= _HISTORY_SUMMARY_THRESHOLD: - return history, None - - to_summarize = history[:-_HISTORY_KEEP_RECENT] - recent = history[-_HISTORY_KEEP_RECENT:] - - # Two-pass for very long histories: summarize first half, combine with second half - if len(to_summarize) > 50: - mid = len(to_summarize) // 2 - first_half = to_summarize[:mid] - second_half = to_summarize[mid:] - - # Summarize first half - first_lines = [] - for m in first_half: - role = m.get("role", "") - content = (m.get("content") or "").strip() - if role in ("user", "assistant") and content: - label = "User" if role == "user" else "Assistant" - first_lines.append(f"{label}: {content[:400]}") - - if first_lines: - try: - first_summary_messages = [ - {"role": "system", "content": "Summarize this conversation in 3-4 sentences covering topics, notes/tasks created, and key decisions."}, - {"role": "user", "content": "\n".join(first_lines)}, - ] - summary_a = await generate_completion(first_summary_messages, model, max_tokens=300) - summary_a = summary_a.strip() - except Exception: - summary_a = "" - else: - summary_a = "" - - # Build lines for final pass from second half - second_lines = [] - for m in second_half: - role = m.get("role", "") - content = (m.get("content") or "").strip() - if role in ("user", "assistant") and content: - label = "User" if role == "user" else "Assistant" - second_lines.append(f"{label}: {content[:400]}") - - if summary_a: - lines = [f"[Earlier summary: {summary_a}]"] + second_lines - else: - lines = second_lines - else: - lines: list[str] = [] - for m in to_summarize: - role = m.get("role", "") - content = (m.get("content") or "").strip() - if role in ("user", "assistant") and content: - label = "User" if role == "user" else "Assistant" - lines.append(f"{label}: {content[:400]}") - - if not lines: - return history, None - - prompt_messages = [ - { - "role": "system", - "content": ( - "Summarize this conversation history. Capture: " - "(1) All notes, tasks, and projects created or modified — include their exact names. " - "(2) Key decisions made and conclusions reached. " - "(3) Open questions and next steps mentioned. " - "(4) The overall topic arc so the conversation can continue naturally. " - "Be specific and factual. Output 4-8 concise sentences. Nothing else." - ), - }, - {"role": "user", "content": "\n".join(lines)}, - ] - - try: - summary = await generate_completion(prompt_messages, model, max_tokens=400) - summary = summary.strip() - if summary: - logger.info( - "Summarized %d history messages (%d chars) for context", - len(to_summarize), len(summary), - ) - return recent, summary - except Exception: - logger.warning("Failed to summarize conversation history", exc_info=True) - - return history, None - - -async def build_context( - user_id: int, - history: list[dict], - current_note_id: int | None, - user_message: str, - exclude_note_ids: list[int] | None = None, - history_summary: str | None = None, - include_note_ids: list[int] | None = None, - excluded_note_ids: list[int] | None = None, - rag_project_id: int | None = None, - workspace_project_id: int | None = None, - user_timezone: str | None = None, - conv_id: int | None = None, - voice_mode: bool = False, - voice_speech_style: str = "conversational", -) -> tuple[list[dict], dict]: - """Build messages array for Ollama with system prompt and context. - - Returns (messages, context_meta) where context_meta contains info about - which notes were included as context. - """ - exclude_set = set(exclude_note_ids or []) - from datetime import date as date_type - - # --- Journal short-circuit --- - # Journal conversations get a different persona, calibration, and an - # ambient-moments context block. CRUCIALLY, no notes-RAG injection here - # (preserves the notes/journal isolation invariant). - if conv_id is not None: - from fabledassistant.models import async_session as _async_session - from fabledassistant.models.conversation import Conversation as _Conversation - async with _async_session() as _sess: - _conv = await _sess.get(_Conversation, conv_id) - if _conv and getattr(_conv, "conversation_type", None) == "journal": - from fabledassistant.services.journal_pipeline import build_journal_system_prompt - day_date = _conv.day_date or date_type.today() - system_content = await build_journal_system_prompt( - user_id=user_id, - day_date=day_date, - user_timezone=user_timezone or "UTC", - conv_id=conv_id, - ) - messages_out: list[dict] = [{"role": "system", "content": system_content}] - messages_out.extend(history) - messages_out.append({"role": "user", "content": user_message}) - return messages_out, { - "context_note_id": None, - "context_note_title": None, - "auto_notes": [], - "auto_injected_notes": [], - } - - assistant_name = await get_setting(user_id, "assistant_name", "Fable") - _today_obj = date_type.today() - today = _today_obj.isoformat() - # Day-of-week paired with the ISO date so the model doesn't have to - # derive the weekday — that derivation is a documented failure mode - # for "this Friday" / "next Monday"-style requests. - today_weekday = _today_obj.strftime("%A") - has_caldav = await is_caldav_configured(user_id) - - # Build tool usage guidance based on available integrations - # --- Static block (Ollama KV-cache prefix) --- - # Everything here must be byte-for-byte identical across requests for the same - # user so Ollama can reuse the cached KV state. No dates, timezones, RAG notes, - # or user-profile data here — those go in the dynamic tail below. - tool_lines = [ - "You have access to tool functions. You MUST use them when the user asks you to create, add, find, schedule, or search for anything.", - "CRITICAL: Call the tool functions directly. NEVER write out function calls as text or code. NEVER describe what you would do — just do it.", - "GROUNDING: When the user asks about their own data — tasks, notes, events, projects, news, anything stored in this system — call the relevant tool to see what actually exists before answering. Never assert facts about the user's data from memory, prior context, or assumption. If you are unsure whether something exists, check with a tool.", - "HONESTY WHEN EMPTY: If a tool returns empty results (no matching tasks, no events in the date range, no search hits, no notes found), tell the user plainly that nothing matched. Do not fabricate example items, do not invent plausible-sounding meetings or deadlines to fill the response, and do not hedge with generic suggestions dressed up as real data. A direct 'you don't have anything on your calendar today' is always better than an invented event.", - "EXISTING WORK: When the user describes ongoing or completed work that references a specific project or task by name or partial name, call search_notes first to locate the existing item. Only call record_moment, create_task, or create_note if no matching task surfaces and the user confirms.", - ] - actions = [ - "create_note (also creates tasks — set status='todo')", "update_note", "delete_note", - "read_note", "list_notes", "list_tasks", "log_work", "search_notes", - "create_project", "list_projects", "get_project", "update_project", - "search_projects", "create_milestone", "update_milestone", "list_milestones", - "save_person", "save_place", "create_list", "add_to_list", "clear_checked_items", - "set_rag_scope", "get_profile", "update_profile", "get_weather", "calculate", - "read_article", - ] - if has_caldav: - actions.extend(["create_event", "list_events", "search_events", "update_event", "delete_event", "list_calendars"]) - tool_lines.append( - "For calendar events, use ISO 8601 datetime format with the user's timezone offset (stated in context below). " - "Always include the UTC offset in datetime strings (e.g. 2026-09-30T14:00:00+01:00)." - ) - tool_lines.append("When the user says 'remind me' with a time before an event, use the reminder_minutes parameter.") - actions.append("lookup") - if Config.searxng_enabled(): - actions.extend(["research_topic", "search_images"]) - tool_lines.append(f"Available actions: {', '.join(actions)}.") - tool_lines.append( - "For relative dates like 'Friday' or 'next week', resolve them to YYYY-MM-DD format. " - "Always include the UTC offset when creating events (user's timezone is stated in context below)." - ) - tool_lines.append("When creating notes, use the `tags` parameter — do not embed #tag text in the note body.") - tool_lines.append( - "When search_images returns results, embed each image directly in your response by writing " - "the 'embed' field verbatim (e.g. ![Cat](/api/images/3)), then the 'citation' field on the " - "next line. Never describe images as text or list their URLs — always render them as markdown images." - ) - tool_lines.append( - "Use update_note for existing notes/tasks; use create_note only for new content. " - "Use search_notes for semantic/conceptual queries. " - "Delete tools require an explicit user request. " - "Never proactively search notes or comment on absent context." - ) - tool_lines.append( - "IMPORTANT: When creating tasks or notes, NEVER infer or guess a project name. " - "Only set the project parameter if the user explicitly names a project. " - "If the user says 'create a task to buy milk', do NOT assign it to a project." - ) - tool_guidance = "\n".join(tool_lines) - - static_block = ( - f"You are a helpful assistant named {assistant_name}, integrated into a note-taking and task-tracking app called Fabled Scribe. " - "Help users with their notes, tasks, and general questions. " - "When note context is provided, use it to give relevant answers.\n\n" - f"{tool_guidance}" - ) - - # --- Dynamic tail (appended after static block, evaluated every request) --- - # Date, timezone, user profile, and entities change per-day or per-user. - # Keeping these at the end preserves the static prefix for KV-cache reuse. - tz_line = f" The user's timezone is {user_timezone}." if user_timezone else "" - - from fabledassistant.services.user_profile import build_profile_context - from fabledassistant.services.knowledge import get_people_and_places_context - profile_context = await build_profile_context(user_id) - profile_section = f"\n\n{profile_context}" if profile_context else "" - entities_context = await get_people_and_places_context(user_id) - entities_section = f"\n\n{entities_context}" if entities_context else "" - - dynamic_tail = f"\n\nToday is {today_weekday}, {today}.{tz_line}{profile_section}{entities_section}" - - # --- System message: stable content only --- - # Workspace context and history summary stay here because they carry - # behavioural instructions / conversational state, not retrieved content. - # Everything retrieval-based (RAG notes, URL content, current note) - # goes into the user turn below so the system message - # prefix stays byte-for-byte identical across requests, enabling Ollama's - # KV prefix cache to fire reliably. - - if voice_mode: - _style_hints = { - "conversational": "Be warm, natural, and conversational — like speaking to a friend.", - "concise": "Be brief and to the point. One or two sentences maximum unless detail is essential.", - "detailed": "Give thorough, informative responses as if narrating an explanation aloud.", - } - style_hint = _style_hints.get(voice_speech_style, _style_hints["conversational"]) - voice_preamble = ( - "VOICE MODE: Respond naturally as if speaking aloud. " - "No markdown, bullet points, headers, or code blocks. Complete sentences only. " - f"{style_hint}\n\n" - ) - system_content = voice_preamble + static_block + dynamic_tail - else: - system_content = static_block + dynamic_tail - - # Inject workspace context (behavioural — must stay in system) - if workspace_project_id is not None: - from fabledassistant.services.projects import get_project - try: - wp = await get_project(user_id, workspace_project_id) - if wp: - system_content += ( - f"\n\n--- Active Workspace ---\n" - f"You are in the \"{wp.title}\" project workspace.\n" - f"All notes and tasks you create or update MUST belong to this project.\n" - f"Always pass project=\"{wp.title}\" when calling create_note.\n" - f"--- End Active Workspace ---" - ) - except Exception: - logger.warning("Failed to fetch workspace project %d", workspace_project_id) - - # Inject compressed history summary (conversational state — stays in system) - if history_summary: - system_content += ( - f"\n\n--- Earlier Conversation ---\n{history_summary}\n--- End Earlier Conversation ---" - ) - - context_meta: dict = { - "context_note_id": None, - "context_note_title": None, - "auto_notes": [], - "auto_injected_notes": [], - } - - # --- User turn context prefix: retrieval-based content --- - # Collected here and prepended to the user message so the system message - # stays stable and the KV prefix cache can fire on every request. - user_context_parts: list[str] = [] - - # Current note being viewed (full body, no truncation) - if current_note_id: - note = await get_note(user_id, current_note_id) - if note: - context_meta["context_note_id"] = note.id - context_meta["context_note_title"] = note.title - user_context_parts.append( - f"--- Current Note ---\n" - f"Title: {note.title}\n" - f"Content:\n{note.body}\n" - f"--- End Note ---" - ) - - # Semantic / keyword note search - search_exclude = set(exclude_set) - if current_note_id: - search_exclude.add(current_note_id) - - found_scored: list[tuple[float | None, object]] = [] - - orphan_only = rag_project_id is None - effective_project_id = rag_project_id if (rag_project_id is not None and rag_project_id != -1) else None - - try: - from fabledassistant.services.embeddings import semantic_search_notes - for score, note in await semantic_search_notes( - user_id, user_message, exclude_ids=search_exclude or None, limit=8, - project_id=effective_project_id, - orphan_only=orphan_only, - ): - found_scored.append((score, note)) - except Exception: - logger.warning("Semantic note search failed, falling back to keyword search", exc_info=True) - - if not found_scored: - keywords = _extract_keywords(user_message) - if keywords: - try: - for note in await search_notes_for_context( - user_id, keywords, exclude_ids=search_exclude or None, limit=8, - project_id=effective_project_id, - orphan_only=orphan_only, - ): - found_scored.append((None, note)) - except Exception: - logger.warning("Failed to search notes for context", exc_info=True) - - excluded_inject_set = set(excluded_note_ids or []) - auto_inject: list[tuple[float, object]] = [] - sidebar_only: list[tuple[float | None, object]] = [] - - for score, n in found_scored: - if ( - score is not None - and score >= RAG_AUTO_THRESHOLD - and len(auto_inject) < RAG_AUTO_LIMIT - and n.id not in excluded_inject_set - ): - auto_inject.append((score, n)) - else: - sidebar_only.append((score, n)) - - if auto_inject: - snippets = [] - for score, n in auto_inject: - body_snippet = (n.body or "")[:RAG_AUTO_SNIPPET] - snippets.append(f"**{n.title}** (relevance: {round(score * 100)}%)\n{body_snippet}") - context_meta["auto_injected_notes"].append({ - "id": n.id, - "title": n.title, - "score": round(score, 2), - }) - user_context_parts.append( - "[The following are reference excerpts from the user's personal notes, " - "not part of their message. Use them only if relevant to answering.]\n" - "--- Relevant Notes ---\n" - + "\n\n".join(snippets) - + "\n--- End Relevant Notes ---" - ) - - for score, n in auto_inject: - context_meta["auto_notes"].append({ - "id": n.id, - "title": n.title, - "score": round(score, 2) if score is not None else None, - "auto_injected": True, - }) - for score, n in sidebar_only: - context_meta["auto_notes"].append({ - "id": n.id, - "title": n.title, - "score": round(score, 2) if score is not None else None, - "auto_injected": False, - }) - context_meta["auto_note_ids"] = [n.id for _, n in found_scored] - - # Explicitly included notes (user opted in via sidebar) - if include_note_ids: - from fabledassistant.services.notes import get_note as _get_note - included_snippets: list[str] = [] - for nid in include_note_ids: - try: - n = await _get_note(user_id, nid) - if n: - body_preview = n.body or "" - included_snippets.append(f"- {n.title}: {body_preview}") - except Exception: - logger.warning("Failed to load included note %d for context", nid, exc_info=True) - if included_snippets: - user_context_parts.append( - "--- Included Notes ---\n" - + "\n".join(included_snippets) - + "\n--- End Included Notes ---" - ) - - # URL content fetched from links in the user message - urls = _find_urls(user_message) - for url in urls[:2]: - content = await fetch_url_content(url) - if content and not content.startswith("[Failed"): - user_context_parts.append( - f"--- Content from {url} ---\n{content}\n--- End URL Content ---" - ) - - # Build final user message — context prefix (if any) followed by the actual message - if user_context_parts: - user_turn = "\n\n".join(user_context_parts) + "\n\n" + user_message - else: - user_turn = user_message - - messages = [{"role": "system", "content": system_content}] - messages.extend(history) - messages.append({"role": "user", "content": user_turn}) - return messages, context_meta diff --git a/src/fabledassistant/services/moments.py b/src/fabledassistant/services/moments.py deleted file mode 100644 index 0079b6a..0000000 --- a/src/fabledassistant/services/moments.py +++ /dev/null @@ -1,185 +0,0 @@ -"""Moment CRUD, entity linking, and embedding sync. - -Moments are small structured extractions from journal conversations. -Stored separately from Notes — they have their own embedding index and -the isolation between notes-RAG and journal-RAG is enforced at the API -boundary, not just the schema. -""" -from __future__ import annotations - -import datetime -from typing import Sequence - -from sqlalchemy import delete, select -from sqlalchemy.ext.asyncio import AsyncSession - -from fabledassistant.models import ( - Moment, - MomentEmbedding, - async_session, - moment_notes, - moment_people, - moment_places, - moment_tasks, -) -from fabledassistant.services.embeddings import get_embedding - - -async def create_moment( - *, - user_id: int, - content: str, - occurred_at: datetime.datetime, - day_date: datetime.date, - conversation_id: int | None = None, - source_message_id: int | None = None, - raw_excerpt: str | None = None, - tags: Sequence[str] = (), - person_ids: Sequence[int] = (), - place_ids: Sequence[int] = (), - task_ids: Sequence[int] = (), - note_ids: Sequence[int] = (), -) -> Moment: - """Insert a Moment, write its embedding, create junction rows.""" - async with async_session() as session: - moment = Moment( - user_id=user_id, - conversation_id=conversation_id, - source_message_id=source_message_id, - day_date=day_date, - occurred_at=occurred_at, - content=content, - raw_excerpt=raw_excerpt, - tags=list(tags), - ) - session.add(moment) - await session.flush() - - await _set_links( - session, - moment_id=moment.id, - person_ids=person_ids, - place_ids=place_ids, - task_ids=task_ids, - note_ids=note_ids, - ) - - embedding_vector = await get_embedding(content) - session.add( - MomentEmbedding( - moment_id=moment.id, - user_id=user_id, - embedding=embedding_vector, - ) - ) - - await session.commit() - await session.refresh(moment, ["people", "places", "tasks", "notes"]) - return moment - - -async def get_moment(*, user_id: int, moment_id: int) -> Moment | None: - async with async_session() as session: - result = await session.execute( - select(Moment).where(Moment.id == moment_id, Moment.user_id == user_id) - ) - return result.scalar_one_or_none() - - -async def update_moment( - *, - user_id: int, - moment_id: int, - content: str | None = None, - tags: Sequence[str] | None = None, - pinned: bool | None = None, - person_ids: Sequence[int] | None = None, - place_ids: Sequence[int] | None = None, - task_ids: Sequence[int] | None = None, - note_ids: Sequence[int] | None = None, -) -> Moment | None: - """Update fields and (optionally) replace junction sets. - - None for a junction parameter ⇒ leave unchanged. Empty sequence ⇒ clear. - Mirrors the Notes update convention. - """ - async with async_session() as session: - result = await session.execute( - select(Moment).where(Moment.id == moment_id, Moment.user_id == user_id) - ) - moment = result.scalar_one_or_none() - if moment is None: - return None - - if content is not None: - moment.content = content - embedding_vector = await get_embedding(content) - await session.execute( - delete(MomentEmbedding).where(MomentEmbedding.moment_id == moment_id) - ) - session.add( - MomentEmbedding( - moment_id=moment_id, - user_id=user_id, - embedding=embedding_vector, - ) - ) - if tags is not None: - moment.tags = list(tags) - if pinned is not None: - moment.pinned = pinned - - await _set_links( - session, - moment_id=moment_id, - person_ids=person_ids, - place_ids=place_ids, - task_ids=task_ids, - note_ids=note_ids, - ) - - await session.commit() - await session.refresh(moment, ["people", "places", "tasks", "notes"]) - return moment - - -async def delete_moment(*, user_id: int, moment_id: int) -> bool: - async with async_session() as session: - result = await session.execute( - select(Moment).where(Moment.id == moment_id, Moment.user_id == user_id) - ) - moment = result.scalar_one_or_none() - if moment is None: - return False - await session.delete(moment) - await session.commit() - return True - - -async def _set_links( - session: AsyncSession, - *, - moment_id: int, - person_ids: Sequence[int] | None, - place_ids: Sequence[int] | None, - task_ids: Sequence[int] | None, - note_ids: Sequence[int] | None, -) -> None: - """Replace junction-table rows. - - None ⇒ leave existing rows alone. Empty sequence ⇒ clear all rows. - """ - for ids, table, fk_col in [ - (person_ids, moment_people, "person_id"), - (place_ids, moment_places, "place_id"), - (task_ids, moment_tasks, "task_id"), - (note_ids, moment_notes, "note_id"), - ]: - if ids is None: - continue - await session.execute(delete(table).where(table.c.moment_id == moment_id)) - if ids: - await session.execute( - table.insert(), - [{"moment_id": moment_id, fk_col: i} for i in ids], - ) diff --git a/src/fabledassistant/services/notes.py b/src/fabledassistant/services/notes.py index 4448b47..493a5c8 100644 --- a/src/fabledassistant/services/notes.py +++ b/src/fabledassistant/services/notes.py @@ -49,29 +49,6 @@ async def _maybe_reactivate_project(project_id: int) -> None: logger.debug("_maybe_reactivate_project failed for project %d", project_id, exc_info=True) -async def _maybe_trigger_project_summary(user_id: int, project_id: int) -> None: - """Fire generate_project_summary() if the project summary is missing or >1h old.""" - import asyncio - from datetime import timedelta - from fabledassistant.models.project import Project - from fabledassistant.services.projects import generate_project_summary - try: - async with async_session() as session: - project = (await session.execute( - select(Project).where(Project.id == project_id) - )).scalars().first() - if project is None: - return - stale = ( - project.summary_updated_at is None - or (datetime.now(timezone.utc) - project.summary_updated_at) > timedelta(hours=1) - ) - if stale: - asyncio.create_task(generate_project_summary(user_id, project_id)) - except Exception: - logger.debug("_maybe_trigger_project_summary failed for project %d", project_id, exc_info=True) - - async def create_note( user_id: int, title: str = "", @@ -122,7 +99,6 @@ async def create_note( if project_id is not None: await _maybe_reactivate_project(project_id) - await _maybe_trigger_project_summary(user_id, project_id) return note @@ -281,8 +257,6 @@ async def update_note(user_id: int, note_id: int, **fields: object) -> Note | No old_body = note.body old_title = note.title old_tags = list(note.tags or []) - # Snapshot status to detect terminal transitions for consolidation trigger. - old_status = note.status for key, value in fields.items(): if not hasattr(note, key): continue @@ -327,16 +301,8 @@ async def update_note(user_id: int, note_id: int, **fields: object) -> Note | No from fabledassistant.services.note_versions import create_version await create_version(user_id, note_id, old_body, old_title, old_tags) - # Trigger consolidation when a task transitions into a terminal status. - # Captured before mutation; the gate inside maybe_consolidate handles the - # auto-consolidate setting. - if note.status in ("done", "cancelled") and old_status != note.status: - from fabledassistant.services.consolidation import maybe_consolidate - await maybe_consolidate(user_id, note.id, reason="task_closed") - if note.project_id is not None: await _maybe_reactivate_project(note.project_id) - await _maybe_trigger_project_summary(user_id, note.project_id) return note diff --git a/src/fabledassistant/services/notifications.py b/src/fabledassistant/services/notifications.py index 2ec1cb6..c8dec98 100644 --- a/src/fabledassistant/services/notifications.py +++ b/src/fabledassistant/services/notifications.py @@ -267,11 +267,9 @@ async def create_in_app_notification(user_id: int, notif_type: str, payload: dic async def _fire_push_notif(user_id: int, title: str, body: str, url: str) -> None: - try: - from fabledassistant.services.push import send_push_notification - await send_push_notification(user_id, title, body, url=url) - except Exception: - logger.exception("Push notification failed for user %d", user_id) + # Push delivery was removed alongside the chat subsystem (Phase 8). + # In-app notifications still flow through the bell-icon feed. + return None async def _fire_share_email(user_id: int, subject: str, body_text: str) -> None: diff --git a/src/fabledassistant/services/pending_actions.py b/src/fabledassistant/services/pending_actions.py deleted file mode 100644 index 98f1349..0000000 --- a/src/fabledassistant/services/pending_actions.py +++ /dev/null @@ -1,167 +0,0 @@ -"""Pending-action service — curator-proposed mutations awaiting user approval. - -See models/pending_curator_action.py for the schema rationale. This module -is the API everything else uses: - -- `create_pending(...)` is called from execute_tool's curator-authority - interceptor when the curator tries to call a mutating tool. The caller - has already captured a snapshot of the target's current state. - -- `list_pending(user_id)` is what the Needs Review panel reads. - -- `approve(action_id, user_id)` replays the original tool call via - `execute_tool` with `authority="user"`, which bypasses the curator - interceptor and just runs. On success, the row moves to status='approved'. - -- `reject(action_id, user_id)` marks the row 'rejected' without executing. - History row stays around for audit; nothing runs. -""" -from __future__ import annotations - -import datetime -import logging -from datetime import timezone -from typing import Any - -from sqlalchemy import select, update - -from fabledassistant.models import async_session -from fabledassistant.models.pending_curator_action import PendingCuratorAction - -logger = logging.getLogger(__name__) - - -async def create_pending( - *, - user_id: int, - conv_id: int | None, - action_type: str, - target_type: str | None, - target_id: int | None, - target_label: str | None, - payload: dict, - current_snapshot: dict, -) -> PendingCuratorAction: - """Persist a curator-proposed mutation for later review. - - Returns the created row so the interceptor can include its id in the - tool-result envelope (handy for tests, logs, and future UI deep-links). - """ - async with async_session() as session: - row = PendingCuratorAction( - user_id=user_id, - conv_id=conv_id, - action_type=action_type, - target_type=target_type, - target_id=target_id, - target_label=target_label, - payload=payload or {}, - current_snapshot=current_snapshot or {}, - status="pending", - ) - session.add(row) - await session.commit() - await session.refresh(row) - logger.info( - "Curator proposed %s on %s/%s (label=%r) — action_id=%d, conv=%s", - action_type, target_type, target_id, target_label, row.id, conv_id, - ) - return row - - -async def list_pending(user_id: int, limit: int = 50) -> list[dict]: - """Return the user's pending actions, newest first. - - Only `status='pending'` — once a row is approved or rejected, it - drops off this list. Use the index `ix_pending_curator_actions_user_pending`. - """ - async with async_session() as session: - result = await session.execute( - select(PendingCuratorAction) - .where( - PendingCuratorAction.user_id == user_id, - PendingCuratorAction.status == "pending", - ) - .order_by(PendingCuratorAction.created_at.desc()) - .limit(limit) - ) - return [row.to_dict() for row in result.scalars().all()] - - -async def _load_for_user(action_id: int, user_id: int) -> PendingCuratorAction | None: - async with async_session() as session: - result = await session.execute( - select(PendingCuratorAction).where( - PendingCuratorAction.id == action_id, - PendingCuratorAction.user_id == user_id, - ) - ) - return result.scalar_one_or_none() - - -async def _mark_reviewed(action_id: int, status: str) -> None: - async with async_session() as session: - await session.execute( - update(PendingCuratorAction) - .where(PendingCuratorAction.id == action_id) - .values(status=status, reviewed_at=datetime.datetime.now(timezone.utc)) - ) - await session.commit() - - -async def approve(action_id: int, user_id: int) -> dict[str, Any]: - """Replay the curator's proposed action with user authority and mark approved. - - The replay path goes through `execute_tool` with `authority="user"` so - the curator interceptor doesn't intercept its own replay (which would - create another pending row and loop forever). - - Returns the tool-result dict from execute_tool. Action stays in - `pending` if replay returns an error so the user can retry; only - transitions to `approved` on success. - """ - row = await _load_for_user(action_id, user_id) - if row is None: - return {"success": False, "error": "Pending action not found"} - if row.status != "pending": - return { - "success": False, - "error": f"Action already {row.status}", - } - - from fabledassistant.services.tools import execute_tool - try: - # authority="user" bypasses the curator interceptor — required so - # the replay actually executes the mutating tool instead of - # creating another pending row (which would infinite-loop). - result = await execute_tool( - user_id, - row.action_type, - row.payload, - conv_id=row.conv_id, - authority="user", - ) - except Exception as e: - logger.exception( - "Replay of curator action %d (%s) failed", - action_id, row.action_type, - ) - return {"success": False, "error": f"{type(e).__name__}: {e}"} - - if result.get("success", True) and not result.get("error"): - await _mark_reviewed(action_id, "approved") - return result - - -async def reject(action_id: int, user_id: int) -> dict[str, Any]: - """Mark the pending action as rejected without executing anything.""" - row = await _load_for_user(action_id, user_id) - if row is None: - return {"success": False, "error": "Pending action not found"} - if row.status != "pending": - return { - "success": False, - "error": f"Action already {row.status}", - } - await _mark_reviewed(action_id, "rejected") - return {"success": True, "id": action_id, "status": "rejected"} diff --git a/src/fabledassistant/services/projects.py b/src/fabledassistant/services/projects.py index 5d7725b..2abcebc 100644 --- a/src/fabledassistant/services/projects.py +++ b/src/fabledassistant/services/projects.py @@ -71,7 +71,6 @@ async def list_projects(user_id: int, status: str | None = None) -> list[Project async def update_project(user_id: int, project_id: int, **fields: object) -> Project | None: - import asyncio async with async_session() as session: result = await session.execute( select(Project).where(Project.id == project_id, Project.user_id == user_id) @@ -85,93 +84,9 @@ async def update_project(user_id: int, project_id: int, **fields: object) -> Pro project.updated_at = datetime.now(timezone.utc) await session.commit() await session.refresh(project) - asyncio.create_task(generate_project_summary(user_id, project_id)) return project -async def generate_project_summary(user_id: int, project_id: int) -> None: - """Generate an LLM summary for a project and persist it. Fire-and-forget safe.""" - try: - async with async_session() as session: - project = (await session.execute( - select(Project).where(Project.id == project_id, Project.user_id == user_id) - )).scalars().first() - if project is None: - return - - note_rows = (await session.execute( - select(Note.title, Note.body) - .where(Note.project_id == project_id, Note.user_id == user_id) - .order_by(Note.updated_at.desc()) - .limit(10) - )).all() - - title = project.title or "" - description = project.description or "" - goal = project.goal or "" - note_snippets = "\n".join( - f"- {r.title}: {(r.body or '')[:200]}" for r in note_rows - ) - - prompt = ( - f"Summarize this project in 3-4 sentences covering its purpose, themes, and content.\n" - f"Title: {title}\nDescription: {description}\nGoal: {goal}\n" - f"Recent notes:\n{note_snippets}" - ) - - from fabledassistant.services.llm import generate_completion - from fabledassistant.config import Config - from fabledassistant.services.settings import get_setting - messages = [{"role": "user", "content": prompt}] - bg_model = await get_setting(user_id, "background_model", Config.OLLAMA_BACKGROUND_MODEL) - summary = await generate_completion(messages, model=bg_model, max_tokens=400, num_ctx=2048) - if not summary: - return - - async with async_session() as session: - project = (await session.execute( - select(Project).where(Project.id == project_id) - )).scalars().first() - if project: - project.auto_summary = summary.strip() - project.summary_updated_at = datetime.now(timezone.utc) - await session.commit() - logger.debug("Generated summary for project %d", project_id) - except Exception: - logger.debug("Failed to generate summary for project %d", project_id, exc_info=True) - - -# Cutoff: summaries older than this were generated by qwen2.5:3b, which -# produced broken output (token repetition, hallucinated topics, misspellings). -# Anything stamped before the switch to gemma3:4b on 2026-04-12 gets -# regenerated at startup so the whole project list ends up on the better model. -_BACKGROUND_MODEL_CUTOVER = datetime(2026, 4, 12, tzinfo=timezone.utc) - - -async def backfill_project_summaries() -> None: - """Generate summaries for projects missing or predating the gemma3:4b cutover. - - Fire-and-forget: each summary runs as its own background task. - """ - import asyncio - from sqlalchemy import or_ - try: - async with async_session() as session: - rows = (await session.execute( - select(Project.id, Project.user_id).where( - or_( - Project.auto_summary.is_(None), - Project.summary_updated_at.is_(None), - Project.summary_updated_at < _BACKGROUND_MODEL_CUTOVER, - ) - ) - )).all() - for row in rows: - asyncio.create_task(generate_project_summary(row.user_id, row.id)) - except Exception: - logger.debug("backfill_project_summaries failed", exc_info=True) - - async def delete_project(user_id: int, project_id: int) -> bool: async with async_session() as session: result = await session.execute( diff --git a/src/fabledassistant/services/push.py b/src/fabledassistant/services/push.py deleted file mode 100644 index b6c7708..0000000 --- a/src/fabledassistant/services/push.py +++ /dev/null @@ -1,238 +0,0 @@ -"""Browser push notification service using VAPID/pywebpush.""" -import asyncio -import base64 -import json -import logging -from datetime import datetime, timezone -from functools import lru_cache -from pathlib import Path - -from sqlalchemy import delete, select - -from fabledassistant.config import Config -from fabledassistant.models import async_session -from fabledassistant.models.push_subscription import PushSubscription - -logger = logging.getLogger(__name__) - -# Persisted alongside other app data so keys survive container restarts. -_VAPID_KEYS_FILE = Path(Config.IMAGE_CACHE_DIR).parent / "vapid_keys.json" - - -@lru_cache(maxsize=1) -def _get_webpush(): - """Lazy import to avoid startup errors if pywebpush is not installed.""" - try: - from pywebpush import webpush - return webpush - except ImportError: - return None - - -def vapid_enabled() -> bool: - return bool(Config.VAPID_PRIVATE_KEY and Config.VAPID_PUBLIC_KEY) - - -def ensure_vapid_keys() -> None: - """Load or auto-generate VAPID keys, storing them in the data volume. - - Called once at startup. If VAPID_PRIVATE_KEY / VAPID_PUBLIC_KEY env vars - are already set they take precedence and nothing is written to disk. - """ - if vapid_enabled(): - logger.info("VAPID keys loaded from environment variables") - return - - # Try to load previously generated keys from the data volume. - if _VAPID_KEYS_FILE.exists(): - try: - data = json.loads(_VAPID_KEYS_FILE.read_text()) - Config.VAPID_PRIVATE_KEY = data["private_key"] - Config.VAPID_PUBLIC_KEY = data["public_key"] - logger.info("VAPID keys loaded from %s", _VAPID_KEYS_FILE) - return - except Exception: - logger.warning("Failed to load VAPID keys from %s — regenerating", _VAPID_KEYS_FILE, exc_info=True) - - # Generate a fresh key pair. - try: - from cryptography.hazmat.primitives import serialization - from py_vapid import Vapid01 - - v = Vapid01() - v.generate_keys() - - # pywebpush expects the private key as a base64url-encoded DER blob - # (passed to Vapid.from_string → from_der), NOT a PEM string. - private_der = v.private_key.private_bytes( - serialization.Encoding.DER, - serialization.PrivateFormat.TraditionalOpenSSL, - serialization.NoEncryption(), - ) - private_b64 = base64.urlsafe_b64encode(private_der).rstrip(b"=").decode() - - pub_bytes = v.public_key.public_bytes( - serialization.Encoding.X962, - serialization.PublicFormat.UncompressedPoint, - ) - public_b64 = base64.urlsafe_b64encode(pub_bytes).rstrip(b"=").decode() - - # Persist so they survive container restarts. - _VAPID_KEYS_FILE.parent.mkdir(parents=True, exist_ok=True) - _VAPID_KEYS_FILE.write_text(json.dumps({"private_key": private_b64, "public_key": public_b64})) - - Config.VAPID_PRIVATE_KEY = private_b64 - Config.VAPID_PUBLIC_KEY = public_b64 - logger.info("Generated new VAPID keys and saved to %s", _VAPID_KEYS_FILE) - except Exception: - logger.warning("Failed to generate VAPID keys — push notifications will be unavailable", exc_info=True) - - -async def regenerate_vapid_keys() -> bool: - """Delete existing VAPID keys, clear all push subscriptions, and generate a fresh pair. - - All existing browser subscriptions are invalidated when keys rotate, so they - must be cleared — users will need to re-enable notifications. - """ - if _VAPID_KEYS_FILE.exists(): - _VAPID_KEYS_FILE.unlink() - Config.VAPID_PRIVATE_KEY = "" - Config.VAPID_PUBLIC_KEY = "" - - # Clear all push subscriptions — they are bound to the old public key. - async with async_session() as session: - await session.execute(delete(PushSubscription)) - await session.commit() - - ensure_vapid_keys() - enabled = vapid_enabled() - if enabled: - logger.info("VAPID keys regenerated successfully") - else: - logger.error("VAPID key regeneration failed") - return enabled - - -async def save_subscription(user_id: int, subscription_json: dict, user_agent: str | None = None) -> PushSubscription: - """Upsert a push subscription by endpoint.""" - endpoint = subscription_json.get("endpoint", "") - keys = subscription_json.get("keys", {}) - p256dh = keys.get("p256dh", "") - auth = keys.get("auth", "") - - async with async_session() as session: - result = await session.execute( - select(PushSubscription).where( - PushSubscription.user_id == user_id, - PushSubscription.endpoint == endpoint, - ) - ) - existing = result.scalars().first() - if existing: - existing.p256dh = p256dh - existing.auth = auth - existing.user_agent = user_agent - existing.last_used = datetime.now(timezone.utc) - await session.commit() - await session.refresh(existing) - return existing - else: - sub = PushSubscription( - user_id=user_id, - endpoint=endpoint, - p256dh=p256dh, - auth=auth, - user_agent=user_agent, - ) - session.add(sub) - await session.commit() - await session.refresh(sub) - return sub - - -async def delete_subscription(user_id: int, endpoint: str) -> None: - async with async_session() as session: - await session.execute( - delete(PushSubscription).where( - PushSubscription.user_id == user_id, - PushSubscription.endpoint == endpoint, - ) - ) - await session.commit() - - -async def _remove_expired_subscription(user_id: int, endpoint: str) -> None: - """Remove a subscription that returned 410 Gone.""" - try: - await delete_subscription(user_id, endpoint) - logger.info("Removed expired push subscription for user %d", user_id) - except Exception: - logger.warning("Failed to remove expired subscription", exc_info=True) - - -async def send_push_notification( - user_id: int, - title: str, - body: str, - url: str = "/", -) -> None: - """Send a push notification to all subscriptions for a user. - - Fire-and-forget — wrap in asyncio.create_task() at call site. - """ - if not vapid_enabled(): - logger.debug("VAPID not configured, skipping push notification") - return - - webpush = _get_webpush() - if webpush is None: - logger.warning("pywebpush not installed, cannot send push notifications") - return - - async with async_session() as session: - result = await session.execute( - select(PushSubscription).where(PushSubscription.user_id == user_id) - ) - subscriptions = list(result.scalars().all()) - - if not subscriptions: - return - - payload = json.dumps({"title": title, "body": body, "url": url}) - vapid_claims = { - "sub": Config.VAPID_CLAIMS_SUB, - } - - def _send_sync(sub: PushSubscription) -> tuple[int, str]: - """Synchronous send — runs in executor.""" - try: - subscription_info = { - "endpoint": sub.endpoint, - "keys": {"p256dh": sub.p256dh, "auth": sub.auth}, - } - response = webpush( - subscription_info=subscription_info, - data=payload, - vapid_private_key=Config.VAPID_PRIVATE_KEY, - vapid_claims=vapid_claims, - ) - return response.status_code, sub.endpoint - except Exception as e: - logger.error("Push send failed for sub %d: %s", sub.id, e, exc_info=True) - return 0, sub.endpoint - - loop = asyncio.get_event_loop() - tasks = [loop.run_in_executor(None, _send_sync, sub) for sub in subscriptions] - results = await asyncio.gather(*tasks, return_exceptions=True) - - for sub, result in zip(subscriptions, results): - if isinstance(result, Exception): - logger.error("Push gather exception for user %d: %s", user_id, result, exc_info=result) - continue - status_code, endpoint = result - if status_code in (200, 201): - logger.info("Push notification sent to sub %d (status %d)", sub.id, status_code) - elif status_code == 410: - asyncio.create_task(_remove_expired_subscription(user_id, endpoint)) - elif status_code: - logger.error("Push returned unexpected status %d for user %d sub %d", status_code, user_id, sub.id) diff --git a/src/fabledassistant/services/research.py b/src/fabledassistant/services/research.py deleted file mode 100644 index c882e64..0000000 --- a/src/fabledassistant/services/research.py +++ /dev/null @@ -1,583 +0,0 @@ -"""Web research pipeline: sub-queries → SearXNG → fetch → synthesize → note.""" - -import asyncio -import json -import logging -import re - -import httpx - -from fabledassistant.config import Config -from fabledassistant.services.llm import fetch_url_content, generate_completion, stream_chat -from fabledassistant.services.notes import create_note, update_note -from fabledassistant.services.wikipedia import wiki_search -from fabledassistant.models.note import Note - -logger = logging.getLogger(__name__) - -SEARXNG_QUERIES = 5 # sub-queries to generate -RESULTS_PER_QUERY = 3 # results fetched from SearXNG per query -PAGES_PER_QUERY = 3 # pages actually read per sub-query (top N results) -MAX_SYNTHESIS_SOURCES = 12 # deduplicated sources passed to synthesis LLM -CHARS_PER_SOURCE = 2000 # content chars per source sent to synthesis - - -def _build_sources_block(sources: list[dict]) -> str: - """Format fetched sources into a text block for LLM prompts.""" - parts = [] - for i, s in enumerate(sources, 1): - content = (s.get("content") or s.get("snippet") or "")[:CHARS_PER_SOURCE] - parts.append( - f"[Source {i}] {s['title']}\nURL: {s['url']}\nSearch query: {s['query']}\n\n{content}" - ) - return "\n\n" + ("─" * 60) + "\n\n".join(parts) - - -async def _generate_outline(topic: str, sources: list[dict], model: str) -> list[dict]: - """Generate a topic outline from fetched research sources. - - Returns a list of {"title": str, "focus": str} dicts (2–8 entries). - Retries once on failure before returning [] (callers fall back to single-note). - """ - import json as _json - - sources_block = _build_sources_block(sources) if sources else "(no sources)" - messages = [ - { - "role": "system", - "content": ( - "You are a research organizer. Given research sources on a topic, produce a JSON array " - "of section objects that together cover the topic comprehensively from distinct angles.\n\n" - "Rules:\n" - "- Return exactly 3–7 sections\n" - "- Each section must cover a unique angle — no overlap between sections\n" - "- Titles must work as standalone note titles (specific, not generic like 'Overview')\n" - "- focus: one sentence describing exactly what this section covers\n" - "- Respond with ONLY a JSON array, no other text\n\n" - 'Example: [{"title": "CRISPR: Molecular Mechanisms", "focus": "How Cas9 identifies and cuts DNA at guide-RNA-specified sites"}]' - ), - }, - { - "role": "user", - "content": f"Topic: {topic}\n\nSources:\n{sources_block}", - }, - ] - for attempt in range(2): - try: - # Pin num_ctx explicitly. The prompt carries up to 12 sources at - # 2000 chars each (~6k tokens of source material alone) plus the - # system prompt — well over Ollama's default model window on - # qwen3. Without this, Ollama silently truncates the prompt, the - # model can't see most of the sources, JSON parsing fails twice, - # and the pipeline falls back to a single monolith note - # (`research.py:251`). Do not remove even if `generate_completion` - # appears to default this — see the comment there. - raw = await generate_completion( - messages, model, max_tokens=400, num_ctx=16384 - ) - raw = raw.strip() - raw = re.sub(r"^```(?:json)?\s*", "", raw) - raw = re.sub(r"\s*```$", "", raw) - idx = raw.find("[") - if idx >= 0: - parsed, _ = _json.JSONDecoder().raw_decode(raw[idx:]) - if isinstance(parsed, list): - sections = [ - s for s in parsed - if isinstance(s, dict) and s.get("title") and s.get("focus") - ] - if len(sections) >= 2: - return sections[:8] - except Exception: - logger.warning( - "Outline generation attempt %d failed for topic '%s'", - attempt + 1, topic, exc_info=True, - ) - return [] - - -async def _synthesize_section( - section_title: str, - section_focus: str, - sources: list[dict], - model: str, -) -> tuple[str, str]: - """Synthesize one focused note section. - - Returns (section_title, body_markdown). Does not stream. - """ - sources_block = _build_sources_block(sources) if sources else "(no sources provided)" - messages = [ - { - "role": "system", - "content": ( - "You are a focused research writer. Write a single well-structured note section " - "on the specific topic provided.\n\n" - "Requirements:\n" - f"- Focus strictly on: {section_focus}\n" - "- 300–600 words of substantive prose\n" - "- Use ### for subsections only when they genuinely aid clarity\n" - "- Do NOT include a top-level # heading — the title is set separately\n" - "- Write in detailed prose paragraphs — not bullet points\n" - "- End with a '## Sources' section listing relevant source URLs as markdown hyperlinks\n" - "- Ignore source material that falls outside your assigned focus" - ), - }, - { - "role": "user", - "content": ( - f"Section title: {section_title}\n" - f"Focus: {section_focus}\n\n" - f"Sources:\n{sources_block}" - ), - }, - ] - raw = await generate_completion(messages, model, max_tokens=2048, num_ctx=16384) - return section_title, raw.strip() - - -async def _generate_executive_summary( - topic: str, - section_bodies: list[tuple[str, str]], - model: str, -) -> str: - """Generate a 2-3 paragraph executive summary from completed section notes. - - Args: - section_bodies: list of (title, body) pairs from the section notes. - - Returns summary markdown (no heading — caller adds structure). - """ - sections_block = "\n\n".join( - f"### {title}\n{body[:1500]}" for title, body in section_bodies - ) - messages = [ - { - "role": "system", - "content": ( - "You are a research summarizer. Given several completed research sections on a topic, " - "write a concise executive summary.\n\n" - "Requirements:\n" - "- 2–3 paragraphs of substantive prose (150–300 words total)\n" - "- Cover the key findings and insights across all sections\n" - "- Highlight the most important or surprising takeaways\n" - "- Write so someone can decide which sections to read in detail\n" - "- Do NOT include headings, bullet points, or source citations\n" - "- Do NOT start with 'This research' or 'This document' — jump straight into the content" - ), - }, - { - "role": "user", - "content": f"Topic: {topic}\n\nSections:\n{sections_block}", - }, - ] - try: - # Pin num_ctx explicitly — see `_generate_outline` comment for the - # rationale. This prompt carries N sections × 1500 chars of section - # prose, which can easily exceed the default model window. Don't - # trust the `generate_completion` default to stick. - raw = await generate_completion( - messages, model, max_tokens=600, num_ctx=16384 - ) - return raw.strip() - except Exception: - logger.warning("Executive summary generation failed for '%s'", topic, exc_info=True) - return "" - - -async def run_research_pipeline( - topic: str, - user_id: int, - model: str, - buf=None, - project_id: int | None = None, -) -> Note: - """Full research pipeline: search → fetch → outline → section notes → index note. - - Emits status events via buf throughout (when buf is provided). - Returns the index note (or a single fallback note on outline failure). - """ - def _status(msg: str) -> None: - if buf is not None: - buf.append_event("status", {"status": msg}) - - # Step 1: Generate sub-queries - _status("Generating search queries...") - queries = await _generate_sub_queries(topic, model) - logger.info("Research: generated %d sub-queries for topic '%s'", len(queries), topic) - - # Step 2: Search all queries in parallel (SearXNG + Wikipedia) - async def _search_with_stagger(i: int, query: str) -> tuple[str, list[dict]]: - if i > 0: - await asyncio.sleep(0.2 * i) - _status(f"Searching: {query}...") - results = await _search_searxng(query) - logger.info("Research: query '%s' → %d results", query, len(results)) - return query, results - - async def _wiki_for_query(query: str) -> list[dict]: - return await wiki_search(query, limit=1) - - searxng_task = asyncio.gather( - *[_search_with_stagger(i, q) for i, q in enumerate(queries)] - ) - wiki_task = asyncio.gather( - *[_wiki_for_query(q) for q in queries] - ) - search_results, wiki_results = await asyncio.gather(searxng_task, wiki_task) - - # Deduplicate URLs across all queries - seen_urls: set[str] = set() - url_tasks: list[tuple[str, dict, str]] = [] # (url, result_dict, query) - wiki_sources: list[dict] = [] # Wikipedia articles (already have content) - - for query, results in search_results: - for result in results[:PAGES_PER_QUERY]: - url = result.get("url", "") - if url and url not in seen_urls: - seen_urls.add(url) - url_tasks.append((url, result, query)) - - # Add Wikipedia results (they already have content via extract) - for query, wiki_hits in zip(queries, wiki_results): - for hit in wiki_hits: - url = hit.get("url", "") - if url and url not in seen_urls: - seen_urls.add(url) - wiki_sources.append({ - "url": url, - "title": hit["title"], - "query": query, - "snippet": hit["extract"][:200], - "content": hit["extract"], - }) - - # Fetch all unique SearXNG URLs in parallel - async def _fetch_source(url: str, result: dict, query: str) -> dict: - title = result.get("title", url) - _status(f"Reading: {title[:60]}...") - content = await fetch_url_content(url) - return { - "url": url, - "title": title, - "query": query, - "snippet": result.get("snippet", ""), - "content": content, - } - - fetched_sources: list[dict] = list(await asyncio.gather( - *[_fetch_source(url, result, query) for url, result, query in url_tasks] - )) - - all_sources = wiki_sources + fetched_sources - - if not all_sources: - raise ValueError(f"No results found for '{topic}'") - - good_sources = [s for s in all_sources if not s["content"].startswith("[Failed to fetch")] - - if not good_sources: - raise ValueError(f"Could not read any sources for '{topic}'") - - synthesis_sources = good_sources[:MAX_SYNTHESIS_SOURCES] - logger.info( - "Research: %d/%d sources successfully fetched, using %d for synthesis", - len(good_sources), len(all_sources), len(synthesis_sources), - ) - - # Step 3: Generate topic outline - _status("Generating outline...") - outline = await _generate_outline(topic, synthesis_sources, model) - - # Fallback: outline failed or too short → single monolithic note - if not outline: - logger.warning("Research outline empty, falling back to single note for '%s'", topic) - _status("Synthesizing report...") - title, body = await _synthesize_note(topic, synthesis_sources, model, buf=None) - note = await create_note( - user_id=user_id, title=title, body=body, tags=["research"], project_id=project_id, - ) - logger.info("Research (fallback): created note id=%d title='%s'", note.id, note.title) - return note - - # Step 4: Synthesize each section in parallel - for section in outline: - _status(f"Writing: {section['title']}...") - - raw_results = await asyncio.gather( - *[_synthesize_section(s["title"], s["focus"], synthesis_sources, model) for s in outline], - return_exceptions=True, - ) - - # Collect successful results for index + summary generation - section_results: list[tuple[dict, str, str]] = [] # (outline_entry, title, body) - for section, result in zip(outline, raw_results): - if isinstance(result, Exception): - logger.warning("Section synthesis failed for '%s': %s", section["title"], result) - continue - sec_title, sec_body = result - section_results.append((section, sec_title, sec_body)) - - # All sections failed — fall back to single note - if not section_results: - logger.warning("All section syntheses failed, falling back to single note for '%s'", topic) - _status("Synthesizing report (fallback)...") - title, body = await _synthesize_note(topic, synthesis_sources, model, buf=None) - note = await create_note( - user_id=user_id, title=title, body=body, tags=["research"], project_id=project_id, - ) - return note - - # Step 5: Generate executive summary from section content - _status("Writing summary...") - executive_summary = await _generate_executive_summary( - topic, [(t, b) for _, t, b in section_results], model, - ) - - # Step 6: Create index note first (so section notes can reference it via parent_id) - from datetime import date as _date - index_lines = [ - f"Research overview for **{topic}** — {_date.today().isoformat()}", - "", - f"Generated from {len(synthesis_sources)} web sources across {len(section_results)} sections.", - "", - ] - if executive_summary: - index_lines += ["## Summary", "", executive_summary, ""] - index_lines += ["## Sections", ""] - # Placeholder — will be updated with real links after section notes are created - index_note = await create_note( - user_id=user_id, - title=f"Research: {topic}", - body="\n".join(index_lines), - tags=["research", "research-index"], - project_id=project_id, - ) - - # Step 7: Create section notes with parent_id pointing to index - _status(f"Saving {len(section_results)} notes...") - section_note_pairs: list[tuple[dict, Note]] = [] - for section, sec_title, sec_body in section_results: - try: - note = await create_note( - user_id=user_id, - title=sec_title, - body=sec_body, - tags=["research"], - project_id=project_id, - parent_id=index_note.id, - ) - section_note_pairs.append((section, note)) - except Exception: - logger.warning("Failed to save section note '%s'", sec_title, exc_info=True) - - # Step 8: Update index note body with real links to section notes - for section, note in section_note_pairs: - index_lines.append(f"- [{note.title}](/notes/{note.id}) — {section['focus']}") - - await update_note( - user_id=user_id, - note_id=index_note.id, - body="\n".join(index_lines), - ) - - logger.info( - "Research: created %d section notes + index id=%d for topic '%s'", - len(section_note_pairs), index_note.id, topic, - ) - return index_note - - -async def _generate_sub_queries(topic: str, model: str) -> list[str]: - """Ask the model for focused search queries for the topic.""" - messages = [ - { - "role": "system", - "content": ( - f"You are a research assistant. Given a research topic, generate exactly {SEARXNG_QUERIES} " - "focused web search queries that together would provide comprehensive coverage of the topic. " - "Vary the angle of each query: include overview, implementation details, best practices, " - "common problems, and real-world examples. " - "Respond with ONLY a JSON array of strings, no other text. " - 'Example: ["query one", "query two", "query three"]' - ), - }, - {"role": "user", "content": f"Topic: {topic}"}, - ] - try: - raw = await generate_completion(messages, model, max_tokens=200) - raw = raw.strip() - raw = re.sub(r"^```(?:json)?\s*", "", raw) - raw = re.sub(r"\s*```$", "", raw) - idx = raw.find("[") - if idx >= 0: - parsed, _ = json.JSONDecoder().raw_decode(raw[idx:]) - if isinstance(parsed, list) and parsed: - queries = [str(q).strip() for q in parsed if str(q).strip()] - if queries: - return queries[:SEARXNG_QUERIES] - except Exception: - logger.warning("Sub-query generation failed, falling back to topic", exc_info=True) - return [topic] - - -async def _search_searxng(query: str) -> list[dict]: - """Search SearXNG and return top results as [{url, title, snippet}].""" - url = Config.SEARXNG_URL.rstrip("/") + "/search" - params = {"q": query, "format": "json", "categories": "general"} - for attempt in range(3): - try: - async with httpx.AsyncClient(timeout=10.0) as client: - resp = await client.get(url, params=params) - if resp.status_code == 429: - retry_after = int(resp.headers.get("Retry-After", "5")) - wait = min(retry_after, 10) * (attempt + 1) - logger.warning( - "SearXNG 429 for query '%s' (attempt %d/3), waiting %ds", - query, attempt + 1, wait, - ) - await asyncio.sleep(wait) - continue - resp.raise_for_status() - data = resp.json() - results = data.get("results", []) - out = [] - for r in results[:RESULTS_PER_QUERY]: - out.append({ - "url": r.get("url", ""), - "title": r.get("title", ""), - "snippet": r.get("content", ""), - }) - return out - except httpx.HTTPStatusError: - logger.warning("SearXNG search failed for query '%s'", query, exc_info=True) - return [] - except Exception: - logger.warning("SearXNG search failed for query '%s'", query, exc_info=True) - return [] - logger.warning("SearXNG search gave up after 3 attempts for query '%s'", query) - return [] - - -async def _search_searxng_images(query: str) -> list[dict]: - """Search SearXNG image category and return [{img_src, page_url, title, source_domain}].""" - url = Config.SEARXNG_URL.rstrip("/") + "/search" - params = {"q": query, "format": "json", "categories": "images"} - for attempt in range(3): - try: - async with httpx.AsyncClient(timeout=10.0) as client: - resp = await client.get(url, params=params) - if resp.status_code == 429: - retry_after = int(resp.headers.get("Retry-After", "5")) - wait = min(retry_after, 10) * (attempt + 1) - logger.warning( - "SearXNG image 429 for '%s' (attempt %d/3), waiting %ds", - query, attempt + 1, wait, - ) - await asyncio.sleep(wait) - continue - resp.raise_for_status() - data = resp.json() - out = [] - for r in data.get("results", []): - img_src = r.get("img_src") or r.get("thumbnail_src", "") - if not img_src: - continue - try: - from urllib.parse import urlparse - source_domain = urlparse(r.get("url", "")).netloc or "" - except Exception: - source_domain = "" - out.append({ - "img_src": img_src, - "page_url": r.get("url", ""), - "title": r.get("title", ""), - "source_domain": source_domain, - }) - return out - except httpx.HTTPStatusError: - logger.warning("SearXNG image search failed for '%s'", query, exc_info=True) - return [] - except Exception: - logger.warning("SearXNG image search failed for '%s'", query, exc_info=True) - return [] - logger.warning("SearXNG image search gave up after 3 attempts for '%s'", query) - return [] - - -async def _synthesize_note( - topic: str, - sources: list[dict], - model: str, - buf=None, -) -> tuple[str, str]: - """Synthesize a comprehensive markdown research document from fetched sources. - - Returns (title, body_markdown). - When buf is provided, tokens are streamed into the chat buffer in real time - so the user can see the note being written. Uses an extended context window. - """ - sources_block = _build_sources_block(sources) - - messages = [ - { - "role": "system", - "content": ( - "You are a thorough researcher and writer. " - "Your task is to write an exhaustive, well-structured document on the given topic — " - "not a brief summary or intro paragraph.\n\n" - "Requirements:\n" - "- Write at least 2500 words of substantive content (excluding the Sources section)\n" - "- Choose sections (##) that make sense for the topic — let the subject matter determine the structure. " - "A technical topic might need implementation, configuration, and troubleshooting sections. " - "A comparison topic might need dedicated sections per subject being compared plus a summary. " - "A scientific topic might need background, mechanisms, research findings, and implications. " - "Use your judgment — minimum 6 major sections.\n" - "- Use ### for subsections where they add clarity\n" - "- Write in detailed prose paragraphs — do not reduce sections to bullet-point lists\n" - "- Include specific details, examples, data points, comparisons, and nuance from the sources\n" - "- Do not pad with vague generalities — every paragraph should say something concrete\n" - "- The first line must be the document title starting with '# '\n" - "- End with a '## Sources' section listing every source as a markdown hyperlink\n\n" - "The reader wants to finish this document with a thorough understanding of the topic, " - "not just an overview." - ), - }, - { - "role": "user", - "content": ( - f"Write a comprehensive reference document on: {topic}\n\n" - f"Sources ({len(sources)} pages fetched):\n{sources_block}" - ), - }, - ] - - if buf is not None: - # Stream tokens into the chat buffer so the user sees the note being written - raw_parts: list[str] = [] - async for token in stream_chat( - messages, model, options={"num_ctx": 16384, "num_predict": 8192} - ): - raw_parts.append(token) - buf.append_event("chunk", {"chunk": token}) - buf.content_so_far += token - raw = "".join(raw_parts).strip() - else: - raw = await generate_completion( - messages, - model, - max_tokens=8192, - num_ctx=16384, - ) - raw = raw.strip() - - # Extract title from first # heading - lines = raw.splitlines() - title = f"Research: {topic}" - body_lines = lines - if lines and lines[0].startswith("# "): - title = lines[0][2:].strip() - body_lines = lines[1:] - - body = "\n".join(body_lines).strip() - return title, body diff --git a/src/fabledassistant/services/stt.py b/src/fabledassistant/services/stt.py deleted file mode 100644 index adc8c5a..0000000 --- a/src/fabledassistant/services/stt.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Speech-to-text service using faster-whisper (in-process, CPU/GPU).""" -import asyncio -import logging -import tempfile -import time -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from faster_whisper import WhisperModel - -logger = logging.getLogger(__name__) - -_model: "WhisperModel | None" = None -_model_lock = asyncio.Lock() -_load_error: str | None = None - - -async def load_stt_model() -> None: - """Load the Whisper model. Called once at startup when voice is enabled.""" - global _model, _load_error - from fabledassistant.services.voice_config import get_stt_model, is_voice_enabled - - if not await is_voice_enabled(): - return - - async with _model_lock: - if _model is not None: - return - try: - from faster_whisper import WhisperModel - - model_name = await get_stt_model() - logger.info("Loading Whisper STT model '%s'...", model_name) - loop = asyncio.get_running_loop() - _model = await loop.run_in_executor( - None, - lambda: WhisperModel(model_name, device="cpu", compute_type="int8"), - ) - logger.info("Whisper STT model '%s' loaded", model_name) - except Exception: - _load_error = "Failed to load Whisper STT model" - logger.exception(_load_error) - - -async def reload_stt_model() -> None: - """Unload the current model and reload with the current config. Safe to call at runtime.""" - global _model, _load_error - async with _model_lock: - _model = None - _load_error = None - await load_stt_model() - - -def stt_available() -> bool: - return _model is not None - - -async def transcribe(audio_bytes: bytes, mime_type: str = "audio/webm", initial_prompt: str | None = None) -> str: - """Transcribe audio bytes to text. Runs the model in a thread executor. - - initial_prompt: optional text to bias the model toward domain-specific vocabulary - (e.g. recent conversation context). Reduces mishearings like "gold" for "cold". - """ - if _model is None: - raise RuntimeError("STT model not loaded") - - suffix = ".webm" - if "ogg" in mime_type: - suffix = ".ogg" - elif "wav" in mime_type: - suffix = ".wav" - elif "mp4" in mime_type or "m4a" in mime_type: - suffix = ".mp4" - - def _run() -> str: - with tempfile.NamedTemporaryFile(suffix=suffix, delete=True) as f: - f.write(audio_bytes) - f.flush() - t0 = time.monotonic() - segments, _ = _model.transcribe( # type: ignore[union-attr] - f.name, - beam_size=5, - initial_prompt=initial_prompt or None, - ) - text = " ".join(seg.text.strip() for seg in segments).strip() - logger.debug("STT transcription took %.2fs", time.monotonic() - t0) - return text - - loop = asyncio.get_running_loop() - return await loop.run_in_executor(None, _run) diff --git a/src/fabledassistant/services/tag_suggestions.py b/src/fabledassistant/services/tag_suggestions.py deleted file mode 100644 index 90fce85..0000000 --- a/src/fabledassistant/services/tag_suggestions.py +++ /dev/null @@ -1,96 +0,0 @@ -"""LLM-powered tag suggestions for notes and tasks.""" - -import json -import logging -import re - -from fabledassistant.config import Config -from fabledassistant.services.llm import generate_completion -from fabledassistant.services.notes import get_all_tags -from fabledassistant.services.settings import get_setting - -logger = logging.getLogger(__name__) - - -async def suggest_tags(user_id: int, title: str, body: str, current_tags: list[str] | None = None) -> list[str]: - """Suggest relevant tags for a note/task based on its content. - - Returns a list of tag strings (without # prefix), excluding tags - already present in current_tags. - """ - if not title.strip() and not body.strip(): - return [] - - existing_tags = await get_all_tags(user_id) - # Tag suggestion is a tiny single-shot task (3-5 tags from a note's - # title + body) — route to the chat model (small/fast) rather than - # tying up the worker on a trivial call. The chat model handles this - # in well under a second. - model = ( - await get_setting(user_id, "default_model", "") - or Config.OLLAMA_MODEL - ) - - existing_list = ", ".join(f"#{t}" for t in existing_tags) if existing_tags else "(none yet)" - - messages = [ - { - "role": "system", - "content": ( - "You are a tag suggestion assistant. Given a note's title and body, " - "suggest 3-5 relevant tags. Prefer reusing existing tags when they fit, " - "but you may suggest new ones too. " - "Tags must use hyphens for multi-word names (e.g. science-fiction, space-travel) — never spaces. " - "Reply with ONLY a JSON array of tag strings (without # prefix). " - 'Example: ["meeting", "project/alpha", "science-fiction"]\n\n' - f"Existing tags: {existing_list}" - ), - }, - { - "role": "user", - "content": f"Title: {title}\n\nBody:\n{body[:2000]}", - }, - ] - - try: - response = await generate_completion(messages, model) - except Exception: - logger.warning("Tag suggestion LLM call failed", exc_info=True) - return [] - - tags = _parse_tag_list(response) - - # Filter out tags already applied - existing = set(current_tags or []) - tags = [t for t in tags if t not in existing] - - return tags[:5] - - -def _parse_tag_list(response: str) -> list[str]: - """Parse a JSON array of tags from LLM response, with fallback for markdown wrapping.""" - text = response.strip() - - # Try direct JSON parse - def _clean(tag: str) -> str: - return str(tag).strip().lstrip("#").replace(" ", "-") - - try: - parsed = json.loads(text) - if isinstance(parsed, list): - return [_clean(t) for t in parsed if t] - except json.JSONDecodeError: - pass - - # Fallback: extract JSON array from markdown code block - match = re.search(r"\[.*?\]", text, re.DOTALL) - if match: - try: - parsed = json.loads(match.group()) - if isinstance(parsed, list): - return [_clean(t) for t in parsed if t] - except json.JSONDecodeError: - pass - - logger.warning("Could not parse tag suggestions from LLM response: %s", text[:200]) - return [] diff --git a/src/fabledassistant/services/tools/__init__.py b/src/fabledassistant/services/tools/__init__.py deleted file mode 100644 index 6d6b4e4..0000000 --- a/src/fabledassistant/services/tools/__init__.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Tool registry package. - -Importing this package loads all tool modules (triggering ``@tool`` -decorator registration) and re-exports the public API that the rest -of the app depends on. -""" - -# Import every tool module so their @tool decorators run at import time. -# Order does not matter — registration is additive. -from fabledassistant.services.tools import ( # noqa: F401 - article, - calendar, - entities, - journal, - notes, - profile, - projects, - rag, - tasks, - utility, - weather, - web, -) -from fabledassistant.services.tools._registry import ( - execute_tool, - get_tools_for_user, -) - -__all__ = [ - "execute_tool", - "get_tools_for_user", -] diff --git a/src/fabledassistant/services/tools/_helpers.py b/src/fabledassistant/services/tools/_helpers.py deleted file mode 100644 index 218237b..0000000 --- a/src/fabledassistant/services/tools/_helpers.py +++ /dev/null @@ -1,164 +0,0 @@ -"""Shared utilities used across tool modules.""" - -from __future__ import annotations - -import asyncio -import logging -import re -from datetime import date, datetime -from difflib import SequenceMatcher - -logger = logging.getLogger(__name__) - -_PUNCT_RE = re.compile(r"[^\w\s]") - - -def schedule_embedding(note_id: int, user_id: int, title: str, body: str) -> None: - """Fire-and-forget: update the embedding for a note after it's created/modified.""" - from fabledassistant.services.embeddings import upsert_note_embedding - - text = f"{title}\n{body}".strip() if body else (title or "") - if text: - asyncio.create_task(upsert_note_embedding(note_id, user_id, text)) - - -_PROJECT_QUERY_NOISE = {"project", "projects"} - - -def _normalize(s: str) -> str: - """Lowercase and collapse non-alphanumerics to single spaces.""" - return re.sub(r"[^a-z0-9]+", " ", s.lower()).strip() - - -def _normalize_query(query: str) -> str: - """Normalize plus drop trailing type-nouns ('project' / 'projects') - that users add as filler when referring to a project by name.""" - tokens = [t for t in _normalize(query).split() if t not in _PROJECT_QUERY_NOISE] - return " ".join(tokens) - - -def score_project_match(query: str, project) -> float: - """Score how well `query` matches `project`. Range [0.0, 1.0]. - - Tiered: exact title → 1.0, substring either-way → 0.85, query found in - description/summary → 0.70, otherwise SequenceMatcher ratio against the - title. Substring tiers exist because LLM-generated colloquial queries - (e.g. "famous supply project" for "Famous-Supply Work topics") would - otherwise score too low under pure SequenceMatcher and be treated as - no match. Filler words like "project" are stripped from the query so - the substring check still fires. - """ - q = _normalize_query(query) - if not q: - return 0.0 - title = _normalize(project.title) - description = _normalize(project.description or "") - summary = _normalize(project.auto_summary or "") - combined = f"{title} {description} {summary}".strip() - - if q == title: - return 1.0 - if q in title or title in q: - return 0.85 - if q in combined: - return 0.70 - # SequenceMatcher against the title — comparing against `combined` - # dilutes the ratio with long description/summary text and produces - # uniformly low scores even for plausible matches. - return SequenceMatcher(None, q, title).ratio() - - -async def resolve_project(user_id: int, project_name: str): - """Exact-then-scored project lookup. Returns the Project or None. - - Resolution order: - 1. Exact title match (case-insensitive via DB query). - 2. Highest `score_project_match` across all projects, threshold 0.55. - """ - from fabledassistant.services.projects import get_project_by_title, list_projects - - proj = await get_project_by_title(user_id, project_name) - if proj is not None: - return proj - - all_p = await list_projects(user_id) - best, best_score = None, 0.0 - for p in all_p: - score = score_project_match(project_name, p) - if score >= 0.55 and score > best_score: - best, best_score = p, score - return best - - -def parse_due_date(value: str | None) -> date | None: - """Parse a due date string, returning None on failure.""" - if not value: - return None - try: - return datetime.strptime(value, "%Y-%m-%d").date() - except (ValueError, TypeError): - logger.warning("Invalid due_date format: %s", value) - return None - - -def fuzzy_title_match(title: str, candidates, threshold: float = 0.82): - """Return (best_match, ratio) if any candidate's title is similar enough. - - Uses SequenceMatcher ratio. Threshold 0.82 catches near-duplicates like - "Game Premise" / "Game Premise Notes" while leaving clearly different - titles alone. Returns (None, 0.0) when no candidate meets the threshold. - """ - needle = title.lower().strip() - best, best_r = None, 0.0 - for c in candidates: - r = SequenceMatcher(None, needle, c.title.lower().strip()).ratio() - if r >= threshold and r > best_r: - best, best_r = c, r - return best, best_r - - -async def check_duplicate( - user_id: int, - title: str, - body: str, - is_task: bool, - confirmed: bool, -) -> dict | None: - """Check for exact, fuzzy, and semantic duplicates. Returns error dict or None.""" - from fabledassistant.services.notes import list_notes - - item_label = "task" if is_task else "note" - - existing, _ = await list_notes(user_id=user_id, q=title, is_task=is_task, limit=1) - exact = next((n for n in existing if n.title.lower() == title.lower()), None) - if exact is not None: - return { - "success": False, - "error": f"A {item_label} titled '{title}' already exists (id: {exact.id}). Use update_note to modify it instead of creating a duplicate.", - } - - clean_q = _PUNCT_RE.sub(" ", title).strip() - candidates, _ = await list_notes(user_id=user_id, q=clean_q, is_task=is_task, limit=20) - near, ratio = fuzzy_title_match(title, candidates) - if near is not None: - return { - "success": False, - "requires_confirmation": True, - "similar_note": {"id": near.id, "title": near.title}, - "error": f"A {item_label} with a very similar title '{near.title}' already exists (similarity: {ratio:.0%}). Ask the user to confirm before creating a separate entry.", - } - - if not confirmed and len(body.strip()) >= 80: - from fabledassistant.services.embeddings import semantic_search_notes as _ssn - sem_query = f"{title}\n{body}".strip() - sem_hits = await _ssn(user_id, sem_query, limit=3, threshold=0.90, is_task=is_task) - if sem_hits: - best_score, best_note = sem_hits[0] - return { - "success": False, - "requires_confirmation": True, - "similar_note": {"id": best_note.id, "title": best_note.title}, - "error": f"A {item_label} with very similar content exists: '{best_note.title}' (semantic similarity: {best_score:.0%}). Ask the user to confirm before creating a separate entry.", - } - - return None diff --git a/src/fabledassistant/services/tools/_registry.py b/src/fabledassistant/services/tools/_registry.py deleted file mode 100644 index c17e63d..0000000 --- a/src/fabledassistant/services/tools/_registry.py +++ /dev/null @@ -1,339 +0,0 @@ -"""Decorator-based tool registry. - -Each tool is registered via ``@tool(...)`` which captures the OpenAI-style -function schema and handler in one place. ``get_tools_for_user`` and -``execute_tool`` are the public API consumed by the rest of the app. -""" - -from __future__ import annotations - -import logging -from dataclasses import dataclass, field -from typing import Any, Callable, Coroutine - -from fabledassistant.config import Config -from fabledassistant.services.caldav import is_caldav_configured - -logger = logging.getLogger(__name__) - -type ToolHandler = Callable[..., Coroutine[Any, Any, dict]] - - -@dataclass(frozen=True, slots=True) -class ToolDef: - name: str - description: str - parameters: dict - handler: ToolHandler - read_only: bool = False - briefing: bool = False - journal: bool = False - requires: str | None = None - required_params: list[str] = field(default_factory=list) - - def schema(self) -> dict: - """Return the OpenAI function-calling schema dict.""" - return { - "type": "function", - "function": { - "name": self.name, - "description": self.description, - "parameters": { - "type": "object", - "properties": self.parameters, - **({"required": self.required_params} if self.required_params else {}), - }, - }, - } - - -_REGISTRY: dict[str, ToolDef] = {} - - -def tool( - *, - name: str, - description: str, - parameters: dict | None = None, - required: list[str] | None = None, - read_only: bool = False, - briefing: bool = False, - journal: bool = False, - requires: str | None = None, -) -> Callable[[ToolHandler], ToolHandler]: - """Register an async tool handler with its schema metadata.""" - - def decorator(fn: ToolHandler) -> ToolHandler: - if name in _REGISTRY: - raise ValueError(f"Duplicate tool registration: {name}") - _REGISTRY[name] = ToolDef( - name=name, - description=description, - parameters=parameters or {}, - handler=fn, - read_only=read_only, - briefing=briefing, - journal=journal, - requires=requires, - required_params=required or [], - ) - return fn - - return decorator - - -async def _check_requires(user_id: int, requires: str) -> bool: - if requires == "caldav": - return await is_caldav_configured(user_id) - if requires == "searxng": - return Config.searxng_enabled() - return True - - -async def get_tools_for_user( - user_id: int, *, conversation_type: str = "chat" -) -> list[dict]: - """Build the tool schema list for a user, scoped by conversation type. - - - 'chat': all tools except those marked journal-only. - - 'journal': all tools except set_rag_scope (scope is implicit in journal). - """ - tools: list[dict] = [] - for td in _REGISTRY.values(): - if td.requires and not await _check_requires(user_id, td.requires): - continue - if conversation_type == "journal": - if td.name == "set_rag_scope": - continue - else: - if td.journal: - continue - tools.append(td.schema()) - logger.debug( - "User %d / %s: %d tools available", user_id, conversation_type, len(tools) - ) - return tools - - - -# Mutating tools that the curator must NOT execute directly. When -# `execute_tool` is called with `authority="curator"`, calls to these -# tools are intercepted and routed to the pending-actions queue for -# user review (services/pending_actions.create_pending). The user -# approves or rejects each from the journal's Needs Review panel. -# -# Why this set in particular: confidently-wrong updates / deletes -# corrupt user data in ways that creates don't. Bad creates are -# deletable; bad updates aren't. The curator stays additive by -# default; mutating ops only land via explicit user approval. -_CURATOR_MUTATING_TOOLS: frozenset[str] = frozenset({ - "update_note", # covers tasks AND notes (same Note model) - "update_milestone", - "update_project", - "update_profile", - "delete_note", - # update_event / delete_event intentionally excluded — calendar - # events should always be explicit user intent, never curator - # inference. The curator simply doesn't get to touch the calendar. -}) - - -async def execute_tool( - user_id: int, - tool_name: str, - arguments: dict, - conv_id: int | None = None, - workspace_project_id: int | None = None, - *, - authority: str = "user", -) -> dict: - """Execute a tool call and return the result. - - `authority` controls the curator-interceptor behavior: - - "user" (default): normal execution. Used by the chat route, journal - curator-trigger route, MCP, and the pending-actions replay path. - - "curator": mutating tools listed in `_CURATOR_MUTATING_TOOLS` are - intercepted and queued via services.pending_actions.create_pending. - Non-mutating tools (create_*, search_*, list_*, record_moment, - log_work, etc.) execute as normal. - - The default "user" preserves all existing callers without changes. - """ - td = _REGISTRY.get(tool_name) - if td is None: - return {"success": False, "error": f"Unknown tool: {tool_name}"} - - if authority == "curator" and tool_name in _CURATOR_MUTATING_TOOLS: - return await _queue_for_review(user_id, conv_id, tool_name, arguments) - - try: - return await td.handler( - user_id=user_id, - arguments=arguments, - conv_id=conv_id, - workspace_project_id=workspace_project_id, - ) - except Exception as e: - logger.exception("Tool execution failed: %s", tool_name) - return {"success": False, "error": str(e)} - - -async def _queue_for_review( - user_id: int, conv_id: int | None, tool_name: str, arguments: dict, -) -> dict: - """Capture a snapshot of the target entity and write a pending action. - - The interceptor never raises — if snapshot capture fails for any - reason, we still queue the action with an empty snapshot rather than - silently dropping the curator's proposal. The user can still see and - approve/reject it; the diff display just won't show a real "before." - """ - from fabledassistant.services.pending_actions import create_pending - - target_type: str | None = None - target_id: int | None = None - target_label: str | None = None - snapshot: dict = {} - try: - helper = _SNAPSHOT_HELPERS.get(tool_name) - if helper is not None: - target_type, target_id, target_label, snapshot = await helper( - user_id, arguments, - ) - except Exception: - logger.exception( - "Snapshot capture failed for curator-proposed %s (user=%d); " - "queuing with empty snapshot", - tool_name, user_id, - ) - - row = await create_pending( - user_id=user_id, - conv_id=conv_id, - action_type=tool_name, - target_type=target_type, - target_id=target_id, - target_label=target_label, - payload=dict(arguments or {}), - current_snapshot=snapshot or {}, - ) - return { - "success": True, - "pending": True, - "action_id": row.id, - "message": ( - f"Proposed {tool_name} queued for review (action #{row.id}). " - "User will approve or reject from the journal." - ), - } - - -# Snapshot helpers — each fetches the current state of the target entity -# for the diff display in the Needs Review panel. Lazy-imported because -# the registry module is otherwise dep-free of model/service code. -async def _snapshot_note( - user_id: int, arguments: dict, -) -> tuple[str | None, int | None, str | None, dict]: - """Resolve update_note / delete_note's target — same fuzzy match - update_note_tool uses, so the snapshot reflects what'd actually be - mutated on approval. - """ - from fabledassistant.services.notes import get_note_by_title, list_notes - - query = str(arguments.get("query") or "").strip() - if not query: - return None, None, None, {} - - note = await get_note_by_title(user_id, query) - if note is None: - notes, _ = await list_notes(user_id=user_id, q=query, limit=5) - note = notes[0] if notes else None - if note is None: - return "note", None, query, {} - - snapshot = { - "id": note.id, - "title": note.title, - "is_task": bool(note.is_task), - "status": note.status, - "priority": note.priority, - "due_date": str(note.due_date) if note.due_date else None, - "tags": list(note.tags or []), - "body": (note.body or "")[:500], # cap — diffs only show short context - "description": (note.description or "")[:500] if hasattr(note, "description") else None, - "project_id": note.project_id, - "milestone_id": note.milestone_id, - } - target_type = "task" if note.is_task else "note" - return target_type, note.id, note.title, snapshot - - -async def _snapshot_milestone( - user_id: int, arguments: dict, -) -> tuple[str | None, int | None, str | None, dict]: - from fabledassistant.services.milestones import find_milestone_by_title - - name = str(arguments.get("milestone") or arguments.get("title") or "").strip() - if not name: - return None, None, None, {} - ms = await find_milestone_by_title(user_id, name) - if ms is None: - return "milestone", None, name, {} - snapshot = { - "id": ms.id, - "title": ms.title, - "description": (ms.description or "")[:500], - "status": ms.status, - "project_id": ms.project_id, - } - return "milestone", ms.id, ms.title, snapshot - - -async def _snapshot_project( - user_id: int, arguments: dict, -) -> tuple[str | None, int | None, str | None, dict]: - from fabledassistant.services.tools._helpers import resolve_project - - name = str(arguments.get("project") or arguments.get("title") or "").strip() - if not name: - return None, None, None, {} - proj = await resolve_project(user_id, name) - if proj is None: - return "project", None, name, {} - snapshot = { - "id": proj.id, - "title": proj.title, - "description": (proj.description or "")[:500], - "goal": (proj.goal or "")[:500], - "status": proj.status, - } - return "project", proj.id, proj.title, snapshot - - -async def _snapshot_profile( - user_id: int, _arguments: dict, -) -> tuple[str | None, int | None, str | None, dict]: - from fabledassistant.services.user_profile import get_profile - - profile = await get_profile(user_id) - if profile is None: - return "profile", user_id, None, {} - snapshot = { - "display_name": profile.display_name, - "job_title": profile.job_title, - "industry": profile.industry, - "expertise_level": profile.expertise_level, - "response_style": profile.response_style, - "tone": profile.tone, - "interests": list(profile.interests or []), - } - return "profile", user_id, profile.display_name or "your profile", snapshot - - -_SNAPSHOT_HELPERS = { - "update_note": _snapshot_note, - "delete_note": _snapshot_note, - "update_milestone": _snapshot_milestone, - "update_project": _snapshot_project, - "update_profile": _snapshot_profile, -} diff --git a/src/fabledassistant/services/tools/article.py b/src/fabledassistant/services/tools/article.py deleted file mode 100644 index 9a97f2d..0000000 --- a/src/fabledassistant/services/tools/article.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Generic article-reading LLM tool. - -The ``read_article`` tool fetches any URL and returns its main body text via -trafilatura. URL-generic — not coupled to any feed system. -""" -from __future__ import annotations - -from fabledassistant.services.article_fetcher import fetch_article_text -from fabledassistant.services.tools._registry import tool - - -@tool( - name="read_article", - description=( - "Fetch the main body text of an article at a URL. Use when the user asks " - "to read, summarize, or discuss a specific article they've linked. " - "Returns the cleaned article text or an empty result if extraction fails." - ), - parameters={ - "url": { - "type": "string", - "description": "The article URL to fetch.", - }, - }, - required=["url"], - read_only=True, -) -async def read_article_tool(*, user_id, arguments, **_ctx): - url = (arguments.get("url") or "").strip() - if not url: - return {"success": False, "error": "url is required"} - content = await fetch_article_text(url) - if not content: - return {"success": True, "type": "article", "data": {"url": url, "content": None, "note": "no content extracted"}} - return {"success": True, "type": "article", "data": {"url": url, "content": content[:6000]}} diff --git a/src/fabledassistant/services/tools/calendar.py b/src/fabledassistant/services/tools/calendar.py deleted file mode 100644 index 6149854..0000000 --- a/src/fabledassistant/services/tools/calendar.py +++ /dev/null @@ -1,503 +0,0 @@ -"""Calendar and event tools.""" - -from __future__ import annotations - -import re -from datetime import date as _date, datetime, time as _time, timezone - -from fabledassistant.services.events import ( - create_event as events_create_event, - delete_event as events_delete_event, - find_events_by_query, - list_events as events_list_events, - search_events as events_search_events, - update_event as events_update_event, -) -from fabledassistant.services.tools._helpers import resolve_project -from fabledassistant.services.tools._registry import tool -from fabledassistant.services.tz import get_user_tz - - -_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") -_TIME_RE = re.compile(r"^\d{2}:\d{2}(:\d{2})?$") - - -async def _combine_local_in_user_tz( - user_id: int, date_str: str, time_str: str | None -) -> datetime: - """Build a UTC datetime from separate date and time strings. - - The whole point of this helper: a `YYYY-MM-DD` string carries no TZ - metadata that a model could mis-tag, so the calendar day cannot drift - across the local→UTC boundary. The wall-clock `HH:MM` likewise has no - TZ; we attach the user's local zone explicitly via ``datetime.combine`` - and then convert to UTC for storage. - - Strict shape validation rejects anything that isn't a bare date or a - bare time — no `2026-05-01Z`, no `08:00 UTC` slipping through. - """ - if not _DATE_RE.match(date_str): - raise ValueError( - f"start_date / end_date must be YYYY-MM-DD with no timezone; got {date_str!r}" - ) - d = _date.fromisoformat(date_str) - if time_str is None or time_str == "": - t = _time(0, 0) - else: - if not _TIME_RE.match(time_str): - raise ValueError( - f"start_time / end_time must be HH:MM (or HH:MM:SS), no timezone; got {time_str!r}" - ) - t = _time.fromisoformat(time_str) - user_tz = await get_user_tz(user_id) - local = datetime.combine(d, t, tzinfo=user_tz) - return local.astimezone(timezone.utc) - - -async def _parse_datetime_in_user_tz( - user_id: int, value: str -) -> tuple[datetime, bool]: - """Legacy single-string parser. Kept as a fallback when the model - emits the older `start` / `end` shape; new calls should use - `start_date`+`start_time` (and `end_date`+`end_time`) which sidestep - the TZ-tagging foot-gun this parser is vulnerable to. - - Naive inputs are interpreted in the **user's local timezone** and then - converted to UTC for storage. Never default to UTC for naive inputs — - that's how all-day events landed on the wrong day for non-UTC users. - - Returns ``(utc_datetime, was_date_only)``. - """ - was_date_only = "T" not in value and " " not in value - if was_date_only: - value = f"{value}T00:00:00" - dt = datetime.fromisoformat(value.replace("Z", "+00:00")) - if dt.tzinfo is None: - user_tz = await get_user_tz(user_id) - dt = dt.replace(tzinfo=user_tz) - return dt.astimezone(timezone.utc), was_date_only - - -async def _resolve_event_start( - user_id: int, args: dict -) -> tuple[datetime, bool]: - """Resolve the start datetime from either the new split fields - (`start_date` + optional `start_time`) or the legacy combined `start`. - Returns ``(utc_datetime, was_date_only)``.""" - if "start_date" in args and args["start_date"]: - date_str = args["start_date"] - time_str = args.get("start_time") or None - return await _combine_local_in_user_tz(user_id, date_str, time_str), time_str is None - if "start" in args and args["start"]: - return await _parse_datetime_in_user_tz(user_id, args["start"]) - raise ValueError("Either start_date or start is required") - - -async def _resolve_event_end( - user_id: int, args: dict -) -> datetime | None: - """Resolve the end datetime from either the new split fields or the - legacy combined `end`. Returns ``None`` when no end fields are set.""" - if "end_date" in args and args["end_date"]: - date_str = args["end_date"] - time_str = args.get("end_time") or None - return await _combine_local_in_user_tz(user_id, date_str, time_str) - if "end" in args and args["end"]: - dt, _ = await _parse_datetime_in_user_tz(user_id, args["end"]) - return dt - return None - - -def _candidate_summary(event) -> dict: - """Compact event summary used in ambiguous-match responses. - - Keeps the candidate list small so the model can disambiguate from - the same turn without bloating context. Includes id (for the - follow-up call), title, start_dt, and location when present. - """ - return { - "id": event.id, - "title": event.title, - "start_dt": event.start_dt.isoformat() if event.start_dt else None, - "location": event.location or None, - } - - -async def _resolve_event_for_action( - *, user_id: int, arguments: dict, action: str, -): - """Pick the single event the model intends to update or delete. - - Resolution rules: - - ``event_id`` in arguments → exact lookup (skip query). Used by - the model to disambiguate after a multi-match refusal. - - else ``query`` → ``find_events_by_query``: - - 0 results → return error tuple ("not_found", ...) - - 1 result → return that event - - 2+ results → return ("ambiguous", error, candidates) so the - caller can refuse the call and show candidates to the model. - - Returns either an Event (success) or a 2- or 3-tuple of - ``(error_kind, error_dict)`` for the caller to translate into a - tool-call response. - """ - from fabledassistant.services.events import get_event - - event_id = arguments.get("event_id") - if event_id is not None: - try: - event_id_int = int(event_id) - except (TypeError, ValueError): - return ("invalid_id", { - "success": False, - "error": f"event_id must be an integer; got {event_id!r}.", - }) - ev = await get_event(user_id=user_id, event_id=event_id_int) - if ev is None: - return ("not_found", { - "success": False, - "error": f"No event found with id={event_id_int}.", - }) - return ev - - query = arguments.get("query", "") - if not query: - return ("invalid_query", { - "success": False, - "error": "Either query or event_id is required.", - }) - matches = await find_events_by_query(user_id=user_id, query=query) - if not matches: - return ("not_found", { - "success": False, - "error": f"No event found matching {query!r}.", - }) - if len(matches) == 1: - return matches[0] - # Multi-match: refuse and surface candidates so the model can - # disambiguate via event_id on the next call. Prevents the silent- - # picks-matches[0] failure mode that mutated the wrong event in the - # 2026-04-29 dentist-appointment incident (Fable #161). - return ("ambiguous", { - "success": False, - "error": ( - f"Found {len(matches)} events matching {query!r}. " - f"Pick one by passing `event_id` instead of `query`, " - f"or refine the search term to match a single event." - ), - "action": action, - "candidates": [_candidate_summary(m) for m in matches[:8]], - }) - - -def _validate_weekday(start_dt_utc: datetime, user_tz, expected: str | None) -> str | None: - """Verify the resolved local date falls on the expected day of the week. - - Models routinely miscompute "this Friday" / "next Monday" when the - system prompt only carries an ISO date without a weekday. When the - model passes `expected_weekday`, the backend rejects mismatches with - a self-correcting error message naming the actual weekday. - - Returns an error string on mismatch, or ``None`` when the check - passes (or no expected weekday was supplied). - """ - if not expected: - return None - expected_norm = expected.strip().lower() - valid = {"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"} - if expected_norm not in valid: - return f"expected_weekday must be a full English weekday name; got {expected!r}." - local = start_dt_utc.astimezone(user_tz) - actual = local.strftime("%A").lower() - if actual == expected_norm: - return None - return ( - f"Date {local.date().isoformat()} falls on {actual.title()}, " - f"not {expected_norm.title()}. Recompute the date for " - f"{expected_norm.title()} or confirm with the user before retrying." - ) - - -@tool( - name="create_event", - description=( - "Create a calendar event for the user. Use this when the user asks " - "to schedule, add, or create a meeting, appointment, or event. " - "Always pass `start_date` (YYYY-MM-DD) and `start_time` (HH:MM) as " - "separate fields in the user's local time — never combine them and " - "never include a timezone suffix. The server attaches the user's " - "configured timezone. Omit `start_time` (or set `all_day=true`) " - "for all-day events like birthdays or holidays. " - "When the user names a weekday ('this Friday', 'next Monday'), " - "state the resolved calendar date in your reply BEFORE calling " - "this tool, and pass `expected_weekday` so the server can verify " - "the date falls on the day you intended.\n\n" - "DON'T call this tool with placeholder values. If the user " - "mentions an event without giving you concrete details (a real " - "title, a specific time, and location when it applies), record " - "a moment instead and ask for the missing pieces — do NOT create " - "an event with a stand-in title like 'Appointment', 'Meeting', " - "or 'Event' and a description that says 'details TBD'. Wait for " - "the user's reply, then call create_event ONCE with the actual " - "title, time, and location. Premature placeholder events pollute " - "the calendar and require an immediate update_event to fix — " - "both visible to the user, neither what they asked for." - ), - parameters={ - "title": {"type": "string", "description": "A descriptive event title"}, - "start_date": {"type": "string", "description": "Start calendar date as YYYY-MM-DD in the user's local time. No timezone suffix."}, - "start_time": {"type": "string", "description": "Start wall-clock time as HH:MM (24-hour). Omit for all-day events. No timezone suffix."}, - "end_date": {"type": "string", "description": "Optional end calendar date as YYYY-MM-DD."}, - "end_time": {"type": "string", "description": "Optional end wall-clock time as HH:MM."}, - "duration": {"type": "integer", "description": "Optional duration in minutes (default 60, ignored if end_date/end_time is set or all_day is true)"}, - "description": {"type": "string", "description": "Optional event description"}, - "location": {"type": "string", "description": "Optional event location"}, - "color": {"type": "string", "description": "Optional hex color for the event (e.g. '#6366f1')"}, - "all_day": {"type": "boolean", "description": "True for all-day events (birthdays, holidays, deadlines)"}, - "recurrence": {"type": "string", "description": "Optional iCalendar RRULE (e.g. 'FREQ=YEARLY' for annual, 'FREQ=WEEKLY' for weekly, 'FREQ=MONTHLY' for monthly)"}, - "reminder_minutes": {"type": "integer", "description": "Optional reminder N minutes before the event (e.g. 30 for 30 minutes before)"}, - "attendees": {"type": "array", "items": {"type": "string"}, "description": "Optional list of attendee email addresses"}, - "calendar_name": {"type": "string", "description": "Optional calendar name to create the event in. Falls back to default calendar."}, - "project": {"type": "string", "description": "Optional project name to associate this event with"}, - "expected_weekday": {"type": "string", "description": "Optional weekday name (e.g. 'friday') the start_date should fall on. Pass this whenever the user names a weekday so the server can verify the date is correct. Rejects with a corrective error if the date falls on a different day."}, - # Legacy combined fields kept for backward compatibility with saved - # tool-call payloads in conversation history. New calls should use - # start_date + start_time. Hidden from typical model output via the - # description above; still accepted by the resolver as a fallback. - "start": {"type": "string", "description": "[Deprecated] Combined start datetime — prefer start_date + start_time."}, - "end": {"type": "string", "description": "[Deprecated] Combined end datetime — prefer end_date + end_time."}, - }, - required=["title"], -) -async def create_event_tool(*, user_id, arguments, **_ctx): - all_day = arguments.get("all_day", False) - try: - start_dt, start_was_date_only = await _resolve_event_start(user_id, arguments) - except ValueError as exc: - return {"success": False, "error": str(exc)} - except TypeError as exc: - return {"success": False, "error": f"Invalid start: {exc}"} - if start_was_date_only: - all_day = True - user_tz = await get_user_tz(user_id) - weekday_err = _validate_weekday(start_dt, user_tz, arguments.get("expected_weekday")) - if weekday_err: - return {"success": False, "error": weekday_err} - try: - end_dt = await _resolve_event_end(user_id, arguments) - except ValueError as exc: - return {"success": False, "error": str(exc)} - except TypeError as exc: - return {"success": False, "error": f"Invalid end: {exc}"} - project_id = None - project_name = arguments.get("project") - if project_name: - proj = await resolve_project(user_id, project_name) - if proj: - project_id = proj.id - event = await events_create_event( - user_id=user_id, - title=arguments.get("title", "Untitled Event"), - start_dt=start_dt, - end_dt=end_dt, - all_day=all_day, - description=arguments.get("description") or "", - location=arguments.get("location") or "", - color=arguments.get("color") or "", - recurrence=arguments.get("recurrence"), - project_id=project_id, - duration=arguments.get("duration"), - reminder_minutes=arguments.get("reminder_minutes"), - attendees=arguments.get("attendees"), - calendar_name=arguments.get("calendar_name"), - ) - return {"success": True, "type": "event", "data": event.to_dict()} - - -@tool( - name="list_events", - description="List calendar events in a date range. Use this when the user asks what events or meetings they have. Pass plain local dates (YYYY-MM-DD) — the server interprets them in the user's timezone and expands to a full local day.", - parameters={ - "date_from": {"type": "string", "description": "Start of range as a local date (YYYY-MM-DD) or local datetime. Interpreted in the user's timezone."}, - "date_to": {"type": "string", "description": "End of range as a local date (YYYY-MM-DD) or local datetime. A bare date is expanded to 23:59:59 local."}, - }, - required=["date_from", "date_to"], - read_only=True, - briefing=True, -) -async def list_events_tool(*, user_id, arguments, **_ctx): - # Bare local dates are expanded to a full local day in the user's TZ: - # date_from → 00:00 local, date_to → 23:59:59 local, both converted to - # UTC before the DB query. Previously the tool description told the - # model to pass UTC ranges, which missed events for non-UTC users. - try: - date_from, _ = await _parse_datetime_in_user_tz( - user_id, arguments["date_from"] - ) - date_to_str = arguments["date_to"] - if "T" not in date_to_str and " " not in date_to_str: - date_to_str = f"{date_to_str}T23:59:59" - date_to, _ = await _parse_datetime_in_user_tz(user_id, date_to_str) - except (ValueError, TypeError, KeyError) as exc: - return {"success": False, "error": f"Invalid date range: {exc}"} - events = await events_list_events(user_id=user_id, date_from=date_from, date_to=date_to) - return { - "success": True, - "type": "events", - "data": {"count": len(events), "events": events}, - } - - -@tool( - name="search_events", - description="Search calendar events by keyword. Use this when the user asks to find a specific event or meeting.", - parameters={ - "query": {"type": "string", "description": "Search keyword to match against event titles, locations, and descriptions"}, - "include_past": {"type": "boolean", "description": "Set to true to include past events in results (default: future events only)"}, - }, - required=["query"], - read_only=True, - briefing=True, -) -async def search_events_tool(*, user_id, arguments, **_ctx): - events = await events_search_events( - user_id=user_id, - query=arguments.get("query", ""), - include_past=arguments.get("include_past", False), - ) - return { - "success": True, - "type": "events", - "data": { - "query": arguments.get("query", ""), - "count": len(events), - "events": [e.to_dict() for e in events], - }, - } - - -@tool( - name="update_event", - description=( - "Update an existing calendar event. Use this when the user asks to " - "change, move, reschedule, or modify an event. Pass `start_date` " - "(YYYY-MM-DD) and `start_time` (HH:MM) as separate fields in the " - "user's local time when rescheduling — never combine them, never " - "include a timezone suffix. " - "When the user names a weekday ('move to Friday'), state the " - "resolved calendar date in your reply BEFORE calling this tool, " - "and pass `expected_weekday` so the server can verify the date " - "falls on the day you intended.\n\n" - "Identify the event with EITHER `query` (a title substring) OR " - "`event_id` (when you already have an exact id from a prior tool " - "result). If `query` matches multiple events, the tool returns " - "an ambiguity error with a candidate list — pick one by passing " - "its `event_id` on the next call, or refine the query so it " - "matches a single event." - ), - parameters={ - "query": {"type": "string", "description": "Search term to find the event to update (matches against title). Required unless event_id is set."}, - "event_id": {"type": "integer", "description": "Exact event id, used to disambiguate when a prior call returned multiple candidates. Takes precedence over query."}, - "title": {"type": "string", "description": "New title for the event"}, - "start_date": {"type": "string", "description": "New start calendar date as YYYY-MM-DD in the user's local time. No timezone suffix."}, - "start_time": {"type": "string", "description": "New start wall-clock time as HH:MM. No timezone suffix."}, - "end_date": {"type": "string", "description": "New end calendar date as YYYY-MM-DD."}, - "end_time": {"type": "string", "description": "New end wall-clock time as HH:MM."}, - "all_day": {"type": "boolean", "description": "Whether the event is all-day"}, - "description": {"type": "string", "description": "New event description"}, - "location": {"type": "string", "description": "New event location"}, - "color": {"type": "string", "description": "New hex color for the event (e.g. '#6366f1')"}, - "recurrence": {"type": "string", "description": "New iCalendar RRULE"}, - "reminder_minutes": {"type": "integer", "description": "Reminder N minutes before the event. Pass 0 to remove an existing reminder."}, - "expected_weekday": {"type": "string", "description": "Optional weekday name (e.g. 'friday') the new start_date should fall on. Pass whenever the user names a weekday."}, - # Legacy combined fields kept for backcompat — see create_event. - "start": {"type": "string", "description": "[Deprecated] Combined start datetime — prefer start_date + start_time."}, - "end": {"type": "string", "description": "[Deprecated] Combined end datetime — prefer end_date + end_time."}, - }, - required=[], -) -async def update_event_tool(*, user_id, arguments, **_ctx): - resolved = await _resolve_event_for_action( - user_id=user_id, arguments=arguments, action="update", - ) - if isinstance(resolved, tuple): - return resolved[1] # error dict from the resolver - event_to_update = resolved - fields: dict = {} - for str_field in ("title", "description", "location", "color", "recurrence"): - if arguments.get(str_field) is not None: - fields[str_field] = arguments[str_field] - if arguments.get("all_day") is not None: - fields["all_day"] = arguments["all_day"] - if "reminder_minutes" in arguments: - rm = arguments["reminder_minutes"] - fields["reminder_minutes"] = None if rm == 0 else rm - # Resolve start: split fields preferred, legacy `start` as fallback. - if arguments.get("start_date") or arguments.get("start"): - try: - start_dt, _ = await _resolve_event_start(user_id, arguments) - except (ValueError, TypeError) as exc: - return {"success": False, "error": f"Invalid start: {exc}"} - user_tz = await get_user_tz(user_id) - weekday_err = _validate_weekday(start_dt, user_tz, arguments.get("expected_weekday")) - if weekday_err: - return {"success": False, "error": weekday_err} - fields["start_dt"] = start_dt - if arguments.get("end_date") or arguments.get("end"): - try: - end_dt = await _resolve_event_end(user_id, arguments) - except (ValueError, TypeError) as exc: - return {"success": False, "error": f"Invalid end: {exc}"} - if end_dt is not None: - fields["end_dt"] = end_dt - updated = await events_update_event(user_id=user_id, event_id=event_to_update.id, **fields) - if updated is None: - return {"success": False, "error": "Event not found or update failed."} - return {"success": True, "type": "event_updated", "data": updated.to_dict()} - - -@tool( - name="delete_event", - description=( - "Delete a calendar event. Use this when the user asks to cancel, " - "remove, or delete an event. Identify the event with EITHER " - "`query` (a title substring) OR `event_id` (when you have an " - "exact id). If `query` matches multiple events, the tool returns " - "an ambiguity error with a candidate list — pick one by passing " - "its `event_id` on the next call, or refine the query so it " - "matches a single event. Deleting the wrong event is a costly " - "user error; never guess between candidates." - ), - parameters={ - "query": {"type": "string", "description": "Search term to find the event to delete (matches against title). Required unless event_id is set."}, - "event_id": {"type": "integer", "description": "Exact event id, used to disambiguate when a prior call returned multiple candidates. Takes precedence over query."}, - }, - required=[], -) -async def delete_event_tool(*, user_id, arguments, **_ctx): - resolved = await _resolve_event_for_action( - user_id=user_id, arguments=arguments, action="delete", - ) - if isinstance(resolved, tuple): - return resolved[1] - event_to_delete = resolved - await events_delete_event(user_id=user_id, event_id=event_to_delete.id) - return {"success": True, "type": "event_deleted", "data": {"id": event_to_delete.id, "title": event_to_delete.title}} - - -@tool( - name="list_calendars", - description="List all available calendars. Use this when the user asks which calendars they have.", - parameters={}, - read_only=True, - requires="caldav", -) -async def list_calendars_tool(*, user_id, arguments, **_ctx): - from fabledassistant.services.caldav import list_calendars - - calendars = await list_calendars(user_id=user_id) - return { - "success": True, - "type": "calendars", - "data": {"count": len(calendars), "calendars": calendars}, - } diff --git a/src/fabledassistant/services/tools/entities.py b/src/fabledassistant/services/tools/entities.py deleted file mode 100644 index 1a30972..0000000 --- a/src/fabledassistant/services/tools/entities.py +++ /dev/null @@ -1,231 +0,0 @@ -"""Entity tools: people, places, and lists.""" - -from __future__ import annotations - -from fabledassistant.services.notes import create_note, list_notes, update_note -from fabledassistant.services.tools._helpers import schedule_embedding -from fabledassistant.services.tools._registry import tool - - -def _build_person_body(meta: dict, extra_notes: str | None = None) -> str: - """Build markdown body from person metadata.""" - lines = [] - for key, label in (("relationship", "Relationship"), ("phone", "Phone"), ("email", "Email"), ("birthday", "Birthday"), ("address", "Address")): - if meta.get(key): - lines.append(f"**{label}:** {meta[key]}") - if extra_notes: - lines.append(f"\n{extra_notes}") - return "\n".join(lines) - - -def _build_place_body(meta: dict, extra_notes: str | None = None) -> str: - """Build markdown body from place metadata.""" - lines = [] - for key, label in (("address", "Address"), ("phone", "Phone"), ("hours", "Hours"), ("url", "Website")): - if meta.get(key): - lines.append(f"**{label}:** {meta[key]}") - if extra_notes: - lines.append(f"\n{extra_notes}") - return "\n".join(lines) - - -_PERSON_FIELDS = ("relationship", "phone", "email", "birthday", "address") -_PLACE_FIELDS = ("address", "phone", "hours", "url") - - -@tool( - name="save_person", - description=( - "Save or update a person in the user's knowledge base. Creates a new entry if the " - "person doesn't exist, or updates an existing one. Use when the user introduces " - "someone by name or adds/corrects details about a known person." - ), - parameters={ - "name": {"type": "string", "description": "Full name of the person (used to find existing entry or as new entry title)"}, - "relationship": {"type": "string", "description": "Relationship to the user (e.g. daughter, dentist, colleague)"}, - "phone": {"type": "string", "description": "Phone number"}, - "email": {"type": "string", "description": "Email address"}, - "birthday": {"type": "string", "description": "Birthday in YYYY-MM-DD format"}, - "address": {"type": "string", "description": "Home or mailing address"}, - "notes": {"type": "string", "description": "Any additional free-form notes about this person"}, - }, - required=["name"], -) -async def save_person_tool(*, user_id, arguments, **_ctx): - name = str(arguments.get("name", "")).strip() - if not name: - return {"success": False, "error": "name is required"} - - existing, _ = await list_notes(user_id=user_id, q=name, is_task=False, limit=10) - target = next((n for n in existing if n.note_type == "person"), None) - - if target is not None: - meta = dict(target.entity_meta or {}) - for field in _PERSON_FIELDS: - val = arguments.get(field) - if val is not None: - meta[field] = str(val) - extra_notes = arguments.get("notes") - body = _build_person_body(meta, extra_notes) - updated = await update_note(user_id=user_id, note_id=target.id, body=body, entity_meta=meta) - if updated is None: - return {"success": False, "error": "Failed to update person."} - schedule_embedding(target.id, user_id, target.title, body) - return {"success": True, "type": "person_updated", "data": {"id": target.id, "name": target.title}} - - meta = {} - for field in _PERSON_FIELDS: - val = arguments.get(field) - if val: - meta[field] = str(val) - body = _build_person_body(meta, arguments.get("notes", "")) - note = await create_note( - user_id=user_id, title=name, body=body, - tags=["person"], note_type="person", entity_meta=meta, - ) - schedule_embedding(note.id, user_id, name, note.body) - return {"success": True, "type": "person", "data": {"id": note.id, "name": name}} - - -@tool( - name="save_place", - description=( - "Save or update a named location in the user's knowledge base. Creates a new entry " - "if the place doesn't exist, or updates an existing one. Use when the user wants to " - "remember or correct details about a place (business, venue, address)." - ), - parameters={ - "name": {"type": "string", "description": "Name of the place (e.g. 'Dr. Smith Dental', 'Costco')"}, - "address": {"type": "string", "description": "Street address"}, - "phone": {"type": "string", "description": "Phone number"}, - "hours": {"type": "string", "description": "Opening hours (e.g. 'Mon-Fri 9am-5pm')"}, - "url": {"type": "string", "description": "Website URL"}, - "notes": {"type": "string", "description": "Any additional free-form notes about this place"}, - }, - required=["name"], -) -async def save_place_tool(*, user_id, arguments, **_ctx): - name = str(arguments.get("name", "")).strip() - if not name: - return {"success": False, "error": "name is required"} - - existing, _ = await list_notes(user_id=user_id, q=name, is_task=False, limit=10) - target = next((n for n in existing if n.note_type == "place"), None) - - if target is not None: - meta = dict(target.entity_meta or {}) - for field in _PLACE_FIELDS: - val = arguments.get(field) - if val is not None: - meta[field] = str(val) - extra_notes = arguments.get("notes") - body = _build_place_body(meta, extra_notes) - updated = await update_note(user_id=user_id, note_id=target.id, body=body, entity_meta=meta) - if updated is None: - return {"success": False, "error": "Failed to update place."} - schedule_embedding(target.id, user_id, target.title, body) - return {"success": True, "type": "place_updated", "data": {"id": target.id, "name": target.title}} - - meta = {} - for field in _PLACE_FIELDS: - val = arguments.get(field) - if val: - meta[field] = str(val) - body = _build_place_body(meta, arguments.get("notes", "")) - note = await create_note( - user_id=user_id, title=name, body=body, - tags=["place"], note_type="place", entity_meta=meta, - ) - schedule_embedding(note.id, user_id, name, note.body) - return {"success": True, "type": "place", "data": {"id": note.id, "name": name}} - - -@tool( - name="create_list", - description="Create a named list (shopping list, to-do list, packing list, etc.). Items are stored as a markdown task list. Use add_to_list to add items to an existing list.", - parameters={ - "name": {"type": "string", "description": "List name (e.g. 'Grocery List', 'Hardware Store')"}, - "items": {"type": "array", "items": {"type": "string"}, "description": "Initial list items (unchecked)"}, - "store": {"type": "string", "description": "Optional associated store or place name"}, - "tags": {"type": "array", "items": {"type": "string"}, "description": "Tags for the list"}, - }, - required=["name"], -) -async def create_list_tool(*, user_id, arguments, **_ctx): - name = str(arguments.get("name", "")).strip() - if not name: - return {"success": False, "error": "name is required"} - items = arguments.get("items") or [] - if not isinstance(items, list): - items = [] - body = "\n".join(f"- [ ] {item}" for item in items if str(item).strip()) - meta = {} - store = arguments.get("store") - if store: - meta["store"] = str(store) - tags = arguments.get("tags") or [] - if not isinstance(tags, list): - tags = [] - note = await create_note( - user_id=user_id, title=name, body=body, - tags=tags, note_type="list", entity_meta=meta, - ) - schedule_embedding(note.id, user_id, name, body) - return {"success": True, "type": "list", "data": {"id": note.id, "name": name, "item_count": len(items)}} - - -@tool( - name="add_to_list", - description="Add one or more items to an existing list. Items are appended as unchecked entries.", - parameters={ - "list_name": {"type": "string", "description": "Name of the list to add items to"}, - "items": {"type": "array", "items": {"type": "string"}, "description": "Items to add"}, - }, - required=["list_name", "items"], -) -async def add_to_list_tool(*, user_id, arguments, **_ctx): - list_name = str(arguments.get("list_name", "")).strip() - items = arguments.get("items") or [] - if not list_name: - return {"success": False, "error": "list_name is required"} - if not isinstance(items, list) or not items: - return {"success": False, "error": "items must be a non-empty array"} - existing, _ = await list_notes(user_id=user_id, q=list_name, is_task=False, limit=10) - target = next((n for n in existing if n.note_type == "list" and n.title.lower() == list_name.lower()), None) - if target is None: - target = next((n for n in existing if n.note_type == "list"), None) - if target is None: - return {"success": False, "error": f"No list named '{list_name}' found. Use create_list to create it first."} - new_lines = "\n".join(f"- [ ] {item}" for item in items if str(item).strip()) - existing_body = (target.body or "").rstrip() - new_body = f"{existing_body}\n{new_lines}".lstrip() - await update_note(user_id=user_id, note_id=target.id, body=new_body) - schedule_embedding(target.id, user_id, target.title, new_body) - return {"success": True, "type": "list_updated", "data": {"id": target.id, "name": target.title, "added": len(items)}} - - -@tool( - name="clear_checked_items", - description="Remove all checked/completed items from a list, keeping only unchecked items.", - parameters={ - "list_name": {"type": "string", "description": "Name of the list to clear"}, - }, - required=["list_name"], -) -async def clear_checked_items_tool(*, user_id, arguments, **_ctx): - import re as _re - - list_name = str(arguments.get("list_name", "")).strip() - if not list_name: - return {"success": False, "error": "list_name is required"} - existing, _ = await list_notes(user_id=user_id, q=list_name, is_task=False, limit=10) - target = next((n for n in existing if n.note_type == "list" and n.title.lower() == list_name.lower()), None) - if target is None: - target = next((n for n in existing if n.note_type == "list"), None) - if target is None: - return {"success": False, "error": f"No list named '{list_name}' found."} - body = target.body or "" - cleaned = _re.sub(r"^- \[[xX]\] .+\n?", "", body, flags=_re.MULTILINE).strip() - await update_note(user_id=user_id, note_id=target.id, body=cleaned) - schedule_embedding(target.id, user_id, target.title, cleaned) - return {"success": True, "type": "list_cleared", "data": {"id": target.id, "name": target.title}} diff --git a/src/fabledassistant/services/tools/journal.py b/src/fabledassistant/services/tools/journal.py deleted file mode 100644 index 500fbdf..0000000 --- a/src/fabledassistant/services/tools/journal.py +++ /dev/null @@ -1,385 +0,0 @@ -"""LLM tools for journal conversations: record_moment and search_journal.""" -from __future__ import annotations - -import datetime -import logging -import re -from typing import Any - -from sqlalchemy import func, select - -from fabledassistant.models import Note, async_session -from fabledassistant.services.journal_search import search_journal as svc_search_journal -from fabledassistant.services.moments import create_moment -from fabledassistant.services.tools._registry import tool - -logger = logging.getLogger(__name__) - - -# Generic placeholders the model occasionally emits for `place_names` when no -# real place was named. Filtered out server-side as belt-and-suspenders to the -# prompt-layer guidance — these aren't places, just role-labels for locations -# the user already has named (Home / Work weather targets, etc.). -_PLACEHOLDER_PLACE_NAMES = frozenset({ - "work", "home", "office", "the office", "my office", "my work", - "my home", "house", "my house", "the house", -}) - -# Short closed-class words excluded from the keyword-overlap check below. -# Case is normalized to lowercase before comparison. -_STOPWORDS = frozenset({ - "the", "a", "an", "and", "or", "but", "at", "in", "on", "of", "to", - "for", "with", "by", "from", "about", "as", "is", "was", "were", "be", - "been", "being", "have", "has", "had", "do", "does", "did", "will", - "would", "could", "should", "may", "might", "must", "this", "that", - "these", "those", "i", "you", "he", "she", "we", "they", "it", "his", - "her", "their", "my", "your", "our", "its", "me", "him", "us", "them", - "are", "if", "so", "no", "not", "yes", "now", "then", "than", "too", - "very", "just", "also", "any", "all", "some", "one", "two", "out", - "up", "down", "off", "over", "under", "into", "onto", "upon", -}) - - -def _content_keywords(text: str) -> set[str]: - """Tokenize text into the meaningful keyword set used for overlap checks. - - Lowercased, alphanumeric runs only, stopwords removed, tokens shorter - than 3 chars dropped. Numeric tokens are kept (e.g. "Branch 14" yields - {"branch", "14"}) because they often anchor task references. - """ - tokens = re.split(r"[^a-z0-9]+", text.lower()) - return {t for t in tokens if len(t) >= 3 and t not in _STOPWORDS} - - -def _filter_placeholder_places(names: list[str]) -> tuple[list[str], list[str]]: - """Drop generic placeholders from a `place_names` list. - - Returns ``(kept, dropped)``. The dropped list is used purely for log - visibility — the moment is still created, the bogus links are just - not persisted. - """ - kept: list[str] = [] - dropped: list[str] = [] - for n in names: - norm = (n or "").strip() - if norm and norm.lower() in _PLACEHOLDER_PLACE_NAMES: - dropped.append(norm) - else: - kept.append(norm) - return kept, dropped - - -async def _filter_task_ids_by_keyword_overlap( - *, user_id: int, content: str, task_ids: list[int] -) -> list[int]: - """Drop task links whose title shares no meaningful keyword with the - moment content. - - Reproducer this guards against (2026-04-27): the model emitted - `task_titles=["Research Weston's ADHD Evaluation"]` on a moment about - restaging Docker — the title was real (Weston's task is in the prep - context) but the user never referenced it. Without this guard, the - only task surfaced in the prep gets attached to every moment as - filler. After this guard the link is dropped (zero-overlap) and a - log entry is emitted at INFO so we can observe how often this fires. - """ - if not task_ids: - return [] - async with async_session() as session: - stmt = select(Note.id, Note.title).where( - Note.user_id == user_id, - Note.id.in_(task_ids), - ) - rows = (await session.execute(stmt)).all() - title_by_id = {nid: (title or "") for nid, title in rows} - content_kw = _content_keywords(content) - kept: list[int] = [] - for tid in task_ids: - title = title_by_id.get(tid, "") - title_kw = _content_keywords(title) - if title_kw and content_kw & title_kw: - kept.append(tid) - else: - logger.info( - "record_moment: dropped task link id=%s title=%r — no keyword overlap with content %r", - tid, title, content[:80], - ) - return kept - - -async def _resolve_entity_ids_by_name( - *, - user_id: int, - names: list[str], - note_type: str | None = None, - is_task_only: bool | None = None, -) -> list[int]: - """Case-insensitive title lookup → note IDs. - - Names with no match are silently dropped (logged at debug). Used by - record_moment to resolve user-mentioned names without forcing the - LLM to track tool-return IDs across calls. - """ - if not names: - return [] - cleaned = [n.strip() for n in names if n and n.strip()] - if not cleaned: - return [] - lowered = [n.lower() for n in cleaned] - async with async_session() as session: - stmt = select(Note.id, func.lower(Note.title).label("ltitle")).where( - Note.user_id == user_id, - func.lower(Note.title).in_(lowered), - ) - if note_type is not None: - stmt = stmt.where(Note.note_type == note_type) - if is_task_only is True: - stmt = stmt.where(Note.status.isnot(None)) - elif is_task_only is False: - stmt = stmt.where(Note.status.is_(None)) - rows = (await session.execute(stmt)).all() - # Pick first match per name; preserve input order. - by_lower: dict[str, int] = {} - for note_id, ltitle in rows: - if ltitle not in by_lower: - by_lower[ltitle] = note_id - resolved: list[int] = [] - for low, original in zip(lowered, cleaned): - if low in by_lower: - resolved.append(by_lower[low]) - else: - logger.debug( - "record_moment: no entity match for %r (note_type=%s, task_only=%s)", - original, note_type, is_task_only, - ) - return resolved - - -@tool( - name="record_moment", - description=( - "Record a meaningful beat from the conversation as a structured Moment. " - "Use freely (no confirmation) for anything significant the user mentions: " - "events, encounters, decisions, observations, feelings worth remembering. " - "Each Moment is one or two sentences distilling the beat — not a full transcript. " - "STRONGLY PREFER the *_names parameters when linking entities — the server " - "resolves names to IDs by lookup, so you cannot accidentally invent or " - "re-use the wrong ID. Use *_ids only when you have an exact ID returned " - "from another tool call in this same turn. " - "`task_titles` and `note_titles` must be exact titles returned by a prior " - "search_notes call in this same turn. Do NOT pass user-typed phrases, " - "project names, or invented titles. If you have not searched yet, call " - "search_notes first." - ), - parameters={ - "content": { - "type": "string", - "description": ( - "1-2 sentence distillation of the moment in the user's " - "voice — first-person or imperative, like a journal jot. " - "GOOD: 'Restaging Docker on the Bedford swarm; one " - "Windows node had network breakage.' / 'Appointment " - "Friday — details TBD.' " - "BAD: 'The user mentioned having an appointment.' / " - "'User reports Docker swarm restage.' Strip 'the user…' " - "/ 'user mentioned…' framings entirely." - ), - }, - "occurred_at": { - "type": "string", - "description": "ISO 8601 datetime when the moment happened. Pass 'now' for the present moment; the handler resolves it.", - }, - "raw_excerpt": { - "type": "string", - "description": "Optional: literal user phrase the moment summarizes. Useful for trust UI.", - }, - "tags": { - "type": "array", - "items": {"type": "string"}, - "description": "Optional tags (no # prefix).", - }, - "person_names": { - "type": "array", - "items": {"type": "string"}, - "description": "PREFERRED. Names of people mentioned (e.g. ['Victoria', 'Mom']). Server resolves to existing person notes by case-insensitive title match. Names with no match are silently skipped.", - }, - "place_names": { - "type": "array", - "items": {"type": "string"}, - "description": "PREFERRED. Names of places mentioned (e.g. ['the new ramen place']). Server resolves to existing place notes by title.", - }, - "task_titles": { - "type": "array", - "items": {"type": "string"}, - "description": "Titles of tasks this moment references. Server resolves to task IDs by title match.", - }, - "note_titles": { - "type": "array", - "items": {"type": "string"}, - "description": "Titles of notes this moment references. Server resolves to note IDs by title match.", - }, - "person_ids": { - "type": "array", - "items": {"type": "integer"}, - "description": "DISCOURAGED — use person_names instead. Only valid if you obtained the ID from a tool result in this same turn. Never invent IDs.", - }, - "place_ids": { - "type": "array", - "items": {"type": "integer"}, - "description": "DISCOURAGED — use place_names instead. Same caveat as person_ids.", - }, - "task_ids": { - "type": "array", - "items": {"type": "integer"}, - "description": "DISCOURAGED — use task_titles instead. Same caveat as person_ids.", - }, - "note_ids": { - "type": "array", - "items": {"type": "integer"}, - "description": "DISCOURAGED — use note_titles instead. Same caveat as person_ids.", - }, - }, - required=["content"], - read_only=False, - journal=True, -) -async def record_moment_tool(*, user_id, arguments, conv_id=None, **_ctx): - content = arguments.get("content", "").strip() - if not content: - return {"success": False, "error": "content is required"} - - occurred_raw = arguments.get("occurred_at") - now = datetime.datetime.now(datetime.timezone.utc) - if occurred_raw and occurred_raw != "now": - try: - occurred_dt = datetime.datetime.fromisoformat(occurred_raw) - if occurred_dt.tzinfo is None: - occurred_dt = occurred_dt.replace(tzinfo=datetime.timezone.utc) - except ValueError: - occurred_dt = now - else: - occurred_dt = now - - # Start with any explicit IDs the LLM provided. - person_ids = list(arguments.get("person_ids") or []) - place_ids = list(arguments.get("place_ids") or []) - task_ids = list(arguments.get("task_ids") or []) - note_ids = list(arguments.get("note_ids") or []) - - # Resolve names → IDs and merge. Names are the preferred path because the - # LLM is unreliable at tracking IDs across tool calls (see prior bug: - # hallucinated person_ids=[1,2] referencing test-data notes). - person_ids.extend( - await _resolve_entity_ids_by_name( - user_id=user_id, - names=arguments.get("person_names") or [], - note_type="person", - ) - ) - raw_place_names = arguments.get("place_names") or [] - kept_place_names, dropped_place_names = _filter_placeholder_places(raw_place_names) - if dropped_place_names: - logger.info( - "record_moment: dropped placeholder place_names %r — not real places", - dropped_place_names, - ) - place_ids.extend( - await _resolve_entity_ids_by_name( - user_id=user_id, - names=kept_place_names, - note_type="place", - ) - ) - task_ids.extend( - await _resolve_entity_ids_by_name( - user_id=user_id, - names=arguments.get("task_titles") or [], - is_task_only=True, - ) - ) - note_ids.extend( - await _resolve_entity_ids_by_name( - user_id=user_id, - names=arguments.get("note_titles") or [], - is_task_only=False, - ) - ) - - # Dedupe while preserving order. - person_ids = list(dict.fromkeys(person_ids)) - place_ids = list(dict.fromkeys(place_ids)) - task_ids = list(dict.fromkeys(task_ids)) - note_ids = list(dict.fromkeys(note_ids)) - - # Drop task links that don't share a keyword with the moment content. - # Belt-and-suspenders to the prompt-layer rule "only link tasks the user - # explicitly references" — if the model attaches a task anyway (because - # it's in the prep context), the keyword check refuses to persist a link - # the moment can't justify. - task_ids = await _filter_task_ids_by_keyword_overlap( - user_id=user_id, content=content, task_ids=task_ids, - ) - - moment = await create_moment( - user_id=user_id, - content=content, - occurred_at=occurred_dt, - day_date=occurred_dt.date(), - conversation_id=conv_id, - raw_excerpt=arguments.get("raw_excerpt"), - tags=arguments.get("tags") or [], - person_ids=person_ids, - place_ids=place_ids, - task_ids=task_ids, - note_ids=note_ids, - ) - return { - "success": True, - "type": "moment_recorded", - "moment": moment.to_dict(), - } - - -@tool( - name="search_journal", - description=( - "Search the user's journal: past Moments and (optionally) raw transcripts. " - "Use to recall things mentioned in prior days. Three modes: " - "(a) date filter only -> recent moments in that range; " - "(b) person_id/place_id filter -> moments mentioning that entity; " - "(c) query string -> semantic search, optionally constrained by date/entity." - ), - parameters={ - "query": {"type": "string", "description": "Optional semantic query."}, - "person_id": {"type": "integer", "description": "Filter to this person."}, - "place_id": {"type": "integer", "description": "Filter to this place."}, - "tag": {"type": "string", "description": "Filter to moments with this tag."}, - "date_from": {"type": "string", "description": "ISO date lower bound (inclusive)."}, - "date_to": {"type": "string", "description": "ISO date upper bound (inclusive)."}, - "include_transcripts": { - "type": "boolean", - "description": "If true, also return matching raw transcript excerpts when query is set. Default false.", - }, - "limit": {"type": "integer", "description": "Max results, default 10."}, - }, - required=[], - read_only=True, - journal=True, -) -async def search_journal_tool(*, user_id, arguments, **_ctx) -> dict[str, Any]: - df_raw = arguments.get("date_from") - dt_raw = arguments.get("date_to") - df = datetime.date.fromisoformat(df_raw) if df_raw else None - dt = datetime.date.fromisoformat(dt_raw) if dt_raw else None - results = await svc_search_journal( - user_id=user_id, - query=arguments.get("query"), - person_id=arguments.get("person_id"), - place_id=arguments.get("place_id"), - tag=arguments.get("tag"), - date_from=df, - date_to=dt, - include_transcripts=bool(arguments.get("include_transcripts", False)), - limit=int(arguments.get("limit", 10)), - ) - return {"success": True, "type": "journal_search_results", "count": len(results), "results": results} diff --git a/src/fabledassistant/services/tools/notes.py b/src/fabledassistant/services/tools/notes.py deleted file mode 100644 index 131ee7a..0000000 --- a/src/fabledassistant/services/tools/notes.py +++ /dev/null @@ -1,455 +0,0 @@ -"""Note tools: create, update, get, list, delete, search.""" - -from __future__ import annotations - -import logging - -from fabledassistant.services.notes import create_note, delete_note, get_note_by_title, list_notes, update_note -from fabledassistant.services.tag_suggestions import suggest_tags -from fabledassistant.services.tools._helpers import ( - check_duplicate, - parse_due_date, - resolve_project, - schedule_embedding, -) -from fabledassistant.services.tools._registry import tool - -logger = logging.getLogger(__name__) - - -@tool( - name="create_note", - description=( - "Create a new note or task. " - "For a knowledge note, omit the status field. " - "For an actionable task (todo, reminder, action item), set status to 'todo'. " - "Use this whenever the user asks to write down, save, record, or add a task/todo. " - "For standalone reusable knowledge, ALSO use this when the user explicitly asks " - "to save something as a note / runbook / how-to, OR when their message contains " - "a fenced code block or a numbered procedure (3+ steps) that's reusable beyond " - "a single task. For task-specific work-in-progress, use log_work instead — that " - "feeds the task's auto-summary." - ), - parameters={ - "title": {"type": "string", "description": "The title"}, - "body": {"type": "string", "description": "Content in markdown. NOTE: when status is set (creating a task), body is ignored — task bodies are auto-maintained from work logs. Use `description` to provide the goal/context for tasks."}, - "description": {"type": "string", "description": "User-stated goal or initial context for a task. Read-only context for the auto-summary pipeline. Ignored when status is omitted (knowledge note)."}, - "tags": {"type": "array", "items": {"type": "string"}, "description": 'Tags (without # prefix, hyphens for multi-word: ["science-fiction", "story/idea"]). Do NOT embed #tags in the body.'}, - "project": {"type": "string", "description": "Optional project name. Only set this if the user explicitly named a project. Do NOT infer a project from the content or context."}, - "status": {"type": "string", "enum": ["todo", "in_progress", "done", "cancelled"], "description": "Set to 'todo' to create a task. Omit entirely for a knowledge note."}, - "due_date": {"type": "string", "description": "Due date in YYYY-MM-DD format (tasks only)"}, - "priority": {"type": "string", "enum": ["none", "low", "medium", "high"], "description": "Priority level (tasks only, default: none)"}, - "parent_task": {"type": "string", "description": "Title of a parent task to make this a sub-task of"}, - "milestone": {"type": "string", "description": "Milestone title within the project to assign to"}, - "recurrence_rule": {"type": "object", "description": 'Recurrence rule (tasks only). Interval form: {"type":"interval","every":3,"unit":"month"} (units: day/week/month/year). Calendar form: {"type":"calendar","unit":"month","day_of_month":1}. Annual: add "month":6.'}, - "confirmed": {"type": "boolean", "description": "Set to true only when the user has explicitly confirmed creation after being warned about a similar existing item."}, - }, - required=["title"], -) -async def create_note_tool(*, user_id, arguments, **_ctx): - title = arguments.get("title", "Untitled") - body = arguments.get("body", "") - description = arguments.get("description") - tags = arguments.get("tags", []) - if not isinstance(title, str): - return {"success": False, "error": "title must be a string. Call create_note once per item."} - if not isinstance(body, str): - body = "" - if not isinstance(tags, list): - tags = [] - - is_task = "status" in arguments and arguments["status"] is not None - status = arguments.get("status", "todo") if is_task else None - - # Task bodies are auto-maintained by the consolidation pipeline; drop any - # body argument arriving with a task creation so it never lands in the DB. - if is_task: - body = "" - - project_name = arguments.get("project") - milestone_name = arguments.get("milestone") - parent_task_name = arguments.get("parent_task") - project_id = None - milestone_id = None - - if project_name: - proj = await resolve_project(user_id, project_name) - if proj is None: - return {"success": False, "error": f"Project '{project_name}' not found. Use list_projects to find the correct project name and retry with the exact name."} - project_id = proj.id - if milestone_name: - from fabledassistant.services.milestones import get_milestone_by_title as _gmbt - ms = await _gmbt(user_id, proj.id, milestone_name) - if ms is None: - return {"success": False, "error": f"Milestone '{milestone_name}' not found in project '{proj.title}'. Use list_milestones to see available milestones."} - milestone_id = ms.id - - dup = await check_duplicate(user_id, title, body, is_task=is_task, confirmed=bool(arguments.get("confirmed"))) - if dup is not None: - return dup - - note = await create_note( - user_id=user_id, - title=title, - body=body, - description=description, - tags=tags, - status=status, - priority=arguments.get("priority", "none") if is_task else None, - due_date=parse_due_date(arguments.get("due_date")) if is_task else None, - recurrence_rule=arguments.get("recurrence_rule") if is_task else None, - project_id=project_id, - milestone_id=milestone_id, - ) - - if parent_task_name: - parent_notes, _ = await list_notes(user_id=user_id, q=parent_task_name, is_task=True, limit=1) - if parent_notes: - note = await update_note(user_id, note.id, parent_id=parent_notes[0].id) - - suggested = await suggest_tags(user_id, title, body) - schedule_embedding(note.id, user_id, title, body) - - if is_task: - return { - "success": True, - "type": "task", - "data": { - "id": note.id, - "title": note.title, - "status": note.status, - "priority": note.priority, - "due_date": str(note.due_date) if note.due_date else None, - "project_id": note.project_id, - "milestone_id": note.milestone_id, - "parent_id": note.parent_id, - }, - "suggested_tags": suggested, - } - return { - "success": True, - "type": "note", - "data": {"id": note.id, "title": note.title, "project_id": note.project_id}, - "suggested_tags": suggested, - } - - -@tool( - name="update_note", - description="Update an existing note or task — content, title, status, priority, or due date. Use for edits, marking tasks done, changing priority. Never use create_note for existing notes.", - parameters={ - "query": {"type": "string", "description": "Title or keyword to find the note or task to update"}, - "body": {"type": "string", "description": "New note content in markdown (omit if only updating task fields). REJECTED on tasks — task bodies are auto-maintained from work logs; use `log_work` to record progress or `description` to revise the goal."}, - "description": {"type": "string", "description": "Update the user-stated goal/context (tasks). Distinct from `body` (machine-maintained on tasks)."}, - "title": {"type": "string", "description": "Optional new title"}, - "mode": {"type": "string", "enum": ["replace", "append"], "description": "How to apply the new body: 'replace' overwrites existing content (default), 'append' adds after existing content"}, - "status": {"type": "string", "enum": ["todo", "in_progress", "done", "cancelled"], "description": "New task status. Use to mark a task done, start it, cancel it, etc."}, - "priority": {"type": "string", "enum": ["none", "low", "medium", "high"], "description": "New task priority"}, - "due_date": {"type": "string", "description": "New due date in YYYY-MM-DD format"}, - "tags": {"type": "array", "items": {"type": "string"}, "description": "Tags to apply (see tag_mode for how they're applied)"}, - "tag_mode": {"type": "string", "enum": ["add", "remove", "replace"], "description": "'add' appends tags without duplicates (default), 'remove' removes listed tags, 'replace' sets tags to exactly this list (destructive — avoid unless explicitly requested)"}, - "project": {"type": "string", "description": "Optional project name to assign this note/task to (set to empty string to remove project)"}, - "milestone": {"type": "string", "description": "Optional milestone title within the project to assign this task to (set to empty string to remove milestone)"}, - "recurrence_rule": {"type": "object", "description": 'Set or update recurrence. Interval form: {"type":"interval","every":3,"unit":"month"}. Calendar form: {"type":"calendar","unit":"month","day_of_month":1}. Pass null to remove.'}, - }, - required=["query"], -) -async def update_note_tool(*, user_id, arguments, **_ctx): - query = arguments.get("query", "") - new_body = arguments.get("body", "") - new_title = arguments.get("title") - mode = arguments.get("mode", "replace") - - note = await get_note_by_title(user_id, query) - if note is None: - notes, _ = await list_notes(user_id=user_id, q=query, limit=5) - note_only = [n for n in notes if n.status is None] - candidates = note_only or notes - if not candidates: - return {"success": False, "error": f"No note found matching '{query}'."} - note = candidates[0] - - # Schema-level separation: task bodies are auto-maintained from work logs. - # Reject body writes here rather than silently dropping so the LLM gets - # nudged toward log_work / description. - if note.is_task and arguments.get("body"): - return { - "success": False, - "error": ( - "Cannot write to `body` on a task — the body is auto-maintained " - "from work logs. Use the `log_work` tool to record progress, or " - "update `description` to revise the goal." - ), - } - - update_fields: dict = {} - if new_title: - update_fields["title"] = new_title - if new_body: - if mode == "append" and note.body: - update_fields["body"] = note.body + "\n\n" + new_body - else: - update_fields["body"] = new_body - if "description" in arguments: - update_fields["description"] = arguments["description"] - if "status" in arguments: - update_fields["status"] = arguments["status"] - if "priority" in arguments: - update_fields["priority"] = arguments["priority"] - if "due_date" in arguments: - update_fields["due_date"] = parse_due_date(arguments["due_date"]) - if "tags" in arguments: - new_tags = arguments["tags"] - if isinstance(new_tags, list): - tag_mode = arguments.get("tag_mode", "add") - if tag_mode == "add": - existing = list(note.tags or []) - for t in new_tags: - if t not in existing: - existing.append(t) - update_fields["tags"] = existing - elif tag_mode == "remove": - existing = list(note.tags or []) - update_fields["tags"] = [t for t in existing if t not in new_tags] - else: - update_fields["tags"] = new_tags - - if "project" in arguments: - project_name = arguments["project"] - if project_name: - proj = await resolve_project(user_id, project_name) - if proj is None: - return {"success": False, "error": f"Project '{project_name}' not found. Use list_projects to see available projects."} - update_fields["project_id"] = proj.id - else: - update_fields["project_id"] = None - update_fields["milestone_id"] = None - - if "milestone" in arguments: - milestone_name = arguments["milestone"] - if milestone_name: - ref_project_id = update_fields.get("project_id") or note.project_id - if ref_project_id is None: - return {"success": False, "error": "Cannot assign a milestone without a project. Set project first."} - from fabledassistant.services.milestones import get_milestone_by_title as _gmbt_upd - ms = await _gmbt_upd(user_id, ref_project_id, milestone_name) - if ms is None: - return {"success": False, "error": f"Milestone '{milestone_name}' not found. Use list_milestones to see available milestones."} - update_fields["milestone_id"] = ms.id - else: - update_fields["milestone_id"] = None - - updated = await update_note(user_id, note.id, **update_fields) - if updated is None: - return {"success": False, "error": "Failed to update note."} - - suggested = await suggest_tags(user_id, updated.title, updated.body or "") - schedule_embedding(updated.id, user_id, updated.title, updated.body or "") - item_type = "task" if updated.status is not None else "note" - return { - "success": True, - "type": "note_updated", - "updated": f"{item_type} '{updated.title}' (id: {updated.id})", - "data": { - "id": updated.id, - "title": updated.title, - "item_type": item_type, - "status": updated.status, - "tags": updated.tags or [], - "project_id": updated.project_id, - }, - "suggested_tags": suggested, - } - - -@tool( - name="search_notes", - description=( - "Find notes or tasks by meaning. Returns a ranked list of matches with " - "short previews. Use this when looking for items on a topic but you " - "don't know the exact title. For the full body of a known item, use " - "read_note instead. Do not include 'task', 'note', or 'project' in the " - "`query` — use the `type` and `project` parameters instead." - ), - parameters={ - "query": {"type": "string", "description": "A natural-language description of what you're looking for — concepts, themes, topics, or keywords"}, - "type": {"type": "string", "enum": ["note", "task"], "description": "Restrict results to only notes or only tasks. Omit to search both."}, - "project": {"type": "string", "description": "Optional project name to restrict search to notes in that project"}, - }, - required=["query"], - read_only=True, - briefing=True, -) -async def search_notes_tool(*, user_id, arguments, **_ctx): - query = arguments.get("query", "") - type_filter = arguments.get("type") - project_name = arguments.get("project") - - is_task: bool | None = None - if type_filter == "note": - is_task = False - elif type_filter == "task": - is_task = True - - search_project_id: int | None = None - if project_name: - proj = await resolve_project(user_id, project_name) - if proj: - search_project_id = proj.id - - results_map: dict[int, dict] = {} - - try: - from fabledassistant.services.embeddings import semantic_search_notes as _ssn - sem_results = await _ssn( - user_id, query, limit=8, threshold=0.40, - project_id=search_project_id, - ) - for score, n in sem_results: - n_is_task = n.status is not None - if is_task is True and not n_is_task: - continue - if is_task is False and n_is_task: - continue - results_map[n.id] = { - "id": n.id, - "title": n.title, - "type": "task" if n_is_task else "note", - "status": n.status, - "relevance": f"{round(score * 100)}%", - "preview": (n.body[:300] + "\u2026") if n.body and len(n.body) > 300 else (n.body or ""), - } - logger.debug("search_notes semantic: %d results for %r", len(results_map), query) - except Exception: - logger.debug("Semantic search unavailable in search_notes, using keyword only", exc_info=True) - - try: - kw_notes, _ = await list_notes( - user_id=user_id, q=query, is_task=is_task, - project_id=search_project_id, limit=8, - ) - for n in kw_notes: - if n.id not in results_map: - results_map[n.id] = { - "id": n.id, - "title": n.title, - "type": "task" if n.status is not None else "note", - "status": n.status, - "relevance": "keyword", - "preview": (n.body[:300] + "\u2026") if n.body and len(n.body) > 300 else (n.body or ""), - } - except Exception: - logger.warning("Keyword fallback in search_notes failed", exc_info=True) - - results = list(results_map.values())[:8] - return { - "success": True, - "type": "search", - "data": {"query": query, "count": len(results), "results": results}, - } - - -@tool( - name="read_note", - description="Read the full content of one specific note or task. Use when you know which item you want and need its complete body text. For discovering items by topic, use search_notes instead.", - parameters={ - "query": {"type": "string", "description": "Title or keyword to identify the note or task"}, - }, - required=["query"], - read_only=True, - briefing=True, -) -async def get_note_tool(*, user_id, arguments, **_ctx): - query = arguments.get("query", "") - note = await get_note_by_title(user_id, query) - if note is None: - notes, _ = await list_notes(user_id=user_id, q=query, limit=3) - if not notes: - return {"success": False, "error": f"No note found matching '{query}'."} - note = notes[0] - return { - "success": True, - "type": "note_content", - "data": {"id": note.id, "title": note.title, "body": note.body or "", "tags": note.tags or []}, - } - - -@tool( - name="list_notes", - description="Browse recent notes (not tasks) with optional filters. Returns titles and short previews. Use when the user asks 'show my notes' or wants to browse by tag, project, or recency. For tasks use list_tasks; for topic search use search_notes.", - parameters={ - "q": {"type": "string", "description": "Optional keyword filter"}, - "tags": {"type": "array", "items": {"type": "string"}, "description": "Filter notes that have ALL of these tags"}, - "project": {"type": "string", "description": "Filter notes by project name"}, - "sort": {"type": "string", "enum": ["updated_at", "created_at", "title"], "description": "Sort field (default: updated_at)"}, - "limit": {"type": "integer", "description": "Maximum number of notes to return (default 10)"}, - }, - read_only=True, - briefing=True, -) -async def list_notes_tool(*, user_id, arguments, **_ctx): - tags_raw = arguments.get("tags") - tags = tags_raw if isinstance(tags_raw, list) else None - project_id = None - project_name = arguments.get("project") - if project_name: - proj = await resolve_project(user_id, project_name) - if proj is None: - return {"success": False, "error": f"Project '{project_name}' not found."} - project_id = proj.id - notes, total = await list_notes( - user_id=user_id, - q=arguments.get("q") or arguments.get("query"), - tags=tags, - is_task=False, - project_id=project_id, - sort=arguments.get("sort", "updated_at"), - order="desc", - limit=int(arguments.get("limit", 10)), - ) - results = [ - { - "id": n.id, - "title": n.title, - "tags": n.tags or [], - "updated_at": n.updated_at.isoformat(), - "preview": (n.body[:200] + "...") if n.body and len(n.body) > 200 else (n.body or ""), - } - for n in notes - ] - return { - "success": True, - "type": "notes_list", - "data": {"total": total, "count": len(results), "results": results}, - } - - -@tool( - name="delete_note", - description="Delete any item from the user's knowledge base permanently — notes, tasks, persons (created via save_person), places (created via save_place), and lists (created via create_list) are all stored as notes and use this single delete tool. Use ONLY when the user explicitly asks to delete or remove an item. Always confirm with the user first — this cannot be undone.", - parameters={ - "query": {"type": "string", "description": "Title or keyword to find the item to delete (works for notes, tasks, persons, places, and lists)"}, - "confirmed": {"type": "boolean", "description": "Must be true — only set after the user has explicitly confirmed they want this item deleted."}, - }, - required=["query"], -) -async def delete_note_tool(*, user_id, arguments, **_ctx): - query = arguments.get("query", "") - note = await get_note_by_title(user_id, query) - if note is None: - notes, _ = await list_notes(user_id=user_id, q=query, limit=3) - if not notes: - return {"success": False, "error": f"No note or task found matching '{query}'."} - note = notes[0] - if not arguments.get("confirmed"): - item_type = "task" if note.status is not None else "note" - return { - "success": False, - "requires_confirmation": True, - "error": f"Deleting {item_type} '{note.title}' is permanent. Ask the user to confirm, then retry with confirmed=true.", - } - deleted = await delete_note(user_id, note.id) - if not deleted: - return {"success": False, "error": "Failed to delete."} - item_type = "task" if note.status is not None else "note" - return {"success": True, "type": f"{item_type}_deleted", "data": {"id": note.id, "title": note.title}} diff --git a/src/fabledassistant/services/tools/profile.py b/src/fabledassistant/services/tools/profile.py deleted file mode 100644 index d7796ab..0000000 --- a/src/fabledassistant/services/tools/profile.py +++ /dev/null @@ -1,77 +0,0 @@ -"""User profile tools.""" - -from __future__ import annotations - -from fabledassistant.services.tools._registry import tool - - -@tool( - name="get_profile", - description=( - "Retrieve the user's stored profile: name, job title, industry, expertise level, " - "preferred response style, tone, and interests. Use this when you need to personalise " - "a response and the user's profile hasn't already been injected into context." - ), - parameters={}, - read_only=True, -) -async def get_profile_tool(*, user_id, arguments, **_ctx): - from fabledassistant.services.user_profile import get_profile as _get_profile - - profile = await _get_profile(user_id) - return { - "success": True, - "type": "profile", - "data": { - "display_name": profile.display_name or "", - "job_title": profile.job_title or "", - "industry": profile.industry or "", - "expertise_level": profile.expertise_level or "intermediate", - "response_style": profile.response_style or "balanced", - "tone": profile.tone or "casual", - "interests": profile.interests or [], - }, - } - - -@tool( - name="update_profile", - description=( - "Update the user's stored profile when they share personal information: their name, " - "job, industry, expertise, preferred response style, tone, or interests. " - "Only set fields the user has explicitly mentioned." - ), - parameters={ - "display_name": {"type": "string", "description": "User's preferred display name"}, - "job_title": {"type": "string", "description": "Job title (e.g. 'Software Engineer')"}, - "industry": {"type": "string", "description": "Industry (e.g. 'Healthcare', 'Finance')"}, - "expertise_level": {"type": "string", "enum": ["novice", "intermediate", "expert"], "description": "User's general expertise level"}, - "response_style": {"type": "string", "enum": ["concise", "balanced", "detailed"], "description": "Preferred response length/depth"}, - "tone": {"type": "string", "enum": ["casual", "professional", "technical"], "description": "Preferred communication tone"}, - "interests": {"type": "array", "items": {"type": "string"}, "description": "List of topics or hobbies the user is interested in"}, - }, -) -async def update_profile_tool(*, user_id, arguments, **_ctx): - from fabledassistant.services.user_profile import VALID_EXPERTISE, VALID_STYLES, VALID_TONES, update_profile as _update_profile - - data: dict = {} - for field in ("display_name", "job_title", "industry"): - val = arguments.get(field) - if val is not None: - data[field] = str(val) - expertise = arguments.get("expertise_level") - if expertise in VALID_EXPERTISE: - data["expertise_level"] = expertise - style = arguments.get("response_style") - if style in VALID_STYLES: - data["response_style"] = style - tone = arguments.get("tone") - if tone in VALID_TONES: - data["tone"] = tone - interests = arguments.get("interests") - if isinstance(interests, list): - data["interests"] = [str(i) for i in interests if str(i).strip()] - if not data: - return {"success": False, "error": "No valid fields provided to update."} - await _update_profile(user_id, data) - return {"success": True, "type": "profile_updated", "data": {"fields_updated": list(data.keys())}} diff --git a/src/fabledassistant/services/tools/projects.py b/src/fabledassistant/services/tools/projects.py deleted file mode 100644 index a685062..0000000 --- a/src/fabledassistant/services/tools/projects.py +++ /dev/null @@ -1,250 +0,0 @@ -"""Project and milestone tools.""" - -from __future__ import annotations - -from fabledassistant.services.tools._helpers import fuzzy_title_match, resolve_project -from fabledassistant.services.tools._registry import tool - - -@tool( - name="create_project", - description="Create a new project. Use list_projects first to check for duplicates. Only call after user has explicitly confirmed.", - parameters={ - "title": {"type": "string", "description": "Project title"}, - "description": {"type": "string", "description": "Brief description"}, - "goal": {"type": "string", "description": "The goal or desired outcome"}, - "color": {"type": "string", "description": "Optional hex color for the project (e.g. '#6366f1')"}, - "confirmed": {"type": "boolean", "description": "Must be true — only set after the user has explicitly confirmed they want this project created."}, - }, - required=["title", "confirmed"], -) -async def create_project_tool(*, user_id, arguments, **_ctx): - from fabledassistant.services.projects import create_project as _create_project, get_project_by_title as _gpbt, list_projects as _lp - - proj_title = arguments.get("title", "New Project") - existing_proj = await _gpbt(user_id, proj_title) - if existing_proj is not None: - return {"success": False, "error": f"A project titled '{existing_proj.title}' already exists (id: {existing_proj.id}). Use that project instead of creating a duplicate."} - all_projects = await _lp(user_id) - near_proj, ratio = fuzzy_title_match(proj_title, all_projects, threshold=0.55) - if near_proj is not None: - return {"success": False, "requires_confirmation": True, "error": f"A project with a similar name '{near_proj.title}' already exists (id: {near_proj.id}, similarity: {ratio:.0%}). This is likely the project you meant — use that project's name instead. Only call create_project again if you are certain this should be a completely new, separate project with a different confirmed=true."} - if not arguments.get("confirmed"): - return {"success": False, "requires_confirmation": True, "error": f"Project creation requires explicit user confirmation. Ask the user: 'Shall I create a new project titled \"{proj_title}\"?' Then retry with confirmed=true if they agree."} - project = await _create_project( - user_id, - title=proj_title, - description=arguments.get("description", ""), - goal=arguments.get("goal", ""), - color=arguments.get("color") or None, - ) - return {"success": True, "type": "project", "data": project.to_dict()} - - -@tool( - name="list_projects", - description="List the user's projects. Use when asked about projects, initiatives, or campaigns.", - parameters={ - "status": {"type": "string", "enum": ["active", "completed", "archived"], "description": "Filter by status (omit for all)"}, - }, - read_only=True, - briefing=True, -) -async def list_projects_tool(*, user_id, arguments, **_ctx): - from fabledassistant.services.projects import list_projects as _list_projects - - projects = await _list_projects(user_id, status=arguments.get("status")) - return { - "success": True, - "type": "projects_list", - "data": {"count": len(projects), "projects": [p.to_dict() for p in projects]}, - } - - -@tool( - name="get_project", - description="Get details and summary of a specific project.", - parameters={ - "query": {"type": "string", "description": "Project name or keyword to find it"}, - }, - required=["query"], - read_only=True, - briefing=True, -) -async def get_project_tool(*, user_id, arguments, **_ctx): - from fabledassistant.services.projects import get_project_by_title as _gpbt, get_project_summary as _gps, list_projects as _lp - - query = arguments.get("query", "") - project = await _gpbt(user_id, query) - if project is None: - all_projects = await _lp(user_id) - matches = [p for p in all_projects if query.lower() in p.title.lower()] - if matches: - project = matches[0] - if project is None: - return {"success": False, "error": f"No project found matching '{query}'"} - summary = await _gps(user_id, project.id) - data = project.to_dict() - data["summary"] = summary - return {"success": True, "type": "project_detail", "data": data} - - -@tool( - name="update_project", - description="Update a project's title, status, description, goal, or color.", - parameters={ - "query": {"type": "string", "description": "Project name to find"}, - "title": {"type": "string", "description": "New project title"}, - "status": {"type": "string", "enum": ["active", "completed", "archived"]}, - "description": {"type": "string"}, - "goal": {"type": "string"}, - "color": {"type": "string", "description": "Hex color (e.g. '#6366f1'), or empty string to clear"}, - }, - required=["query"], -) -async def update_project_tool(*, user_id, arguments, **_ctx): - from fabledassistant.services.projects import get_project_by_title as _gpbt, update_project as _up, list_projects as _lp - - query = arguments.get("query", "") - project = await _gpbt(user_id, query) - if project is None: - all_projects = await _lp(user_id) - matches = [p for p in all_projects if query.lower() in p.title.lower()] - if matches: - project = matches[0] - if project is None: - return {"success": False, "error": f"No project found matching '{query}'"} - fields = {} - for k in ("title", "status", "description", "goal"): - if k in arguments: - fields[k] = arguments[k] - if "color" in arguments: - fields["color"] = arguments["color"] or None - updated = await _up(user_id, project.id, **fields) - return {"success": True, "type": "project_updated", "data": updated.to_dict()} - - -@tool( - name="search_projects", - description="Search for projects by name, description, or theme. Use this to find which project a user is referring to and get its ID for set_rag_scope.", - parameters={ - "query": {"type": "string", "description": "Search query — project name, topic, or theme"}, - }, - required=["query"], - read_only=True, - briefing=True, -) -async def search_projects_tool(*, user_id, arguments, **_ctx): - from fabledassistant.services.projects import list_projects - from fabledassistant.services.tools._helpers import score_project_match - - query = str(arguments.get("query", "")) - projects = await list_projects(user_id) - scored: list[tuple[float, object]] = [(score_project_match(query, p), p) for p in projects] - scored.sort(key=lambda x: x[0], reverse=True) - results = [] - for score, p in scored[:5]: - results.append({ - "id": p.id, - "title": p.title, - "summary_snippet": (p.auto_summary or p.description or "")[:200], - "score": round(score, 3), - }) - return {"success": True, "type": "projects_list", "data": {"projects": results}} - - -@tool( - name="create_milestone", - description="Create a milestone inside a project. Confirm name and project with user before calling.", - parameters={ - "project": {"type": "string", "description": "Project title"}, - "title": {"type": "string", "description": "Milestone title"}, - "description": {"type": "string", "description": "Optional description"}, - "confirmed": {"type": "boolean", "description": "Must be true — only set after the user has explicitly confirmed they want this milestone created."}, - }, - required=["project", "title", "confirmed"], -) -async def create_milestone_tool(*, user_id, arguments, **_ctx): - from fabledassistant.services.milestones import create_milestone as _cm, get_milestone_by_title as _gmbt2, list_milestones as _lms - - project_name = arguments.get("project", "") - ms_title = arguments.get("title", "Untitled Milestone") - if not project_name: - return {"success": False, "error": "project is required"} - if not arguments.get("confirmed"): - return {"success": False, "requires_confirmation": True, "error": f"Milestone creation requires explicit user confirmation. Ask the user: 'Shall I create a milestone titled \"{ms_title}\" in project \"{project_name}\"?' Then retry with confirmed=true if they agree."} - proj = await resolve_project(user_id, project_name) - if proj is None: - return {"success": False, "error": f"Project '{project_name}' not found. Use list_projects to find the correct project name and retry with the exact name."} - existing_ms = await _gmbt2(user_id, proj.id, ms_title) - if existing_ms is not None: - return {"success": False, "error": f"A milestone titled '{existing_ms.title}' already exists in '{proj.title}' (id: {existing_ms.id}). Use update_milestone to modify it instead."} - all_ms = await _lms(user_id, proj.id) - near_ms, ratio = fuzzy_title_match(ms_title, all_ms) - if near_ms is not None: - return {"success": False, "error": f"A milestone with a very similar title '{near_ms.title}' already exists in '{proj.title}' (id: {near_ms.id}, similarity: {ratio:.0%}). Use update_milestone to modify it instead."} - ms = await _cm(user_id, proj.id, title=ms_title, description=arguments.get("description")) - return {"success": True, "type": "milestone", "data": ms.to_dict()} - - -@tool( - name="update_milestone", - description="Update the title, description, or status of an existing milestone.", - parameters={ - "project": {"type": "string", "description": "Project title the milestone belongs to"}, - "milestone": {"type": "string", "description": "Current milestone title to look up"}, - "title": {"type": "string", "description": "New title (omit to keep current)"}, - "description": {"type": "string", "description": "New description (omit to keep current)"}, - "status": {"type": "string", "enum": ["active", "done"], "description": "New status: 'active' (in progress) or 'done' (completed)"}, - }, - required=["project", "milestone"], -) -async def update_milestone_tool(*, user_id, arguments, **_ctx): - from fabledassistant.services.milestones import get_milestone_by_title as _gmbt, update_milestone as _um - - project_name = arguments.get("project", "") - milestone_name = arguments.get("milestone", "") - if not project_name or not milestone_name: - return {"success": False, "error": "Both project and milestone are required"} - proj = await resolve_project(user_id, project_name) - if proj is None: - return {"success": False, "error": f"Project '{project_name}' not found"} - ms = await _gmbt(user_id, proj.id, milestone_name) - if ms is None: - return {"success": False, "error": f"Milestone '{milestone_name}' not found in project '{proj.title}'. Use list_milestones to see available milestones."} - fields: dict[str, object] = {} - if "title" in arguments: - fields["title"] = arguments["title"] - if "description" in arguments: - fields["description"] = arguments["description"] - if "status" in arguments: - fields["status"] = arguments["status"] - if not fields: - return {"success": False, "error": "No fields to update — provide at least one of: title, description, status"} - updated = await _um(user_id, ms.id, **fields) - return {"success": True, "type": "milestone", "data": updated.to_dict()} - - -@tool( - name="list_milestones", - description="List milestones for a project with their progress percentages.", - parameters={ - "project": {"type": "string", "description": "Project name to look up"}, - }, - required=["project"], - read_only=True, - briefing=True, -) -async def list_milestones_tool(*, user_id, arguments, **_ctx): - from fabledassistant.services.milestones import get_project_milestone_summary - - project_name = arguments.get("project", "") - proj = await resolve_project(user_id, project_name) - if proj is None: - return {"success": False, "error": f"No project found matching '{project_name}'"} - summary = await get_project_milestone_summary(user_id, proj.id) - return { - "success": True, - "type": "milestones_list", - "data": {"project": proj.title, "count": len(summary), "milestones": summary}, - } diff --git a/src/fabledassistant/services/tools/rag.py b/src/fabledassistant/services/tools/rag.py deleted file mode 100644 index e595fcd..0000000 --- a/src/fabledassistant/services/tools/rag.py +++ /dev/null @@ -1,34 +0,0 @@ -"""RAG scope tool.""" - -from __future__ import annotations - -from fabledassistant.services.tools._registry import tool - - -@tool( - name="set_rag_scope", - description="Change the RAG scope for this conversation. Use project_id= to scope to a project, project_id=null to scope to orphan notes only, project_id=-1 for all notes.", - parameters={ - "project_id": {"type": ["integer", "null"], "description": "Project ID to scope to, null for orphan-only, -1 for all notes"}, - }, - required=["project_id"], -) -async def set_rag_scope_tool(*, user_id, arguments, conv_id=None, workspace_project_id=None, **_ctx): - if workspace_project_id is not None: - return {"success": False, "error": "Cannot change RAG scope in workspace view"} - if conv_id is None: - return {"success": False, "error": "No conversation context available"} - project_id = arguments.get("project_id") - if project_id is not None and project_id != -1: - from fabledassistant.services.projects import get_project - proj = await get_project(user_id, int(project_id)) - if proj is None: - return {"success": False, "error": "Project not found"} - scope_label = proj.title - elif project_id == -1: - scope_label = "All notes" - else: - scope_label = "Orphan notes only" - from fabledassistant.services.chat import update_conversation - await update_conversation(user_id, conv_id, rag_project_id=project_id) - return {"success": True, "type": "rag_scope_set", "scope_label": scope_label} diff --git a/src/fabledassistant/services/tools/tasks.py b/src/fabledassistant/services/tools/tasks.py deleted file mode 100644 index 48677ff..0000000 --- a/src/fabledassistant/services/tools/tasks.py +++ /dev/null @@ -1,127 +0,0 @@ -"""Task tools: list, log work.""" - -from __future__ import annotations - -from fabledassistant.services.notes import get_note_by_title, list_notes -from fabledassistant.services.tools._helpers import ( - parse_due_date, - resolve_project, -) -from fabledassistant.services.tools._registry import tool - - -@tool( - name="list_tasks", - description="List tasks with optional filters. For overdue tasks, set due_before to today's date.", - parameters={ - "q": {"type": "string", "description": "Optional keyword filter — searches title and body"}, - "status": {"type": "array", "items": {"type": "string", "enum": ["todo", "in_progress", "done", "cancelled"]}, "description": "Filter by one or more statuses. Omit for all."}, - "priority": {"type": "string", "enum": ["none", "low", "medium", "high"], "description": "Filter by priority level"}, - "due_before": {"type": "string", "description": "Return tasks due before this date (YYYY-MM-DD). Use today's date to find overdue tasks."}, - "due_after": {"type": "string", "description": "Return tasks due on or after this date (YYYY-MM-DD)"}, - "project": {"type": "string", "description": "Filter tasks by project name"}, - "milestone": {"type": "string", "description": "Filter tasks by milestone name within a project"}, - "limit": {"type": "integer", "description": "Maximum number of tasks to return (default 10)"}, - }, - read_only=True, - briefing=True, -) -async def list_tasks_tool(*, user_id, arguments, **_ctx): - project_id = None - milestone_id = None - project_name = arguments.get("project") - milestone_name = arguments.get("milestone") - if project_name: - proj = await resolve_project(user_id, project_name) - if proj: - project_id = proj.id - if milestone_name: - from fabledassistant.services.milestones import get_milestone_by_title - ms = await get_milestone_by_title(user_id, proj.id, milestone_name) - if ms: - milestone_id = ms.id - elif milestone_name: - from fabledassistant.services.milestones import find_milestone_by_title - ms = await find_milestone_by_title(user_id, milestone_name) - if ms: - milestone_id = ms.id - notes, total = await list_notes( - user_id=user_id, - q=arguments.get("q") or None, - is_task=True, - status=arguments.get("status"), - priority=arguments.get("priority"), - due_before=parse_due_date(arguments.get("due_before")), - due_after=parse_due_date(arguments.get("due_after")), - project_id=project_id, - milestone_id=milestone_id, - exclude_paused_projects=project_id is None, - limit=int(arguments.get("limit", 10)), - sort="due_date", - order="asc", - ) - results = [] - for n in notes: - results.append({ - "id": n.id, - "title": n.title, - "status": n.status, - "priority": n.priority, - "due_date": str(n.due_date) if n.due_date else None, - "project_id": n.project_id, - "preview": (n.body[:120] + "...") if n.body and len(n.body) > 120 else (n.body or ""), - }) - return { - "success": True, - "type": "tasks", - "data": {"total": total, "count": len(results), "results": results}, - } - - -@tool( - name="log_work", - description=( - "Add a work log entry to a task to record progress, work done, or time spent. " - "Use this when the user says they worked on, completed, or spent time on a task. " - "Work logs feed the task's auto-summary: every few entries the task body is " - "rewritten from the logs by a background pass. Be specific (commands run, " - "decisions made, what failed vs. what worked) — the summary is only as good " - "as the logs." - ), - parameters={ - "task": {"type": "string", "description": "Title or keyword identifying the task (required)"}, - "content": {"type": "string", "description": "Description of the work done (required)"}, - "duration_minutes": {"type": "integer", "description": "Optional time spent in minutes"}, - }, - required=["task", "content"], -) -async def log_work_tool(*, user_id, arguments, **_ctx): - from fabledassistant.services.task_logs import create_log as _create_log - - task_query = arguments.get("task", "") - content = arguments.get("content", "").strip() - if not task_query: - return {"success": False, "error": "task is required"} - if not content: - return {"success": False, "error": "content is required"} - duration_minutes = arguments.get("duration_minutes") - if duration_minutes is not None: - try: - duration_minutes = int(duration_minutes) - except (TypeError, ValueError): - duration_minutes = None - note = await get_note_by_title(user_id, task_query) - if note is None or note.status is None: - notes, _ = await list_notes(user_id=user_id, q=task_query, is_task=True, limit=5) - note = notes[0] if notes else None - if note is None: - return {"success": False, "error": f"No task found matching '{task_query}'."} - try: - log = await _create_log(user_id, note.id, content, duration_minutes) - except ValueError as e: - return {"success": False, "error": str(e)} - - from fabledassistant.services.consolidation import maybe_consolidate - await maybe_consolidate(user_id, note.id, reason="log_added") - - return {"success": True, "log": log.to_dict(), "task": note.title} diff --git a/src/fabledassistant/services/tools/utility.py b/src/fabledassistant/services/tools/utility.py deleted file mode 100644 index 74f9ec1..0000000 --- a/src/fabledassistant/services/tools/utility.py +++ /dev/null @@ -1,34 +0,0 @@ -"""General-purpose utility tools.""" - -from __future__ import annotations - -from fabledassistant.services.tools._registry import tool - - -@tool( - name="calculate", - description=( - "Evaluate a mathematical expression and return the exact result. Use this for any " - "arithmetic, percentages, unit conversions, or multi-step calculations where precision " - "matters. Supports standard math operators (+, -, *, /, **, %) and all Python math " - "module functions (sqrt, log, sin, cos, floor, ceil, etc.)." - ), - parameters={ - "expression": {"type": "string", "description": "A valid Python math expression (e.g. '(450 * 1.13) / 12', 'sqrt(144)', 'log(1000, 10)')"}, - }, - required=["expression"], -) -async def calculate_tool(*, user_id, arguments, **_ctx): - import math as _math - - expr = str(arguments.get("expression", "")).strip() - if not expr: - return {"success": False, "error": "expression is required"} - allowed_names = {k: v for k, v in vars(_math).items() if not k.startswith("_")} - allowed_names["abs"] = abs - allowed_names["round"] = round - try: - result = eval(expr, {"__builtins__": {}}, allowed_names) # noqa: S307 - except Exception as calc_err: - return {"success": False, "error": f"Could not evaluate expression: {calc_err}"} - return {"success": True, "type": "calculation", "data": {"expression": expr, "result": result}} diff --git a/src/fabledassistant/services/tools/weather.py b/src/fabledassistant/services/tools/weather.py deleted file mode 100644 index 2f2f872..0000000 --- a/src/fabledassistant/services/tools/weather.py +++ /dev/null @@ -1,122 +0,0 @@ -"""Weather tool.""" - -from __future__ import annotations - -import json as _wx_json -import logging -from datetime import datetime, timedelta, timezone - -from fabledassistant.services.tools._registry import tool - -logger = logging.getLogger(__name__) - - -@tool( - name="get_weather", - description=( - "Get the weather forecast. By default returns today only — use days=7 when the user " - "explicitly asks for a multi-day forecast or 'this week'. " - "Pass a city/region name to look up any location, or omit to get the user's configured home/work locations." - ), - parameters={ - "location": {"type": "string", "description": "City/region to look up, or 'home'/'work' for saved locations. Omit for all saved."}, - "days": {"type": "integer", "description": "Number of days to return (1–8). Default 1 (today only). Use 7 or 8 when the user asks for a weekly forecast or multiple days."}, - }, - read_only=True, - briefing=True, -) -async def get_weather_tool(*, user_id, arguments, **_ctx): - from fabledassistant.services.weather import ( - _fetch_open_meteo, - geocode, - get_cached_weather, - get_temp_unit, - parse_forecast, - refresh_location_cache, - ) - - location = (arguments.get("location") or "").strip() - num_days = max(1, min(8, int(arguments.get("days") or 1))) - temp_unit = await get_temp_unit(user_id) - - def _apply_unit(days: list[dict]) -> list[dict]: - if temp_unit != "F": - return days - def _c_to_f(v: float | None) -> float | None: - return round(v * 9 / 5 + 32, 1) if v is not None else None - return [ - {**d, "temp_max": _c_to_f(d.get("temp_max")), "temp_min": _c_to_f(d.get("temp_min"))} - for d in days - ] - - _known = {"home", "work", "all", ""} - if location.lower() not in _known: - try: - lat, lon, label = await geocode(location) - raw = await _fetch_open_meteo(lat, lon) - days = _apply_unit(parse_forecast(raw))[:num_days] - return {"success": True, "data": {"locations": [{ - "location_key": "query", - "location_label": label, - "fetched_at": datetime.now(timezone.utc).isoformat(), - "days": days, - "temp_unit": temp_unit, - "changes_since_last_fetch": [], - }]}} - except Exception as e: - return {"success": False, "error": f"Could not get weather for '{location}': {e}"} - - locations = await get_cached_weather(user_id) - if location.lower() not in ("all", ""): - locations = [loc for loc in locations if loc["location_key"] == location.lower()] - now_utc = datetime.now(timezone.utc) - stale_cutoff = now_utc - timedelta(hours=6) - stale_keys = [] - for loc in locations: - try: - fetched = datetime.fromisoformat(loc.get("fetched_at", "")) - except Exception: - fetched = None - if fetched is None or fetched < stale_cutoff: - stale_keys.append(loc["location_key"]) - if stale_keys: - try: - from fabledassistant.services.settings import get_setting as _wx_get_setting - raw_cfg = await _wx_get_setting(user_id, "journal_config", "{}") - cfg = _wx_json.loads(raw_cfg) if isinstance(raw_cfg, str) else (raw_cfg or {}) - cfg_locs = cfg.get("locations", {}) if isinstance(cfg, dict) else {} - for key in stale_keys: - meta = cfg_locs.get(key) or {} - if meta.get("lat") and meta.get("lon"): - try: - await refresh_location_cache( - user_id=user_id, - location_key=key, - location_label=meta.get("label", key), - lat=meta["lat"], - lon=meta["lon"], - ) - except Exception: - logger.debug("Weather refresh failed for %s", key, exc_info=True) - locations = await get_cached_weather(user_id) - if location.lower() not in ("all", ""): - locations = [loc for loc in locations if loc["location_key"] == location.lower()] - except Exception: - logger.debug("Weather staleness refresh failed", exc_info=True) - - stamped_locations = [] - for loc in locations: - days = _apply_unit(loc.get("days", []))[:num_days] - try: - fetched = datetime.fromisoformat(loc.get("fetched_at", "")) - age_h = (now_utc - fetched).total_seconds() / 3600.0 - except Exception: - age_h = None - stamped_locations.append({ - **loc, - "days": days, - "temp_unit": temp_unit, - "cache_age_hours": round(age_h, 1) if age_h is not None else None, - "is_stale": age_h is not None and age_h > 12.0, - }) - return {"success": True, "data": {"locations": stamped_locations}} diff --git a/src/fabledassistant/services/tools/web.py b/src/fabledassistant/services/tools/web.py deleted file mode 100644 index 8a2652e..0000000 --- a/src/fabledassistant/services/tools/web.py +++ /dev/null @@ -1,195 +0,0 @@ -"""Web search, research, and image tools.""" - -from __future__ import annotations - -import logging - -from fabledassistant.services.tools._registry import tool - -logger = logging.getLogger(__name__) - - -@tool( - name="lookup", - description=( - "Look up a topic, concept, or factual question. Returns a concise answer from " - "Wikipedia or web sources. Use for definitions, explanations, 'what is X', " - "'how does Y work', current events, or version numbers. No note is saved. " - "For comprehensive written reports saved as notes, use research_topic instead." - ), - parameters={ - "query": {"type": "string", "description": "The topic or question to look up"}, - }, - required=["query"], -) -async def lookup_tool(*, user_id, arguments, **_ctx): - import asyncio - - from fabledassistant.config import Config - from fabledassistant.services.wikipedia import wiki_summary - - query = arguments.get("query", "").strip() - if not query: - return {"success": False, "error": "query is required"} - - searxng_enabled = Config.searxng_enabled() - - async def _searxng_results() -> list[dict]: - if not searxng_enabled: - return [] - from fabledassistant.services.research import _search_searxng - return await _search_searxng(query) or [] - - wiki, search_results = await asyncio.gather( - wiki_summary(query), - _searxng_results(), - ) - - wiki_payload: dict | None = None - if wiki: - wiki_payload = { - "title": wiki["title"], - "extract": wiki["extract"], - "url": wiki["url"], - } - thumb_url = wiki.get("thumbnail_url") or "" - if thumb_url: - from fabledassistant.services.images import fetch_and_store_image - record = await fetch_and_store_image( - url=thumb_url, - title=wiki["title"], - source_domain="en.wikipedia.org", - ) - if record: - wiki_payload["image"] = { - "embed": f"![{wiki['title']}](/api/images/{record.id})", - "citation": f"*Source: [Wikipedia]({wiki['url']})*", - } - - web_payload: list[dict] = [] - if search_results: - # Sequential fetches: trafilatura/lxml is not safe to run concurrently - # via run_in_executor — parallel calls can trip a libxml2 double-free. - from fabledassistant.services.article_fetcher import fetch_article_text - - for r in search_results[:2]: - url = r.get("url", "") - if not url: - continue - try: - content = await fetch_article_text(url) - except Exception: - content = None - web_payload.append({ - "url": url, - "title": r.get("title", url), - "snippet": r.get("snippet", ""), - "content": (content or "")[:4000], - }) - - if not wiki_payload and not web_payload: - return { - "success": True, - "type": "lookup", - "data": { - "query": query, - "message": "No results found. You can answer from your own knowledge.", - }, - } - - data: dict = { - "query": query, - "wikipedia": wiki_payload, - "web": web_payload, - } - if wiki_payload and wiki_payload.get("image"): - data["image_instructions"] = ( - "If an image is relevant to your reply, embed it by writing the wikipedia.image.embed " - "field verbatim, then the citation field on the next line. Otherwise omit it." - ) - return { - "success": True, - "type": "lookup", - "data": data, - } - - -@tool( - name="research_topic", - description=( - "Deep web research — searches multiple sources, synthesizes findings, and saves the result " - "as a structured note with sections and citations. Use when the user says 'research', " - "'look into', or wants a comprehensive write-up. Takes 30–120 seconds. " - "For a quick factual answer without saving a note, use lookup." - ), - parameters={ - "topic": {"type": "string", "description": "The topic or question to research"}, - }, - required=["topic"], - requires="searxng", -) -async def research_topic_tool(*, user_id, arguments, **_ctx): - # Research is always intercepted in generation_task.py (round 0) before execute_tool. - # Reaching here indicates a code path regression. - topic = arguments.get("topic", "") - logger.error( - "research_topic reached execute_tool — should have been intercepted upstream " - "in generation_task.py. topic=%r", - topic, - ) - return {"success": False, "error": "Research could not be started. Please try again."} - - -@tool( - name="search_images", - description="Search and display images inline. Use ONLY when the user explicitly asks to see, show, or find an image or photo. Not for factual questions — use lookup for those.", - parameters={ - "query": {"type": "string", "description": "The image search query"}, - }, - required=["query"], - requires="searxng", -) -async def search_images_tool(*, user_id, arguments, **_ctx): - from fabledassistant.services.images import fetch_and_store_image - from fabledassistant.services.research import _search_searxng_images - - query = arguments.get("query", "") - if not query: - return {"success": False, "error": "query is required"} - raw_results = await _search_searxng_images(query) - if not raw_results: - return {"success": False, "error": f"No images found for '{query}'"} - images = [] - for r in raw_results[:2]: - img_url = r.get("img_src", "") - if not img_url: - continue - record = await fetch_and_store_image( - url=img_url, - title=r.get("title"), - source_domain=r.get("source_domain"), - ) - if record: - title = record.title or query - page_url = r.get("page_url", "") - source_domain = r.get("source_domain", "") - images.append({ - "embed": f"![{title}](/api/images/{record.id})", - "citation": f"*Source: [{source_domain or 'image'}]({page_url})*", - "page_url": page_url, - "title": title, - }) - if not images: - return {"success": False, "error": f"Could not retrieve images for '{query}'"} - return { - "success": True, - "type": "image_search", - "data": { - "query": query, - "images": images, - "instructions": ( - "Embed each image in your response by writing the 'embed' field verbatim, " - "then the 'citation' field on the next line. Do not paraphrase or list URLs." - ), - }, - } diff --git a/src/fabledassistant/services/tts.py b/src/fabledassistant/services/tts.py deleted file mode 100644 index 55922e2..0000000 --- a/src/fabledassistant/services/tts.py +++ /dev/null @@ -1,288 +0,0 @@ -"""Text-to-speech service using piper-tts (in-process, CPU). - -Voice model files (.onnx + .onnx.json sidecar) live in two directories: - -- /opt/piper-voices/ — bundled at image build time, read-only. -- /data/voices/ — admin-downloaded at runtime, persisted across - restarts via the same volume as other /data/*. - -A single PiperVoice is kept loaded at a time (one voice per request would -be too slow; keeping all warm would use a lot of RAM for a feature most -users won't enable). The active voice is whichever one the user picked -via Settings → Voice — selection changes trigger reload_tts_model(). -""" -from __future__ import annotations - -import asyncio -import io -import json -import logging -import time -import wave -from pathlib import Path -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from piper import PiperVoice - -logger = logging.getLogger(__name__) - -# Two directories scanned for voices. Order matters for tie-breaking when -# both contain the same voice id — /data wins (admin-downloaded override). -_BUNDLED_VOICES_DIR = Path("/opt/piper-voices") -_USER_VOICES_DIR = Path("/data/voices") - -_DEFAULT_VOICE_ID = "en_US-amy-medium" - -_voice: "PiperVoice | None" = None -_voice_id: str | None = None # id of the currently-loaded voice -_voice_lock = asyncio.Lock() -_load_error: str | None = None - - -def _voice_dirs() -> list[Path]: - """Directories scanned for voice files, in precedence order (later wins).""" - return [_BUNDLED_VOICES_DIR, _USER_VOICES_DIR] - - -def _discover_voice(voice_id: str) -> Path | None: - """Find the .onnx file for `voice_id` across the scanned directories. - - /data wins over /opt if both contain the same id so an admin-downloaded - voice can override a bundled one if they want. - """ - found: Path | None = None - for d in _voice_dirs(): - candidate = d / f"{voice_id}.onnx" - if candidate.is_file() and (d / f"{voice_id}.onnx.json").is_file(): - found = candidate - return found - - -def _scan_voices() -> list[Path]: - """Return all valid voice files (.onnx + .onnx.json) across all dirs. - - Same-id duplicates are deduplicated; later directories override earlier - so a /data voice shadows a bundled one with the same name. - """ - by_id: dict[str, Path] = {} - for d in _voice_dirs(): - if not d.is_dir(): - continue - for onnx in sorted(d.glob("*.onnx")): - sidecar = onnx.with_suffix(".onnx.json") - if not sidecar.is_file(): - continue - by_id[onnx.stem] = onnx - return list(by_id.values()) - - -def _voice_metadata(onnx_path: Path) -> dict: - """Read the .onnx.json sidecar and return a UI-shaped metadata dict. - - The sidecar's full schema is large; we surface only what the picker - needs: id (filename stem), label (derived), language, quality, sample_rate. - """ - sidecar = onnx_path.with_suffix(".onnx.json") - info: dict = {} - try: - info = json.loads(sidecar.read_text()) - except Exception: - logger.warning("Could not parse piper sidecar %s", sidecar, exc_info=True) - - voice_id = onnx_path.stem - lang_info = info.get("language") or {} - audio_info = info.get("audio") or {} - # Filename convention: -- e.g. en_US-amy-medium - parts = voice_id.split("-") - dataset = parts[1] if len(parts) >= 2 else voice_id - quality = parts[2] if len(parts) >= 3 else "medium" - lang_code = lang_info.get("code") or (parts[0] if parts else "") - region_name = lang_info.get("name_native") or lang_info.get("region") or lang_code - - return { - "id": voice_id, - "label": f"{dataset.capitalize()} ({region_name}, {quality})", - "language": lang_code, - "quality": quality, - "sample_rate": audio_info.get("sample_rate", 22050), - } - - -async def load_tts_model() -> None: - """Load the active piper voice. Called once at startup if voice is enabled. - - The active voice is determined by the per-user `voice_tts_voice` setting - when known; falls back to `_DEFAULT_VOICE_ID` if unset or invalid. - Because the active voice is per-user but the loaded model is a single - in-process resource, this loads the default at startup; per-user voice - selection triggers a reload at request time. - """ - global _voice, _voice_id, _load_error - from fabledassistant.services.voice_config import is_voice_enabled - - if not await is_voice_enabled(): - return - - async with _voice_lock: - if _voice is not None: - return - try: - target_id = _DEFAULT_VOICE_ID - onnx_path = _discover_voice(target_id) - if onnx_path is None: - # No bundled default? Fall back to whatever is on disk. - discovered = _scan_voices() - if not discovered: - _load_error = ( - "No piper voice models found in /opt/piper-voices " - "or /data/voices" - ) - logger.error(_load_error) - return - onnx_path = discovered[0] - target_id = onnx_path.stem - logger.warning( - "Default voice %r not found; using %r instead", - _DEFAULT_VOICE_ID, target_id, - ) - - logger.info("Loading piper TTS voice %s from %s", target_id, onnx_path) - loop = asyncio.get_running_loop() - from piper import PiperVoice - - _voice = await loop.run_in_executor( - None, lambda: PiperVoice.load(str(onnx_path)) - ) - _voice_id = target_id - logger.info("Piper TTS voice %s loaded", target_id) - except Exception: - _load_error = "Failed to load piper TTS voice" - logger.exception(_load_error) - - -async def reload_tts_model(voice_id: str | None = None) -> None: - """Unload the current voice and load `voice_id` (or re-load the default). - - Safe to call at runtime — used when the user changes voice in Settings. - """ - global _voice, _voice_id, _load_error - async with _voice_lock: - _voice = None - _voice_id = None - _load_error = None - if voice_id: - await _switch_voice(voice_id) - else: - await load_tts_model() - - -async def _switch_voice(voice_id: str) -> None: - """Internal: load a specific voice by id. No-op if already loaded.""" - global _voice, _voice_id, _load_error - async with _voice_lock: - if _voice is not None and _voice_id == voice_id: - return - onnx_path = _discover_voice(voice_id) - if onnx_path is None: - _load_error = f"Voice {voice_id!r} not found in /opt or /data" - logger.warning(_load_error) - return - try: - logger.info("Switching piper voice to %s", voice_id) - loop = asyncio.get_running_loop() - from piper import PiperVoice - - _voice = await loop.run_in_executor( - None, lambda: PiperVoice.load(str(onnx_path)) - ) - _voice_id = voice_id - _load_error = None - except Exception: - _load_error = f"Failed to load piper voice {voice_id!r}" - logger.exception(_load_error) - - -def tts_available() -> bool: - return _voice is not None - - -def list_voices() -> list[dict]: - """Return metadata for every voice on disk, in stable order.""" - return [_voice_metadata(p) for p in _scan_voices()] - - -async def synthesise( - text: str, - voice: str = _DEFAULT_VOICE_ID, - speed: float = 1.0, - voice_blend: list[dict] | None = None, # accepted for backcompat; ignored -) -> bytes: - """Synthesise text → WAV bytes (PCM 16-bit mono). Runs in executor. - - `voice_blend` is accepted for backward compatibility with callers that - still pass it, but piper has no voice-blending equivalent, so it is - silently ignored. The first voice in the blend (if any) is used; if - that doesn't exist on disk, the request uses the default voice. - - `speed` is the kokoro-shaped multiplier (1.0=normal, 1.5=faster). - Piper uses `length_scale` where smaller = faster, so the conversion - is `length_scale = 1.0 / speed`. - """ - # Backcompat: if blend was passed, just use the first entry's voice. - if voice_blend and isinstance(voice_blend, list) and voice_blend: - first = voice_blend[0] - if isinstance(first, dict) and first.get("voice"): - voice = str(first["voice"]) - - # Lazy-load / switch if needed. - if _voice is None or _voice_id != voice: - await _switch_voice(voice) - if _voice is None: - raise RuntimeError(f"TTS voice {voice!r} not loaded: {_load_error}") - - speed = max(0.5, min(2.0, speed)) - length_scale = 1.0 / speed - - def _run() -> bytes: - # piper.SynthesisConfig is optional; defaults are sensible. - # We override length_scale only. - from piper import SynthesisConfig - - config = SynthesisConfig(length_scale=length_scale) - - t0 = time.monotonic() - buf = io.BytesIO() - sample_rate = _voice.config.sample_rate # type: ignore[union-attr] - total_samples = 0 - with wave.open(buf, "wb") as wav: - wav.setnchannels(1) - wav.setsampwidth(2) # 16-bit PCM - wav.setframerate(sample_rate) - try: - for chunk in _voice.synthesize(text, config): # type: ignore[union-attr] - pcm = chunk.audio_int16_bytes - if not pcm: - continue - wav.writeframes(pcm) - total_samples += len(pcm) // 2 - except Exception: - logger.exception( - "Piper synthesis error: %d chars, preview=%r", - len(text), text[:80], - ) - raise - - elapsed = time.monotonic() - t0 - logger.info( - "Piper synthesis: %d chars → %d samples (%.2fs, %.0f chars/s, voice=%s)", - len(text), total_samples, elapsed, - len(text) / elapsed if elapsed > 0 else 0, - _voice_id, - ) - if not total_samples: - return b"" - return buf.getvalue() - - loop = asyncio.get_running_loop() - return await loop.run_in_executor(None, _run) diff --git a/src/fabledassistant/services/user_profile.py b/src/fabledassistant/services/user_profile.py index 8b776b8..0813012 100644 --- a/src/fabledassistant/services/user_profile.py +++ b/src/fabledassistant/services/user_profile.py @@ -1,7 +1,11 @@ -"""User profile service — structured per-user preferences for LLM context.""" -import asyncio +"""User profile service — structured per-user preferences. + +Post-pivot the LLM-driven observation/consolidation surface is gone (curator +deleted in Phase 8). The profile model is preserved as user-managed metadata +(name, job, expertise, style, tone, interests, work schedule) — Claude can +read it via the upcoming MCP profile tools. +""" import logging -from datetime import date, datetime, timedelta, timezone from sqlalchemy import select @@ -14,9 +18,6 @@ VALID_EXPERTISE = {"novice", "intermediate", "expert"} VALID_STYLES = {"concise", "balanced", "detailed"} VALID_TONES = {"casual", "professional", "technical"} -# Trigger consolidation when raw observations reach this count -_CONSOLIDATION_THRESHOLD = 14 - async def get_profile(user_id: int) -> UserProfile: """Get or create the profile row for a user.""" @@ -53,169 +54,3 @@ async def update_profile(user_id: int, data: dict) -> UserProfile: await session.commit() await session.refresh(profile) return profile - - -async def append_observations(user_id: int, bullets: str) -> None: - """ - Append a new dated observation entry from the day's briefing closeout. - Automatically triggers consolidation when the raw list grows large. - """ - if not bullets.strip(): - return - - async with async_session() as session: - result = await session.execute( - select(UserProfile).where(UserProfile.user_id == user_id) - ) - profile = result.scalar_one_or_none() - if profile is None: - profile = UserProfile(user_id=user_id) - session.add(profile) - - existing: list = list(profile.observations_raw or []) - existing.append({ - "date": date.today().isoformat(), - "bullets": bullets.strip(), - }) - # Keep at most 60 raw entries as a rolling window - profile.observations_raw = existing[-60:] - profile.observations_updated_at = datetime.now(timezone.utc) - await session.commit() - raw_count = len(profile.observations_raw or []) - - logger.info("Appended observations for user %d (%d raw entries)", user_id, raw_count) - - if raw_count >= _CONSOLIDATION_THRESHOLD: - asyncio.create_task(_consolidate_observations(user_id)) - - -async def consolidate_observations(user_id: int) -> str: - """Public entry point to manually trigger observation consolidation.""" - return await _consolidate_observations(user_id) - - -async def clear_learned_data(user_id: int) -> None: - """Reset all learned observations and summary for a user.""" - async with async_session() as session: - result = await session.execute( - select(UserProfile).where(UserProfile.user_id == user_id) - ) - profile = result.scalar_one_or_none() - if profile: - profile.learned_summary = None - profile.observations_raw = [] - profile.observations_updated_at = None - await session.commit() - - -async def _consolidate_observations(user_id: int) -> str: - """ - LLM pass: synthesise all raw observation bullets into an updated - learned_summary paragraph. Prunes raw entries older than 30 days afterwards. - """ - from fabledassistant.config import Config - from fabledassistant.services.llm import generate_completion - from fabledassistant.services.settings import get_setting - - async with async_session() as session: - result = await session.execute( - select(UserProfile).where(UserProfile.user_id == user_id) - ) - profile = result.scalar_one_or_none() - if not profile or not profile.observations_raw: - return "" - observations = list(profile.observations_raw) - existing_summary = profile.learned_summary or "" - - obs_text = "\n\n".join( - f"[{entry['date']}]\n{entry['bullets']}" - for entry in observations - ) - - system = ( - "You are synthesising preference observations into a concise user profile summary. " - "Consolidate the observations into 3-6 factual sentences describing the user's patterns, " - "preferences, and habits. Be specific and useful for a personal assistant. " - "Merge with any existing summary, removing duplicates and outdated information. " - "Output only the consolidated summary paragraph — no preamble, no bullet points." - ) - user_prompt = "" - if existing_summary: - user_prompt += f"Existing summary:\n{existing_summary}\n\n" - user_prompt += f"New observations:\n{obs_text}" - - # Profile observation consolidation reasons over multiple documents to - # produce a coherent summary — closer to curator-shaped work than chat. - # Route to worker (background_model). Falls back to OLLAMA_MODEL only if - # neither setting nor BACKGROUND default is available. - model = ( - await get_setting(user_id, "background_model", "") - or Config.OLLAMA_BACKGROUND_MODEL - or Config.OLLAMA_MODEL - ) - try: - new_summary = (await generate_completion( - [ - {"role": "system", "content": system}, - {"role": "user", "content": user_prompt}, - ], - model, - )).strip() - except Exception: - logger.warning("Observation consolidation failed for user %d", user_id, exc_info=True) - new_summary = "" - - if new_summary: - cutoff = (date.today() - timedelta(days=30)).isoformat() - async with async_session() as session: - result = await session.execute( - select(UserProfile).where(UserProfile.user_id == user_id) - ) - profile = result.scalar_one_or_none() - if profile: - profile.learned_summary = new_summary - profile.observations_raw = [ - o for o in (profile.observations_raw or []) - if o.get("date", "") >= cutoff - ] - await session.commit() - logger.info("Consolidated observations for user %d", user_id) - - return new_summary - - -async def build_profile_context(user_id: int) -> str: - """ - Build a formatted context string from the user's structured profile - for injection into LLM system prompts (briefing and chat). - Returns an empty string if no meaningful data is set. - """ - profile = await get_profile(user_id) - - parts: list[str] = [] - - if profile.display_name: - parts.append(f"User's name: {profile.display_name}") - if profile.job_title or profile.industry: - job = " in ".join(filter(None, [profile.job_title, profile.industry])) - parts.append(f"Occupation: {job}") - if profile.expertise_level and profile.expertise_level != "intermediate": - parts.append( - f"Expertise level: {profile.expertise_level} — calibrate explanation depth accordingly" - ) - if profile.response_style or profile.tone: - style = profile.response_style or "balanced" - tone = profile.tone or "casual" - parts.append(f"Preferred response style: {style}, tone: {tone}") - if profile.interests: - parts.append(f"Interests: {', '.join(profile.interests)}") - if profile.work_schedule: - sched = profile.work_schedule - days = ", ".join(sched.get("days") or []) or "weekdays" - start = sched.get("start", "9:00") - end = sched.get("end", "17:00") - parts.append(f"Work schedule: {days}, {start}–{end}") - if profile.learned_summary: - parts.append(f"What the assistant has learned about this user: {profile.learned_summary}") - - return "\n".join(parts) diff --git a/src/fabledassistant/services/voice_config.py b/src/fabledassistant/services/voice_config.py deleted file mode 100644 index 546a792..0000000 --- a/src/fabledassistant/services/voice_config.py +++ /dev/null @@ -1,40 +0,0 @@ -"""System-wide voice configuration stored in the admin user's settings row. - -Falls back to Config env vars so operators can still seed defaults via the -environment without touching the UI. -""" -from sqlalchemy import select - -from fabledassistant.config import Config -from fabledassistant.models import async_session -from fabledassistant.models.setting import Setting -from fabledassistant.models.user import User - -VOICE_SETTING_KEYS = ("voice_enabled", "voice_stt_model") - - -async def get_voice_config() -> dict[str, str]: - """Return voice config, preferring DB values over env-var defaults.""" - config: dict[str, str] = { - "voice_enabled": "true" if Config.VOICE_ENABLED else "false", - "voice_stt_model": Config.STT_MODEL, - } - async with async_session() as session: - result = await session.execute( - select(Setting) - .join(User, Setting.user_id == User.id) - .where(User.role == "admin", Setting.key.in_(VOICE_SETTING_KEYS)) - ) - for setting in result.scalars().all(): - config[setting.key] = setting.value - return config - - -async def is_voice_enabled() -> bool: - config = await get_voice_config() - return config.get("voice_enabled", "false").lower() in ("1", "true", "yes") - - -async def get_stt_model() -> str: - config = await get_voice_config() - return config.get("voice_stt_model", Config.STT_MODEL) diff --git a/src/fabledassistant/services/voice_library.py b/src/fabledassistant/services/voice_library.py deleted file mode 100644 index 3bc7810..0000000 --- a/src/fabledassistant/services/voice_library.py +++ /dev/null @@ -1,251 +0,0 @@ -"""Piper voice library — browse + install voices from the HuggingFace catalog. - -Piper voices live in https://huggingface.co/rhasspy/piper-voices, with a -machine-readable catalog at: - - https://huggingface.co/rhasspy/piper-voices/raw/main/voices.json - -That catalog enumerates every available voice (~250+ across many languages) -with file sizes and download paths. We cache it in memory with a TTL so -the Settings UI's "browse voices" panel doesn't hit HuggingFace every time -the page opens, but a manual "refresh" button can force a re-fetch. - -Downloads land in /data/voices so they persist across container restarts. -Bundled voices in /opt/piper-voices are read-only and cannot be deleted -via the admin UI. -""" -from __future__ import annotations - -import asyncio -import json -import logging -import re -import shutil -import time -import urllib.request -from pathlib import Path - -logger = logging.getLogger(__name__) - -CATALOG_URL = ( - "https://huggingface.co/rhasspy/piper-voices/raw/main/voices.json" -) -VOICE_BASE_URL = ( - "https://huggingface.co/rhasspy/piper-voices/resolve/main" -) -_USER_VOICES_DIR = Path("/data/voices") -_BUNDLED_VOICES_DIR = Path("/opt/piper-voices") - -# Voice IDs are like `en_US-amy-medium`, `fr_FR-siwis-low` — strict -# allowlist to prevent path traversal in download/delete handlers. -_VOICE_ID_RE = re.compile(r"^[a-zA-Z]{2,3}(?:_[a-zA-Z]{2,4})?-[a-zA-Z0-9_+]+-(?:x_low|low|medium|high)$") - -# In-memory cache. Catalog rarely changes (kokoro-style "model update" -# checks would be overkill — admin can force-refresh from the UI). -_CATALOG_CACHE: dict | None = None -_CATALOG_FETCHED_AT: float = 0.0 -_CATALOG_TTL_SECS = 24 * 3600 # 24h — long, but the manual refresh button overrides -_CATALOG_LOCK = asyncio.Lock() - - -def _is_valid_voice_id(voice_id: str) -> bool: - return bool(_VOICE_ID_RE.match(voice_id)) - - -async def fetch_catalog(force_refresh: bool = False) -> dict: - """Return the piper-voices catalog dict, fetching from HF if stale. - - Cached in memory across requests. force_refresh=True bypasses the TTL. - Raises on network error so the UI can surface the failure. - """ - global _CATALOG_CACHE, _CATALOG_FETCHED_AT - async with _CATALOG_LOCK: - now = time.monotonic() - fresh_enough = ( - _CATALOG_CACHE is not None - and (now - _CATALOG_FETCHED_AT) < _CATALOG_TTL_SECS - ) - if fresh_enough and not force_refresh: - return _CATALOG_CACHE # type: ignore[return-value] - - loop = asyncio.get_running_loop() - - def _do_fetch() -> dict: - with urllib.request.urlopen(CATALOG_URL, timeout=30) as resp: - raw = resp.read().decode("utf-8") - return json.loads(raw) - - try: - catalog = await loop.run_in_executor(None, _do_fetch) - except Exception: - logger.exception("Failed to fetch piper voices catalog from %s", CATALOG_URL) - raise - - _CATALOG_CACHE = catalog - _CATALOG_FETCHED_AT = now - logger.info("Piper voices catalog refreshed: %d entries", len(catalog)) - return catalog - - -def _is_installed(voice_id: str) -> tuple[bool, str | None]: - """Return (installed, source) where source is 'bundled' / 'user' / None.""" - if (_BUNDLED_VOICES_DIR / f"{voice_id}.onnx").is_file(): - return True, "bundled" - if (_USER_VOICES_DIR / f"{voice_id}.onnx").is_file(): - return True, "user" - return False, None - - -def shape_catalog_for_ui(catalog: dict) -> list[dict]: - """Project the raw HF catalog into a list of UI-friendly voice cards. - - The HF catalog uses voice IDs as keys with nested file-size metadata. - The UI just needs id, language, dataset, quality, size, install state. - Sorted by language then dataset for stable display. - """ - out: list[dict] = [] - for voice_id, info in catalog.items(): - if not isinstance(info, dict): - continue - installed, source = _is_installed(voice_id) - lang_info = info.get("language") or {} - files = info.get("files") or {} - # Sum the file sizes for the .onnx + .onnx.json pair so the UI - # can show "≈ 62 MB" before the user clicks Download. - total_bytes = 0 - for f_info in files.values(): - if isinstance(f_info, dict): - total_bytes += int(f_info.get("size_bytes", 0) or 0) - out.append({ - "id": voice_id, - "name": info.get("name") or voice_id.split("-")[1], - "language_code": lang_info.get("code") or voice_id.split("-")[0], - "language_name": lang_info.get("name_native") - or lang_info.get("name_english") - or lang_info.get("code", ""), - "country": lang_info.get("country_english") or lang_info.get("region", ""), - "quality": info.get("quality") or voice_id.split("-")[-1], - "num_speakers": int(info.get("num_speakers") or 1), - "size_bytes": total_bytes, - "installed": installed, - "installed_source": source, - }) - out.sort(key=lambda v: (v["language_code"], v["name"], v["quality"])) - return out - - -def _resolve_file_urls(catalog: dict, voice_id: str) -> dict[str, str]: - """Return absolute URLs for the .onnx + .onnx.json files of `voice_id`. - - Walks the catalog entry's `files` dict; that's the authoritative path - list (the layout in the repo can drift, e.g. some voices live under - different sub-paths). Returns {extension: url}. - """ - info = catalog.get(voice_id) - if not isinstance(info, dict): - raise KeyError(f"Voice {voice_id!r} not in catalog") - files = info.get("files") or {} - urls: dict[str, str] = {} - for rel_path in files.keys(): - if rel_path.endswith(".onnx"): - urls["onnx"] = f"{VOICE_BASE_URL}/{rel_path}" - elif rel_path.endswith(".onnx.json"): - urls["onnx.json"] = f"{VOICE_BASE_URL}/{rel_path}" - if "onnx" not in urls or "onnx.json" not in urls: - raise ValueError( - f"Catalog entry for {voice_id!r} is missing .onnx or .onnx.json file" - ) - return urls - - -async def install_voice(voice_id: str) -> dict: - """Download a voice's .onnx + .onnx.json into /data/voices. - - Idempotent — if already installed under /data, returns success without - re-downloading. Bundled voices in /opt are not touched; the same voice - can also live under /data if the admin wants to override it. - Returns {"id", "size_bytes", "skipped"}. - """ - if not _is_valid_voice_id(voice_id): - raise ValueError(f"Invalid voice id: {voice_id!r}") - - _USER_VOICES_DIR.mkdir(parents=True, exist_ok=True) - onnx_path = _USER_VOICES_DIR / f"{voice_id}.onnx" - sidecar_path = _USER_VOICES_DIR / f"{voice_id}.onnx.json" - - if onnx_path.is_file() and sidecar_path.is_file(): - return { - "id": voice_id, - "size_bytes": onnx_path.stat().st_size + sidecar_path.stat().st_size, - "skipped": True, - } - - catalog = await fetch_catalog() - urls = _resolve_file_urls(catalog, voice_id) - - loop = asyncio.get_running_loop() - - def _download() -> int: - total = 0 - # Use atomic write — download to .tmp then rename. Means a failed - # partial download doesn't leave a corrupt model in place that - # would later cause PiperVoice.load() to throw. - for ext, url in urls.items(): - target = _USER_VOICES_DIR / f"{voice_id}.{ext}" - tmp = target.with_suffix(target.suffix + ".tmp") - logger.info("Downloading piper voice file %s → %s", url, target) - with urllib.request.urlopen(url, timeout=120) as resp: - with open(tmp, "wb") as fh: - shutil.copyfileobj(resp, fh, length=1024 * 1024) - tmp.replace(target) - total += target.stat().st_size - return total - - try: - size = await loop.run_in_executor(None, _download) - except Exception: - # Clean up partials. - for ext in ("onnx", "onnx.json"): - for p in ( - _USER_VOICES_DIR / f"{voice_id}.{ext}", - _USER_VOICES_DIR / f"{voice_id}.{ext}.tmp", - ): - try: - if p.exists(): - p.unlink() - except Exception: - pass - logger.exception("Voice install failed for %s — cleaned up partials", voice_id) - raise - - logger.info("Installed piper voice %s (%d bytes)", voice_id, size) - return {"id": voice_id, "size_bytes": size, "skipped": False} - - -async def uninstall_voice(voice_id: str) -> dict: - """Remove a /data voice. Bundled voices in /opt cannot be removed via API. - - Returns {"id", "removed": bool}. - """ - if not _is_valid_voice_id(voice_id): - raise ValueError(f"Invalid voice id: {voice_id!r}") - - # Refuse to delete a bundled voice. If a /data override exists alongside - # a bundled one with the same id, only the /data copy is removed. - if not (_USER_VOICES_DIR / f"{voice_id}.onnx").is_file() and not ( - _USER_VOICES_DIR / f"{voice_id}.onnx.json" - ).is_file(): - if (_BUNDLED_VOICES_DIR / f"{voice_id}.onnx").is_file(): - raise PermissionError( - f"Voice {voice_id!r} is bundled with the image — cannot be removed" - ) - return {"id": voice_id, "removed": False} - - removed_any = False - for ext in ("onnx", "onnx.json"): - p = _USER_VOICES_DIR / f"{voice_id}.{ext}" - if p.is_file(): - p.unlink() - removed_any = True - logger.info("Uninstalled piper voice %s (files removed: %s)", voice_id, removed_any) - return {"id": voice_id, "removed": removed_any} diff --git a/src/fabledassistant/services/weather.py b/src/fabledassistant/services/weather.py deleted file mode 100644 index 46051a1..0000000 --- a/src/fabledassistant/services/weather.py +++ /dev/null @@ -1,408 +0,0 @@ -"""Weather service: geocoding, Open-Meteo 7-day forecast, caching, change detection.""" - -import logging -from datetime import datetime, timezone - -import httpx -from sqlalchemy import select - -from fabledassistant.models import async_session -from fabledassistant.models.weather_cache import WeatherCache - -logger = logging.getLogger(__name__) - -NOMINATIM_URL = "https://nominatim.openstreetmap.org/search" -OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast" -OPEN_METEO_DAILY = ( - "temperature_2m_max,temperature_2m_min,precipitation_sum," - "precipitation_probability_max,weathercode,windspeed_10m_max" -) -OPEN_METEO_HOURLY = "precipitation_probability" - -# WMO weather code → description (subset; covers the most common codes) -_WMO_CODES: dict[int, str] = { - 0: "Clear sky", - 1: "Mainly clear", 2: "Partly cloudy", 3: "Overcast", - 45: "Fog", 48: "Depositing rime fog", - 51: "Drizzle: light", 53: "Drizzle: moderate", 55: "Drizzle: dense", - 61: "Rain: slight", 63: "Rain: moderate", 65: "Rain: heavy", - 71: "Snow: slight", 73: "Snow: moderate", 75: "Snow: heavy", - 77: "Snow grains", - 80: "Rain showers: slight", 81: "Rain showers: moderate", 82: "Rain showers: violent", - 85: "Snow showers: slight", 86: "Snow showers: heavy", - 95: "Thunderstorm", - 96: "Thunderstorm with slight hail", 99: "Thunderstorm with heavy hail", -} - - -def describe_weathercode(code: int) -> str: - return _WMO_CODES.get(code, f"Unknown (code {code})") - - -async def get_temp_unit(user_id: int) -> str: - """Read the user's preferred temperature unit ('C' or 'F'), default 'C'.""" - from fabledassistant.services.settings import get_setting - raw = await get_setting(user_id, "temp_unit", "C") - return raw if raw in ("C", "F") else "C" - - -def parse_forecast(raw: dict) -> list[dict]: - """Convert Open-Meteo daily response into a clean list of day dicts.""" - daily = raw.get("daily", {}) - dates = daily.get("time", []) - precip_prob = daily.get("precipitation_probability_max", []) - return [ - { - "date": dates[i], - "temp_max": daily.get("temperature_2m_max", [])[i], - "temp_min": daily.get("temperature_2m_min", [])[i], - "precip_mm": daily.get("precipitation_sum", [])[i], - "precip_probability": precip_prob[i] if i < len(precip_prob) else None, - "weathercode": daily.get("weathercode", [])[i], - "description": describe_weathercode(daily.get("weathercode", [])[i]), - "windspeed_max": daily.get("windspeed_10m_max", [])[i], - } - for i in range(len(dates)) - ] - - -def detect_changes(old_days: list[dict], new_days: list[dict]) -> list[str]: - """Return human-readable change strings where the forecast has meaningfully shifted.""" - old_by_date = {d["date"]: d for d in old_days} - changes = [] - for day in new_days: - date = day["date"] - old = old_by_date.get(date) - if not old: - continue - notes = [] - # Weather condition changed - if day["weathercode"] != old["weathercode"]: - notes.append( - f"conditions changed from '{describe_weathercode(old['weathercode'])}' " - f"to '{describe_weathercode(day['weathercode'])}'" - ) - # Precipitation appeared or disappeared (threshold: 1mm) - was_dry = old["precip_mm"] < 1.0 - now_wet = day["precip_mm"] >= 1.0 - was_wet = old["precip_mm"] >= 1.0 - now_dry = day["precip_mm"] < 1.0 - if was_dry and now_wet: - notes.append(f"rain now expected ({day['precip_mm']:.1f}mm)") - elif was_wet and now_dry: - notes.append("rain no longer expected") - # Temperature shifted by 4°C or more - temp_shift = abs(day["temp_max"] - old["temp_max"]) - if temp_shift >= 4: - direction = "warmer" if day["temp_max"] > old["temp_max"] else "colder" - notes.append(f"high temperature {direction} by {temp_shift:.0f}°C") - if notes: - changes.append(f"{date}: " + "; ".join(notes)) - return changes - - -def _summarize_precip(hourly_probs: list[tuple[int, int]], threshold: int = 30) -> str | None: - """Build a human-readable precipitation summary from (hour, probability) pairs. - - Returns None when no significant precipitation is expected. - """ - wet_hours = [(h, p) for h, p in hourly_probs if p >= threshold] - if not wet_hours: - return None - - peak_hour, peak_prob = max(wet_hours, key=lambda x: x[1]) - daytime_hours = [h for h, _ in hourly_probs if 6 <= h <= 22] - if not daytime_hours: - return None - - wet_daytime = [h for h, p in hourly_probs if 6 <= h <= 22 and p >= threshold] - if len(wet_daytime) >= 10: - return f"Rain likely all day (up to {peak_prob}%)" - - if not wet_daytime: - return None - - def _fmt_hour(h: int) -> str: - if h == 0 or h == 24: - return "12 AM" - if h == 12: - return "12 PM" - return f"{h} AM" if h < 12 else f"{h - 12} PM" - - start = wet_daytime[0] - end = wet_daytime[-1] - if start == end: - return f"{peak_prob}% chance around {_fmt_hour(start)}" - return f"Rain likely {_fmt_hour(start)}–{_fmt_hour(end + 1)} (up to {peak_prob}%)" - - -def _extract_hourly_precip_for_date(raw: dict, date_str: str) -> list[tuple[int, int]]: - """Extract (hour, probability) pairs for a specific date from cached forecast JSON.""" - hourly = raw.get("hourly", {}) - times = hourly.get("precipitation_probability", []) - time_labels = hourly.get("time", []) - pairs: list[tuple[int, int]] = [] - prefix = date_str + "T" - for i, t in enumerate(time_labels): - if t.startswith(prefix) and i < len(times) and times[i] is not None: - hour = int(t[11:13]) - pairs.append((hour, times[i])) - return pairs - - -def parse_weather_card_data( - cache_row, - temp_unit: str = "C", -) -> dict | None: - """ - Parse a WeatherCache row into the metadata.weather card schema. - Returns None if the cache row is missing or unparseable. Stale data is - returned as-is — the frontend uses `fetched_at` to convey freshness, and - WeatherCard handles missing today/forecast fields gracefully. - """ - from datetime import date, timedelta - - if cache_row is None or cache_row.fetched_at is None or not cache_row.forecast_json: - return None - - raw = cache_row.forecast_json or {} - current_weather = raw.get("current_weather", {}) - days = parse_forecast(raw) - - today_str = date.today().isoformat() - yesterday_str = (date.today() - timedelta(days=1)).isoformat() - - today_day = next((d for d in days if d["date"] == today_str), None) - yesterday_day = next((d for d in days if d["date"] == yesterday_str), None) - future_days = [d for d in days if d["date"] > today_str][:5] - - imperial = temp_unit == "F" - - def to_temp(c: float) -> int: - return round(c * 9 / 5 + 32) if imperial else round(c) - - def to_wind(kmh: float) -> int: - return round(kmh * 0.621371) if imperial else round(kmh) - - def day_label(date_str: str) -> str: - from datetime import date as _date - try: - return _date.fromisoformat(date_str).strftime("%a") - except ValueError: - return date_str - - wind_unit = "mph" if imperial else "km/h" - - today_hourly = _extract_hourly_precip_for_date(raw, today_str) - today_precip_summary = _summarize_precip(today_hourly) - - def _forecast_day(d: dict) -> dict: - entry: dict = { - "day": day_label(d["date"]), - "condition": d["description"], - "high": to_temp(d["temp_max"]), - "low": to_temp(d["temp_min"]), - "precip_probability": d["precip_probability"], - "precip_mm": d["precip_mm"], - "windspeed_max": to_wind(d["windspeed_max"]), - } - hourly = _extract_hourly_precip_for_date(raw, d["date"]) - summary = _summarize_precip(hourly) - if summary: - entry["precip_summary"] = summary - if hourly: - peak = max(hourly, key=lambda x: x[1]) - if peak[1] >= 30: - h = peak[0] - entry["precip_peak_hour"] = f"{h} AM" if h < 12 else ("12 PM" if h == 12 else f"{h - 12} PM") - return entry - - return { - "location": getattr(cache_row, "location_label", ""), - "fetched_at": cache_row.fetched_at.isoformat(), - "current_temp": to_temp(current_weather.get("temperature", 0)), - "condition": describe_weathercode(current_weather.get("weathercode", 0)), - "today_high": to_temp(today_day["temp_max"]) if today_day else None, - "today_low": to_temp(today_day["temp_min"]) if today_day else None, - "yesterday_high": to_temp(yesterday_day["temp_max"]) if yesterday_day else None, - "yesterday_low": to_temp(yesterday_day["temp_min"]) if yesterday_day else None, - "wind_unit": wind_unit, - "precip_summary": today_precip_summary, - "forecast": [_forecast_day(d) for d in future_days], - } - - -async def get_cached_weather_rows( - user_id: int, - valid_keys: set[str] | None = None, -) -> list: - """Return raw WeatherCache ORM rows for a user (for card parsing). - - If ``valid_keys`` is provided, only rows whose ``location_key`` is in the - set are returned. This is how callers drop orphaned cache rows whose - location is no longer in the user's ``journal_config.locations`` (e.g. - leftovers from the briefing era, or a location that's been removed). - Passing an empty set returns no rows. - """ - async with async_session() as session: - stmt = select(WeatherCache).where(WeatherCache.user_id == user_id) - if valid_keys is not None: - stmt = stmt.where(WeatherCache.location_key.in_(list(valid_keys))) - result = await session.execute(stmt) - return list(result.scalars().all()) - - -async def geocode(query: str) -> tuple[float, float, str]: - """Return (lat, lon, display_name) for a place name using Nominatim.""" - async with httpx.AsyncClient(timeout=10.0, headers={"User-Agent": "FabledAssistant/1.0"}) as client: - resp = await client.get(NOMINATIM_URL, params={"q": query, "format": "json", "limit": 1}) - resp.raise_for_status() - results = resp.json() - if not results: - raise ValueError(f"No geocoding result for: {query!r}") - r = results[0] - return float(r["lat"]), float(r["lon"]), r.get("display_name", query) - - -async def fetch_current_conditions(lat: float, lon: float) -> dict | None: - """Fetch current temperature + conditions + next 3 hours precipitation. - - Lightweight call — no daily forecast, no caching needed. - Returns dict with: temperature, windspeed, weathercode, description, - and precip_next_3h (list of hourly precip probabilities). - """ - try: - async with httpx.AsyncClient(timeout=10.0) as client: - resp = await client.get(OPEN_METEO_URL, params={ - "latitude": lat, - "longitude": lon, - "current_weather": "true", - "hourly": "precipitation_probability", - "forecast_days": 1, - "timezone": "auto", - }) - resp.raise_for_status() - data = resp.json() - - current = data.get("current_weather", {}) - hourly = data.get("hourly", {}) - hourly_times = hourly.get("time", []) - hourly_precip = hourly.get("precipitation_probability", []) - - # Find the current hour index and get next 3 hours of precip - current_time = current.get("time", "") - precip_next_3h = [] - try: - idx = next(i for i, t in enumerate(hourly_times) if t >= current_time) - precip_next_3h = hourly_precip[idx:idx + 3] - except (StopIteration, IndexError): - pass - - code = current.get("weathercode", 0) - return { - "temperature": current.get("temperature"), - "windspeed": current.get("windspeed"), - "weathercode": code, - "description": describe_weathercode(code), - "time": current_time, - "precip_next_3h": precip_next_3h, - } - except Exception: - logger.debug("Failed to fetch current conditions for %s, %s", lat, lon, exc_info=True) - return None - - -async def fetch_hourly_precip(lat: float, lon: float) -> dict[str, int]: - """Fetch today's hourly precipitation probabilities. - - Returns a dict of ISO hour string → probability percentage, e.g.: - {"2026-04-08T14:00": 65, "2026-04-08T15:00": 80, ...} - """ - try: - async with httpx.AsyncClient(timeout=10.0) as client: - resp = await client.get(OPEN_METEO_URL, params={ - "latitude": lat, - "longitude": lon, - "hourly": "precipitation_probability", - "forecast_days": 1, - "timezone": "auto", - }) - resp.raise_for_status() - data = resp.json() - hourly = data.get("hourly", {}) - times = hourly.get("time", []) - probs = hourly.get("precipitation_probability", []) - return {t: p for t, p in zip(times, probs) if p is not None} - except Exception: - logger.debug("Failed to fetch hourly precip for %s, %s", lat, lon, exc_info=True) - return {} - - -async def _fetch_open_meteo(lat: float, lon: float) -> dict: - """Fetch 7-day forecast from Open-Meteo with current conditions, hourly precip, and yesterday's data.""" - async with httpx.AsyncClient(timeout=15.0) as client: - resp = await client.get(OPEN_METEO_URL, params={ - "latitude": lat, - "longitude": lon, - "daily": OPEN_METEO_DAILY, - "hourly": OPEN_METEO_HOURLY, - "current_weather": "true", - "past_days": 1, - "timezone": "auto", - "forecast_days": 7, - }) - resp.raise_for_status() - return resp.json() - - -async def refresh_location_cache( - user_id: int, location_key: str, location_label: str, lat: float, lon: float -) -> WeatherCache: - """Fetch fresh forecast and update the cache row for one location.""" - raw = await _fetch_open_meteo(lat, lon) - async with async_session() as session: - result = await session.execute( - select(WeatherCache).where( - WeatherCache.user_id == user_id, - WeatherCache.location_key == location_key, - ) - ) - cache = result.scalars().first() - if cache is None: - cache = WeatherCache( - user_id=user_id, - location_key=location_key, - location_label=location_label, - ) - session.add(cache) - # Rotate: current becomes previous before overwriting - cache.previous_json = cache.forecast_json - cache.forecast_json = raw - cache.location_label = location_label - cache.fetched_at = datetime.now(timezone.utc) - await session.commit() - await session.refresh(cache) - return cache - - -async def get_cached_weather(user_id: int) -> list[dict]: - """Return all cached weather entries for the user, with parsed days and change notes.""" - async with async_session() as session: - result = await session.execute( - select(WeatherCache).where(WeatherCache.user_id == user_id) - ) - rows = list(result.scalars().all()) - - out = [] - for row in rows: - current_days = parse_forecast(row.forecast_json) if row.forecast_json else [] - previous_days = parse_forecast(row.previous_json) if row.previous_json else [] - changes = detect_changes(previous_days, current_days) if previous_days else [] - out.append({ - "location_key": row.location_key, - "location_label": row.location_label, - "fetched_at": row.fetched_at.isoformat(), - "days": current_days, - "changes_since_last_fetch": changes, - }) - return out diff --git a/src/fabledassistant/services/wikipedia.py b/src/fabledassistant/services/wikipedia.py deleted file mode 100644 index 17185cf..0000000 --- a/src/fabledassistant/services/wikipedia.py +++ /dev/null @@ -1,113 +0,0 @@ -"""Wikipedia API: lightweight topic lookups and article search.""" -import logging -from urllib.parse import quote as url_quote - -import httpx - -logger = logging.getLogger(__name__) - -_SUMMARY_URL = "https://en.wikipedia.org/api/rest_v1/page/summary" -_SEARCH_URL = "https://en.wikipedia.org/w/api.php" -_TIMEOUT = 5.0 -_USER_AGENT = "FabledAssistant/1.0 (https://fabledsword.com)" - - -def _extract_thumbnail(data: dict) -> str: - """Prefer the 320px thumbnail (kinder to cache/bandwidth) over originalimage.""" - thumb = data.get("thumbnail", {}).get("source", "") - if thumb: - return thumb - return data.get("originalimage", {}).get("source", "") - - -async def wiki_summary(query: str) -> dict | None: - """Fetch a Wikipedia summary for a given title/query. - - Does a direct title lookup via the REST v1 summary endpoint. - Returns a dict with title, extract, url, and thumbnail_url on success; - thumbnail_url is an empty string when the article has no image. - Returns None on any failure. - """ - title = url_quote(query.replace(" ", "_")) - url = f"{_SUMMARY_URL}/{title}" - headers = {"User-Agent": _USER_AGENT} - try: - async with httpx.AsyncClient() as client: - resp = await client.get(url, headers=headers, timeout=_TIMEOUT, follow_redirects=True) - resp.raise_for_status() - data = resp.json() - if data.get("type") == "disambiguation": - logger.debug("wiki_summary: disambiguation page for %r", query) - return None - extract = data.get("extract", "").strip() - if not extract: - logger.debug("wiki_summary: empty extract for %r", query) - return None - return { - "title": data.get("title", query), - "extract": extract, - "url": data.get("content_urls", {}).get("desktop", {}).get("page", ""), - "thumbnail_url": _extract_thumbnail(data), - } - except Exception as exc: - logger.debug("wiki_summary failed for %r: %s", query, exc) - return None - - -async def wiki_search(query: str, limit: int = 3) -> list[dict]: - """Search Wikipedia and return summaries for the top results. - - Uses the MediaWiki search API then fetches summaries for each hit. - Skips disambiguation pages and empty extracts. - Returns a list of dicts with title, extract, and url. - """ - headers = {"User-Agent": _USER_AGENT} - params = { - "action": "query", - "list": "search", - "srsearch": query, - "srlimit": str(limit), - "format": "json", - } - results: list[dict] = [] - try: - async with httpx.AsyncClient() as client: - resp = await client.get( - _SEARCH_URL, params=params, headers=headers, timeout=_TIMEOUT - ) - resp.raise_for_status() - data = resp.json() - hits = data.get("query", {}).get("search", []) - for hit in hits: - title = hit.get("title", "") - encoded = url_quote(title.replace(" ", "_")) - summary_url = f"{_SUMMARY_URL}/{encoded}" - try: - sr = await client.get( - summary_url, headers=headers, timeout=_TIMEOUT, follow_redirects=True - ) - sr.raise_for_status() - sdata = sr.json() - if sdata.get("type") == "disambiguation": - logger.debug("wiki_search: skipping disambiguation %r", title) - continue - extract = sdata.get("extract", "").strip() - if not extract: - logger.debug("wiki_search: empty extract for %r", title) - continue - results.append( - { - "title": sdata.get("title", title), - "extract": extract, - "url": sdata.get("content_urls", {}) - .get("desktop", {}) - .get("page", ""), - "thumbnail_url": _extract_thumbnail(sdata), - } - ) - except Exception as exc: - logger.debug("wiki_search: summary fetch failed for %r: %s", title, exc) - except Exception as exc: - logger.debug("wiki_search failed for %r: %s", query, exc) - return [] - return results diff --git a/tests/test_calendar_tool_tz.py b/tests/test_calendar_tool_tz.py deleted file mode 100644 index fa47fcb..0000000 --- a/tests/test_calendar_tool_tz.py +++ /dev/null @@ -1,795 +0,0 @@ -"""Regression tests: calendar tool respects the user's configured timezone.""" - -from datetime import timezone -from unittest.mock import AsyncMock, patch - -import pytest - - -@pytest.mark.asyncio -async def test_create_event_all_day_bare_date_interprets_local_midnight(): - """Bare date like '2026-09-30' for a NY user = 2026-09-30 00:00 NY, - stored as 2026-09-30 04:00 UTC — NOT 2026-09-30 00:00 UTC (which would - be 2026-09-29 19:00 NY and land on the wrong day).""" - from fabledassistant.services.tools.calendar import create_event_tool - - captured = {} - - async def fake_create_event(**kwargs): - captured.update(kwargs) - event = AsyncMock() - event.to_dict.return_value = {"id": 1, **{k: v for k, v in kwargs.items() if not callable(v)}} - return event - - with patch( - "fabledassistant.services.tools.calendar.get_user_tz", - AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")), - ), patch( - "fabledassistant.services.tools.calendar.events_create_event", - side_effect=fake_create_event, - ): - result = await create_event_tool( - user_id=1, - arguments={"title": "Birthday", "start": "2026-09-30"}, - ) - - assert result["success"] is True - start_dt = captured["start_dt"] - assert start_dt.tzinfo is not None - # Converted to UTC: 00:00 NY (EDT, UTC-4) == 04:00 UTC on the same day - utc = start_dt.astimezone(timezone.utc) - assert (utc.year, utc.month, utc.day) == (2026, 9, 30) - assert utc.hour == 4 # EDT offset; would be 5 in EST - assert captured["all_day"] is True - - -@pytest.mark.asyncio -async def test_create_event_naive_datetime_is_user_local(): - """'2026-03-15T14:30' for a NY user means 2:30pm NY, not 2:30pm UTC.""" - from fabledassistant.services.tools.calendar import create_event_tool - - captured = {} - - async def fake_create_event(**kwargs): - captured.update(kwargs) - event = AsyncMock() - event.to_dict.return_value = {"id": 1} - return event - - with patch( - "fabledassistant.services.tools.calendar.get_user_tz", - AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")), - ), patch( - "fabledassistant.services.tools.calendar.events_create_event", - side_effect=fake_create_event, - ): - await create_event_tool( - user_id=1, - arguments={"title": "Meeting", "start": "2026-03-15T14:30"}, - ) - - utc = captured["start_dt"].astimezone(timezone.utc) - # 14:30 NY on 2026-03-15 (after DST start) = 18:30 UTC - assert (utc.year, utc.month, utc.day, utc.hour, utc.minute) == (2026, 3, 15, 18, 30) - - -@pytest.mark.asyncio -async def test_create_event_explicit_offset_is_respected(): - """Model-supplied timezone offsets must not be overridden by user TZ.""" - from fabledassistant.services.tools.calendar import create_event_tool - - captured = {} - - async def fake_create_event(**kwargs): - captured.update(kwargs) - event = AsyncMock() - event.to_dict.return_value = {"id": 1} - return event - - with patch( - "fabledassistant.services.tools.calendar.get_user_tz", - AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")), - ), patch( - "fabledassistant.services.tools.calendar.events_create_event", - side_effect=fake_create_event, - ): - await create_event_tool( - user_id=1, - arguments={"title": "Meeting", "start": "2026-03-15T14:30+00:00"}, - ) - - utc = captured["start_dt"].astimezone(timezone.utc) - assert (utc.hour, utc.minute) == (14, 30) - - -@pytest.mark.asyncio -async def test_list_events_bare_date_range_covers_local_day(): - """date_from/date_to as bare dates should cover the full LOCAL day.""" - from fabledassistant.services.tools.calendar import list_events_tool - - captured = {} - - async def fake_list_events(*, user_id, date_from, date_to): - captured["date_from"] = date_from - captured["date_to"] = date_to - return [] - - with patch( - "fabledassistant.services.tools.calendar.get_user_tz", - AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")), - ), patch( - "fabledassistant.services.tools.calendar.events_list_events", - side_effect=fake_list_events, - ): - await list_events_tool( - user_id=1, - arguments={"date_from": "2026-09-30", "date_to": "2026-09-30"}, - ) - - df = captured["date_from"].astimezone(timezone.utc) - dt = captured["date_to"].astimezone(timezone.utc) - # 2026-09-30 00:00 NY (EDT) = 04:00 UTC same day - assert (df.year, df.month, df.day, df.hour) == (2026, 9, 30, 4) - # 2026-09-30 23:59:59 NY (EDT) = 03:59:59 UTC next day - assert (dt.year, dt.month, dt.day, dt.hour) == (2026, 10, 1, 3) - - -# ── Split date/time field tests (durable shape) ────────────────────────────── -# -# These exercise the start_date + start_time path that the model is now -# steered toward. The split-field shape is structurally immune to the -# class of bugs where a model emits a TZ-tagged combined datetime that -# the parser correctly honors but lands on the wrong calendar day for -# negative-offset users. - - -@pytest.mark.asyncio -async def test_create_event_split_fields_friday_8am_no_drift_eastern(): - """The reported bug: 'next Friday at 8am' for a NY user must land on - 2026-05-01 08:00 NY, never 04-30 19:00. With split fields the model - can't TZ-tag the date string, so the calendar day is fixed.""" - from fabledassistant.services.tools.calendar import create_event_tool - - captured = {} - - async def fake_create_event(**kwargs): - captured.update(kwargs) - event = AsyncMock() - event.to_dict.return_value = {"id": 1} - return event - - with patch( - "fabledassistant.services.tools.calendar.get_user_tz", - AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")), - ), patch( - "fabledassistant.services.tools.calendar.events_create_event", - side_effect=fake_create_event, - ): - result = await create_event_tool( - user_id=1, - arguments={ - "title": "Meeting", - "start_date": "2026-05-01", - "start_time": "08:00", - }, - ) - - assert result["success"] is True - utc = captured["start_dt"].astimezone(timezone.utc) - # 08:00 NY (EDT, UTC-4) on 2026-05-01 = 12:00 UTC same day - assert (utc.year, utc.month, utc.day, utc.hour, utc.minute) == (2026, 5, 1, 12, 0) - assert captured["all_day"] is False - - -@pytest.mark.asyncio -async def test_create_event_split_fields_no_drift_pacific(): - """Same scenario for a UTC-8 user — the calendar day must be 5/1 - regardless of how big the offset gets.""" - from fabledassistant.services.tools.calendar import create_event_tool - - captured = {} - - async def fake_create_event(**kwargs): - captured.update(kwargs) - event = AsyncMock() - event.to_dict.return_value = {"id": 1} - return event - - with patch( - "fabledassistant.services.tools.calendar.get_user_tz", - AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/Los_Angeles")), - ), patch( - "fabledassistant.services.tools.calendar.events_create_event", - side_effect=fake_create_event, - ): - await create_event_tool( - user_id=1, - arguments={ - "title": "Standup", - "start_date": "2026-05-01", - "start_time": "08:00", - }, - ) - - utc = captured["start_dt"].astimezone(timezone.utc) - # 08:00 LA (PDT, UTC-7) = 15:00 UTC same day - assert (utc.year, utc.month, utc.day, utc.hour) == (2026, 5, 1, 15) - - -@pytest.mark.asyncio -async def test_create_event_split_fields_no_drift_positive_offset(): - """A positive-offset user (Tokyo, UTC+9) — 08:00 Tokyo on 2026-05-01 - is 23:00 UTC on 2026-04-30, but the local calendar day must still be - stored as 2026-05-01 from the user's perspective.""" - from fabledassistant.services.tools.calendar import create_event_tool - - captured = {} - - async def fake_create_event(**kwargs): - captured.update(kwargs) - event = AsyncMock() - event.to_dict.return_value = {"id": 1} - return event - - with patch( - "fabledassistant.services.tools.calendar.get_user_tz", - AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("Asia/Tokyo")), - ), patch( - "fabledassistant.services.tools.calendar.events_create_event", - side_effect=fake_create_event, - ): - await create_event_tool( - user_id=1, - arguments={ - "title": "Sync", - "start_date": "2026-05-01", - "start_time": "08:00", - }, - ) - - utc = captured["start_dt"].astimezone(timezone.utc) - # 08:00 Tokyo (UTC+9) = 23:00 UTC previous day; the round-trip back - # to Tokyo TZ recovers 2026-05-01 08:00. - assert (utc.year, utc.month, utc.day, utc.hour) == (2026, 4, 30, 23) - tokyo = captured["start_dt"].astimezone(__import__("zoneinfo").ZoneInfo("Asia/Tokyo")) - assert (tokyo.year, tokyo.month, tokyo.day, tokyo.hour) == (2026, 5, 1, 8) - - -@pytest.mark.asyncio -async def test_create_event_split_date_only_is_all_day(): - """Omitting start_time means the event is all-day; cache row uses - local midnight.""" - from fabledassistant.services.tools.calendar import create_event_tool - - captured = {} - - async def fake_create_event(**kwargs): - captured.update(kwargs) - event = AsyncMock() - event.to_dict.return_value = {"id": 1} - return event - - with patch( - "fabledassistant.services.tools.calendar.get_user_tz", - AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")), - ), patch( - "fabledassistant.services.tools.calendar.events_create_event", - side_effect=fake_create_event, - ): - await create_event_tool( - user_id=1, - arguments={"title": "Birthday", "start_date": "2026-09-30"}, - ) - - assert captured["all_day"] is True - utc = captured["start_dt"].astimezone(timezone.utc) - assert (utc.year, utc.month, utc.day, utc.hour) == (2026, 9, 30, 4) - - -@pytest.mark.asyncio -async def test_create_event_split_rejects_tz_suffix_in_date(): - """The whole point of split fields: a TZ suffix on the date string - must be rejected, not silently honored.""" - from fabledassistant.services.tools.calendar import create_event_tool - - with patch( - "fabledassistant.services.tools.calendar.get_user_tz", - AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")), - ): - result = await create_event_tool( - user_id=1, - arguments={"title": "x", "start_date": "2026-05-01Z", "start_time": "08:00"}, - ) - - assert result["success"] is False - assert "YYYY-MM-DD" in result["error"] - - -@pytest.mark.asyncio -async def test_create_event_split_rejects_tz_suffix_in_time(): - """A TZ suffix on the time string must also be rejected.""" - from fabledassistant.services.tools.calendar import create_event_tool - - with patch( - "fabledassistant.services.tools.calendar.get_user_tz", - AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")), - ): - result = await create_event_tool( - user_id=1, - arguments={"title": "x", "start_date": "2026-05-01", "start_time": "08:00 UTC"}, - ) - - assert result["success"] is False - assert "HH:MM" in result["error"] - - -@pytest.mark.asyncio -async def test_create_event_legacy_combined_start_still_works(): - """Backcompat: saved tool-call payloads using the old `start` field - must still produce the same UTC datetime as before.""" - from fabledassistant.services.tools.calendar import create_event_tool - - captured = {} - - async def fake_create_event(**kwargs): - captured.update(kwargs) - event = AsyncMock() - event.to_dict.return_value = {"id": 1} - return event - - with patch( - "fabledassistant.services.tools.calendar.get_user_tz", - AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")), - ), patch( - "fabledassistant.services.tools.calendar.events_create_event", - side_effect=fake_create_event, - ): - result = await create_event_tool( - user_id=1, - arguments={"title": "Legacy", "start": "2026-05-01T08:00"}, - ) - - assert result["success"] is True - utc = captured["start_dt"].astimezone(timezone.utc) - assert (utc.year, utc.month, utc.day, utc.hour) == (2026, 5, 1, 12) - - -@pytest.mark.asyncio -async def test_update_event_split_fields_reschedule_no_drift(): - """update_event with split fields must drift no calendar day either.""" - from fabledassistant.services.tools.calendar import update_event_tool - - captured = {} - - async def fake_find(*, user_id, query): - ev = AsyncMock() - ev.id = 99 - ev.title = "Coffee" - return [ev] - - async def fake_update(*, user_id, event_id, **fields): - captured.update(fields) - ev = AsyncMock() - ev.to_dict.return_value = {"id": event_id, **fields} - return ev - - with patch( - "fabledassistant.services.tools.calendar.get_user_tz", - AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")), - ), patch( - "fabledassistant.services.tools.calendar.find_events_by_query", - side_effect=fake_find, - ), patch( - "fabledassistant.services.tools.calendar.events_update_event", - side_effect=fake_update, - ): - result = await update_event_tool( - user_id=1, - arguments={ - "query": "Coffee", - "start_date": "2026-05-01", - "start_time": "08:00", - }, - ) - - assert result["success"] is True - utc = captured["start_dt"].astimezone(timezone.utc) - assert (utc.year, utc.month, utc.day, utc.hour) == (2026, 5, 1, 12) - - -# ── expected_weekday verification (catches "this Friday" → Thursday bugs) ──── - - -@pytest.mark.asyncio -async def test_create_event_expected_weekday_match_succeeds(): - """When expected_weekday agrees with the resolved local date's - weekday, the event is created normally.""" - from fabledassistant.services.tools.calendar import create_event_tool - - captured = {} - - async def fake_create_event(**kwargs): - captured.update(kwargs) - event = AsyncMock() - event.to_dict.return_value = {"id": 1} - return event - - with patch( - "fabledassistant.services.tools.calendar.get_user_tz", - AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")), - ), patch( - "fabledassistant.services.tools.calendar.events_create_event", - side_effect=fake_create_event, - ): - result = await create_event_tool( - user_id=1, - arguments={ - "title": "Meeting", - "start_date": "2026-05-01", # is a Friday - "start_time": "08:00", - "expected_weekday": "friday", - }, - ) - - assert result["success"] is True - assert captured["start_dt"] is not None - - -@pytest.mark.asyncio -async def test_create_event_expected_weekday_mismatch_rejects_and_names_actual(): - """The reported failure mode: model picks Thursday and calls it - Friday. With expected_weekday set, the create is rejected and the - error names the actual weekday so the model can self-correct.""" - from fabledassistant.services.tools.calendar import create_event_tool - - create_called = False - - async def fake_create_event(**kwargs): - nonlocal create_called - create_called = True - event = AsyncMock() - event.to_dict.return_value = {"id": 1} - return event - - with patch( - "fabledassistant.services.tools.calendar.get_user_tz", - AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")), - ), patch( - "fabledassistant.services.tools.calendar.events_create_event", - side_effect=fake_create_event, - ): - result = await create_event_tool( - user_id=1, - arguments={ - "title": "Dentist", - "start_date": "2026-04-30", # Thursday - "start_time": "08:00", - "expected_weekday": "friday", - }, - ) - - assert result["success"] is False - assert "Thursday" in result["error"] - assert "Friday" in result["error"] - # Critical: the event must NOT have been created when the check failed. - assert create_called is False - - -@pytest.mark.asyncio -async def test_create_event_expected_weekday_omitted_skips_check(): - """Backcompat: when expected_weekday isn't passed, no validation - runs — the existing create flow is unchanged.""" - from fabledassistant.services.tools.calendar import create_event_tool - - captured = {} - - async def fake_create_event(**kwargs): - captured.update(kwargs) - event = AsyncMock() - event.to_dict.return_value = {"id": 1} - return event - - with patch( - "fabledassistant.services.tools.calendar.get_user_tz", - AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")), - ), patch( - "fabledassistant.services.tools.calendar.events_create_event", - side_effect=fake_create_event, - ): - result = await create_event_tool( - user_id=1, - arguments={ - "title": "x", - "start_date": "2026-04-30", - "start_time": "08:00", - }, - ) - - assert result["success"] is True - - -@pytest.mark.asyncio -async def test_create_event_expected_weekday_invalid_value_rejects(): - """Garbage in expected_weekday produces a clear validation error, - not a silent pass.""" - from fabledassistant.services.tools.calendar import create_event_tool - - with patch( - "fabledassistant.services.tools.calendar.get_user_tz", - AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")), - ): - result = await create_event_tool( - user_id=1, - arguments={ - "title": "x", - "start_date": "2026-05-01", - "start_time": "08:00", - "expected_weekday": "fri", # abbreviation not accepted - }, - ) - - assert result["success"] is False - assert "weekday" in result["error"].lower() - - -@pytest.mark.asyncio -async def test_update_event_expected_weekday_mismatch_rejects(): - """update_event must enforce the same weekday check on reschedules.""" - from fabledassistant.services.tools.calendar import update_event_tool - - update_called = False - - async def fake_find(*, user_id, query): - ev = AsyncMock() - ev.id = 99 - ev.title = "Coffee" - return [ev] - - async def fake_update(*, user_id, event_id, **fields): - nonlocal update_called - update_called = True - ev = AsyncMock() - ev.to_dict.return_value = {"id": event_id} - return ev - - with patch( - "fabledassistant.services.tools.calendar.get_user_tz", - AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")), - ), patch( - "fabledassistant.services.tools.calendar.find_events_by_query", - side_effect=fake_find, - ), patch( - "fabledassistant.services.tools.calendar.events_update_event", - side_effect=fake_update, - ): - result = await update_event_tool( - user_id=1, - arguments={ - "query": "Coffee", - "start_date": "2026-04-30", # Thursday - "start_time": "08:00", - "expected_weekday": "friday", - }, - ) - - assert result["success"] is False - assert "Thursday" in result["error"] - assert update_called is False - - -# ── Multi-match disambiguation (Fable #161) ────────────────────────────────── - - -@pytest.mark.asyncio -async def test_update_event_refuses_ambiguous_query_with_candidates(): - """The reported failure: model called update_event(query='Appointment') - when two events matched. Pre-fix: silently mutated matches[0] (the - wrong event). Post-fix: returns success=False with a candidates list - so the model can pick the right one.""" - from fabledassistant.services.tools.calendar import update_event_tool - - # Two events both matching "Appointment" - ev_a = AsyncMock() - ev_a.id = 2 - ev_a.title = "Appointment" - ev_a.start_dt = __import__("datetime").datetime(2026, 4, 30, 12, 0, tzinfo=timezone.utc) - ev_a.location = "" - ev_b = AsyncMock() - ev_b.id = 15 - ev_b.title = "Appointment" - ev_b.start_dt = __import__("datetime").datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc) - ev_b.location = "Dentist" - - update_called = False - - async def fake_find(*, user_id, query): - return [ev_a, ev_b] - - async def fake_update(*, user_id, event_id, **fields): - nonlocal update_called - update_called = True - return AsyncMock() - - with patch( - "fabledassistant.services.tools.calendar.find_events_by_query", - side_effect=fake_find, - ), patch( - "fabledassistant.services.tools.calendar.events_update_event", - side_effect=fake_update, - ): - result = await update_event_tool( - user_id=1, - arguments={"query": "Appointment", "title": "Dentist"}, - ) - - assert result["success"] is False - assert "Found 2 events" in result["error"] - assert "event_id" in result["error"].lower() - assert "candidates" in result - candidate_ids = [c["id"] for c in result["candidates"]] - assert candidate_ids == [2, 15] - # Critical: nothing was mutated. - assert update_called is False - - -@pytest.mark.asyncio -async def test_update_event_with_event_id_skips_query_lookup(): - """Once the model picks a candidate via event_id, the call proceeds - against that exact event — no query, no ambiguity.""" - from fabledassistant.services.tools.calendar import update_event_tool - - # find_events_by_query should NOT be called when event_id is supplied - find_called = False - - async def fake_find(*, user_id, query): - nonlocal find_called - find_called = True - return [] - - target = AsyncMock() - target.id = 15 - target.title = "Appointment" - target.start_dt = __import__("datetime").datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc) - target.location = "" - - async def fake_get_event(*, user_id, event_id): - return target if event_id == 15 else None - - captured = {} - - async def fake_update(*, user_id, event_id, **fields): - captured["event_id"] = event_id - captured.update(fields) - ev = AsyncMock() - ev.to_dict.return_value = {"id": event_id, **fields} - return ev - - with patch( - "fabledassistant.services.tools.calendar.find_events_by_query", - side_effect=fake_find, - ), patch( - "fabledassistant.services.events.get_event", - side_effect=fake_get_event, - ), patch( - "fabledassistant.services.tools.calendar.events_update_event", - side_effect=fake_update, - ): - result = await update_event_tool( - user_id=1, - arguments={"event_id": 15, "title": "Dentist: Permanent Crown Fitting"}, - ) - - assert result["success"] is True - assert captured["event_id"] == 15 - assert captured["title"] == "Dentist: Permanent Crown Fitting" - assert find_called is False - - -@pytest.mark.asyncio -async def test_update_event_with_event_id_not_found_returns_error(): - """A bad event_id (non-existent) must surface clearly, not silently - fall back to query-based lookup.""" - from fabledassistant.services.tools.calendar import update_event_tool - - async def fake_get_event(*, user_id, event_id): - return None - - with patch( - "fabledassistant.services.events.get_event", - side_effect=fake_get_event, - ): - result = await update_event_tool( - user_id=1, - arguments={"event_id": 999, "title": "anything"}, - ) - - assert result["success"] is False - assert "999" in result["error"] - assert "No event found" in result["error"] - - -@pytest.mark.asyncio -async def test_update_event_neither_query_nor_event_id_returns_error(): - """Calling update_event with neither identifier is a usage error.""" - from fabledassistant.services.tools.calendar import update_event_tool - - result = await update_event_tool(user_id=1, arguments={"title": "x"}) - assert result["success"] is False - assert "query or event_id" in result["error"] - - -@pytest.mark.asyncio -async def test_delete_event_refuses_ambiguous_query_with_candidates(): - """delete_event must enforce the same disambiguation. Costly to get - wrong — silent matches[0] would delete the wrong event entirely.""" - from fabledassistant.services.tools.calendar import delete_event_tool - - ev_a = AsyncMock() - ev_a.id = 2; ev_a.title = "Appointment" - ev_a.start_dt = __import__("datetime").datetime(2026, 4, 30, 12, 0, tzinfo=timezone.utc) - ev_a.location = "" - ev_b = AsyncMock() - ev_b.id = 15; ev_b.title = "Appointment" - ev_b.start_dt = __import__("datetime").datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc) - ev_b.location = "Dentist" - - delete_called = False - - async def fake_find(*, user_id, query): - return [ev_a, ev_b] - - async def fake_delete(*, user_id, event_id): - nonlocal delete_called - delete_called = True - - with patch( - "fabledassistant.services.tools.calendar.find_events_by_query", - side_effect=fake_find, - ), patch( - "fabledassistant.services.tools.calendar.events_delete_event", - side_effect=fake_delete, - ): - result = await delete_event_tool( - user_id=1, arguments={"query": "Appointment"}, - ) - - assert result["success"] is False - assert len(result["candidates"]) == 2 - # Most important assertion: nothing was actually deleted. - assert delete_called is False - - -@pytest.mark.asyncio -async def test_create_event_weekday_check_uses_local_not_utc(): - """The weekday check must use the LOCAL date, not the UTC date. - A late-evening Friday event in Tokyo (UTC+9) crosses midnight UTC, - so a UTC-day check would call it Saturday — but the user's calendar - says Friday. The check must respect the user's local view.""" - from fabledassistant.services.tools.calendar import create_event_tool - - captured = {} - - async def fake_create_event(**kwargs): - captured.update(kwargs) - event = AsyncMock() - event.to_dict.return_value = {"id": 1} - return event - - with patch( - "fabledassistant.services.tools.calendar.get_user_tz", - AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("Asia/Tokyo")), - ), patch( - "fabledassistant.services.tools.calendar.events_create_event", - side_effect=fake_create_event, - ): - result = await create_event_tool( - user_id=1, - arguments={ - "title": "Friday night", - "start_date": "2026-05-01", # Friday in Tokyo - "start_time": "23:00", # 14:00 UTC same day; safe - "expected_weekday": "friday", - }, - ) - - assert result["success"] is True diff --git a/tests/test_consolidation.py b/tests/test_consolidation.py deleted file mode 100644 index fb86782..0000000 --- a/tests/test_consolidation.py +++ /dev/null @@ -1,317 +0,0 @@ -"""Tests for the task-body consolidation pipeline (gate + full pass). - -Design: docs/superpowers/specs/2026-05-13-task-as-durable-record-design.md -""" -from datetime import datetime, timezone -from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock, patch - - -# ── Gate logic ─────────────────────────────────────────────────────────────── - - -async def test_maybe_consolidate_below_threshold_does_not_fire(): - """log_added with only 2 logs since last pass → no consolidation.""" - from fabledassistant.services import consolidation - - with patch.object( - consolidation, "_logs_since_last_consolidation", - new=AsyncMock(return_value=2), - ), patch.object( - consolidation, "consolidate_task", new=AsyncMock(), - ) as mock_consolidate, patch.object( - consolidation, "_auto_consolidate_enabled", new=AsyncMock(return_value=True), - ): - await consolidation.maybe_consolidate( - user_id=1, task_id=42, reason="log_added", - ) - - mock_consolidate.assert_not_awaited() - - -async def test_maybe_consolidate_at_threshold_fires(): - """log_added with N logs since last pass → consolidation scheduled.""" - from fabledassistant.services import consolidation - - # asyncio.create_task is the actual scheduler; replace it so the test - # doesn't need an event-loop background task to settle. - with patch.object( - consolidation, "_logs_since_last_consolidation", - new=AsyncMock(return_value=3), - ), patch.object( - consolidation, "_auto_consolidate_enabled", new=AsyncMock(return_value=True), - ), patch( - "fabledassistant.services.consolidation.asyncio.create_task", - ) as mock_create_task: - await consolidation.maybe_consolidate( - user_id=1, task_id=42, reason="log_added", - ) - - mock_create_task.assert_called_once() - - -async def test_maybe_consolidate_task_closed_always_fires(): - """task_closed bypasses the log-count gate.""" - from fabledassistant.services import consolidation - - with patch.object( - consolidation, "_logs_since_last_consolidation", - new=AsyncMock(return_value=0), - ), patch.object( - consolidation, "_auto_consolidate_enabled", new=AsyncMock(return_value=True), - ), patch( - "fabledassistant.services.consolidation.asyncio.create_task", - ) as mock_create_task: - await consolidation.maybe_consolidate( - user_id=1, task_id=42, reason="task_closed", - ) - - mock_create_task.assert_called_once() - - -async def test_maybe_consolidate_setting_off_blocks_both_reasons(): - """auto_consolidate_tasks=false → neither trigger fires.""" - from fabledassistant.services import consolidation - - with patch.object( - consolidation, "_logs_since_last_consolidation", - new=AsyncMock(return_value=5), - ), patch.object( - consolidation, "_auto_consolidate_enabled", new=AsyncMock(return_value=False), - ), patch( - "fabledassistant.services.consolidation.asyncio.create_task", - ) as mock_create_task: - await consolidation.maybe_consolidate( - user_id=1, task_id=42, reason="log_added", - ) - await consolidation.maybe_consolidate( - user_id=1, task_id=42, reason="task_closed", - ) - - mock_create_task.assert_not_called() - - -async def test_maybe_consolidate_unknown_reason_is_noop(): - """Unknown reasons get logged and skipped — not raised.""" - from fabledassistant.services import consolidation - - with patch.object( - consolidation, "_logs_since_last_consolidation", - new=AsyncMock(return_value=99), - ), patch.object( - consolidation, "_auto_consolidate_enabled", new=AsyncMock(return_value=True), - ), patch( - "fabledassistant.services.consolidation.asyncio.create_task", - ) as mock_create_task: - await consolidation.maybe_consolidate( - user_id=1, task_id=42, reason="some_other_reason", - ) - - mock_create_task.assert_not_called() - - -# ── Prompt builder ─────────────────────────────────────────────────────────── - - -def test_build_consolidation_prompt_includes_title_goal_and_logs(): - from fabledassistant.services.consolidation import _build_consolidation_prompt - - logs = [ - SimpleNamespace( - content="tried REJECT, logs noisy", - created_at=datetime(2026, 5, 13, 10, 0, tzinfo=timezone.utc), - ), - SimpleNamespace( - content="switched to DROP", - created_at=datetime(2026, 5, 13, 11, 0, tzinfo=timezone.utc), - ), - ] - prompt = _build_consolidation_prompt( - title="firewall tuning", description="quiet the logs", logs=logs, - ) - assert "firewall tuning" in prompt - assert "quiet the logs" in prompt - assert "tried REJECT" in prompt - assert "switched to DROP" in prompt - - -def test_build_consolidation_prompt_handles_missing_description(): - from fabledassistant.services.consolidation import _build_consolidation_prompt - - logs = [ - SimpleNamespace( - content="a", created_at=datetime(2026, 5, 13, tzinfo=timezone.utc), - ), - ] - prompt = _build_consolidation_prompt(title="t", description=None, logs=logs) - assert "no goal recorded" in prompt - - -def test_build_consolidation_prompt_truncates_to_char_budget(): - from fabledassistant.services.consolidation import ( - _build_consolidation_prompt, MAX_PROMPT_INPUT_CHARS, - ) - - big = "x" * (MAX_PROMPT_INPUT_CHARS + 1000) - logs = [ - SimpleNamespace( - content=big, created_at=datetime(2026, 5, 13, tzinfo=timezone.utc), - ), - SimpleNamespace( - content="should be dropped", - created_at=datetime(2026, 5, 13, 1, tzinfo=timezone.utc), - ), - ] - prompt = _build_consolidation_prompt(title="t", description=None, logs=logs) - # First log consumes the budget; the second is dropped. - assert "should be dropped" not in prompt - - -# ── consolidate_task orchestration (mocked DB + LLM + embedding) ───────────── - - -def _make_mock_session(task, logs): - """Return a mock async_session that hands out task and logs to successive - .execute() calls. consolidate_task calls execute three times: select task, - select logs (same context), then re-select task in the write-back block.""" - mock_session = AsyncMock() - - task_result = MagicMock() - task_result.scalars.return_value.first.return_value = task - logs_result = MagicMock() - logs_result.scalars.return_value.all.return_value = logs - - mock_session.execute = AsyncMock( - side_effect=[task_result, logs_result, task_result], - ) - mock_session.commit = AsyncMock() - mock_session.refresh = AsyncMock() - mock_session.__aenter__ = AsyncMock(return_value=mock_session) - mock_session.__aexit__ = AsyncMock(return_value=False) - return mock_session - - -async def test_consolidate_task_writes_body_and_timestamp_and_reembeds(): - task = MagicMock() - task.id = 42 - task.title = "Cert renewal" - task.description = "renew LE before Nov 30" - task.status = "in_progress" - task.body = "old body" - task.consolidated_at = None - logs = [ - SimpleNamespace( - content="tried certbot renew", - created_at=datetime(2026, 5, 13, tzinfo=timezone.utc), - ), - SimpleNamespace( - content="switched to manual", - created_at=datetime(2026, 5, 13, 1, tzinfo=timezone.utc), - ), - ] - fake_summary = ( - "Started with certbot renew; DNS split-horizon failed. " - "Switched to manual flow." - ) - - # Each consolidate_task call needs its own per-task lock state. - # _locks is module-level defaultdict — patch it on the module to avoid - # cross-test leakage of the held-lock state. - from collections import defaultdict - import asyncio as _asyncio - - with patch( - "fabledassistant.services.consolidation._locks", - new=defaultdict(_asyncio.Lock), - ), patch( - "fabledassistant.services.consolidation.async_session", - return_value=_make_mock_session(task, logs), - ), patch( - "fabledassistant.services.llm.generate_completion", - new=AsyncMock(return_value=fake_summary), - ) as mock_llm, patch( - "fabledassistant.services.embeddings.upsert_note_embedding", - new=AsyncMock(), - ) as mock_embed, patch( - "fabledassistant.services.settings.get_setting", - new=AsyncMock(return_value="gemma3:4b"), - ): - from fabledassistant.services.consolidation import consolidate_task - await consolidate_task(1, 42) - - mock_llm.assert_awaited_once() - mock_embed.assert_awaited_once() - assert task.body == fake_summary - assert task.consolidated_at is not None - - -async def test_consolidate_task_llm_failure_leaves_body_untouched(): - task = MagicMock() - task.id = 42 - task.title = "X" - task.description = "y" - task.status = "in_progress" - task.body = "old body content" - task.consolidated_at = None - logs = [ - SimpleNamespace( - content="a", created_at=datetime(2026, 5, 13, tzinfo=timezone.utc), - ), - ] - - from collections import defaultdict - import asyncio as _asyncio - - with patch( - "fabledassistant.services.consolidation._locks", - new=defaultdict(_asyncio.Lock), - ), patch( - "fabledassistant.services.consolidation.async_session", - return_value=_make_mock_session(task, logs), - ), patch( - "fabledassistant.services.llm.generate_completion", - new=AsyncMock(side_effect=RuntimeError("ollama down")), - ), patch( - "fabledassistant.services.embeddings.upsert_note_embedding", - new=AsyncMock(), - ) as mock_embed, patch( - "fabledassistant.services.settings.get_setting", - new=AsyncMock(return_value="gemma3:4b"), - ): - from fabledassistant.services.consolidation import consolidate_task - await consolidate_task(1, 42) # must not raise - - assert task.body == "old body content" - assert task.consolidated_at is None - mock_embed.assert_not_awaited() - - -async def test_consolidate_task_skips_when_no_logs(): - task = MagicMock() - task.id = 42 - task.title = "X" - task.description = "y" - task.status = "in_progress" - task.body = "old" - task.consolidated_at = None - - from collections import defaultdict - import asyncio as _asyncio - - with patch( - "fabledassistant.services.consolidation._locks", - new=defaultdict(_asyncio.Lock), - ), patch( - "fabledassistant.services.consolidation.async_session", - return_value=_make_mock_session(task, []), - ), patch( - "fabledassistant.services.llm.generate_completion", new=AsyncMock(), - ) as mock_llm, patch( - "fabledassistant.services.settings.get_setting", - new=AsyncMock(return_value="gemma3:4b"), - ): - from fabledassistant.services.consolidation import consolidate_task - await consolidate_task(1, 42) - - mock_llm.assert_not_awaited() - assert task.body == "old" diff --git a/tests/test_generation_log.py b/tests/test_generation_log.py deleted file mode 100644 index 8dada22..0000000 --- a/tests/test_generation_log.py +++ /dev/null @@ -1,180 +0,0 @@ -"""Unit tests for the tool-call telemetry normalization logic. - -The DB-touching path of `log_tool_outcomes` requires a running PostgreSQL, -so we test the pure normalization by stubbing the session. The shape -contract that `generation_task.py` actually emits is: - - {"function": , "arguments": ..., "result": , "status": ...} - -with `result["success"]` being False for failures and `result["error"]` -the message. We verify the helper splits these correctly into the -attempted / succeeded / failed buckets. -""" -from __future__ import annotations - -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - - -@pytest.mark.asyncio -async def test_log_tool_outcomes_splits_success_and_failure(): - """A mix of success and error entries should split into the right buckets.""" - captured: dict = {} - - class _StubSession: - def __init__(self): - self.added = [] - - async def __aenter__(self): - return self - - async def __aexit__(self, *exc): - return False - - def add(self, row): - self.added.append(row) - captured["row"] = row - - async def commit(self): - return None - - stub = _StubSession() - - from fabledassistant.services import generation_log - - with patch.object(generation_log, "async_session", lambda: stub): - await generation_log.log_tool_outcomes( - user_id=1, - conv_id=42, - assistant_message_id=99, - model="qwen3:8b", - think_enabled=False, - tools_available=["record_moment", "search_notes", "create_task"], - tool_calls=[ - { - "function": "record_moment", - "arguments": {}, - "result": {"success": True, "moment_id": 17}, - "status": "success", - }, - { - "function": "create_task", - "arguments": {}, - "result": {"success": False, "error": "missing required field 'title'"}, - "status": "error", - }, - ], - ) - - row = captured["row"] - assert row.user_id == 1 - assert row.conv_id == 42 - assert row.assistant_message_id == 99 - assert row.model == "qwen3:8b" - assert row.think_enabled is False - assert row.tools_available == ["create_task", "record_moment", "search_notes"] - assert row.tools_attempted == ["record_moment", "create_task"] - assert row.tools_succeeded == ["record_moment"] - assert row.tools_failed == [{"name": "create_task", "error": "missing required field 'title'"}] - - -@pytest.mark.asyncio -async def test_log_tool_outcomes_handles_empty_tool_calls(): - """A turn with no tool calls produces an empty-attempted row, not an exception.""" - captured: dict = {} - - class _StubSession: - async def __aenter__(self): - return self - - async def __aexit__(self, *exc): - return False - - def add(self, row): - captured["row"] = row - - async def commit(self): - return None - - from fabledassistant.services import generation_log - - with patch.object(generation_log, "async_session", lambda: _StubSession()): - await generation_log.log_tool_outcomes( - user_id=1, - conv_id=42, - assistant_message_id=99, - model="mistral-small:22b", - think_enabled=False, - tools_available=["record_moment"], - tool_calls=[], - ) - - row = captured["row"] - assert row.tools_attempted == [] - assert row.tools_succeeded == [] - assert row.tools_failed == [] - - -@pytest.mark.asyncio -async def test_log_tool_outcomes_swallows_exceptions(): - """Telemetry failure must NOT propagate — it would break user-facing flow.""" - from fabledassistant.services import generation_log - - bad_session = MagicMock() - bad_session.return_value.__aenter__ = AsyncMock(side_effect=RuntimeError("db down")) - - with patch.object(generation_log, "async_session", bad_session): - # Should NOT raise. - await generation_log.log_tool_outcomes( - user_id=1, - conv_id=42, - assistant_message_id=99, - model="qwen3:8b", - think_enabled=False, - tools_available=[], - tool_calls=[], - ) - - -@pytest.mark.asyncio -async def test_log_tool_outcomes_detects_success_false_without_error_field(): - """A tool result with success=False but no error string should still go to failed.""" - captured: dict = {} - - class _StubSession: - async def __aenter__(self): - return self - - async def __aexit__(self, *exc): - return False - - def add(self, row): - captured["row"] = row - - async def commit(self): - return None - - from fabledassistant.services import generation_log - - with patch.object(generation_log, "async_session", lambda: _StubSession()): - await generation_log.log_tool_outcomes( - user_id=1, - conv_id=42, - assistant_message_id=99, - model="qwen3:8b", - think_enabled=False, - tools_available=["search_notes"], - tool_calls=[ - { - "function": "search_notes", - "arguments": {}, - "result": {"success": False}, # no explicit error field - "status": "success", # but status incorrectly says success - }, - ], - ) - - row = captured["row"] - assert row.tools_succeeded == [] - assert row.tools_failed == [{"name": "search_notes", "error": "unspecified"}] diff --git a/tests/test_journal_closeout.py b/tests/test_journal_closeout.py deleted file mode 100644 index 8b556f8..0000000 --- a/tests/test_journal_closeout.py +++ /dev/null @@ -1,249 +0,0 @@ -"""Tests for journal closeout extraction helpers.""" - -import datetime -from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - - -def _msg(role: str, content: str, kind: str | None = None): - """Build a Message-like stand-in for the filter helpers.""" - metadata = {"kind": kind} if kind else None - return SimpleNamespace(role=role, content=content, msg_metadata=metadata) - - -def _fake_conv(conv_id: int = 1): - return SimpleNamespace(id=conv_id) - - -def _patch_db(messages, conv=None): - """Build a context manager that fakes async_session() and the two - select() calls (conversation lookup + messages query).""" - session = AsyncMock() - session.execute = AsyncMock() - conv_result = MagicMock() - conv_result.scalar_one_or_none = MagicMock(return_value=conv) - msg_result = MagicMock() - scalars = MagicMock() - scalars.all = MagicMock(return_value=list(messages)) - msg_result.scalars = MagicMock(return_value=scalars) - session.execute.side_effect = [conv_result, msg_result] - - session_ctx = MagicMock() - session_ctx.__aenter__ = AsyncMock(return_value=session) - session_ctx.__aexit__ = AsyncMock(return_value=None) - return session_ctx - - -def test_filter_excludes_daily_prep_messages(): - from fabledassistant.services.journal_closeout import _filter_messages - - msgs = [ - _msg("assistant", "Good morning — here is today's plan…", kind="daily_prep"), - _msg("user", "I want to focus on the auth refactor today."), - _msg("assistant", "Got it. I'll keep tool calls quiet."), - ] - kept = _filter_messages(msgs) - contents = [m.content for m in kept] - assert "Good morning — here is today's plan…" not in contents - assert "I want to focus on the auth refactor today." in contents - assert "Got it. I'll keep tool calls quiet." in contents - - -def test_build_transcript_labels_roles_and_caps_content(): - from fabledassistant.services.journal_closeout import _build_transcript - - msgs = [ - _msg("user", "hello"), - _msg("assistant", "hi"), - _msg("user", "x" * 600), - ] - out = _build_transcript(msgs) - lines = out.splitlines() - assert lines[0] == "USER: hello" - assert lines[1] == "ASSISTANT: hi" - # Third line content truncated to 500 chars - assert lines[2].startswith("USER: ") - assert len(lines[2]) == len("USER: ") + 500 - - -def test_build_transcript_keeps_only_last_20_messages(): - from fabledassistant.services.journal_closeout import _build_transcript - - msgs = [_msg("user", f"msg-{i}") for i in range(30)] - out = _build_transcript(msgs) - lines = out.splitlines() - assert len(lines) == 20 - # Newest 20 means msg-10 through msg-29 - assert lines[0] == "USER: msg-10" - assert lines[-1] == "USER: msg-29" - - -def test_system_prompt_lists_structured_fields_to_exclude(): - """The prompt must explicitly tell the LLM not to restate structured - fields, so the freeform learned_summary stays a narrow lane.""" - from fabledassistant.services.journal_closeout import SYSTEM_PROMPT - - text = SYSTEM_PROMPT.lower() - for field in ("name", "job title", "industry", "expertise", "response style", "tone", "interests"): - assert field in text, f"system prompt should mention '{field}'" - assert "(nothing to note)" in SYSTEM_PROMPT - - -@pytest.mark.asyncio -async def test_run_for_user_happy_path_appends_bullets(): - from fabledassistant.services import journal_closeout - - yesterday = datetime.date(2026, 5, 11) - msgs = [ - _msg("assistant", "Good morning — here's today.", kind="daily_prep"), - _msg("user", "Skip the news section — I never read it."), - _msg("assistant", "Noted. Will drop it."), - ] - - with ( - patch.object(journal_closeout, "async_session", return_value=_patch_db(msgs, conv=_fake_conv())), - patch.object(journal_closeout, "get_setting", new=AsyncMock(return_value="bg-model")), - patch.object(journal_closeout, "generate_completion", new=AsyncMock(return_value="- User skips news section")), - patch.object(journal_closeout, "append_observations", new=AsyncMock()) as mock_append, - ): - await journal_closeout.run_for_user(user_id=42, yesterday=yesterday) - - mock_append.assert_awaited_once_with(42, "- User skips news section") - - -@pytest.mark.asyncio -async def test_run_for_user_skips_when_no_conversation(): - from fabledassistant.services import journal_closeout - - with ( - patch.object(journal_closeout, "async_session", return_value=_patch_db([], conv=None)), - patch.object(journal_closeout, "append_observations", new=AsyncMock()) as mock_append, - patch.object(journal_closeout, "generate_completion", new=AsyncMock()) as mock_llm, - ): - await journal_closeout.run_for_user(user_id=42, yesterday=datetime.date(2026, 5, 11)) - - mock_append.assert_not_awaited() - mock_llm.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_run_for_user_skips_when_only_prep_message_after_filter(): - """If only the daily_prep message exists, the in-Python filter pass - leaves zero messages and we short-circuit before the LLM.""" - from fabledassistant.services import journal_closeout - - msgs_post_sql = [ - _msg("assistant", "Daily prep block.", kind="daily_prep"), - ] - with ( - patch.object(journal_closeout, "async_session", return_value=_patch_db(msgs_post_sql, conv=_fake_conv())), - patch.object(journal_closeout, "append_observations", new=AsyncMock()) as mock_append, - patch.object(journal_closeout, "generate_completion", new=AsyncMock()) as mock_llm, - ): - await journal_closeout.run_for_user(user_id=42, yesterday=datetime.date(2026, 5, 11)) - - mock_append.assert_not_awaited() - mock_llm.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_run_for_user_respects_nothing_to_note_sentinel(): - from fabledassistant.services import journal_closeout - - msgs = [_msg("user", "hi"), _msg("assistant", "hi back")] - with ( - patch.object(journal_closeout, "async_session", return_value=_patch_db(msgs, conv=_fake_conv())), - patch.object(journal_closeout, "get_setting", new=AsyncMock(return_value="bg-model")), - patch.object(journal_closeout, "generate_completion", new=AsyncMock(return_value="(nothing to note)")), - patch.object(journal_closeout, "append_observations", new=AsyncMock()) as mock_append, - ): - await journal_closeout.run_for_user(user_id=42, yesterday=datetime.date(2026, 5, 11)) - - mock_append.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_update_user_schedule_registers_closeout_when_enabled(monkeypatch): - from fabledassistant.services import journal_scheduler as sched - - fake_scheduler = MagicMock() - fake_scheduler.get_job = MagicMock(return_value=None) - monkeypatch.setattr(sched, "_scheduler", fake_scheduler) - monkeypatch.setattr(sched, "get_journal_config", AsyncMock(return_value={ - "prep_enabled": False, # isolate the closeout job - "closeout_enabled": True, - "day_rollover_hour": 4, - })) - monkeypatch.setattr(sched, "get_user_timezone", AsyncMock(return_value="UTC")) - - await sched.update_user_schedule(user_id=7) - - added = [c.kwargs.get("id") for c in fake_scheduler.add_job.call_args_list] - assert "journal_closeout_7" in added - - -@pytest.mark.asyncio -async def test_update_user_schedule_does_not_register_closeout_when_disabled(monkeypatch): - from fabledassistant.services import journal_scheduler as sched - - fake_scheduler = MagicMock() - fake_scheduler.get_job = MagicMock(return_value=None) - monkeypatch.setattr(sched, "_scheduler", fake_scheduler) - monkeypatch.setattr(sched, "get_journal_config", AsyncMock(return_value={ - "prep_enabled": False, - "closeout_enabled": False, - "day_rollover_hour": 4, - })) - monkeypatch.setattr(sched, "get_user_timezone", AsyncMock(return_value="UTC")) - - await sched.update_user_schedule(user_id=7) - - added = [c.kwargs.get("id") for c in fake_scheduler.add_job.call_args_list] - assert "journal_closeout_7" not in added - - -@pytest.mark.asyncio -async def test_closeout_catchup_skips_when_already_have_entry(monkeypatch): - from fabledassistant.services import journal_scheduler as sched - - monkeypatch.setattr(sched, "get_user_timezone", AsyncMock(return_value="UTC")) - monkeypatch.setattr(sched, "get_journal_config", AsyncMock(return_value={ - "closeout_enabled": True, - "day_rollover_hour": 0, # slot has always passed - })) - yesterday = (datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=1)).date() - fake_profile = SimpleNamespace(observations_raw=[{"date": yesterday.isoformat(), "bullets": "..."}]) - - from fabledassistant.services import user_profile as up - monkeypatch.setattr(up, "get_profile", AsyncMock(return_value=fake_profile)) - - run_mock = AsyncMock() - from fabledassistant.services import journal_closeout as jc - monkeypatch.setattr(jc, "run_for_user", run_mock) - - await sched._closeout_catchup(user_id=7) - run_mock.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_closeout_catchup_runs_when_no_entry_yet(monkeypatch): - from fabledassistant.services import journal_scheduler as sched - - monkeypatch.setattr(sched, "get_user_timezone", AsyncMock(return_value="UTC")) - monkeypatch.setattr(sched, "get_journal_config", AsyncMock(return_value={ - "closeout_enabled": True, - "day_rollover_hour": 0, - })) - fake_profile = SimpleNamespace(observations_raw=[]) - - from fabledassistant.services import user_profile as up - monkeypatch.setattr(up, "get_profile", AsyncMock(return_value=fake_profile)) - - run_mock = AsyncMock() - from fabledassistant.services import journal_closeout as jc - monkeypatch.setattr(jc, "run_for_user", run_mock) - - await sched._closeout_catchup(user_id=7) - run_mock.assert_awaited_once() diff --git a/tests/test_journal_message_count.py b/tests/test_journal_message_count.py deleted file mode 100644 index 4975791..0000000 --- a/tests/test_journal_message_count.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Regression coverage for the journal `message_count: 0` bug. - -`_day_payload` (routes/journal.py) loads messages in a separate query and -serializes the Conversation with `conv.to_dict()`. `Conversation.to_dict()` -derives `message_count` from the `messages` relationship, which is NOT -eager-loaded on that instance — so it silently fell back to 0 for every -journal day (observed via the fable MCP: conversation #291 reported -message_count 0 with 9 messages present). - -Full HTTP coverage of `_day_payload` needs a live DB (not available in the -unit-test env — see test_events_routes.py). These tests pin the model-level -contract that necessitates the route-side override. -""" -from datetime import date, datetime, timezone - -from fabledassistant.models.conversation import Conversation, Message - - -def _conv() -> Conversation: - now = datetime(2026, 5, 19, 9, 0, tzinfo=timezone.utc) - return Conversation( - user_id=1, - conversation_type="journal", - day_date=date(2026, 5, 19), - title="2026-05-19", - created_at=now, - updated_at=now, - ) - - -def test_to_dict_reports_zero_when_messages_relationship_not_loaded(): - """The trap: an untouched `messages` relationship is absent from the - instance state, so to_dict() reports 0 regardless of DB rows. This is - exactly the journal path's situation and why the route must override.""" - conv = _conv() - assert conv.to_dict()["message_count"] == 0 - - -def test_to_dict_counts_when_messages_loaded(): - """When the relationship IS populated the count is correct — confirming - the model isn't broken, the journal path just never loaded it.""" - conv = _conv() - conv.messages = [ - Message(conversation_id=1, role="assistant", content="prep"), - Message(conversation_id=1, role="user", content="hi"), - ] - assert conv.to_dict()["message_count"] == 2 - - -def test_day_payload_override_yields_true_count(): - """Pins the fix contract: _day_payload already holds the real message - list and overrides message_count with len(messages), the same way the - chat-list path (services/chat.py) supplies its own count.""" - conv = _conv() # messages relationship deliberately not loaded - messages = [object(), object(), object()] # stand-ins for the loaded rows - - conv_dict = conv.to_dict() - conv_dict["message_count"] = len(messages) - - assert conv_dict["message_count"] == 3 diff --git a/tests/test_journal_prep_filtering.py b/tests/test_journal_prep_filtering.py deleted file mode 100644 index 362dca3..0000000 --- a/tests/test_journal_prep_filtering.py +++ /dev/null @@ -1,195 +0,0 @@ -"""Tests for the journal prep filtering helpers added in #159.""" - -import datetime -from types import SimpleNamespace -from zoneinfo import ZoneInfo - -import pytest - - -# ── _task_to_prep_dict ──────────────────────────────────────────────────────── - - -def test_task_to_prep_dict_marks_overdue_with_days_count(): - from fabledassistant.services.journal_prep import _task_to_prep_dict - - today = datetime.date(2026, 4, 29) - task = SimpleNamespace( - id=2, title="Research Weston's ADHD Evaluation", - status="todo", priority="high", - due_date=datetime.date(2026, 2, 20), - ) - d = _task_to_prep_dict(task, today) - assert d["days_overdue"] == 68 - assert d["due_date"] == "2026-02-20" - - -def test_task_to_prep_dict_due_today_no_overdue_marker(): - from fabledassistant.services.journal_prep import _task_to_prep_dict - - today = datetime.date(2026, 4, 29) - task = SimpleNamespace( - id=10, title="Pick up dry cleaning", - status="todo", priority="none", - due_date=today, - ) - d = _task_to_prep_dict(task, today) - assert "days_overdue" not in d - - -def test_task_to_prep_dict_handles_no_due_date(): - from fabledassistant.services.journal_prep import _task_to_prep_dict - - task = SimpleNamespace( - id=11, title="Long-running side project", - status="in_progress", priority="medium", - due_date=None, - ) - d = _task_to_prep_dict(task, datetime.date(2026, 4, 29)) - assert d["due_date"] is None - assert "days_overdue" not in d - - -# ── _filter_proximate_events ────────────────────────────────────────────────── - - -def test_filter_proximate_drops_far_future_recurring_event(): - """The reported failure: a recurring Birthday event with start_dt - 2026-09-29 surfaced in every daily prep. The proximity filter drops it.""" - from fabledassistant.services.journal_prep import _filter_proximate_events - - events = [ - {"id": 13, "title": "Birthday", "start_dt": "2026-09-29T00:00:00+00:00"}, - ] - kept = _filter_proximate_events( - events, - day_date=datetime.date(2026, 4, 29), - user_tz=ZoneInfo("America/New_York"), - ) - assert kept == [] - - -def test_filter_proximate_keeps_events_within_window(): - """Events within ±7 days of the prep date stay.""" - from fabledassistant.services.journal_prep import _filter_proximate_events - - events = [ - {"id": 1, "title": "Today", "start_dt": "2026-04-29T13:00:00+00:00"}, - {"id": 2, "title": "Tomorrow", "start_dt": "2026-04-30T12:00:00+00:00"}, - {"id": 3, "title": "Next week", "start_dt": "2026-05-05T15:00:00+00:00"}, - {"id": 4, "title": "Two weeks out", "start_dt": "2026-05-15T15:00:00+00:00"}, - ] - kept = _filter_proximate_events( - events, - day_date=datetime.date(2026, 4, 29), - user_tz=ZoneInfo("America/New_York"), - ) - kept_ids = [e["id"] for e in kept] - assert 1 in kept_ids - assert 2 in kept_ids - assert 3 in kept_ids - assert 4 not in kept_ids - - -def test_filter_proximate_uses_local_date_not_utc(): - """An event at 04:30 UTC on 2026-04-30 = 00:30 local on 2026-04-30 in - NY (EDT, UTC-4). Local date is 2026-04-30, delta from 2026-04-29 = 1 - day. Must NOT cross-classify based on UTC date alone.""" - from fabledassistant.services.journal_prep import _filter_proximate_events - - events = [ - {"id": 99, "title": "Late-night dentist", "start_dt": "2026-04-30T04:30:00+00:00"}, - ] - kept = _filter_proximate_events( - events, - day_date=datetime.date(2026, 4, 29), - user_tz=ZoneInfo("America/New_York"), - ) - assert len(kept) == 1 - - -def test_filter_proximate_keeps_unparseable_dates(): - """Bad date strings are kept rather than silently suppressing real - events on a parser bug.""" - from fabledassistant.services.journal_prep import _filter_proximate_events - - events = [ - {"id": 50, "title": "Garbage", "start_dt": "not-a-date"}, - {"id": 51, "title": "Empty", "start_dt": ""}, - {"id": 52, "title": "Missing"}, - ] - kept = _filter_proximate_events( - events, - day_date=datetime.date(2026, 4, 29), - user_tz=ZoneInfo("America/New_York"), - ) - assert len(kept) == 3 - - -# ── _render_sections_for_prompt — overdue framing ──────────────────────────── - - -def test_render_overdue_includes_staleness_duration(): - """The rendered prompt block must surface days_overdue so the LLM - can frame stale tasks correctly.""" - from fabledassistant.services.journal_prep import _render_sections_for_prompt - - sections = { - "tasks_due_today": [], - "tasks_upcoming": [], - "tasks_overdue": [{ - "id": 2, "title": "Research Weston's ADHD Evaluation", - "status": "todo", "priority": "high", - "due_date": "2026-02-20", "days_overdue": 68, - }], - } - rendered = _render_sections_for_prompt(sections) - assert "OVERDUE TASKS" in rendered - # Unambiguous overdue phrasing (replaced the old "68 days ago", which the - # model parroted alongside the header into a self-contradiction). - assert "68 days overdue" in rendered - assert "2026-02-20" in rendered - # Crucially, NOT framed as due today. - assert "DUE TODAY" not in rendered - - -def test_render_due_today_no_due_date_repetition(): - """Tasks in the DUE TODAY bucket don't need the (due 2026-04-29) - parenthetical — the section header already says 'today'.""" - from fabledassistant.services.journal_prep import _render_sections_for_prompt - - sections = { - "tasks_due_today": [{ - "id": 1, "title": "Pick up dry cleaning", - "status": "todo", "priority": "none", - "due_date": "2026-04-29", - }], - "tasks_upcoming": [], - "tasks_overdue": [], - } - rendered = _render_sections_for_prompt(sections) - assert "TASKS DUE TODAY" in rendered - assert "Pick up dry cleaning" in rendered - # The line itself shouldn't repeat the due date. - line = next( - (l for l in rendered.splitlines() if "Pick up dry cleaning" in l), - "", - ) - assert "2026-04-29" not in line - - -def test_render_three_buckets_in_correct_order(): - """When all three buckets have content, the prompt sees them in - DUE TODAY → UPCOMING → OVERDUE order so the LLM leads with today.""" - from fabledassistant.services.journal_prep import _render_sections_for_prompt - - sections = { - "tasks_due_today": [{"id": 1, "title": "Today task", "status": "todo", "priority": "none", "due_date": "2026-04-29"}], - "tasks_upcoming": [{"id": 2, "title": "Upcoming task", "status": "todo", "priority": "none", "due_date": "2026-05-02"}], - "tasks_overdue": [{"id": 3, "title": "Stale task", "status": "todo", "priority": "none", "due_date": "2026-02-20", "days_overdue": 68}], - } - rendered = _render_sections_for_prompt(sections) - today_idx = rendered.index("TASKS DUE TODAY") - upcoming_idx = rendered.index("UPCOMING TASKS") - overdue_idx = rendered.index("OVERDUE TASKS") - assert today_idx < upcoming_idx < overdue_idx diff --git a/tests/test_journal_prep_hardening.py b/tests/test_journal_prep_hardening.py deleted file mode 100644 index 5b50e21..0000000 --- a/tests/test_journal_prep_hardening.py +++ /dev/null @@ -1,123 +0,0 @@ -"""Coverage for the daily-prep hardening pass. - -Targets the two prep-quality defects observed via the fable MCP on -2026-05-19 (conversation #291): - - 1. Fabricated weather ("partly cloudy with a high of 68°F and a 15% - chance of rain") emitted with an empty weather section. - 2. Self-contradictory overdue phrasing ("still on the list, not - currently due, 88 days ago"; "1 days ago"). - -All targets are pure sync functions — no DB / LLM needed. -""" -import datetime - -from fabledassistant.services.journal_prep import ( - _prose_fabricated_weather, - _render_sections_for_prompt, - _render_task_line, -) - -TODAY = datetime.date(2026, 5, 19) - - -# ── Overdue phrasing ──────────────────────────────────────────────────────── - - -def test_overdue_line_singular_day(): - line = _render_task_line( - {"title": "Change my oil", "due_date": "2026-05-18", "days_overdue": 1}, - include_due=False, include_overdue=True, - ) - assert "1 day overdue" in line - assert "1 days" not in line # the old ungrammatical form is gone - - -def test_overdue_line_plural_days_no_contradiction(): - line = _render_task_line( - {"title": "Research X", "due_date": "2026-02-20", "days_overdue": 88}, - include_due=False, include_overdue=True, - ) - assert "88 days overdue" in line - assert "was due 2026-02-20" in line - assert "days ago" not in line # replaced by unambiguous "overdue" - - -def test_overdue_section_header_is_unambiguous(): - out = _render_sections_for_prompt({ - "tasks_overdue": [ - {"title": "Research X", "due_date": "2026-02-20", "days_overdue": 88}, - ], - }) - assert "past their due date, still open" in out - # The phrase that the model parroted into the self-contradiction is gone. - assert "not currently due" not in out - - -# ── Weather absent-marker ─────────────────────────────────────────────────── - - -def test_empty_weather_emits_explicit_none_marker(): - out = _render_sections_for_prompt({ - "tasks_overdue": [ - {"title": "Change my oil", "due_date": "2026-05-18", "days_overdue": 1}, - ], - "weather": [], - }) - assert "WEATHER: none available" in out - assert "Do NOT mention weather" in out - - -def test_present_weather_renders_data_not_marker(): - out = _render_sections_for_prompt({ - "tasks_due_today": [{"title": "Ship it"}], - "weather": [{ - "location_label": "Home", - "forecast_json": {"daily": { - "temperature_2m_max": [70], - "temperature_2m_min": [52], - "precipitation_probability_max": [10], - }}, - }], - }) - assert "WEATHER:" in out - assert "none available" not in out - assert "high 70°" in out - - -def test_quiet_day_returns_fabrication_forbidding_text(): - out = _render_sections_for_prompt({}) - assert "quiet day" in out - assert "Do not invent weather" in out - - -# ── Fabricated-weather guard ──────────────────────────────────────────────── - - -def test_guard_flags_the_real_observed_hallucination(): - prose = ( - "You have two overdue tasks. The weather today is partly cloudy " - "with a high of 68°F and a 15% chance of rain. What's on your mind?" - ) - assert _prose_fabricated_weather(prose, {"weather": []}) is True - - -def test_guard_flags_bare_temperature_glyph(): - assert _prose_fabricated_weather("Expect around 72° later.", {"weather": []}) is True - - -def test_guard_passes_clean_prose(): - prose = "Two tasks are overdue and nothing is due today. What's on your mind?" - assert _prose_fabricated_weather(prose, {"weather": []}) is False - - -def test_guard_no_false_positive_on_rain_in_task_title(): - # Bare "rain" must NOT trip the guard — only "chance of rain" etc. does. - prose = "Don't forget to buy rain boots and finish the training module." - assert _prose_fabricated_weather(prose, {"weather": []}) is False - - -def test_guard_allows_weather_when_data_present(): - prose = "Home is 70°F with a 10% chance of rain." - sections = {"weather": [{"location_label": "Home"}]} - assert _prose_fabricated_weather(prose, sections) is False diff --git a/tests/test_journal_search.py b/tests/test_journal_search.py deleted file mode 100644 index 44c2e4f..0000000 --- a/tests/test_journal_search.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Unit tests for the cosine helper inside journal_search.""" -import math - -from fabledassistant.services.journal_search import _cosine - - -def test_cosine_identical_vectors(): - v = [1.0, 2.0, 3.0] - assert math.isclose(_cosine(v, v), 1.0, rel_tol=1e-9) - - -def test_cosine_orthogonal_vectors(): - a = [1.0, 0.0] - b = [0.0, 1.0] - assert math.isclose(_cosine(a, b), 0.0, abs_tol=1e-9) - - -def test_cosine_zero_vector_returns_zero(): - assert _cosine([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]) == 0.0 - - -def test_cosine_empty_returns_zero(): - assert _cosine([], [1.0]) == 0.0 diff --git a/tests/test_lookup_tool.py b/tests/test_lookup_tool.py deleted file mode 100644 index 1d9833d..0000000 --- a/tests/test_lookup_tool.py +++ /dev/null @@ -1,111 +0,0 @@ -import pytest -from unittest.mock import AsyncMock, MagicMock, patch - -from fabledassistant.services.tools.web import lookup_tool - - -@pytest.mark.asyncio -async def test_lookup_wikipedia_hit(): - wiki_data = { - "title": "QUIC", - "extract": "QUIC is a transport layer protocol.", - "url": "https://en.wikipedia.org/wiki/QUIC", - "thumbnail_url": "", - } - - with patch("fabledassistant.services.wikipedia.wiki_summary", new_callable=AsyncMock, return_value=wiki_data), \ - patch("fabledassistant.config.Config") as mock_config: - mock_config.searxng_enabled.return_value = False - result = await lookup_tool(user_id=1, arguments={"query": "QUIC"}) - - assert result["success"] is True - assert result["type"] == "lookup" - assert result["data"]["wikipedia"]["title"] == "QUIC" - assert result["data"]["web"] == [] - assert "image" not in result["data"]["wikipedia"] - - -@pytest.mark.asyncio -async def test_lookup_wikipedia_miss_searxng_fallback(): - searxng_results = [ - {"url": "https://example.com/quic", "title": "QUIC Explained", "snippet": "An overview..."}, - ] - - with patch("fabledassistant.services.wikipedia.wiki_summary", new_callable=AsyncMock, return_value=None), \ - patch("fabledassistant.config.Config") as mock_config, \ - patch("fabledassistant.services.research._search_searxng", new_callable=AsyncMock, return_value=searxng_results), \ - patch("fabledassistant.services.article_fetcher.fetch_article_text", new_callable=AsyncMock, return_value="Full article about QUIC..."): - mock_config.searxng_enabled.return_value = True - result = await lookup_tool(user_id=1, arguments={"query": "QUIC"}) - - assert result["success"] is True - assert result["data"]["wikipedia"] is None - assert len(result["data"]["web"]) == 1 - assert result["data"]["web"][0]["url"] == "https://example.com/quic" - - -@pytest.mark.asyncio -async def test_lookup_parallel_both_sources(): - wiki_data = { - "title": "President of the United States", - "extract": "The president is the head of state.", - "url": "https://en.wikipedia.org/wiki/President_of_the_United_States", - "thumbnail_url": "", - } - searxng_results = [ - {"url": "https://whitehouse.gov", "title": "The White House", "snippet": "Current admin..."}, - ] - - with patch("fabledassistant.services.wikipedia.wiki_summary", new_callable=AsyncMock, return_value=wiki_data), \ - patch("fabledassistant.config.Config") as mock_config, \ - patch("fabledassistant.services.research._search_searxng", new_callable=AsyncMock, return_value=searxng_results), \ - patch("fabledassistant.services.article_fetcher.fetch_article_text", new_callable=AsyncMock, return_value="Current president is..."): - mock_config.searxng_enabled.return_value = True - result = await lookup_tool(user_id=1, arguments={"query": "president of the united states"}) - - assert result["success"] is True - assert result["data"]["wikipedia"]["title"] == "President of the United States" - assert len(result["data"]["web"]) == 1 - - -@pytest.mark.asyncio -async def test_lookup_wikipedia_thumbnail_cached(): - wiki_data = { - "title": "Hedgehog", - "extract": "Hedgehogs are spiny mammals.", - "url": "https://en.wikipedia.org/wiki/Hedgehog", - "thumbnail_url": "https://upload.wikimedia.org/.../Hedgehog.jpg", - } - mock_record = MagicMock() - mock_record.id = 42 - - with patch("fabledassistant.services.wikipedia.wiki_summary", new_callable=AsyncMock, return_value=wiki_data), \ - patch("fabledassistant.config.Config") as mock_config, \ - patch("fabledassistant.services.images.fetch_and_store_image", new_callable=AsyncMock, return_value=mock_record): - mock_config.searxng_enabled.return_value = False - result = await lookup_tool(user_id=1, arguments={"query": "hedgehog"}) - - assert result["data"]["wikipedia"]["image"]["embed"] == "![Hedgehog](/api/images/42)" - assert "Wikipedia" in result["data"]["wikipedia"]["image"]["citation"] - assert "image_instructions" in result["data"] - - -@pytest.mark.asyncio -async def test_lookup_wikipedia_miss_no_searxng(): - with patch("fabledassistant.services.wikipedia.wiki_summary", new_callable=AsyncMock, return_value=None), \ - patch("fabledassistant.config.Config") as mock_config: - mock_config.searxng_enabled.return_value = False - result = await lookup_tool(user_id=1, arguments={"query": "xyznonexistent"}) - - assert result["success"] is True - assert "message" in result["data"] - - -@pytest.mark.asyncio -async def test_lookup_always_available(): - with patch("fabledassistant.services.tools._registry.is_caldav_configured", new_callable=AsyncMock, return_value=False), \ - patch("fabledassistant.services.settings.get_setting", new_callable=AsyncMock, return_value="false"): - from fabledassistant.services.tools import get_tools_for_user - tools = await get_tools_for_user(user_id=1) - tool_names = {t["function"]["name"] for t in tools} - assert "lookup" in tool_names diff --git a/tests/test_notes_consolidation_trigger.py b/tests/test_notes_consolidation_trigger.py deleted file mode 100644 index cb8f109..0000000 --- a/tests/test_notes_consolidation_trigger.py +++ /dev/null @@ -1,118 +0,0 @@ -"""Tests for the status-terminal consolidation trigger in update_note.""" -from unittest.mock import AsyncMock, MagicMock, patch - - -def _mock_session_for_update(mock_note): - mock_session = AsyncMock() - mock_result = MagicMock() - mock_result.scalars.return_value.first.return_value = mock_note - mock_session.execute = AsyncMock(return_value=mock_result) - mock_session.commit = AsyncMock() - mock_session.refresh = AsyncMock() - mock_session.__aenter__ = AsyncMock(return_value=mock_session) - mock_session.__aexit__ = AsyncMock(return_value=False) - return mock_session - - -async def test_update_note_to_done_triggers_consolidation(): - """status: in_progress → done should fire maybe_consolidate(task_closed).""" - mock_note = MagicMock() - mock_note.id = 42 - mock_note.status = "in_progress" # pre-update state - mock_note.body = "" - mock_note.title = "" - mock_note.tags = [] - mock_note.started_at = None - mock_note.completed_at = None - mock_note.recurrence_rule = None - mock_note.project_id = None - - with patch( - "fabledassistant.services.notes.async_session", - return_value=_mock_session_for_update(mock_note), - ), patch( - "fabledassistant.services.consolidation.maybe_consolidate", - new=AsyncMock(), - ) as mock_mc: - from fabledassistant.services.notes import update_note - await update_note(1, 42, status="done") - - mock_mc.assert_awaited_once_with(1, 42, reason="task_closed") - - -async def test_update_note_to_cancelled_triggers_consolidation(): - mock_note = MagicMock() - mock_note.id = 42 - mock_note.status = "in_progress" - mock_note.body = "" - mock_note.title = "" - mock_note.tags = [] - mock_note.started_at = None - mock_note.completed_at = None - mock_note.recurrence_rule = None - mock_note.project_id = None - - with patch( - "fabledassistant.services.notes.async_session", - return_value=_mock_session_for_update(mock_note), - ), patch( - "fabledassistant.services.consolidation.maybe_consolidate", - new=AsyncMock(), - ) as mock_mc: - from fabledassistant.services.notes import update_note - await update_note(1, 42, status="cancelled") - - mock_mc.assert_awaited_once_with(1, 42, reason="task_closed") - - -async def test_update_note_to_in_progress_does_not_trigger_consolidation(): - """Non-terminal status changes don't fire the trigger.""" - mock_note = MagicMock() - mock_note.id = 42 - mock_note.status = "todo" - mock_note.body = "" - mock_note.title = "" - mock_note.tags = [] - mock_note.started_at = None - mock_note.completed_at = None - mock_note.recurrence_rule = None - mock_note.project_id = None - - with patch( - "fabledassistant.services.notes.async_session", - return_value=_mock_session_for_update(mock_note), - ), patch( - "fabledassistant.services.consolidation.maybe_consolidate", - new=AsyncMock(), - ) as mock_mc: - from fabledassistant.services.notes import update_note - await update_note(1, 42, status="in_progress") - - mock_mc.assert_not_awaited() - - -async def test_update_note_already_done_does_not_retrigger(): - """If the status was already 'done' and update_note(status='done') runs - again, no fresh trigger fires — only transitions count.""" - mock_note = MagicMock() - mock_note.id = 42 - mock_note.status = "done" - mock_note.body = "" - mock_note.title = "" - mock_note.tags = [] - mock_note.started_at = None - mock_note.completed_at = None - mock_note.recurrence_rule = None - mock_note.project_id = None - - with patch( - "fabledassistant.services.notes.async_session", - return_value=_mock_session_for_update(mock_note), - ), patch( - "fabledassistant.services.consolidation.maybe_consolidate", - new=AsyncMock(), - ) as mock_mc: - from fabledassistant.services.notes import update_note - await update_note(1, 42, status="done") - - mock_mc.assert_not_awaited() diff --git a/tests/test_record_moment_guards.py b/tests/test_record_moment_guards.py deleted file mode 100644 index 2772dcd..0000000 --- a/tests/test_record_moment_guards.py +++ /dev/null @@ -1,181 +0,0 @@ -"""Tests for the record_moment server-side data-hygiene guards. - -These guard against two failure modes observed in real journal usage: - -1. The LLM emits ``task_titles`` referencing a task that's only in the - prep context — not actually mentioned by the user. Without a check, - every moment ends up linked to whatever's open in the user's queue. - -2. The LLM emits generic placeholder ``place_names`` like ``"work"`` / - ``"home"`` instead of real place notes. These role-labels aren't - places. - -Both are exercised through the pure helpers; full-stack handler tests -would require a session-bound DB fixture this suite doesn't have yet. -""" - -from unittest.mock import AsyncMock, patch - -import pytest - - -def test_content_keywords_drops_stopwords_and_short_tokens(): - from fabledassistant.services.tools.journal import _content_keywords - - kw = _content_keywords( - "I went to the store to pick up milk and bread for the kids." - ) - # Stopwords (the, to, for, and, i) gone; short tokens (kids? 4 chars - kept) - # filtered. Real content words kept. - assert "store" in kw - assert "milk" in kw - assert "bread" in kw - assert "kids" in kw - assert "the" not in kw - assert "to" not in kw - assert "for" not in kw - assert "i" not in kw - - -def test_content_keywords_extracts_named_entities(): - """Place names and project nouns survive tokenization. Short numeric - fragments (e.g. '14') get filtered alongside other <3-char tokens — - the surrounding alpha keywords carry the overlap weight in practice.""" - from fabledassistant.services.tools.journal import _content_keywords - - kw = _content_keywords("Migration prep at Branch 14 Bedford.") - assert "branch" in kw - assert "bedford" in kw - assert "migration" in kw - - -def test_filter_placeholder_places_drops_generic_role_labels(): - from fabledassistant.services.tools.journal import _filter_placeholder_places - - kept, dropped = _filter_placeholder_places( - ["work", "Famous Supply", "home", "Branch 14 Bedford", "the office"] - ) - assert "Famous Supply" in kept - assert "Branch 14 Bedford" in kept - assert "work" in dropped - assert "home" in dropped - assert "the office" in dropped - - -def test_filter_placeholder_places_is_case_insensitive(): - from fabledassistant.services.tools.journal import _filter_placeholder_places - - kept, dropped = _filter_placeholder_places(["WORK", "Home", "OFFICE"]) - assert kept == [] - assert set(dropped) == {"WORK", "Home", "OFFICE"} - - -def test_filter_placeholder_places_preserves_real_places_named_similarly(): - """A user-defined place that happens to be ONE word but isn't a generic - role-label (e.g. 'Akron') stays.""" - from fabledassistant.services.tools.journal import _filter_placeholder_places - - kept, dropped = _filter_placeholder_places(["Akron", "Cleveland"]) - assert kept == ["Akron", "Cleveland"] - assert dropped == [] - - -@pytest.mark.asyncio -async def test_keyword_overlap_drops_unrelated_task_link(): - """The reported failure mode: model attached Weston's ADHD task to a - Docker-swarm moment. With the keyword guard, the link gets dropped.""" - from fabledassistant.services.tools.journal import ( - _filter_task_ids_by_keyword_overlap, - ) - - # Mock the DB lookup to return the offending task title for id=2. - fake_session = AsyncMock() - fake_session.execute = AsyncMock() - fake_session.execute.return_value.all = lambda: [ - (2, "Research Weston's ADHD Evaluation"), - ] - fake_session.__aenter__ = AsyncMock(return_value=fake_session) - fake_session.__aexit__ = AsyncMock(return_value=None) - - with patch( - "fabledassistant.services.tools.journal.async_session", - return_value=fake_session, - ): - kept = await _filter_task_ids_by_keyword_overlap( - user_id=1, - content="Working on restaging Docker in the swarm; encountered network breakage on a Windows node.", - task_ids=[2], - ) - - assert kept == [] - - -@pytest.mark.asyncio -async def test_keyword_overlap_keeps_genuinely_referenced_task(): - """When the moment content shares a meaningful keyword with the task - title, the link is preserved.""" - from fabledassistant.services.tools.journal import ( - _filter_task_ids_by_keyword_overlap, - ) - - fake_session = AsyncMock() - fake_session.execute = AsyncMock() - fake_session.execute.return_value.all = lambda: [ - (5, "Restage Docker on Fam-dockerwin04"), - ] - fake_session.__aenter__ = AsyncMock(return_value=fake_session) - fake_session.__aexit__ = AsyncMock(return_value=None) - - with patch( - "fabledassistant.services.tools.journal.async_session", - return_value=fake_session, - ): - kept = await _filter_task_ids_by_keyword_overlap( - user_id=1, - content="Reinstalled Microsoft container support; Docker restage now works.", - task_ids=[5], - ) - - # 'docker' appears in both → keep - assert kept == [5] - - -@pytest.mark.asyncio -async def test_keyword_overlap_handles_partial_relevance(): - """Mixed ids: keep the related one, drop the unrelated one.""" - from fabledassistant.services.tools.journal import ( - _filter_task_ids_by_keyword_overlap, - ) - - fake_session = AsyncMock() - fake_session.execute = AsyncMock() - fake_session.execute.return_value.all = lambda: [ - (2, "Research Weston's ADHD Evaluation"), - (5, "Restage Docker on Fam-dockerwin04"), - ] - fake_session.__aenter__ = AsyncMock(return_value=fake_session) - fake_session.__aexit__ = AsyncMock(return_value=None) - - with patch( - "fabledassistant.services.tools.journal.async_session", - return_value=fake_session, - ): - kept = await _filter_task_ids_by_keyword_overlap( - user_id=1, - content="Working on restaging Docker in the swarm.", - task_ids=[2, 5], - ) - - assert kept == [5] - - -@pytest.mark.asyncio -async def test_keyword_overlap_empty_task_ids_returns_empty(): - from fabledassistant.services.tools.journal import ( - _filter_task_ids_by_keyword_overlap, - ) - - kept = await _filter_task_ids_by_keyword_overlap( - user_id=1, content="anything", task_ids=[], - ) - assert kept == [] diff --git a/tests/test_research_pipeline.py b/tests/test_research_pipeline.py deleted file mode 100644 index 8974d5a..0000000 --- a/tests/test_research_pipeline.py +++ /dev/null @@ -1,231 +0,0 @@ -"""Tests for the multi-note research pipeline.""" -import json -import pytest -from unittest.mock import AsyncMock, patch - - -@pytest.mark.asyncio -async def test_generate_outline_parses_valid_json(): - """_generate_outline returns parsed sections when model returns valid JSON.""" - from fabledassistant.services.research import _generate_outline - - outline_json = json.dumps([ - {"title": "Section One", "focus": "Focus one"}, - {"title": "Section Two", "focus": "Focus two"}, - {"title": "Section Three", "focus": "Focus three"}, - ]) - with patch("fabledassistant.services.research.generate_completion", new_callable=AsyncMock, return_value=outline_json): - result = await _generate_outline("test topic", [], "test-model") - - assert len(result) == 3 - assert result[0]["title"] == "Section One" - assert result[0]["focus"] == "Focus one" - - -@pytest.mark.asyncio -async def test_generate_outline_returns_empty_on_parse_failure(): - """_generate_outline returns [] when model output is not parseable JSON.""" - from fabledassistant.services.research import _generate_outline - - with patch("fabledassistant.services.research.generate_completion", new_callable=AsyncMock, return_value="not json at all"): - result = await _generate_outline("test topic", [], "test-model") - - assert result == [] - - -@pytest.mark.asyncio -async def test_generate_outline_returns_empty_when_fewer_than_2_sections(): - """_generate_outline returns [] when model returns fewer than 2 valid sections.""" - from fabledassistant.services.research import _generate_outline - - outline_json = json.dumps([{"title": "Only One", "focus": "Focus"}]) - with patch("fabledassistant.services.research.generate_completion", new_callable=AsyncMock, return_value=outline_json): - result = await _generate_outline("test topic", [], "test-model") - - assert result == [] - - -@pytest.mark.asyncio -async def test_generate_outline_truncates_to_8(): - """_generate_outline truncates results to at most 8 sections.""" - from fabledassistant.services.research import _generate_outline - - sections = [{"title": f"Section {i}", "focus": f"Focus {i}"} for i in range(10)] - with patch("fabledassistant.services.research.generate_completion", new_callable=AsyncMock, return_value=json.dumps(sections)): - result = await _generate_outline("test topic", [], "test-model") - - assert len(result) == 8 - - -@pytest.mark.asyncio -async def test_generate_outline_skips_malformed_entries(): - """_generate_outline filters out entries missing title or focus.""" - from fabledassistant.services.research import _generate_outline - - outline_json = json.dumps([ - {"title": "Good One", "focus": "Good focus"}, - {"title": "Missing focus"}, - {"focus": "Missing title"}, - {"title": "Good Two", "focus": "Good focus two"}, - {"title": "Good Three", "focus": "Good focus three"}, - ]) - with patch("fabledassistant.services.research.generate_completion", new_callable=AsyncMock, return_value=outline_json): - result = await _generate_outline("test topic", [], "test-model") - - assert len(result) == 3 - assert all(s["title"] and s["focus"] for s in result) - - -@pytest.mark.asyncio -async def test_synthesize_section_returns_title_and_body(): - """_synthesize_section returns (section_title, body) from model output.""" - from fabledassistant.services.research import _synthesize_section - - with patch("fabledassistant.services.research.generate_completion", new_callable=AsyncMock, return_value="The body of this section."): - title, body = await _synthesize_section( - section_title="Quantum Entanglement: Mechanisms", - section_focus="How entanglement works at the particle level", - sources=[], - model="test-model", - ) - - assert title == "Quantum Entanglement: Mechanisms" - assert body == "The body of this section." - - -@pytest.mark.asyncio -async def test_synthesize_section_strips_whitespace(): - """_synthesize_section strips leading/trailing whitespace from body.""" - from fabledassistant.services.research import _synthesize_section - - with patch("fabledassistant.services.research.generate_completion", new_callable=AsyncMock, return_value="\n\n body text \n\n"): - title, body = await _synthesize_section("Title", "Focus", [], "model") - - assert body == "body text" - - -@pytest.mark.asyncio -async def test_pipeline_creates_section_notes_and_index(): - """run_research_pipeline creates N section notes + 1 index note with links, returns index.""" - from unittest.mock import MagicMock - - outline = [ - {"title": "Section A", "focus": "Focus A"}, - {"title": "Section B", "focus": "Focus B"}, - {"title": "Section C", "focus": "Focus C"}, - ] - - note_id_counter = iter(range(10, 20)) - - def _make_note(user_id, title, body, tags, project_id=None, parent_id=None): - n = MagicMock() - n.id = next(note_id_counter) - n.title = title - return n - - with patch("fabledassistant.services.research._generate_sub_queries", new_callable=AsyncMock, return_value=["q1"]), \ - patch("fabledassistant.services.research._search_searxng", new_callable=AsyncMock, return_value=[{"url": "http://x.com", "title": "X", "snippet": "s"}]), \ - patch("fabledassistant.services.research.fetch_url_content", new_callable=AsyncMock, return_value="content"), \ - patch("fabledassistant.services.research._generate_outline", new_callable=AsyncMock, return_value=outline), \ - patch("fabledassistant.services.research._synthesize_section", new_callable=AsyncMock, side_effect=lambda t, f, s, m: (t, f"Body for {t}")), \ - patch("fabledassistant.services.research._generate_executive_summary", new_callable=AsyncMock, return_value="Executive summary text."), \ - patch("fabledassistant.services.research.create_note", new_callable=AsyncMock, side_effect=_make_note), \ - patch("fabledassistant.services.research.update_note", new_callable=AsyncMock) as mock_update: - - from fabledassistant.services.research import run_research_pipeline - result = await run_research_pipeline("test topic", user_id=1, model="test-model") - - assert result.title == "Research: test topic" - # Index note body should be updated with section links - mock_update.assert_called_once() - updated_body = mock_update.call_args.kwargs.get("body", "") - assert "/notes/" in updated_body - - -@pytest.mark.asyncio -async def test_pipeline_falls_back_to_single_note_when_outline_empty(): - """run_research_pipeline falls back to single-note synthesis when _generate_outline returns [].""" - from unittest.mock import MagicMock - - single_note = MagicMock() - single_note.id = 99 - single_note.title = "Research: test topic" - - with patch("fabledassistant.services.research._generate_sub_queries", new_callable=AsyncMock, return_value=["q1"]), \ - patch("fabledassistant.services.research._search_searxng", new_callable=AsyncMock, return_value=[{"url": "http://x.com", "title": "X", "snippet": "s"}]), \ - patch("fabledassistant.services.research.fetch_url_content", new_callable=AsyncMock, return_value="content"), \ - patch("fabledassistant.services.research._generate_outline", new_callable=AsyncMock, return_value=[]), \ - patch("fabledassistant.services.research._synthesize_note", new_callable=AsyncMock, return_value=("Research: test topic", "body")), \ - patch("fabledassistant.services.research.create_note", new_callable=AsyncMock, return_value=single_note): - - from fabledassistant.services.research import run_research_pipeline - result = await run_research_pipeline("test topic", user_id=1, model="test-model") - - assert result.id == 99 - - -@pytest.mark.asyncio -async def test_pipeline_falls_back_when_all_sections_fail(): - """run_research_pipeline falls back to single note when all section syntheses raise.""" - from unittest.mock import MagicMock - - outline = [ - {"title": "Section A", "focus": "Focus A"}, - {"title": "Section B", "focus": "Focus B"}, - {"title": "Section C", "focus": "Focus C"}, - ] - fallback_note = MagicMock() - fallback_note.id = 77 - fallback_note.title = "Research: test topic" - - with patch("fabledassistant.services.research._generate_sub_queries", new_callable=AsyncMock, return_value=["q1"]), \ - patch("fabledassistant.services.research._search_searxng", new_callable=AsyncMock, return_value=[{"url": "http://x.com", "title": "X", "snippet": "s"}]), \ - patch("fabledassistant.services.research.fetch_url_content", new_callable=AsyncMock, return_value="content"), \ - patch("fabledassistant.services.research._generate_outline", new_callable=AsyncMock, return_value=outline), \ - patch("fabledassistant.services.research._synthesize_section", new_callable=AsyncMock, side_effect=RuntimeError("synthesis failed")), \ - patch("fabledassistant.services.research._synthesize_note", new_callable=AsyncMock, return_value=("Research: test topic", "body")), \ - patch("fabledassistant.services.research.create_note", new_callable=AsyncMock, return_value=fallback_note): - - from fabledassistant.services.research import run_research_pipeline - result = await run_research_pipeline("test topic", user_id=1, model="test-model") - - assert result.id == 77 - - -@pytest.mark.asyncio -async def test_pipeline_includes_wikipedia_sources(): - """run_research_pipeline should merge Wikipedia results into the source pool.""" - from unittest.mock import MagicMock - - wiki_results = [{"title": "Wiki Article", "extract": "Wikipedia content about the topic.", "url": "https://en.wikipedia.org/wiki/Topic"}] - - outline = [ - {"title": "Section A", "focus": "Focus A"}, - {"title": "Section B", "focus": "Focus B"}, - ] - - note_id_counter = iter(range(30, 40)) - - def _make_note(user_id, title, body, tags, project_id=None, parent_id=None): - n = MagicMock() - n.id = next(note_id_counter) - n.title = title - return n - - with patch("fabledassistant.services.research._generate_sub_queries", new_callable=AsyncMock, return_value=["q1"]), \ - patch("fabledassistant.services.research._search_searxng", new_callable=AsyncMock, return_value=[{"url": "http://x.com", "title": "X", "snippet": "s"}]), \ - patch("fabledassistant.services.research.wiki_search", new_callable=AsyncMock, return_value=wiki_results), \ - patch("fabledassistant.services.research.fetch_url_content", new_callable=AsyncMock, return_value="content"), \ - patch("fabledassistant.services.research._generate_outline", new_callable=AsyncMock, return_value=outline) as mock_outline, \ - patch("fabledassistant.services.research._synthesize_section", new_callable=AsyncMock, side_effect=lambda t, f, s, m: (t, f"Body for {t}")), \ - patch("fabledassistant.services.research._generate_executive_summary", new_callable=AsyncMock, return_value="Summary."), \ - patch("fabledassistant.services.research.create_note", new_callable=AsyncMock, side_effect=_make_note), \ - patch("fabledassistant.services.research.update_note", new_callable=AsyncMock): - - from fabledassistant.services.research import run_research_pipeline - await run_research_pipeline("test topic", user_id=1, model="test-model") - - # The sources passed to _generate_outline should include the Wikipedia article - sources_arg = mock_outline.call_args[0][1] # second positional arg - source_urls = [s["url"] for s in sources_arg] - assert "https://en.wikipedia.org/wiki/Topic" in source_urls diff --git a/tests/test_tool_use_fixes.py b/tests/test_tool_use_fixes.py deleted file mode 100644 index c63e2da..0000000 --- a/tests/test_tool_use_fixes.py +++ /dev/null @@ -1,145 +0,0 @@ -"""Tests for the tool-use fixes from the 2026-05-08 journal session.""" - -from types import SimpleNamespace -from unittest.mock import AsyncMock, patch - -import pytest - - -def _project(pid: int, title: str, description: str = "", auto_summary: str = ""): - return SimpleNamespace( - id=pid, title=title, description=description, auto_summary=auto_summary, - ) - - -@pytest.mark.asyncio -async def test_search_projects_tool_returns_success_true(): - """The dispatcher sets status from result['success']; without this key - the call gets labeled 'error' even when data is returned.""" - from fabledassistant.services.tools import projects as projects_tool - - fake_projects = [_project(5, "Famous-Supply Work topics", "AT&T fiber circuit")] - - # `list_projects` is imported locally inside search_projects_tool, so we - # patch the source module rather than the consumer. - with patch("fabledassistant.services.projects.list_projects", new=AsyncMock(return_value=fake_projects)): - result = await projects_tool.search_projects_tool( - user_id=1, arguments={"query": "famous supply"}, - ) - - assert result["success"] is True - assert result["type"] == "projects_list" - assert len(result["data"]["projects"]) == 1 - - -def test_strip_type_nouns_removes_task_word(): - from fabledassistant.services.notes import _strip_type_nouns - assert _strip_type_nouns("sebring secondary task") == ["sebring", "secondary"] - - -def test_strip_type_nouns_removes_all_variants(): - from fabledassistant.services.notes import _strip_type_nouns - assert _strip_type_nouns("project notes task") == [] - - -def test_strip_type_nouns_case_insensitive(): - from fabledassistant.services.notes import _strip_type_nouns - assert _strip_type_nouns("Sebring Task NOTES") == ["Sebring"] - - -def test_strip_type_nouns_preserves_real_content_words(): - from fabledassistant.services.notes import _strip_type_nouns - assert _strip_type_nouns("circuit configuration") == ["circuit", "configuration"] - - -def test_strip_type_nouns_handles_empty_string(): - from fabledassistant.services.notes import _strip_type_nouns - assert _strip_type_nouns("") == [] - assert _strip_type_nouns(" ") == [] - - -def test_score_project_match_exact_title_returns_1(): - from fabledassistant.services.tools._helpers import score_project_match - p = _project(5, "Famous-Supply Work topics") - assert score_project_match("Famous-Supply Work topics", p) == 1.0 - - -def test_score_project_match_colloquial_substring_at_least_85(): - """'famous supply' is a substring of normalized 'famous supply work topics' - after stripping the hyphen. Substring match returns 0.85.""" - from fabledassistant.services.tools._helpers import score_project_match - p = _project(5, "Famous-Supply Work topics", auto_summary="AT&T fiber circuit") - score = score_project_match("famous supply project", p) - assert score >= 0.85, f"expected substring tier (>=0.85), got {score}" - - -def test_score_project_match_query_in_summary_returns_70(): - from fabledassistant.services.tools._helpers import score_project_match - p = _project(12, "Minstrel", auto_summary="self-hosted music server") - assert score_project_match("music server", p) == 0.70 - - -def test_score_project_match_unrelated_returns_low(): - from fabledassistant.services.tools._helpers import score_project_match - p = _project(12, "Minstrel", auto_summary="self-hosted music server") - assert score_project_match("garden renovation", p) < 0.5 - - -def test_score_project_match_empty_query_returns_zero(): - from fabledassistant.services.tools._helpers import score_project_match - p = _project(5, "Famous-Supply Work topics") - assert score_project_match("", p) == 0.0 - - -@pytest.mark.asyncio -async def test_search_projects_tool_ranks_substring_match_above_others(): - """Substring hits (score 0.85) must rank above SequenceMatcher misses - for unrelated projects.""" - from fabledassistant.services.tools import projects as projects_tool - - fake_projects = [ - _project(12, "Minstrel", auto_summary="self-hosted music server"), - _project(5, "Famous-Supply Work topics", auto_summary="AT&T fiber circuit"), - _project(13, "ImageRepo", auto_summary="self-hosted Flask app"), - ] - - with patch("fabledassistant.services.projects.list_projects", new=AsyncMock(return_value=fake_projects)): - result = await projects_tool.search_projects_tool( - user_id=1, arguments={"query": "famous supply project"}, - ) - - top = result["data"]["projects"][0] - assert top["id"] == 5, f"expected Famous-Supply to rank first, got {top}" - assert top["score"] >= 0.85 - - -@pytest.mark.asyncio -async def test_resolve_project_finds_colloquial_match(monkeypatch): - """resolve_project must surface 'Famous-Supply Work topics' when the - user passes 'famous supply project' — substring match via the shared - score helper, score 0.85 ≥ 0.55 threshold.""" - from fabledassistant.services.tools import _helpers - - fake_projects = [ - _project(12, "Minstrel", auto_summary="self-hosted music server"), - _project(5, "Famous-Supply Work topics", auto_summary="AT&T fiber circuit"), - ] - - async def fake_get_project_by_title(uid, name): - return None # force the scored path - - async def fake_list_projects(uid): - return fake_projects - - monkeypatch.setattr( - "fabledassistant.services.projects.get_project_by_title", - fake_get_project_by_title, - ) - monkeypatch.setattr( - "fabledassistant.services.projects.list_projects", - fake_list_projects, - ) - - result = await _helpers.resolve_project(user_id=1, project_name="famous supply project") - assert result is not None - assert result.id == 5 diff --git a/tests/test_tools_notes.py b/tests/test_tools_notes.py deleted file mode 100644 index 5298b09..0000000 --- a/tests/test_tools_notes.py +++ /dev/null @@ -1,200 +0,0 @@ -"""Tests for the create_note / update_note LLM tools. - -Verifies the new task-as-durable-record contract: -- create_note drops `body` when a task is being created (status set). -- create_note forwards `description` to the service. -- update_note rejects `body` writes when the target is a task. -- update_note accepts `description` updates on tasks. -""" -from types import SimpleNamespace -from unittest.mock import AsyncMock, patch - - -async def test_create_note_tool_ignores_body_when_creating_task(): - """Status present → is_task=True → body must be dropped before the - create_note service call; description must be forwarded.""" - from fabledassistant.services.tools import notes as notes_tool - - captured: dict = {} - - async def fake_create_note(**kwargs): - captured.update(kwargs) - return SimpleNamespace( - id=1, - title=kwargs["title"], - status=kwargs.get("status"), - priority=kwargs.get("priority"), - due_date=None, - project_id=None, - milestone_id=None, - parent_id=None, - ) - - with patch( - "fabledassistant.services.tools.notes.create_note", new=fake_create_note, - ), patch( - "fabledassistant.services.tools.notes.check_duplicate", - new=AsyncMock(return_value=None), - ), patch( - "fabledassistant.services.tools.notes.suggest_tags", - new=AsyncMock(return_value=[]), - ), patch( - "fabledassistant.services.tools.notes.schedule_embedding", - ): - await notes_tool.create_note_tool( - user_id=1, - arguments={ - "title": "renew cert", - "status": "todo", - "description": "the goal text", - "body": "this should be dropped on tasks", - }, - ) - - assert captured.get("description") == "the goal text" - # Body dropped because is_task=True. consolidation owns the body field. - assert not captured.get("body") - - -async def test_create_note_tool_preserves_body_for_knowledge_notes(): - """No status → is_task=False → body is preserved as today.""" - from fabledassistant.services.tools import notes as notes_tool - - captured: dict = {} - - async def fake_create_note(**kwargs): - captured.update(kwargs) - # Knowledge-note return path reads note.project_id; SimpleNamespace - # needs the attribute even when it's None. - return SimpleNamespace( - id=1, title=kwargs["title"], status=None, project_id=None, - ) - - with patch( - "fabledassistant.services.tools.notes.create_note", new=fake_create_note, - ), patch( - "fabledassistant.services.tools.notes.check_duplicate", - new=AsyncMock(return_value=None), - ), patch( - "fabledassistant.services.tools.notes.suggest_tags", - new=AsyncMock(return_value=[]), - ), patch( - "fabledassistant.services.tools.notes.schedule_embedding", - ): - await notes_tool.create_note_tool( - user_id=1, - arguments={ - "title": "runbook", - "body": "preserved markdown content", - }, - ) - - assert captured.get("body") == "preserved markdown content" - - -async def test_update_note_tool_rejects_body_on_tasks(): - """When the resolved note has is_task=True, providing body must error.""" - from fabledassistant.services.tools import notes as notes_tool - - # SimpleNamespace can't fake a @property; build is_task as a real attr. - fake_task = SimpleNamespace( - id=42, title="t", status="in_progress", - body="", tags=[], project_id=None, milestone_id=None, - ) - fake_task.is_task = True - - with patch( - "fabledassistant.services.tools.notes.get_note_by_title", - new=AsyncMock(return_value=fake_task), - ), patch( - "fabledassistant.services.tools.notes.update_note", new=AsyncMock(), - ) as mock_update: - result = await notes_tool.update_note_tool( - user_id=1, - arguments={"query": "t", "body": "trying to overwrite"}, - ) - - assert result["success"] is False - err = result.get("error", "").lower() - assert "body" in err or "log_work" in err - mock_update.assert_not_awaited() - - -async def test_update_note_tool_accepts_description_on_tasks(): - """description updates flow through to the service even on tasks.""" - from fabledassistant.services.tools import notes as notes_tool - - fake_task = SimpleNamespace( - id=42, title="t", status="in_progress", - body="", tags=[], project_id=None, milestone_id=None, - description=None, - ) - fake_task.is_task = True - - captured: dict = {} - - async def fake_update_note(uid, nid, **kwargs): - captured.update(kwargs) - # Apply the description so the post-update inspection makes sense. - for k, v in kwargs.items(): - setattr(fake_task, k, v) - return fake_task - - with patch( - "fabledassistant.services.tools.notes.get_note_by_title", - new=AsyncMock(return_value=fake_task), - ), patch( - "fabledassistant.services.tools.notes.update_note", - new=fake_update_note, - ), patch( - "fabledassistant.services.tools.notes.suggest_tags", - new=AsyncMock(return_value=[]), - ), patch( - "fabledassistant.services.tools.notes.schedule_embedding", - ): - result = await notes_tool.update_note_tool( - user_id=1, - arguments={"query": "t", "description": "updated goal"}, - ) - - assert result["success"] is True - assert captured.get("description") == "updated goal" - - -async def test_update_note_tool_accepts_body_on_knowledge_notes(): - """Body writes are still allowed on non-task notes.""" - from fabledassistant.services.tools import notes as notes_tool - - fake_note = SimpleNamespace( - id=10, title="n", status=None, - body="old", tags=[], project_id=None, milestone_id=None, - ) - fake_note.is_task = False - - captured: dict = {} - - async def fake_update_note(uid, nid, **kwargs): - captured.update(kwargs) - for k, v in kwargs.items(): - setattr(fake_note, k, v) - return fake_note - - with patch( - "fabledassistant.services.tools.notes.get_note_by_title", - new=AsyncMock(return_value=fake_note), - ), patch( - "fabledassistant.services.tools.notes.update_note", - new=fake_update_note, - ), patch( - "fabledassistant.services.tools.notes.suggest_tags", - new=AsyncMock(return_value=[]), - ), patch( - "fabledassistant.services.tools.notes.schedule_embedding", - ): - result = await notes_tool.update_note_tool( - user_id=1, - arguments={"query": "n", "body": "new body content"}, - ) - - assert result["success"] is True - assert captured.get("body") == "new body content" diff --git a/tests/test_tools_tasks.py b/tests/test_tools_tasks.py deleted file mode 100644 index 8d7d4a0..0000000 --- a/tests/test_tools_tasks.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Tests for the task tools — log_work in particular wires into the -consolidation pipeline after every successful log.""" -from types import SimpleNamespace -from unittest.mock import AsyncMock, patch - - -async def test_log_work_tool_invokes_maybe_consolidate(): - """log_work must call maybe_consolidate(user_id, task_id, reason='log_added') - after a successful task_logs.create_log.""" - from fabledassistant.services.tools import tasks as tasks_tool - - fake_task = SimpleNamespace(id=42, title="X", status="in_progress") - fake_log = SimpleNamespace( - to_dict=lambda: {"id": 1, "content": "did stuff"}, - ) - - # get_note_by_title is imported at the top of tools/tasks.py — patch the - # consumer module's bound symbol, not the source. - with patch( - "fabledassistant.services.tools.tasks.get_note_by_title", - new=AsyncMock(return_value=fake_task), - ), patch( - "fabledassistant.services.task_logs.create_log", - new=AsyncMock(return_value=fake_log), - ), patch( - "fabledassistant.services.consolidation.maybe_consolidate", - new=AsyncMock(), - ) as mock_mc: - result = await tasks_tool.log_work_tool( - user_id=1, arguments={"task": "X", "content": "did stuff"}, - ) - - assert result["success"] is True - mock_mc.assert_awaited_once_with(1, 42, reason="log_added") - - -async def test_log_work_tool_does_not_trigger_on_create_log_failure(): - """If create_log raises ValueError, maybe_consolidate must not be called.""" - from fabledassistant.services.tools import tasks as tasks_tool - - fake_task = SimpleNamespace(id=42, title="X", status="in_progress") - - with patch( - "fabledassistant.services.tools.tasks.get_note_by_title", - new=AsyncMock(return_value=fake_task), - ), patch( - "fabledassistant.services.task_logs.create_log", - new=AsyncMock(side_effect=ValueError("bad input")), - ), patch( - "fabledassistant.services.consolidation.maybe_consolidate", - new=AsyncMock(), - ) as mock_mc: - result = await tasks_tool.log_work_tool( - user_id=1, arguments={"task": "X", "content": "did stuff"}, - ) - - assert result["success"] is False - mock_mc.assert_not_awaited() diff --git a/tests/test_voice_library.py b/tests/test_voice_library.py deleted file mode 100644 index 59f1d84..0000000 --- a/tests/test_voice_library.py +++ /dev/null @@ -1,156 +0,0 @@ -"""Unit tests for the voice library catalog shaper + ID validation. - -DB- and network-touching paths (fetch_catalog, install_voice) need a -real environment, so we test only the pure transformations here: - -- Voice ID regex accepts valid piper IDs and rejects path-traversal attempts. -- shape_catalog_for_ui projects an HF-shaped catalog dict into the UI list. -- _resolve_file_urls picks the right .onnx and .onnx.json paths. -""" -from __future__ import annotations - -import pytest - -from fabledassistant.services import voice_library as lib - - -def test_voice_id_accepts_real_piper_ids(): - for vid in [ - "en_US-amy-medium", - "en_US-ryan-medium", - "en_GB-alan-low", - "fr_FR-siwis-medium", - "de_DE-thorsten-high", - "es_AR-daniela-high", - "zh_CN-huayan-medium", - "ar_JO-kareem-low", - "ca_ES-upc_ona-x_low", - ]: - assert lib._is_valid_voice_id(vid), f"should accept {vid!r}" - - -def test_voice_id_rejects_path_traversal_and_garbage(): - # Regex purpose is path-traversal + structurally-malformed prevention, - # NOT enforcement of the HF catalog's lowercase-language convention. - # Uppercase variants like `EN_US-amy-medium` pass the regex but would - # 404 at install time against HF — harmless, no need to over-tighten. - for vid in [ - "../../etc/passwd", - "en_US-amy-medium/../something", - "../en_US-amy-medium", - "", - "en_US-amy", # missing quality - "amy-medium", # missing language - "en_US--medium", # empty dataset - "en_US-amy-large", # invalid quality - ".onnx", - "en_US-amy-medium.onnx", # has extension; we want the bare id - ]: - assert not lib._is_valid_voice_id(vid), f"should reject {vid!r}" - - -def test_shape_catalog_projects_expected_fields(monkeypatch, tmp_path): - """Catalog dict → list of UI cards, with install state from disk scan.""" - # Redirect the install-scan paths to a tmp dir; mark en_US-amy-medium - # as installed under /data so we can verify the field flips. - user_dir = tmp_path / "data_voices" - bundled_dir = tmp_path / "opt_voices" - user_dir.mkdir() - bundled_dir.mkdir() - (user_dir / "en_US-amy-medium.onnx").write_text("model") - (user_dir / "en_US-amy-medium.onnx.json").write_text("{}") - - monkeypatch.setattr(lib, "_USER_VOICES_DIR", user_dir) - monkeypatch.setattr(lib, "_BUNDLED_VOICES_DIR", bundled_dir) - - catalog = { - "en_US-amy-medium": { - "name": "amy", - "language": { - "code": "en_US", - "name_native": "English", - "country_english": "United States", - }, - "quality": "medium", - "num_speakers": 1, - "files": { - "en/en_US/amy/medium/en_US-amy-medium.onnx": { - "size_bytes": 63_500_000, - }, - "en/en_US/amy/medium/en_US-amy-medium.onnx.json": { - "size_bytes": 4_300, - }, - }, - }, - "fr_FR-siwis-medium": { - "name": "siwis", - "language": { - "code": "fr_FR", - "name_native": "Français", - "country_english": "France", - }, - "quality": "medium", - "num_speakers": 1, - "files": { - "fr/fr_FR/siwis/medium/fr_FR-siwis-medium.onnx": { - "size_bytes": 60_000_000, - }, - "fr/fr_FR/siwis/medium/fr_FR-siwis-medium.onnx.json": { - "size_bytes": 3_900, - }, - }, - }, - } - out = lib.shape_catalog_for_ui(catalog) - assert len(out) == 2 - by_id = {v["id"]: v for v in out} - - amy = by_id["en_US-amy-medium"] - assert amy["name"] == "amy" - assert amy["language_code"] == "en_US" - assert amy["quality"] == "medium" - assert amy["size_bytes"] == 63_500_000 + 4_300 - assert amy["installed"] is True - assert amy["installed_source"] == "user" - - siwis = by_id["fr_FR-siwis-medium"] - assert siwis["language_code"] == "fr_FR" - assert siwis["installed"] is False - assert siwis["installed_source"] is None - - # Sorted by language_code, then name — en_US before fr_FR. - assert out[0]["id"] == "en_US-amy-medium" - assert out[1]["id"] == "fr_FR-siwis-medium" - - -def test_resolve_file_urls_picks_onnx_and_sidecar(): - catalog = { - "en_US-amy-medium": { - "files": { - "en/en_US/amy/medium/en_US-amy-medium.onnx": {"size_bytes": 1}, - "en/en_US/amy/medium/en_US-amy-medium.onnx.json": {"size_bytes": 2}, - }, - } - } - urls = lib._resolve_file_urls(catalog, "en_US-amy-medium") - assert urls["onnx"].endswith("en_US-amy-medium.onnx") - assert urls["onnx.json"].endswith("en_US-amy-medium.onnx.json") - assert urls["onnx"].startswith("https://huggingface.co/rhasspy/piper-voices/resolve/main/") - - -def test_resolve_file_urls_raises_when_voice_missing(): - with pytest.raises(KeyError): - lib._resolve_file_urls({}, "en_US-amy-medium") - - -def test_resolve_file_urls_raises_when_files_incomplete(): - catalog = { - "en_US-amy-medium": { - "files": { - # Only the .onnx, missing the sidecar — that's malformed. - "en/en_US/amy/medium/en_US-amy-medium.onnx": {"size_bytes": 1}, - } - } - } - with pytest.raises(ValueError, match="missing"): - lib._resolve_file_urls(catalog, "en_US-amy-medium") diff --git a/tests/test_weather_service.py b/tests/test_weather_service.py deleted file mode 100644 index 882cfa9..0000000 --- a/tests/test_weather_service.py +++ /dev/null @@ -1,166 +0,0 @@ -import pytest -from unittest.mock import AsyncMock, patch, MagicMock - - -def test_parse_open_meteo_response(): - """parse_forecast() should extract daily data into a clean list.""" - from fabledassistant.services.weather import parse_forecast - raw = { - "daily": { - "time": ["2026-03-11", "2026-03-12"], - "temperature_2m_max": [15.0, 12.0], - "temperature_2m_min": [8.0, 6.0], - "precipitation_sum": [0.0, 3.2], - "weathercode": [0, 61], - "windspeed_10m_max": [10.0, 25.0], - } - } - days = parse_forecast(raw) - assert len(days) == 2 - assert days[0]["date"] == "2026-03-11" - assert days[0]["temp_max"] == 15.0 - assert days[0]["temp_min"] == 8.0 - assert days[0]["precip_mm"] == 0.0 - assert days[0]["weathercode"] == 0 - assert days[1]["date"] == "2026-03-12" - assert days[1]["precip_mm"] == 3.2 - - -def test_describe_weathercode(): - """describe_weathercode() should return human-readable strings.""" - from fabledassistant.services.weather import describe_weathercode - assert describe_weathercode(0) == "Clear sky" - assert describe_weathercode(61) == "Rain: slight" - assert describe_weathercode(95) == "Thunderstorm" - assert "Unknown" in describe_weathercode(999) - - -def test_detect_forecast_changes_no_change(): - """detect_changes() should return empty list when forecasts are identical.""" - from fabledassistant.services.weather import detect_changes - day = {"date": "2026-03-12", "temp_max": 12.0, "temp_min": 6.0, - "precip_mm": 3.2, "weathercode": 61} - assert detect_changes([day], [day]) == [] - - -def test_detect_forecast_changes_rain_added(): - """detect_changes() should flag when precipitation appears.""" - from fabledassistant.services.weather import detect_changes - old = [{"date": "2026-03-12", "temp_max": 14.0, "temp_min": 8.0, - "precip_mm": 0.0, "weathercode": 2}] - new = [{"date": "2026-03-12", "temp_max": 12.0, "temp_min": 7.0, - "precip_mm": 4.5, "weathercode": 61}] - changes = detect_changes(old, new) - assert len(changes) == 1 - assert "2026-03-12" in changes[0] - assert "rain" in changes[0].lower() or "precip" in changes[0].lower() - - -def test_parse_weather_card_data_returns_none_when_stale(): - """Should return None when cache is older than 24 hours.""" - from datetime import datetime, timezone, timedelta - from fabledassistant.services.weather import parse_weather_card_data - - cache = MagicMock() - cache.fetched_at = datetime.now(timezone.utc) - timedelta(hours=25) - cache.forecast_json = {} - assert parse_weather_card_data(cache) is None - - -def test_parse_weather_card_data_returns_card_schema(): - """Should return the correct metadata.weather schema for fresh cache.""" - from datetime import datetime, timezone, timedelta, date - from fabledassistant.services.weather import parse_weather_card_data - - today = date.today().isoformat() - yesterday = (date.today() - timedelta(days=1)).isoformat() - tomorrow = (date.today() + timedelta(days=1)).isoformat() - - cache = MagicMock() - cache.fetched_at = datetime.now(timezone.utc) - cache.location_label = "Berlin, DE" - cache.forecast_json = { - "current_weather": {"temperature": 12.0, "weathercode": 2}, - "daily": { - "time": [yesterday, today, tomorrow], - "temperature_2m_max": [14.0, 16.0, 18.0], - "temperature_2m_min": [9.0, 8.0, 10.0], - "precipitation_sum": [0.0, 0.0, 1.5], - "weathercode": [1, 2, 61], - "windspeed_10m_max": [10.0, 12.0, 8.0], - } - } - - card = parse_weather_card_data(cache) - assert card is not None - assert card["current_temp"] == 12 - assert card["condition"] == "Partly cloudy" - assert card["today_high"] == 16 - assert card["today_low"] == 8 - assert card["yesterday_high"] == 14 - assert card["yesterday_low"] == 9 - assert len(card["forecast"]) >= 1 - assert card["location"] == "Berlin, DE" - assert card["precip_summary"] is None - - -def test_summarize_precip_dry(): - from fabledassistant.services.weather import _summarize_precip - assert _summarize_precip([(h, 10) for h in range(24)]) is None - - -def test_summarize_precip_all_day(): - from fabledassistant.services.weather import _summarize_precip - result = _summarize_precip([(h, 60) for h in range(24)]) - assert result is not None - assert "all day" in result.lower() - - -def test_summarize_precip_window(): - from fabledassistant.services.weather import _summarize_precip - hourly = [(h, 5) for h in range(24)] - for h in range(14, 18): - hourly[h] = (h, 65) - result = _summarize_precip(hourly) - assert result is not None - assert "2 PM" in result - assert "65%" in result - - -def test_parse_weather_card_includes_hourly_precip_summary(): - from datetime import datetime, timezone, timedelta, date - from fabledassistant.services.weather import parse_weather_card_data - - today = date.today().isoformat() - yesterday = (date.today() - timedelta(days=1)).isoformat() - tomorrow = (date.today() + timedelta(days=1)).isoformat() - - hourly_times = [f"{today}T{h:02d}:00" for h in range(24)] - hourly_probs = [5] * 24 - for h in range(14, 18): - hourly_probs[h] = 70 - - cache = MagicMock() - cache.fetched_at = datetime.now(timezone.utc) - cache.location_label = "Berlin, DE" - cache.forecast_json = { - "current_weather": {"temperature": 12.0, "weathercode": 61}, - "daily": { - "time": [yesterday, today, tomorrow], - "temperature_2m_max": [14.0, 16.0, 18.0], - "temperature_2m_min": [9.0, 8.0, 10.0], - "precipitation_sum": [0.0, 5.0, 1.5], - "precipitation_probability_max": [0, 70, 30], - "weathercode": [1, 61, 61], - "windspeed_10m_max": [10.0, 12.0, 8.0], - }, - "hourly": { - "time": hourly_times, - "precipitation_probability": hourly_probs, - }, - } - - card = parse_weather_card_data(cache) - assert card is not None - assert card["precip_summary"] is not None - assert "2 PM" in card["precip_summary"] diff --git a/tests/test_wikipedia.py b/tests/test_wikipedia.py deleted file mode 100644 index 6b8f2ac..0000000 --- a/tests/test_wikipedia.py +++ /dev/null @@ -1,173 +0,0 @@ -"""Tests for the Wikipedia service module.""" -import pytest -from unittest.mock import AsyncMock, MagicMock, patch - -from fabledassistant.services.wikipedia import wiki_summary, wiki_search - - -def _make_mock_response(json_data: dict, status_code: int = 200) -> MagicMock: - mock_resp = MagicMock() - mock_resp.status_code = status_code - mock_resp.json.return_value = json_data - mock_resp.raise_for_status = MagicMock() - return mock_resp - - -@pytest.mark.asyncio -async def test_wiki_summary_returns_extract(): - """Successful lookup returns a dict with title, extract, and url.""" - payload = { - "type": "standard", - "title": "Python (programming language)", - "extract": "Python is a high-level programming language.", - "content_urls": {"desktop": {"page": "https://en.wikipedia.org/wiki/Python_(programming_language)"}}, - } - mock_resp = _make_mock_response(payload) - - with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) - mock_client.get = AsyncMock(return_value=mock_resp) - mock_client_cls.return_value = mock_client - - result = await wiki_summary("Python programming language") - - assert result is not None - assert result["title"] == "Python (programming language)" - assert "Python" in result["extract"] - assert result["url"].startswith("https://") - assert result["thumbnail_url"] == "" - - -@pytest.mark.asyncio -async def test_wiki_summary_includes_thumbnail(): - """Thumbnail URL is extracted when the article has an image.""" - payload = { - "type": "standard", - "title": "Hedgehog", - "extract": "Hedgehogs are spiny mammals.", - "content_urls": {"desktop": {"page": "https://en.wikipedia.org/wiki/Hedgehog"}}, - "thumbnail": {"source": "https://upload.wikimedia.org/foo-320px.jpg"}, - "originalimage": {"source": "https://upload.wikimedia.org/foo.jpg"}, - } - mock_resp = _make_mock_response(payload) - - with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) - mock_client.get = AsyncMock(return_value=mock_resp) - mock_client_cls.return_value = mock_client - - result = await wiki_summary("Hedgehog") - - assert result is not None - assert result["thumbnail_url"] == "https://upload.wikimedia.org/foo-320px.jpg" - - -@pytest.mark.asyncio -async def test_wiki_summary_returns_none_on_404(): - """HTTP error (e.g. 404) causes wiki_summary to return None.""" - import httpx - - mock_resp = MagicMock() - mock_resp.raise_for_status.side_effect = httpx.HTTPStatusError( - "404", request=MagicMock(), response=MagicMock() - ) - - with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) - mock_client.get = AsyncMock(return_value=mock_resp) - mock_client_cls.return_value = mock_client - - result = await wiki_summary("NonExistentPageXYZ123") - - assert result is None - - -@pytest.mark.asyncio -async def test_wiki_summary_returns_none_on_disambiguation(): - """Disambiguation pages cause wiki_summary to return None.""" - payload = { - "type": "disambiguation", - "title": "Mercury", - "extract": "Mercury may refer to:", - "content_urls": {"desktop": {"page": "https://en.wikipedia.org/wiki/Mercury"}}, - } - mock_resp = _make_mock_response(payload) - - with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) - mock_client.get = AsyncMock(return_value=mock_resp) - mock_client_cls.return_value = mock_client - - result = await wiki_summary("Mercury") - - assert result is None - - -@pytest.mark.asyncio -async def test_wiki_search_returns_results(): - """wiki_search returns a list of result dicts for a successful query.""" - search_payload = { - "query": { - "search": [ - {"title": "Quantum mechanics"}, - {"title": "Quantum field theory"}, - ] - } - } - summary_payloads = [ - { - "type": "standard", - "title": "Quantum mechanics", - "extract": "Quantum mechanics is a fundamental theory in physics.", - "content_urls": {"desktop": {"page": "https://en.wikipedia.org/wiki/Quantum_mechanics"}}, - }, - { - "type": "standard", - "title": "Quantum field theory", - "extract": "Quantum field theory is a framework in theoretical physics.", - "content_urls": {"desktop": {"page": "https://en.wikipedia.org/wiki/Quantum_field_theory"}}, - }, - ] - - search_resp = _make_mock_response(search_payload) - summary_resp_1 = _make_mock_response(summary_payloads[0]) - summary_resp_2 = _make_mock_response(summary_payloads[1]) - - with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) - mock_client.get = AsyncMock(side_effect=[search_resp, summary_resp_1, summary_resp_2]) - mock_client_cls.return_value = mock_client - - results = await wiki_search("quantum physics", limit=2) - - assert len(results) == 2 - assert results[0]["title"] == "Quantum mechanics" - assert "quantum" in results[0]["extract"].lower() - assert results[1]["title"] == "Quantum field theory" - - -@pytest.mark.asyncio -async def test_wiki_search_returns_empty_on_failure(): - """wiki_search returns an empty list when the network request fails.""" - import httpx - - with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) - mock_client.get = AsyncMock(side_effect=httpx.ConnectError("connection refused")) - mock_client_cls.return_value = mock_client - - results = await wiki_search("anything") - - assert results == []