feat(tools): score_project_match helper with substring-first tiering
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user