Files
FabledGrimoire/src/fabledassistant/services/tools/_helpers.py
T
bvandeusenandClaude Opus 4.6 77339d5c58 refactor(tools): consolidate LLM tools from 42 to 38
Merge create_task into create_note (set status='todo' for tasks, omit
for notes), merge delete_task into delete_note, consolidate entity
tools (create/update_person → save_person, create/update_place →
save_place), rename get_note → read_note with clearer descriptions,
move calculate out of rag.py into utility.py, and extract shared
duplicate detection into check_duplicate() helper.

Updates all downstream references in generation_task.py, quick_capture.py,
ToolCallCard.vue, and WorkspaceView.vue.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 13:38:45 -04:00

125 lines
4.6 KiB
Python

"""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
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