a551f52682
CI surfaced three issues:
- 'famous supply project' didn't substring-match 'Famous-Supply Work topics'
because the trailing filler word 'project' blocked the substring tier.
Strip {project, projects} from the query before the substring check.
- SequenceMatcher fallback against `combined` (title + description +
summary) diluted ratios to ~0.5 for plausible matches. Use title
directly; the 0.70 tier already handles description/summary mentions.
- Test patches used patch.object on a consumer module where
list_projects is imported locally — patch the source module instead.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
165 lines
6.1 KiB
Python
165 lines
6.1 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))
|
|
|
|
|
|
_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
|