Skip to content

Commit 28c861f

Browse files
fix(generation): stop the module_page prompt tripping its own validator, and bound the spotlight importer list (#1606)
* fix(generation): stop the module_page prompt from using banned vocabulary The module_page system prompt asked the model to "Ground every claim in the supplied material". "the supplied material" is a literal match for the supplied_context artifact rule, so when the model echoed the instruction's own wording the finished page was rejected and thrown away with its tokens already spent. Every observed failure was a module_page, always the same phrase. Reword the instruction to point at the files and signals below instead, and drop the same adjective from the scope-line sentence so the vocabulary is not reinforced anywhere in the prompt. Add a guard so this cannot come back: every system prompt, the corrective directive, and every generation template are asserted not to match any ArtifactRule pattern. The same class of bug had already shipped once before, documented in the comment directly above the offending line. Back that with one corrective re-ask. A rejected page has been paid for, and the only recovery was --resume regenerating it from scratch, so a single retry that names the rule the last attempt broke is cheaper than losing it. The re-ask names the rule but not the words that tripped it, since handing those back re-plants the vocabulary. A token-limit failure is not retried: the second call carries the same max_tokens and would truncate in the same place. A second failure raises exactly as before, so the stub-fallback path is unchanged. * fix(generation): bound the importer list on a symbol spotlight page symbol_spotlight.j2 rendered one bullet per importing file with no bound. On this repository tests/conftest.py has 939 importers, so its three spotlight pages came out at ~46.6k characters each, of which 45.7k was the importer list alone. EMBED_TEXT_MAX_CHARS is 30,000, so the tail was dropped from the vector and the pages were searchable only by their first few hundred importers. The stored content was intact; the recall was not. file_page.j2 already bounds its "Used by" section, over the same data, at 25 with an "and N more" line. Apply that bound here rather than inventing a second one. The summary sentence above the list keeps counting every importer, so truncating the list does not truncate the fact. Three of 4,253 pages were over the cap, all three this one file. * fix(generation): account for the attempt a corrective retry discards The retry rebound the response, so the rejected attempt's tokens were dropped from the run report. Billing was always right, since the provider cost tracker sees both calls, but the reported total came out one whole generation short for every page that needed a second attempt. Carry the discarded counts forward onto the response that survives. Mark the recovered page with self_repair. The report already reads that key and renders a "Self-repaired pages" row, and nothing has ever written it, so the row has been permanently zero. It is also the only way to tell a page the backstop rescued from a page it lost: the artifact tallies count the rejection either way. Reword one line of the corrective directive off "the material you were given". It is a phrase away from the rule it is quoting, and it is the sentence in the whole prompt most likely to be echoed back.
1 parent 946a9dc commit 28c861f

7 files changed

Lines changed: 425 additions & 16 deletions

File tree

packages/core/src/repowise/core/generation/page_generator/core.py

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
import hashlib
1717
from collections.abc import Callable, Sequence
18-
from dataclasses import dataclass
18+
from dataclasses import dataclass, replace
1919
from pathlib import Path
2020
from typing import TYPE_CHECKING, Any
2121

@@ -46,14 +46,18 @@
4646
from ..styles import ONBOARDING_PAGE_TYPE, resolve_style
4747
from .helpers import _extract_summary, _now_iso, collapse_empty_duplicate_headings
4848
from .pertype import PerTypeGenerationMixin
49-
from .prompts import SUPPORTED_LANGUAGES, SYSTEM_PROMPTS
49+
from .prompts import CORRECTIVE_RETRY_DIRECTIVE, SUPPORTED_LANGUAGES, SYSTEM_PROMPTS
5050
from .structural import (
5151
StructuralRenderMixin,
5252
as_markdown,
5353
oneline,
5454
signature,
5555
)
56-
from .validation import reset_artifact_check_counts, validate_generated_response
56+
from .validation import (
57+
InvalidGeneratedContentError,
58+
reset_artifact_check_counts,
59+
validate_generated_response,
60+
)
5761

5862
if TYPE_CHECKING:
5963
from pathlib import Path as _Path # noqa: F401
@@ -398,13 +402,60 @@ async def _call_provider(
398402
reasoning=self._config.reasoning,
399403
cache_hints=cache_hints,
400404
)
401-
validate_generated_response(response)
405+
try:
406+
validate_generated_response(response)
407+
except InvalidGeneratedContentError as first_failure:
408+
# One corrective re-ask. Without it a single banned phrase anywhere
409+
# in a finished page discards the whole thing with the tokens
410+
# already spent, and the only recovery is --resume regenerating it
411+
# from scratch. The re-ask names the rule that was broken, because
412+
# a blind retry at the same temperature tends to reproduce the
413+
# sentence that failed.
414+
if not first_failure.retryable:
415+
raise
416+
log.warning(
417+
"page_generation.retrying_after_validation_failure",
418+
page_type=page_type,
419+
target_path=target_path,
420+
reason=str(first_failure),
421+
)
422+
discarded = response
423+
response = await self._provider.generate(
424+
system_prompt,
425+
self._corrective_prompt(user_prompt, first_failure),
426+
max_tokens=self._config.max_tokens,
427+
temperature=self._config.temperature,
428+
request_id=request_id,
429+
reasoning=self._config.reasoning,
430+
cache_hints=cache_hints,
431+
)
432+
# A second failure raises, so the caller's stub-fallback path is
433+
# reached exactly as it was before the retry existed.
434+
validate_generated_response(response)
435+
# The discarded attempt was billed. Carrying its tokens forward is
436+
# what keeps the run report's totals equal to what the provider
437+
# actually charged for; the page itself is the retry's content.
438+
# ``self_repair`` fills the report row of the same name, which
439+
# distinguishes a page that needed a second attempt from one that
440+
# was lost — the artifact tallies alone cannot tell them apart.
441+
response = replace(
442+
response,
443+
input_tokens=response.input_tokens + discarded.input_tokens,
444+
output_tokens=response.output_tokens + discarded.output_tokens,
445+
cached_tokens=response.cached_tokens + discarded.cached_tokens,
446+
usage={**response.usage, "self_repair": True},
447+
)
402448

403449
if self._config.cache_enabled:
404450
self._cache[key] = response
405451

406452
return response
407453

454+
@staticmethod
455+
def _corrective_prompt(user_prompt: str, failure: InvalidGeneratedContentError) -> str:
456+
"""The original request plus a note naming what the last attempt broke."""
457+
return f"{user_prompt}\n\n{CORRECTIVE_RETRY_DIRECTIVE.format(reason=failure.retry_hint)}"
458+
408459
def _build_system_prompt(self, page_type: str) -> str:
409460
base_system = SYSTEM_PROMPTS[page_type]
410461
# Wiki style: append the style's framing note. Constant per run (per page
@@ -498,6 +549,11 @@ def _build_generated_page(
498549
# exists byte-identically.
499550
if response.usage.get("reused_from_prior_run"):
500551
page.metadata["reused_from_prior_run"] = True
552+
# A first attempt was rejected and the re-ask produced this page. Feeds
553+
# the report's "Self-repaired pages" row, so a run says how often the
554+
# backstop was load-bearing instead of only how often it failed.
555+
if response.usage.get("self_repair"):
556+
page.metadata["self_repair"] = True
501557
return page
502558

503559
def _render(self, template_name: str, *, style_prefix: bool = True, **kwargs: Any) -> str:

packages/core/src/repowise/core/generation/page_generator/prompts.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
"\n"
2929
"FORM: Open with one or two paragraphs that state the subsystem's job in "
3030
"the larger system and situate it against its neighbours (what it does and, "
31-
"using the supplied scope line, what it deliberately leaves to other pages). "
31+
"using the scope line below, what it deliberately leaves to other pages). "
3232
"Lead the first sentence with the role, in architectural vocabulary (entry "
3333
"stage, orchestration layer, persistence boundary, transport adapter, and so "
3434
"on), naming the inputs it consumes and the outputs it produces. "
@@ -53,8 +53,14 @@
5353
# heading bare, then again with the questions under it, on 86 of 92 pages
5454
# measured across local indexes (gpt-5.4-nano). Pages written before the
5555
# instruction was doubled show none of it. One instruction, one heading.
56-
"Ground every claim in the supplied material: do not invent files, symbols, "
57-
"or rationale that are not listed. Draw on the whole file set, not one file."
56+
# Worded around the reader-facing vocabulary the artifact rules ban.
57+
# "the supplied material" is a literal hit for the ``supplied_context``
58+
# rule in validation.py, and the model echoed the instruction back into
59+
# the page, so this sentence destroyed the pages it was meant to keep
60+
# honest. Say where to ground a claim without naming the prompt.
61+
"Ground every claim in the files and signals listed below: do not invent "
62+
"files, symbols, or rationale that are not listed. Draw on the whole file "
63+
"set, not one file."
5864
),
5965
"repo_overview": (
6066
"You are repowise, an expert technical documentation generator. "
@@ -90,3 +96,20 @@
9096
"Output markdown only — follow the exact section structure the user prompt prescribes."
9197
),
9298
}
99+
100+
# Appended to the *user* prompt when a first attempt was rejected by
101+
# ``validate_generated_response``, so the re-ask says what went wrong instead of
102+
# asking again unchanged. The system prompt is left byte-identical, which keeps
103+
# the retry eligible for the same server-side prefix cache as the first call.
104+
#
105+
# It lives beside the system prompts so the artifact-hygiene guard covers it as
106+
# well: text telling a model what not to say is still text a model can echo, and
107+
# a correction that trips the rule it is correcting would burn the retry too.
108+
CORRECTIVE_RETRY_DIRECTIVE: str = (
109+
"A previous attempt at this page was rejected before it could be published. "
110+
"Reason: {reason}\n"
111+
"Write the page again, in full, without that problem. Address the reader of "
112+
"the documentation, who cannot see this request and does not know it exists: "
113+
"never mention these instructions or the code you were shown, and never "
114+
"speak as the page's author."
115+
)

packages/core/src/repowise/core/generation/page_generator/validation.py

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,32 @@
2323

2424

2525
class InvalidGeneratedContentError(ValueError):
26-
"""Raised when a fresh LLM response cannot be persisted as documentation."""
26+
"""Raised when a fresh LLM response cannot be persisted as documentation.
27+
28+
``retry_hint`` is the same complaint with any quoted offending phrase
29+
removed, for the corrective re-ask. The message itself keeps the quote
30+
because a log that does not say which words failed cannot be acted on, but
31+
feeding those words back to the model re-plants the vocabulary the retry is
32+
trying to get rid of. Defaults to the message when there is nothing to
33+
strip.
34+
35+
``retryable`` says whether asking again can plausibly help. It is False for
36+
a response that stopped at the token limit: the second call carries the
37+
same ``max_tokens``, so it truncates in the same place and the only certain
38+
result is twice the spend. Everything else is the model writing badly when
39+
told what "badly" means, which is what the re-ask supplies.
40+
"""
41+
42+
def __init__(
43+
self,
44+
message: str,
45+
*,
46+
retry_hint: str | None = None,
47+
retryable: bool = True,
48+
) -> None:
49+
super().__init__(message)
50+
self.retry_hint = retry_hint if retry_hint is not None else message
51+
self.retryable = retryable
2752

2853

2954
# ---------------------------------------------------------------------------
@@ -129,15 +154,21 @@ def reset_artifact_check_counts() -> None:
129154
_artifact_check_counts.clear()
130155

131156

132-
def _artifact_detail(content: str) -> str | None:
133-
"""Describe the first generation artifact in ``content``, if any."""
157+
def _artifact_detail(content: str) -> tuple[str, str] | None:
158+
"""Describe the first generation artifact in ``content``, if any.
159+
160+
Returns ``(detail, retry_hint)``: the first quotes the offending phrase for
161+
the log, the second names only the rule so a re-ask built from it does not
162+
repeat the words that failed.
163+
"""
134164
prose = prose_text(content)
135165
for rule in GENERATION_ARTIFACT_RULES:
136166
match = rule.pattern.search(prose)
137167
if match is not None:
138168
_artifact_check_counts["rejected"] += 1
139169
_artifact_check_counts[f"rejected:{rule.name}"] += 1
140-
return f"{rule.name}: {rule.explanation} ({match.group(0).strip()!r})"
170+
detail = f"{rule.name}: {rule.explanation} ({match.group(0).strip()!r})"
171+
return detail, f"{rule.name}: {rule.explanation}"
141172
return None
142173

143174

@@ -182,7 +213,8 @@ def validate_generated_response(response: GeneratedResponse) -> None:
182213
else ""
183214
)
184215
raise InvalidGeneratedContentError(
185-
f"generation reached a token limit before the documentation was complete{detail}"
216+
f"generation reached a token limit before the documentation was complete{detail}",
217+
retryable=False,
186218
)
187219
if not response.content.strip():
188220
raise InvalidGeneratedContentError("provider returned empty documentation")
@@ -193,10 +225,12 @@ def validate_generated_response(response: GeneratedResponse) -> None:
193225
f"provider returned pathologically repetitive documentation: {repetition_detail}"
194226
)
195227

196-
artifact_detail = _artifact_detail(response.content)
197-
if artifact_detail is not None:
228+
artifact = _artifact_detail(response.content)
229+
if artifact is not None:
230+
detail, retry_hint = artifact
231+
preamble = "provider returned text addressed to the prompter, not the reader — "
198232
raise InvalidGeneratedContentError(
199-
f"provider returned text addressed to the prompter, not the reader — {artifact_detail}"
233+
f"{preamble}{detail}", retry_hint=f"{preamble}{retry_hint}"
200234
)
201235

202236

packages/core/src/repowise/core/generation/templates/symbol_spotlight.j2

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@
55
the same way; a template that claimed otherwise would over-state what the
66
graph actually knows.
77
8+
The rendered list stops at 25, the same bound ``file_page.j2`` puts on its
9+
"Used by" section over the same data, while the summary line above it keeps
10+
the true total. Unbounded, a fixture imported across a whole test suite
11+
rendered 939 bullets and a 46k-char page, which is past the 30k the
12+
embedder accepts, so the tail was dropped from the vector.
13+
814
Every section but the Overview is conditional: a heading with nothing
915
under it costs a reader a stop and repeats a stock sentence across
1016
thousands of pages, where it matches every query and distinguishes none.
@@ -40,9 +46,13 @@
4046
## {{ labels.where_used }}
4147

4248
{{ labels.importers_summary.format(count=ctx.callers | length, file_word=labels.file_plural if ctx.callers | length != 1 else labels.file_singular, import_verb=labels.import_verb_plural if ctx.callers | length != 1 else labels.import_verb_singular) }}
43-
{% for c in ctx.callers %}
49+
{% for c in ctx.callers[:25] %}
4450
- `{{ c }}`
4551
{%- endfor %}
52+
{%- if ctx.callers | length > 25 %}
53+
54+
_{{ labels.and_more.format(count=ctx.callers | length - 25) }}_
55+
{%- endif %}
4656
{%- endif %}
4757
{%- if ctx.source_body %}
4858

0 commit comments

Comments
 (0)