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))
_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. 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.
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)
q = _normalize_query(query)
if not q:
return 0.0
title = _normalize(project.title)
@@ -50,7 +62,10 @@ def score_project_match(query: str, project) -> float:
return 0.85
if q in combined:
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):
+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")]
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(
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"),
]
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(
user_id=1, arguments={"query": "famous supply project"},
)