From 2d8ccfbe1f157a9e9230b4d272befdeca9c42874 Mon Sep 17 00:00:00 2001 From: Swati Ahuja Date: Wed, 15 Jul 2026 21:22:20 +0530 Subject: [PATCH] fix(search): score symbols on the query's identifier, not the whole question A hybrid query is prose wrapped around an identifier ("where is X defined"). Both symbol and hybrid mode handed the raw query to `search_symbols_single`, but that scorer ranks on token overlap, so the question's prose tokens compete as if they were symbol names. Live, on this repo: search_codebase("filter_dicts_by_key") -> mode=symbol, exact_match=true, `_helpers.py::filter_dicts_by_key` at rank 1 search_codebase("where is filter_dicts_by_key defined") -> mode=hybrid, exact_match=false, and the note "No indexed symbol exactly matches 'filter_dicts_by_key'" The claim is false: the symbol is indexed at `_helpers.py:532`. What ranked instead was `is_ci` (matching the token "is") and `FilterRegistry.get` (matching "filter"). The real symbol never entered the result window, so `_has_exact_symbol` saw no match and the response asserted the symbol was absent from the index. That is the worst shape of wrong: the tool is most confident exactly where it is wrong, and an agent that believes it stops looking for a symbol that is right there. `_identifier_candidates` already extracted the identifier at the call site below, but only to compute the `exact` flag; it never reached the search. This scores symbols on the extracted identifiers when the query is hybrid, and leaves symbol mode (already a bare identifier) and identifier-free queries on the raw text. The test asserts the query the scorer receives rather than the resulting ranking: a fixture small enough to unit-test carries no decoys for the identifier to lose to, so an end-to-end assertion passes against the bug. Verified to fail without the change and pass with it. --- .../repowise/server/mcp_server/tool_search.py | 16 +++++++++- tests/unit/server/mcp/test_search.py | 32 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/packages/server/src/repowise/server/mcp_server/tool_search.py b/packages/server/src/repowise/server/mcp_server/tool_search.py index 5af3e273c..4833e94e7 100644 --- a/packages/server/src/repowise/server/mcp_server/tool_search.py +++ b/packages/server/src/repowise/server/mcp_server/tool_search.py @@ -620,9 +620,23 @@ async def _structured_search( files: list[dict] = [] concepts: list[dict] = [] + # A hybrid query is prose wrapped around an identifier ("where is X + # defined"). The symbol scorer ranks on token overlap, so handing it the + # raw prose lets stopword-ish tokens ("is" -> is_ci, "filter" -> + # FilterRegistry) outrank the identifier the question is actually about, + # which then never reaches _has_exact_symbol and the response claims the + # symbol is unindexed. Score symbols on the extracted identifiers instead. + symbol_query = query + if mode == "hybrid": + _idents = _embedded_identifiers(query) + if _idents: + symbol_query = " ".join(_idents) + for ctx in contexts: if mode in ("symbol", "hybrid"): - s = await search_symbols_single(ctx, query, limit, symbol_kind=symbol_kind, kind=kind) + s = await search_symbols_single( + ctx, symbol_query, limit, symbol_kind=symbol_kind, kind=kind + ) _tag_repo(s, ctx, multi) symbols.extend(s) if mode == "path": diff --git a/tests/unit/server/mcp/test_search.py b/tests/unit/server/mcp/test_search.py index d84da2834..7da3e167b 100644 --- a/tests/unit/server/mcp/test_search.py +++ b/tests/unit/server/mcp/test_search.py @@ -603,6 +603,38 @@ def test_identifier_candidates_by_mode(self): assert _identifier_candidates("where is AuthService defined", "hybrid") == ["AuthService"] assert _identifier_candidates("rate limiting", "concept") == [] + @pytest.mark.asyncio + async def test_hybrid_scores_symbols_on_the_identifier_not_the_prose( + self, setup_mcp, monkeypatch + ): + # Regression: the symbol scorer ranks on token overlap, so handing it the + # whole question let prose tokens ("is" -> is_ci, "filter" -> + # FilterRegistry) outrank the identifier the question asks after. The + # identifier then never reached _has_exact_symbol and the response + # claimed an indexed symbol was absent. + # + # Asserted on the query the scorer RECEIVES, not on ranking: a fixture + # small enough to unit-test has no decoys to lose to, so an end-to-end + # assertion here passes against the bug. + from repowise.server.mcp_server import search_codebase, tool_search + + seen: list[str] = [] + real = tool_search.search_symbols_single + + async def spy(ctx, query, limit, **kwargs): + seen.append(query) + return await real(ctx, query, limit, **kwargs) + + monkeypatch.setattr(tool_search, "search_symbols_single", spy) + + await search_codebase("where is AuthService defined") + assert seen == ["AuthService"], f"symbol scorer got prose, not the identifier: {seen}" + + # A query carrying no identifier still scores on the raw text. + seen.clear() + await search_codebase("AuthService", mode="symbol") + assert seen == ["AuthService"] + @pytest.mark.asyncio async def test_exact_hit_sets_true_and_no_note(self, setup_mcp): from repowise.server.mcp_server import search_codebase