fix(tools): score_project_match strips 'project' filler + uses title for SequenceMatcher

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>
This commit is contained in:
2026-05-12 20:30:37 -04:00
parent 6de855e226
commit a551f52682
2 changed files with 25 additions and 8 deletions
+21 -6
View File
@@ -22,21 +22,33 @@ 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)) asyncio.create_task(upsert_note_embedding(note_id, user_id, text))
_PROJECT_QUERY_NOISE = {"project", "projects"}
def _normalize(s: str) -> str: def _normalize(s: str) -> str:
"""Lowercase and collapse non-alphanumerics to single spaces.""" """Lowercase and collapse non-alphanumerics to single spaces."""
return re.sub(r"[^a-z0-9]+", " ", s.lower()).strip() 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: def score_project_match(query: str, project) -> float:
"""Score how well `query` matches `project`. Range [0.0, 1.0]. """Score how well `query` matches `project`. Range [0.0, 1.0].
Tiered: exact title → 1.0, substring either-way → 0.85, query found in Tiered: exact title → 1.0, substring either-way → 0.85, query found in
description/summary → 0.70, otherwise SequenceMatcher ratio. Substring description/summary → 0.70, otherwise SequenceMatcher ratio against the
tiers exist because LLM-generated colloquial queries (e.g. "famous title. Substring tiers exist because LLM-generated colloquial queries
supply project" for "Famous-Supply Work topics") would otherwise score (e.g. "famous supply project" for "Famous-Supply Work topics") would
too low under pure SequenceMatcher and be treated as no match. 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) q = _normalize_query(query)
if not q: if not q:
return 0.0 return 0.0
title = _normalize(project.title) title = _normalize(project.title)
@@ -50,7 +62,10 @@ def score_project_match(query: str, project) -> float:
return 0.85 return 0.85
if q in combined: if q in combined:
return 0.70 return 0.70
return SequenceMatcher(None, q, combined).ratio() # 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): async def resolve_project(user_id: int, project_name: str):
+4 -2
View File
@@ -20,7 +20,9 @@ async def test_search_projects_tool_returns_success_true():
fake_projects = [_project(5, "Famous-Supply Work topics", "AT&T fiber circuit")] fake_projects = [_project(5, "Famous-Supply Work topics", "AT&T fiber circuit")]
with patch.object(projects_tool, "list_projects", new=AsyncMock(return_value=fake_projects)): # `list_projects` is imported locally inside search_projects_tool, so we
# patch the source module rather than the consumer.
with patch("fabledassistant.services.projects.list_projects", new=AsyncMock(return_value=fake_projects)):
result = await projects_tool.search_projects_tool( result = await projects_tool.search_projects_tool(
user_id=1, arguments={"query": "famous supply"}, user_id=1, arguments={"query": "famous supply"},
) )
@@ -101,7 +103,7 @@ async def test_search_projects_tool_ranks_substring_match_above_others():
_project(13, "ImageRepo", auto_summary="self-hosted Flask app"), _project(13, "ImageRepo", auto_summary="self-hosted Flask app"),
] ]
with patch.object(projects_tool, "list_projects", new=AsyncMock(return_value=fake_projects)): with patch("fabledassistant.services.projects.list_projects", new=AsyncMock(return_value=fake_projects)):
result = await projects_tool.search_projects_tool( result = await projects_tool.search_projects_tool(
user_id=1, arguments={"query": "famous supply project"}, user_id=1, arguments={"query": "famous supply project"},
) )