refactor(tools): decorator-based tool registry replaces monolithic tools.py
Split 2566-line tools.py into a tools/ package with @tool decorator registration. Each tool's schema, metadata, and implementation live together. Briefing eligibility is now a briefing=True flag instead of a separate frozenset allowlist. Conditional inclusion (CalDAV, SearXNG) uses requires= metadata. Public API (get_tools_for_user, execute_tool) unchanged. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
"""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))
|
||||
|
||||
|
||||
async def resolve_project(user_id: int, project_name: str):
|
||||
"""Exact-then-fuzzy project lookup. Returns the Project or None.
|
||||
|
||||
Resolution order:
|
||||
1. Exact title match (case-insensitive via DB)
|
||||
2. project_name is a substring of an existing title
|
||||
3. Existing title is a substring of project_name
|
||||
4. SequenceMatcher ratio >= 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
|
||||
needle = project_name.lower().strip()
|
||||
all_p = await list_projects(user_id)
|
||||
for p in all_p:
|
||||
haystack = p.title.lower().strip()
|
||||
if needle in haystack or haystack in needle:
|
||||
return p
|
||||
best, best_r = None, 0.0
|
||||
for p in all_p:
|
||||
r = SequenceMatcher(None, needle, p.title.lower().strip()).ratio()
|
||||
if r >= 0.55 and r > best_r:
|
||||
best, best_r = p, r
|
||||
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
|
||||
Reference in New Issue
Block a user