Skip to content

Commit 3924bb2

Browse files
SoundMindsAIclaude
andcommitted
feat(worker): wire ConfidenceShape into digest narrative prompt (Story 1.6)
- prompts/digest_narrative.user.jinja gains two new XML blocks inserted after <baseline_vs_achieved>: <confidence> (CI band + aggregate signals — each sub-line independently {% if %}-gated per FR-7) and <per_query_outcomes> (improved/unchanged/regressed counts + comparison_against label + up to 5 named regressor rows). - prompts/digest_narrative.system.md extends the XML-block inventory to document blocks 8 + 9 with conditional-inclusion semantics, and replaces the narrative opening-guidance sentence with the FR-6 string ("Open with the headline metric delta, immediately followed by a one-sentence confidence framing that mentions the CI band [when <confidence> is present], the per-query outcome counts [when <per_query_outcomes> is present], and the worst-regressed query by name [when <per_query_outcomes> has regressors]"). - render_digest_user_prompt gains optional confidence: Mapping | None = None kwarg; passes through to the jinja render verbatim. None default preserves the existing one-callsite contract (worker is the only caller). - backend/workers/digest.py awaits fetch_study_confidence(db, study) immediately before render_digest_user_prompt, serializes via ConfidenceShape.model_dump() (so jinja consumes a plain dict, per cycle-1 GPT-5.5 F3), and threads through the new kwarg. - backend/tests/unit/workers/test_digest_prompt_render.py adds 5 new cases covering AC-14: (1) <confidence> block present + all sub-lines populated; (2) absent when confidence=None; (3) <per_query_outcomes> present + regressor rows render; (4) absent when nested per_query_outcomes is None; (5) system prompt carries the FR-6 opening-guidance string (whitespace-normalized assertion tolerates the markdown soft-wraps) plus the documented block list entries. Verification: 1039 backend unit tests pass (+5 new); 189 contract; 526/526 in-container integration; backend fmt + lint + typecheck + ruff format --check parity clean. No regressions in test_digest_generate / test_digest_zero_trials / test_digest_capability_fallback — the new fetch_study_confidence call returns None for the seeded happy-path study (no per_query_metrics on its single trial), so the jinja blocks skip silently and the existing prompt-render assertions stay valid. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 29132dd commit 3924bb2

6 files changed

Lines changed: 192 additions & 6 deletions

File tree

backend/app/llm/digest_prompt.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ def render_digest_user_prompt(
8080
recommended_config: Mapping[str, Any],
8181
dropped_template_params: Sequence[str],
8282
include_recommendation: bool = True,
83+
confidence: Mapping[str, Any] | None = None,
8384
) -> str:
8485
"""Render the per-study user message for the digest narrative call.
8586
@@ -105,6 +106,12 @@ def render_digest_user_prompt(
105106
include_recommendation: cycle-3 F3 toggle. ``True`` (default) emits
106107
the full structured prompt; ``False`` emits the degraded /
107108
narrative-only variant for the capability-fallback path.
109+
confidence: serialized ``ConfidenceShape`` (via
110+
``ConfidenceShape.model_dump()``) per feat_pr_metric_confidence
111+
FR-6. ``None`` (default) skips both the ``<confidence>`` and
112+
``<per_query_outcomes>`` jinja blocks; a partial shape (some
113+
sub-fields ``None``) emits only the populated sub-lines via the
114+
template's per-sub-field ``{% if %}`` guards.
108115
109116
Returns:
110117
The rendered user message string, ready to send as the OpenAI
@@ -127,6 +134,7 @@ def render_digest_user_prompt(
127134
recommended_config=recommended_config,
128135
dropped_template_params=dropped_template_params,
129136
include_recommendation=include_recommendation,
137+
confidence=confidence,
130138
)
131139

132140

backend/tests/unit/workers/test_digest_prompt_render.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
_SANDBOX_ENV,
2525
DigestPromptBundle,
2626
load_digest_prompts,
27+
render_digest_system_prompt,
2728
render_digest_user_prompt,
2829
)
2930

@@ -155,6 +156,139 @@ def test_autoescape_neutralizes_adversarial_study_name() -> None:
155156
assert "</study><inject>malicious-instruction" not in output
156157

157158

159+
# ---------------------------------------------------------------------------
160+
# feat_pr_metric_confidence Story 1.6 — <confidence> + <per_query_outcomes>
161+
# ---------------------------------------------------------------------------
162+
163+
164+
def _make_test_confidence_dict(**overrides: object) -> dict[str, object]:
165+
"""Build a fully-populated serialized ConfidenceShape for the jinja blocks.
166+
167+
Mirrors what ``ConfidenceShape.model_dump()`` emits at the digest-worker
168+
call site. Tests override sub-fields by passing them as kwargs.
169+
"""
170+
base: dict[str, object] = {
171+
"headline": {"metric": "ndcg", "value": 0.840, "k": 10, "n_queries": 20},
172+
"ci_95": {"low": 0.780, "high": 0.890, "method": "bootstrap_n1000", "n_samples": 20},
173+
"runner_up_gap": {
174+
"value": 0.002,
175+
"classification": "robust_plateau",
176+
"top10_within": 0.004,
177+
"runner_up_metric": 0.838,
178+
},
179+
"late_trial_stddev": {"value": 0.012, "window_size": 20, "min_window_required": 10},
180+
"convergence": {"best_at_trial": 387, "total_trials": 1000, "regime": "early_held"},
181+
"per_query_outcomes": {
182+
"improved": 14,
183+
"unchanged": 4,
184+
"regressed": 2,
185+
"comparison_against": "runner_up",
186+
"top_regressors": [
187+
{
188+
"query_id": "q1",
189+
"query_text": "vintage acoustic guitar",
190+
"winner_score": 0.41,
191+
"comparison_score": 0.92,
192+
"delta": -0.51,
193+
},
194+
{
195+
"query_id": "q2",
196+
"query_text": "leather wallet",
197+
"winner_score": 0.55,
198+
"comparison_score": 0.78,
199+
"delta": -0.23,
200+
},
201+
],
202+
},
203+
}
204+
base.update(overrides)
205+
return base
206+
207+
208+
def test_user_prompt_includes_confidence_block_when_data_present() -> None:
209+
"""FR-6 / AC-14: full confidence dict produces the <confidence> XML block."""
210+
kwargs = dict(CANONICAL_KWARGS)
211+
kwargs["confidence"] = _make_test_confidence_dict()
212+
output = render_digest_user_prompt(**kwargs) # type: ignore[arg-type]
213+
assert "<confidence>" in output
214+
assert "</confidence>" in output
215+
# Headline + CI sub-lines.
216+
assert "ci_low: 0.78" in output
217+
assert "ci_high: 0.89" in output
218+
assert "n_queries: 20" in output
219+
# Aggregate signals.
220+
assert "runner_up_gap: 0.002 (robust_plateau)" in output
221+
assert "late_trial_stddev: 0.012" in output
222+
assert "convergence: early_held (best at trial 387 of 1000)" in output
223+
224+
225+
def test_user_prompt_omits_confidence_block_when_none() -> None:
226+
"""FR-7 / AC-12: confidence=None skips both blocks entirely."""
227+
output = render_digest_user_prompt(**CANONICAL_KWARGS) # type: ignore[arg-type]
228+
# Canonical kwargs don't set `confidence` — defaults to None.
229+
assert "<confidence>" not in output
230+
assert "<per_query_outcomes>" not in output
231+
232+
233+
def test_user_prompt_includes_per_query_outcomes_block_when_nested_data_present() -> None:
234+
"""The <per_query_outcomes> block surfaces nested counts + named regressors."""
235+
kwargs = dict(CANONICAL_KWARGS)
236+
kwargs["confidence"] = _make_test_confidence_dict()
237+
output = render_digest_user_prompt(**kwargs) # type: ignore[arg-type]
238+
assert "<per_query_outcomes>" in output
239+
assert "</per_query_outcomes>" in output
240+
assert "improved: 14" in output
241+
assert "unchanged: 4" in output
242+
assert "regressed: 2" in output
243+
assert "comparison_against: runner_up" in output
244+
# Each regressor row: text + winner → comparison + delta in parens.
245+
assert "- vintage acoustic guitar: 0.41" in output
246+
assert "0.92" in output
247+
assert "(-0.51)" in output
248+
assert "- leather wallet: 0.55" in output
249+
250+
251+
def test_user_prompt_omits_per_query_outcomes_block_when_subfield_is_none() -> None:
252+
"""FR-7: confidence present but per_query_outcomes=None → outer block only."""
253+
kwargs = dict(CANONICAL_KWARGS)
254+
kwargs["confidence"] = _make_test_confidence_dict(per_query_outcomes=None)
255+
output = render_digest_user_prompt(**kwargs) # type: ignore[arg-type]
256+
# The <confidence> block still renders (CI + aggregate signals).
257+
assert "<confidence>" in output
258+
# <per_query_outcomes> stays suppressed.
259+
assert "<per_query_outcomes>" not in output
260+
261+
262+
def test_system_prompt_has_fr6_opening_guidance_and_block_inventory() -> None:
263+
"""AC-14 system-prompt half: the opening guidance + block list are updated.
264+
265+
The replacement string from spec FR-6 is in the prompt file but
266+
soft-wrapped at ~80 columns. We collapse whitespace before asserting so
267+
the test tolerates wrap location while still proving the substring
268+
contract — the LLM sees newlines as whitespace too.
269+
"""
270+
system = render_digest_system_prompt()
271+
# Collapse all runs of whitespace (incl. newlines + indents) into single
272+
# spaces so soft-wrapped sentences match continuous-string assertions.
273+
flat = " ".join(system.split())
274+
# Opening-guidance replacement (FR-6 line edit). Backticks around
275+
# `<confidence>` / `<per_query_outcomes>` tag names are load-bearing —
276+
# they signal to the LLM that these are XML block names, not English.
277+
assert (
278+
"Open with the headline metric delta, immediately followed by a one-sentence "
279+
"confidence framing that mentions the CI band (when `<confidence>` is present), "
280+
"the per-query outcome counts (when `<per_query_outcomes>` is present), and the "
281+
"worst-regressed query by name (when `<per_query_outcomes>` has regressors)."
282+
) in flat
283+
# The original "Open with the headline metric delta. Then explain" sentence
284+
# must NOT exist verbatim — the replacement superseded it.
285+
assert "headline metric delta. Then explain" not in flat
286+
# Block inventory must document the two new XML blocks (these appear on
287+
# their own lines so a direct substring check is fine).
288+
assert "8. `<confidence>`" in system
289+
assert "9. `<per_query_outcomes>`" in system
290+
291+
158292
def test_sandbox_rejects_attribute_access() -> None:
159293
"""Defense in depth: SandboxedEnvironment blocks dunder-access from template authors.
160294

backend/workers/digest.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
known_models,
7373
)
7474
from backend.app.llm.digest_prompt import load_digest_prompts, render_digest_user_prompt
75+
from backend.app.services.study_confidence import fetch_study_confidence
7576

7677
logger = structlog.get_logger(__name__)
7778

@@ -686,6 +687,14 @@ async def generate_digest(ctx: dict[str, Any], study_id: str) -> None:
686687
rubric_summary = rubric_text[:280] + ("..." if len(rubric_text) > 280 else "")
687688
if not rubric_summary:
688689
rubric_summary = "(see judgment list rubric)"
690+
# feat_pr_metric_confidence Story 1.6 (FR-6): assemble the
691+
# per-study ConfidenceShape and serialize for the jinja
692+
# ``<confidence>`` + ``<per_query_outcomes>`` blocks. Returns
693+
# ``None`` on degraded paths (FR-7) so the blocks skip cleanly.
694+
confidence_shape = await fetch_study_confidence(db, study)
695+
confidence_payload = (
696+
confidence_shape.model_dump() if confidence_shape is not None else None
697+
)
689698
user_prompt = render_digest_user_prompt(
690699
study_name=study.name,
691700
cluster_name=cluster_name,
@@ -701,6 +710,7 @@ async def generate_digest(ctx: dict[str, Any], study_id: str) -> None:
701710
recommended_config=recommended_config,
702711
dropped_template_params=dropped,
703712
include_recommendation=structured_output_enabled and not all_dropped,
713+
confidence=confidence_payload,
704714
)
705715
bundle = load_digest_prompts()
706716
openai_client = AsyncOpenAI(api_key=api_key, base_url=settings.openai_base_url)

docs/02_product/planned_features/feat_pr_metric_confidence/implementation_plan.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1055,7 +1055,7 @@ None planned. The feature is purely additive across all surfaces.
10551055
- [ ] Story 1.3 — Domain module `confidence.py`
10561056
- [x] Story 1.4 — `ConfidenceShape` + StudyDetail enrichment
10571057
- [x] Story 1.5 — PR body section + worker plumbing
1058-
- [ ] Story 1.6 — Digest narrative prompt extension
1058+
- [x] Story 1.6 — Digest narrative prompt extension
10591059
- [ ] **Epic 1 gate**
10601060
- [ ] Story 2.1 — TypeScript types + enums
10611061
- [ ] Story 2.2 — `<ConfidencePanel>` component + glossary + page mount

prompts/digest_narrative.system.md

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,32 @@ The user message contains XML-delimited blocks:
2424
7. `<degraded_mode>` (only when `include_recommendation=False`) — the operator's
2525
OpenAI endpoint failed the structured-output capability probe. Return free-
2626
form prose narrative only — no JSON, no recommendations, no follow-ups.
27+
8. `<confidence>` (only when the orchestrator computed a non-null
28+
`ConfidenceShape` for the study) — bootstrap 95% CI on the headline metric
29+
(`ci_low`/`ci_high`/`n_queries`) plus aggregate signals (`runner_up_gap`,
30+
`late_trial_stddev`, `convergence`). Each sub-line is omitted independently
31+
when its sub-field is null (FR-7 graceful-degradation contract). For
32+
studies still running, or studies whose winner trial predates the
33+
`per_query_metrics` migration, the block may be absent or partial.
34+
9. `<per_query_outcomes>` (only when both the winner trial and the runner-up
35+
trial have per-query metrics) — `improved` / `unchanged` / `regressed`
36+
counts, the `comparison_against` reference (`runner_up` in MVP1; `baseline`
37+
when Phase 2 ships), and up to 5 named regressor rows
38+
(`query_text: winner_score → comparison_score (delta)`). Omitted entirely
39+
when the comparison data isn't available.
2740

2841
For the **structured** path (default, `include_recommendation=True`), return a
2942
JSON object with exactly two fields:
3043

3144
- `narrative` — a markdown string (~200–600 words). Open with the headline
32-
metric delta. Then explain *why* the recommendation works, citing the
33-
`<parameter_importance>` map and 2–3 top trials. Reference the
34-
`<recommended_config>` literal params + values where useful, but do NOT
35-
reprint the full config — the data layer already has it.
45+
metric delta, immediately followed by a one-sentence confidence framing that
46+
mentions the CI band (when `<confidence>` is present), the per-query outcome
47+
counts (when `<per_query_outcomes>` is present), and the worst-regressed
48+
query by name (when `<per_query_outcomes>` has regressors). Then explain
49+
*why* the recommendation works, citing the `<parameter_importance>` map and
50+
2–3 top trials. Reference the `<recommended_config>` literal params + values
51+
where useful, but do NOT reprint the full config — the data layer already
52+
has it.
3653
- `suggested_followups` — a JSON array of at most 5 short strings, each a
3754
concrete next action the engineer can take (e.g. "Re-run with a wider
3855
`tie_breaker` range", "Add a judgment for query 'wireless headphones' to

prompts/digest_narrative.user.jinja

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,24 @@ baseline_metric: {{ baseline_metric if baseline_metric is not none else 'N/A (no
1212
achieved_metric: {{ achieved_metric }}
1313
</baseline_vs_achieved>
1414

15-
<top_trials>
15+
{% if confidence %}<confidence>
16+
{% if confidence.ci_95 %}ci_low: {{ confidence.ci_95.low }}
17+
ci_high: {{ confidence.ci_95.high }}
18+
{% endif %}n_queries: {{ confidence.headline.n_queries }}
19+
{% if confidence.runner_up_gap %}runner_up_gap: {{ confidence.runner_up_gap.value }} ({{ confidence.runner_up_gap.classification or 'unclassified' }})
20+
{% endif %}{% if confidence.late_trial_stddev %}late_trial_stddev: {{ confidence.late_trial_stddev.value }}
21+
{% endif %}{% if confidence.convergence %}convergence: {{ confidence.convergence.regime }} (best at trial {{ confidence.convergence.best_at_trial }} of {{ confidence.convergence.total_trials }})
22+
{% endif %}</confidence>
23+
24+
{% endif %}{% if confidence and confidence.per_query_outcomes %}<per_query_outcomes>
25+
improved: {{ confidence.per_query_outcomes.improved }}
26+
unchanged: {{ confidence.per_query_outcomes.unchanged }}
27+
regressed: {{ confidence.per_query_outcomes.regressed }}
28+
comparison_against: {{ confidence.per_query_outcomes.comparison_against }}
29+
{% for r in confidence.per_query_outcomes.top_regressors %}- {{ r.query_text }}: {{ r.winner_score }} → {{ r.comparison_score }} ({{ r.delta }})
30+
{% endfor %}</per_query_outcomes>
31+
32+
{% endif %}<top_trials>
1633
{% for t in top_trials %}trial #{{ t.number }} — primary_metric={{ t.primary_metric }}, params={{ t.params }}
1734
{% endfor %}</top_trials>
1835

0 commit comments

Comments
 (0)