Skip to content

Commit 201af92

Browse files
fix(answer): spend the per-file symbol budget on the question, not the top of the file (#1597)
When a retrieved file held more symbols than its budget, hydration sorted them by start_line and kept a prefix. The bigger the file, the smaller the fraction served and the stronger the top-of-file bias, so on a large file the symbol the question was about could not be reached at all and synthesis answered that the excerpts did not contain the relevant code. Score each symbol's already-loaded name, signature and docstring against the question's content terms, and let that decide which symbols fill the same budget. The count is unchanged, no new I/O is added, and with no signal every score is zero and the sort falls back to start_line as before. Two supporting changes it needs to work: - The question-match test substring-compared the whole qualified name, so every symbol in a package whose path shared a word with the question counted as matched and the promotion collapsed to a no-op. Match the symbol's own name, its full qualified name, or its parent, which keeps a question about a class reaching its methods. - That flood was also the only thing attaching source bodies. A question phrased in prose names no identifier, so nothing matched and the served symbols would carry signatures only. The leading few symbols the question scored against now get a body, bounded per file. Selection order only: the kept slice is still sorted by start_line before it is attached, so consumers continue to read it in document order.
1 parent ba2dfed commit 201af92

4 files changed

Lines changed: 262 additions & 20 deletions

File tree

packages/server/src/repowise/server/mcp_server/tool_answer/answer.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -342,7 +342,7 @@ async def _run_retrieval_pipeline(
342342
with contextlib.suppress(Exception):
343343
async with get_session(ctx.session_factory) as session:
344344
await _hydrate_symbols_for_hits(
345-
session, repo_id, hits, ctx, question_ids=question_ids
345+
session, repo_id, hits, ctx, question_ids=question_ids, question=question
346346
)
347347
# And the shortlist BELOW the synthesis cap: `candidates` names
348348
# those files and, until now, said nothing about any of them.

packages/server/src/repowise/server/mcp_server/tool_answer/config.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,26 @@
3232
_MATCHED_SYMBOL_DOC_CHARS = 400
3333
_MATCHED_SYMBOL_SOURCE_LINES = 40
3434

35+
# Which symbols survive the per-file cap. A `start_line` tiebreak served a
36+
# document-order prefix, so on a file larger than its symbol budget the symbol
37+
# the question was about could not be reached at all. Scoring the already-loaded
38+
# name / signature / docstring against the question's content terms changes which
39+
# symbols fill the same budget, not how many: no extra I/O, no prompt growth.
40+
# A name hit outranks a signature hit outranks a mention in the docstring. With
41+
# no signal every score is 0 and the sort falls back to `start_line` as before.
42+
_RELEVANCE_NAME_WEIGHT = 3
43+
_RELEVANCE_SIG_WEIGHT = 2
44+
_RELEVANCE_DOC_WEIGHT = 1
45+
# Score the docstring's opening prose only, so a long one can't out-score a
46+
# precise name on word count alone.
47+
_RELEVANCE_DOC_CHARS = 400
48+
# A source excerpt used to require a question IDENTIFIER match, so a question
49+
# phrased in prose reached the right symbols and then showed only their
50+
# signatures — the "excerpts don't include the actual code" answer. The leading
51+
# few symbols the question scored against get a body too. Bounded hard: this is
52+
# the one part of the change that adds prompt text.
53+
_RELEVANT_EXCERPT_MAX_SYMBOLS = 2
54+
3555
# How many question-named symbol bodies get_answer inlines in `symbol_bodies`.
3656
# The hydrator already reads these bodies live for synthesis; surfacing them
3757
# in the response collapses the get_answer -> get_symbol drill-down on

packages/server/src/repowise/server/mcp_server/tool_answer/symbols.py

Lines changed: 125 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
from repowise.core.persistence.models import WikiSymbol
1818
from repowise.server.mcp_server._page_paths import hit_file_path
19+
from repowise.server.mcp_server._query_terms import content_terms, split_humps
1920
from repowise.server.mcp_server._verify import verify_and_heal
2021
from repowise.server.mcp_server.tool_answer.config import (
2122
_DEFINES_MAX_FILES,
@@ -29,12 +30,99 @@
2930
_MAX_RICH_SIG_LINES,
3031
_MAX_SYMBOLS_PER_HIT,
3132
_MAX_SYMBOLS_TOP_HIT,
33+
_RELEVANCE_DOC_CHARS,
34+
_RELEVANCE_DOC_WEIGHT,
35+
_RELEVANCE_NAME_WEIGHT,
36+
_RELEVANCE_SIG_WEIGHT,
37+
_RELEVANT_EXCERPT_MAX_SYMBOLS,
3238
_STOPWORDS,
3339
_SYNTH_FULL_BODY_MAX_SYMBOLS,
3440
_SYNTH_FULL_SOURCE_LINES,
3541
)
3642
from repowise.server.mcp_server.tool_search import _prose_dominates
3743

44+
# Suffixes stripped so a question's word reaches the identifier that answers it
45+
# ("routing" -> the `route` symbol). Longest first; never stems below 4 chars.
46+
_STEM_SUFFIXES = ("tion", "ing", "ion", "es", "ed", "er", "s")
47+
48+
49+
def _stem(token: str) -> str:
50+
for suffix in _STEM_SUFFIXES:
51+
if token.endswith(suffix) and len(token) - len(suffix) >= 4:
52+
return token[: -len(suffix)]
53+
return token
54+
55+
56+
def _text_stems(text: str) -> set[str]:
57+
"""Stemmed content tokens of *text*, hump- and separator-split."""
58+
return {
59+
_stem(tok.lower())
60+
for tok in re.split(r"[^A-Za-z0-9]+", split_humps(text))
61+
if len(tok) >= 3 and tok.lower() not in _STOPWORDS
62+
}
63+
64+
65+
def _stem_hit(term: str, tokens: set[str]) -> bool:
66+
"""Whether *term* names one of *tokens*, allowing a shared 4-char root."""
67+
if term in tokens:
68+
return True
69+
return any(
70+
min(len(term), len(tok)) >= 4 and (term.startswith(tok) or tok.startswith(term))
71+
for tok in tokens
72+
)
73+
74+
75+
def _question_names_symbol(row, qids_lower: set[str]) -> bool:
76+
"""Whether an identifier from the question names this symbol.
77+
78+
Substring-matching the whole qualified name marked every symbol in a package
79+
whose path shares a word with the question, which flattened the promotion to
80+
a no-op. Matching is against the symbol's own name, its full qualified name,
81+
or its parent: asking about a class should still reach its methods.
82+
"""
83+
if not qids_lower:
84+
return False
85+
name_lower = (row.name or "").lower()
86+
parent_lower = (row.parent_name or "").lower()
87+
return (
88+
name_lower in qids_lower
89+
or (row.qualified_name or "").lower() in qids_lower
90+
or (bool(parent_lower) and parent_lower in qids_lower)
91+
or any(
92+
q in name_lower
93+
for q in qids_lower
94+
if len(q) >= 5 # avoid spurious substring matches on short tokens
95+
)
96+
)
97+
98+
99+
def _symbol_relevance(entry: dict, terms: set[str]) -> int:
100+
"""How strongly a symbol's own text answers the question's content terms.
101+
102+
Reads only what hydration already loaded, so it adds no I/O to the call.
103+
"""
104+
if not terms:
105+
return 0
106+
name_tokens = _text_stems(entry.get("name") or "")
107+
sig_tokens = _text_stems(entry.get("signature") or "")
108+
# Docstrings are the bulk of the text to tokenize and the weakest signal, so
109+
# they are only read once a term has missed the name and the signature.
110+
doc_tokens: set[str] | None = None
111+
score = 0
112+
for term in terms:
113+
if _stem_hit(term, name_tokens):
114+
score += _RELEVANCE_NAME_WEIGHT
115+
elif _stem_hit(term, sig_tokens):
116+
score += _RELEVANCE_SIG_WEIGHT
117+
else:
118+
if doc_tokens is None:
119+
doc_tokens = _text_stems(
120+
(entry.get("docstring") or "")[:_RELEVANCE_DOC_CHARS]
121+
)
122+
if _stem_hit(term, doc_tokens):
123+
score += _RELEVANCE_DOC_WEIGHT
124+
return score
125+
38126

39127
def _extract_question_identifiers(question: str) -> set[str]:
40128
"""Pull out Python-looking identifiers the question names explicitly.
@@ -774,6 +862,7 @@ async def _hydrate_symbols_for_hits(
774862
hits: list[dict],
775863
ctx: Any = None,
776864
question_ids: set[str] | None = None,
865+
question: str = "",
777866
) -> None:
778867
"""Mutate `hits` in place: attach `symbols` list to top-N file_page hits.
779868
@@ -788,10 +877,16 @@ async def _hydrate_symbols_for_hits(
788877
Top hit gets ``_MAX_SYMBOLS_TOP_HIT`` slots; secondaries get the smaller
789878
``_MAX_SYMBOLS_PER_HIT``. Symbols not matching a question id carry the
790879
short 120-char docstring; matched symbols carry 400 chars + source body.
880+
881+
``question`` decides which symbols fill those slots when the file holds more
882+
than fit, and earns the leading few a source body: a question phrased in
883+
prose names no identifier, so nothing matches and nothing would carry code.
791884
"""
792885
question_ids = question_ids or set()
793886
# Case-folded copy for matching.
794887
qids_lower = {q.lower() for q in question_ids}
888+
# Once per call: the question's terms, stemmed to match identifier roots.
889+
term_stems = {_stem(t) for t in content_terms(question)}
795890

796891
# Identify the top file_page hits in retrieval-rank order. `hits` is
797892
# already sorted by descending score upstream.
@@ -845,21 +940,7 @@ async def _hydrate_symbols_for_hits(
845940
rich_sig = _read_signature_from_source(
846941
repo_root, row.file_path, start_line, text=text
847942
)
848-
# Does the symbol name match any identifier from the question?
849-
name_lower = (row.name or "").lower()
850-
qname_lower = (row.qualified_name or "").lower()
851-
matched = bool(
852-
qids_lower
853-
and (
854-
name_lower in qids_lower
855-
or qname_lower in qids_lower
856-
or any(
857-
q in name_lower or q in qname_lower
858-
for q in qids_lower
859-
if len(q) >= 5 # avoid spurious substring matches on short tokens
860-
)
861-
)
862-
)
943+
matched = _question_names_symbol(row, qids_lower)
863944
entry: dict[str, Any] = {
864945
"name": row.name,
865946
"kind": row.kind,
@@ -868,7 +949,11 @@ async def _hydrate_symbols_for_hits(
868949
"start_line": start_line,
869950
"end_line": end_line,
870951
"_matched": matched,
952+
"_verified": verified,
871953
}
954+
# Scored once here, not in the sort key, so a dense file pays for it per
955+
# symbol rather than per comparison.
956+
entry["_relevance"] = _symbol_relevance(entry, term_stems)
872957
if matched and verified:
873958
src = _read_symbol_source(
874959
repo_root, row.file_path, start_line, end_line, text=text
@@ -877,15 +962,16 @@ async def _hydrate_symbols_for_hits(
877962
entry["source_excerpt"] = src
878963
by_file.setdefault(row.file_path, []).append(entry)
879964

880-
# Sort: matched symbols first (document order within the match group),
881-
# then unmatched in start_line order. Cap per file — top hit gets more
882-
# slots than secondary hits.
965+
# Sort: matched symbols first, then by relevance to the question, then in
966+
# start_line order. Cap per file — top hit gets more slots than secondary
967+
# hits. This decides WHICH symbols are kept; the kept slice is put back into
968+
# reading order below, so consumers still see document order.
883969
for i, h in enumerate(hits):
884970
path = h.get("target_path")
885971
if path not in by_file:
886972
continue
887973
syms = by_file[path]
888-
syms.sort(key=lambda s: (not s["_matched"], s["start_line"]))
974+
syms.sort(key=lambda s: (not s["_matched"], -s["_relevance"], s["start_line"]))
889975
cap = _MAX_SYMBOLS_TOP_HIT if i == 0 else _MAX_SYMBOLS_PER_HIT
890976
# Force-include the exact symbol the question named (via anchoring) so a
891977
# class-name flood — where every sibling method "matches" through the
@@ -903,6 +989,26 @@ async def _hydrate_symbols_for_hits(
903989
if len(kept) >= cap:
904990
break
905991
kept.append(s)
992+
# A prose question names no identifier, so nothing is `_matched` and the
993+
# slate would carry signatures only. Give the leading few symbols the
994+
# question scored against a body, so the excerpts hold the code the
995+
# question is about. `kept` is still in priority order here.
996+
bodied = 0
997+
for s in kept:
998+
if bodied >= _RELEVANT_EXCERPT_MAX_SYMBOLS:
999+
break
1000+
if s.get("source_excerpt") or not s["_relevance"] or not s["_verified"]:
1001+
continue
1002+
src = _read_symbol_source(
1003+
repo_root,
1004+
path,
1005+
s["start_line"],
1006+
s.get("end_line") or 0,
1007+
text=text_cache.get(path),
1008+
)
1009+
if src:
1010+
s["source_excerpt"] = src
1011+
bodied += 1
9061012
# Upgrade the top question-relevant symbols to the inline-body depth
9071013
# BEFORE the reading-order sort, while `kept` is still in priority order
9081014
# (anchors, then matched, then unmatched). The default 40-line excerpt
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""Calibration: the per-file symbol budget must go to the question, not the top.
2+
3+
A file with more symbols than its budget used to serve a document-order prefix,
4+
so on a large file the symbol the question was actually about could not be
5+
reached at all — the more code the file held, the smaller the served fraction
6+
and the stronger the top-of-file bias. The budget is unchanged; what fills it
7+
is now scored against the question's content terms.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from types import SimpleNamespace
13+
14+
from repowise.core.persistence.models import WikiSymbol
15+
from repowise.server.mcp_server.tool_answer.config import _MAX_SYMBOLS_TOP_HIT
16+
from repowise.server.mcp_server.tool_answer.symbols import _hydrate_symbols_for_hits
17+
18+
# Twice the budget, so half the file cannot be served whatever the ordering.
19+
_SYMBOL_COUNT = _MAX_SYMBOLS_TOP_HIT * 2
20+
_BODY_LINES = 4
21+
22+
23+
def _write_module(tmp_path, names: list[str]) -> list[int]:
24+
"""One trivial function per name; returns their 1-indexed def lines."""
25+
lines: list[str] = []
26+
starts: list[int] = []
27+
for name in names:
28+
starts.append(len(lines) + 1)
29+
lines.append(f"def {name}(request):")
30+
lines.extend(f" step{i} = {i}" for i in range(_BODY_LINES))
31+
lines.append(" return request")
32+
(tmp_path / "app.py").write_text("\n".join(lines) + "\n", encoding="utf-8")
33+
return starts
34+
35+
36+
async def _hydrate(session, repo_id, tmp_path, names, question):
37+
starts = _write_module(tmp_path, names)
38+
for i, name in enumerate(names):
39+
session.add(
40+
WikiSymbol(
41+
id=f"sym-{i}",
42+
repository_id=repo_id,
43+
file_path="app.py",
44+
symbol_id=f"app.py::{name}",
45+
name=name,
46+
qualified_name=f"app.{name}",
47+
kind="function",
48+
signature=f"def {name}(request)",
49+
start_line=starts[i],
50+
end_line=starts[i] + _BODY_LINES + 1,
51+
docstring="",
52+
visibility="public",
53+
is_async=False,
54+
complexity_estimate=1,
55+
language="python",
56+
parent_name=None,
57+
)
58+
)
59+
await session.commit()
60+
hits = [{"target_path": "app.py", "page_type": "file_page"}]
61+
await _hydrate_symbols_for_hits(
62+
session, repo_id, hits, SimpleNamespace(path=tmp_path), question=question
63+
)
64+
return hits[0]["symbols"]
65+
66+
67+
_ROUTING_NAMES = [f"helper{i}" for i in range(_SYMBOL_COUNT - 1)] + ["find_route"]
68+
_ROUTING_QUESTION = "How does routing work in this app?"
69+
70+
71+
async def test_late_symbol_named_by_the_question_survives_the_cap(
72+
session, repo_id, tmp_path
73+
) -> None:
74+
"""The routing code is at the end of the file; the question asks about it."""
75+
served = await _hydrate(
76+
session, repo_id, tmp_path, _ROUTING_NAMES, _ROUTING_QUESTION
77+
)
78+
names = [s["name"] for s in served]
79+
80+
assert "find_route" in names, "document order buried the symbol the question named"
81+
assert len(names) <= _MAX_SYMBOLS_TOP_HIT, "the budget itself must not grow"
82+
83+
84+
async def test_no_content_term_falls_back_to_document_order(
85+
session, repo_id, tmp_path
86+
) -> None:
87+
"""With nothing to score, the served slice is the old start_line prefix."""
88+
names = [f"helper{i}" for i in range(_SYMBOL_COUNT)]
89+
served = await _hydrate(session, repo_id, tmp_path, names, "How does it work?")
90+
91+
assert [s["name"] for s in served] == names[:_MAX_SYMBOLS_TOP_HIT]
92+
93+
94+
async def test_served_slice_stays_in_reading_order(session, repo_id, tmp_path) -> None:
95+
"""Relevance decides what is kept, never what order consumers read it in."""
96+
served = await _hydrate(
97+
session, repo_id, tmp_path, _ROUTING_NAMES, _ROUTING_QUESTION
98+
)
99+
names = [s["name"] for s in served]
100+
101+
assert names == sorted(names, key=_ROUTING_NAMES.index)
102+
103+
104+
async def test_prose_question_still_earns_a_source_body(
105+
session, repo_id, tmp_path
106+
) -> None:
107+
"""A prose question matches no identifier, so nothing would carry code."""
108+
served = await _hydrate(
109+
session, repo_id, tmp_path, _ROUTING_NAMES, _ROUTING_QUESTION
110+
)
111+
scored = [s for s in served if s["name"] == "find_route"]
112+
113+
assert scored and scored[0].get("source_excerpt"), (
114+
"the symbol the question scored against was served without its body"
115+
)
116+
assert not any(s["_matched"] for s in served), "prose names no identifier"

0 commit comments

Comments
 (0)