Files
FabledGrimoire/src/fabledassistant/services/tools/_registry.py
T
bvandeusenandClaude Opus 4.7 dbd9f00061 refactor: hard-cut RSS infrastructure (scope C)
Removes the entire RSS feature surface — feeds, items, embeddings, reactions,
discussion-note flow, briefing news context, settings, env-vars, and DB
tables. Keeps the URL-generic article-reader (the read_article LLM tool)
under a clean module so the LLM can still fetch arbitrary article content
from URLs the user provides.

Backend:
- New services/article_fetcher.py — single source of trafilatura URL→text
- New services/tools/article.py — read_article tool (was nested under tools/rss)
- Delete services/rss.py, rss_classifier.py, rss_filtering.py, article_context.py
- Delete services/tools/rss.py
- Delete models/rss_feed.py (RssFeed, RssItem), models/rss_item_embedding.py
- services/embeddings.py: drop upsert/semantic_search/backfill RSS helpers
- services/llm.py: remove _build_briefing_article_context, briefing-conv branch,
  ARTICLE_DISCUSS_SEED skip-RAG branch; drop get_rss_items / add_rss_feed from
  the actions list
- services/generation_task.py: drop _maybe_save_article_discussion_note + caller
- routes/chat.py: drop /api/chat/from-article/<id> endpoint
- routes/journal.py: re-import via web.py refactor (article_fetcher path)
- services/tools/__init__.py: register `article`, drop `rss`
- services/tools/_registry.py: drop the requires=='rss' check
- app.py: drop backfill_rss_item_embeddings + backfill_rss_article_content tasks
- config.py: prose-only edit (no env var change — RSS env vars were never first-class)

Frontend:
- stores/settings.ts: drop rssEnabled
- SettingsView.vue: drop the RSS-classification mention
- api/client.ts: drop openArticleInChat (the from-article endpoint is gone)

Tests:
- Delete tests/test_rss_service.py, test_news_api.py, test_article_reading.py

Migration:
- 0042_drop_rss: DROP TABLE rss_item_embeddings, rss_item_reactions, rss_items,
  rss_feeds; DELETE settings rows for rss_enabled / briefing_*_topics

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 12:33:30 -04:00

139 lines
4.0 KiB
Python

"""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
async def execute_tool(
user_id: int,
tool_name: str,
arguments: dict,
conv_id: int | None = None,
workspace_project_id: int | None = None,
) -> dict:
"""Execute a tool call and return the result."""
td = _REGISTRY.get(tool_name)
if td is None:
return {"success": False, "error": f"Unknown tool: {tool_name}"}
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)}