diff --git a/src/fabledassistant/services/tools/_helpers.py b/src/fabledassistant/services/tools/_helpers.py index 8367631..d6bbc2f 100644 --- a/src/fabledassistant/services/tools/_helpers.py +++ b/src/fabledassistant/services/tools/_helpers.py @@ -22,6 +22,37 @@ def schedule_embedding(note_id: int, user_id: int, title: str, body: str) -> Non asyncio.create_task(upsert_note_embedding(note_id, user_id, text)) +def _normalize(s: str) -> str: + """Lowercase and collapse non-alphanumerics to single spaces.""" + return re.sub(r"[^a-z0-9]+", " ", s.lower()).strip() + + +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. 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. + """ + q = _normalize(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 + return SequenceMatcher(None, q, combined).ratio() + + async def resolve_project(user_id: int, project_name: str): """Exact-then-fuzzy project lookup. Returns the Project or None. diff --git a/tests/test_tool_use_fixes.py b/tests/test_tool_use_fixes.py index 2a9fd49..d7ff034 100644 --- a/tests/test_tool_use_fixes.py +++ b/tests/test_tool_use_fixes.py @@ -54,3 +54,36 @@ 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