|
24 | 24 | _SANDBOX_ENV, |
25 | 25 | DigestPromptBundle, |
26 | 26 | load_digest_prompts, |
| 27 | + render_digest_system_prompt, |
27 | 28 | render_digest_user_prompt, |
28 | 29 | ) |
29 | 30 |
|
@@ -155,6 +156,139 @@ def test_autoescape_neutralizes_adversarial_study_name() -> None: |
155 | 156 | assert "</study><inject>malicious-instruction" not in output |
156 | 157 |
|
157 | 158 |
|
| 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 | + |
158 | 292 | def test_sandbox_rejects_attribute_access() -> None: |
159 | 293 | """Defense in depth: SandboxedEnvironment blocks dunder-access from template authors. |
160 | 294 |
|
|
0 commit comments