[NOTICKET] feat(eval): add help-skill eval harness + goal-based language fallback
Browse filesLive-model eval for the Help skill (eval/help), mirroring eval/intent + eval/readiness:
each case declares state + report_ready + history, streams HelpAgent.astream for real, and
scores RULE compliance (reply language; never/always suggest a report per report_ready) β
not text similarity. Held-out vs carried_over reported separately so prompt overfitting is
visible; orientation cases are manual_review.
help.py: reply-language detection now consults the user-authored goal (objective +
business_questions) when there is no chat turn to read, before the hard Indonesian
fallback. The goal is required at onboarding, so on a fresh analysis it is a reliable
signal β a user whose goal is in English no longer gets an Indonesian reply. Priority:
message > last human turn > goal > default.
help.md v3: language rule promoted to a hard [Reply language] directive; Examples given
stable ids so the eval can mirror them as carried_over regression cases; second example now
uses a real is_report_ready `missing` value instead of the never-emitted
"no comparison over time".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- eval/help/README.md +62 -0
- eval/help/__init__.py +0 -0
- eval/help/help_dataset.json +150 -0
- eval/help/run_eval.py +404 -0
- src/agents/handlers/help.py +39 -16
- src/config/prompts/help.md +21 -9
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Help-skill eval
|
| 2 |
+
|
| 3 |
+
Scores the **live** Help skill (`src/agents/handlers/help.HelpAgent`) β the guide that
|
| 4 |
+
tells a user where they are and what to do next. Each golden case declares an analysis
|
| 5 |
+
state + report-readiness + chat history; the runner streams `HelpAgent.astream` for real
|
| 6 |
+
and asserts the **rules** the reply must obey.
|
| 7 |
+
|
| 8 |
+
Unlike `eval/readiness` (deterministic, no LLM), this calls the model, so it needs a
|
| 9 |
+
working `.env` (Azure OpenAI) and spends tokens. Run it before a deploy that touches
|
| 10 |
+
`config/prompts/help.md` β not on every commit. The fast, no-LLM guard is
|
| 11 |
+
`tests/unit/agents/handlers/test_help.py` (fake chain); this is the end-to-end
|
| 12 |
+
"does the model actually obey the prompt" layer on top.
|
| 13 |
+
|
| 14 |
+
## Run
|
| 15 |
+
|
| 16 |
+
```bash
|
| 17 |
+
uv run python -m eval.help.run_eval
|
| 18 |
+
uv run python -m eval.help.run_eval --limit 4 # smoke test
|
| 19 |
+
uv run python -m eval.help.run_eval --no-table # summary only
|
| 20 |
+
```
|
| 21 |
+
|
| 22 |
+
Each run writes a timestamped `results/help_result_<ts>.json` (never overwritten,
|
| 23 |
+
diffable across runs).
|
| 24 |
+
|
| 25 |
+
## What it measures
|
| 26 |
+
|
| 27 |
+
Not accuracy β Help replies are free prose with no single correct wording. The metric is
|
| 28 |
+
**compliance**: the % of cases whose reply obeys every rule asserted for it.
|
| 29 |
+
|
| 30 |
+
- **`language`** β the reply must match the user's language. This is the regression guard
|
| 31 |
+
for the button-path bug (`/tools/help` passes `message=None`, and the reply used to
|
| 32 |
+
default to English even for an Indonesian conversation).
|
| 33 |
+
- **`report_guard`** β never suggest generating a report when `report_ready.ready=false`;
|
| 34 |
+
do suggest it when `true`. Since `generate_report` is the only gated action, this also
|
| 35 |
+
serves as the "no action leakage" check.
|
| 36 |
+
- **`orientation`** β quality of the suggested starter questions. **Manual review**: these
|
| 37 |
+
run but are excluded from the auto compliance rate. Read their `output_text` in the JSON.
|
| 38 |
+
|
| 39 |
+
Assertion types: `language_match {expected}`, `must_not_contain_any {patterns}`,
|
| 40 |
+
`must_contain_any {patterns}`.
|
| 41 |
+
|
| 42 |
+
## Held-out vs carried-over (why the summary splits them)
|
| 43 |
+
|
| 44 |
+
`carried_over: true` cases **mirror an example in `help.md`** β the case `id` *is* the
|
| 45 |
+
prompt's `<!-- id: ... -->`. They are a regression guard: if the prompt is refactored, the
|
| 46 |
+
demonstrated rule must still hold. What is mirrored is the **input spec + the assertion**,
|
| 47 |
+
never the example's reply text (temperature > 0 makes exact match invalid).
|
| 48 |
+
|
| 49 |
+
Held-out cases (`carried_over: false`) are **absent from the prompt**; their compliance is
|
| 50 |
+
the real generalization signal. If held-out compliance drops while carried-over stays at
|
| 51 |
+
100%, the prompt is overfitting to its own examples ("train on test set"). That's why the
|
| 52 |
+
two are reported separately.
|
| 53 |
+
|
| 54 |
+
**Sync rule (manual, like `intent`):** if `help.md`'s Examples change, keep the mirrored
|
| 55 |
+
`id`s here in sync. Current mirrored ids: `help_ex_orient`, `help_ex_guard_delta`,
|
| 56 |
+
`help_ex_guard_ready`.
|
| 57 |
+
|
| 58 |
+
## Dataset
|
| 59 |
+
|
| 60 |
+
`help_dataset.json` β see the `_about` / `_carried_over` doc keys in the file. Language
|
| 61 |
+
detection reuses `help._detect_reply_language`; `report_ready.missing` uses the codes
|
| 62 |
+
`analysis` / `delta` mapped to the real `is_report_ready` strings in the runner.
|
|
File without changes
|
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"_about": "Golden dataset for the Help skill (`src/agents/handlers/help.HelpAgent`). Unlike intent/readiness this calls the LIVE model: each case declares an analysis state + report-readiness + chat history, the runner streams HelpAgent.astream for real, and asserts RULES the reply must obey (not text similarity β help replies are free prose with no single correct wording). Metric is COMPLIANCE (% of rule assertions that hold), reported separately for held-out vs carried_over cases.",
|
| 3 |
+
"_groups": "language (reply matches the user's language β the button-path bug), report_guard (never suggest a report when report_ready.ready=false; do suggest it when true β this also IS the 'no action leakage' check, since generate_report is the only gated action), orientation (quality of the suggested starter questions β MANUAL review, not auto-scored).",
|
| 4 |
+
"_asserts": "language_match {expected} β detect the reply's language (help._detect_reply_language over the OUTPUT) must equal expected. must_not_contain_any {patterns} β none of the (case-insensitive) patterns appear. must_contain_any {patterns} β at least one appears.",
|
| 5 |
+
"_carried_over": "carried_over:true rows MIRROR an example in config/prompts/help.md (the row `id` IS the help.md `<!-- id: ... -->`). They are the regression guard: if the prompt is refactored, the demonstrated rule must still hold. What is mirrored is the INPUT spec + the assertion β NOT the example's reply text (temperature>0 makes exact match invalid). Held-out rows (carried_over:false) are NOT in the prompt; their compliance is the real generalization signal. If help.md's Examples change, keep these ids in sync (manual, like intent).",
|
| 6 |
+
"_missing_codes": "report_ready.missing uses codes mapped to the real strings is_report_ready emits (imported in run_eval): analysis -> _MISSING_ANALYSIS, delta -> _MISSING_DELTA. Kept as codes so the dataset survives wording changes.",
|
| 7 |
+
"schema": {
|
| 8 |
+
"id": "stable handle; for carried_over rows this equals the help.md example id",
|
| 9 |
+
"group": "language | report_guard | orientation",
|
| 10 |
+
"carried_over": "bool β mirrors a help.md example",
|
| 11 |
+
"manual_review": "bool β run but exclude from the auto compliance rate (read output_text)",
|
| 12 |
+
"state": "{ analysis_title, objective, business_questions[], report_id }",
|
| 13 |
+
"report_ready": "{ ready: bool, missing: [analysis|delta] }",
|
| 14 |
+
"history": "[{ role: human|ai, content }] β drives language on the button path",
|
| 15 |
+
"message": "the human turn; null = button path (HelpAgent falls back to a per-language trigger)",
|
| 16 |
+
"asserts": "[{ type, ...spec }] β the rules the reply must obey",
|
| 17 |
+
"note": "human-readable description"
|
| 18 |
+
},
|
| 19 |
+
"cases": [
|
| 20 |
+
{
|
| 21 |
+
"id": "lang_01", "group": "language", "carried_over": false, "manual_review": false,
|
| 22 |
+
"state": { "analysis_title": "Analisis penjualan", "objective": "memahami performa penjualan bulanan", "business_questions": ["produk mana yang paling laku?"], "report_id": null },
|
| 23 |
+
"report_ready": { "ready": false, "missing": ["analysis"] },
|
| 24 |
+
"history": [{ "role": "human", "content": "aku baru upload datanya, terus aku harus ngapain?" }],
|
| 25 |
+
"message": null,
|
| 26 |
+
"asserts": [{ "type": "language_match", "expected": "Indonesian" }],
|
| 27 |
+
"note": "REGRESSION of the button-path bug: Indonesian conversation, message=null. Reply must be Indonesian, not English."
|
| 28 |
+
},
|
| 29 |
+
{
|
| 30 |
+
"id": "lang_02", "group": "language", "carried_over": false, "manual_review": false,
|
| 31 |
+
"state": { "analysis_title": "Sales analysis", "objective": "understand monthly sales performance", "business_questions": ["which products drive revenue?"], "report_id": null },
|
| 32 |
+
"report_ready": { "ready": false, "missing": ["analysis"] },
|
| 33 |
+
"history": [{ "role": "human", "content": "okay I uploaded my data, what do I do next?" }],
|
| 34 |
+
"message": null,
|
| 35 |
+
"asserts": [{ "type": "language_match", "expected": "English" }],
|
| 36 |
+
"note": "English conversation, button path β reply must stay English."
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
"id": "lang_03", "group": "language", "carried_over": false, "manual_review": false,
|
| 40 |
+
"state": { "analysis_title": "Analisis churn", "objective": "menurunkan churn pelanggan", "business_questions": ["segmen mana yang paling banyak churn?"], "report_id": null },
|
| 41 |
+
"report_ready": { "ready": false, "missing": ["analysis"] },
|
| 42 |
+
"history": [],
|
| 43 |
+
"message": "gimana caranya mulai analisis ini ya?",
|
| 44 |
+
"asserts": [{ "type": "language_match", "expected": "Indonesian" }],
|
| 45 |
+
"note": "Intent path: the real Indonesian user turn drives the language."
|
| 46 |
+
},
|
| 47 |
+
{
|
| 48 |
+
"id": "lang_04", "group": "language", "carried_over": false, "manual_review": false,
|
| 49 |
+
"state": { "analysis_title": "Retention analysis", "objective": "understand user retention", "business_questions": ["what drives repeat usage?"], "report_id": null },
|
| 50 |
+
"report_ready": { "ready": false, "missing": ["analysis"] },
|
| 51 |
+
"history": [],
|
| 52 |
+
"message": null,
|
| 53 |
+
"asserts": [{ "type": "language_match", "expected": "English" }],
|
| 54 |
+
"note": "Fresh analysis, no chat yet, button path β with no turn to read, the user-authored goal (English objective + business_questions, required at onboarding) drives the language."
|
| 55 |
+
},
|
| 56 |
+
{
|
| 57 |
+
"id": "lang_06", "group": "language", "carried_over": false, "manual_review": false,
|
| 58 |
+
"state": { "analysis_title": "Analisis retensi", "objective": "memahami retensi pengguna", "business_questions": ["apa yang mendorong penggunaan berulang?"], "report_id": null },
|
| 59 |
+
"report_ready": { "ready": false, "missing": ["analysis"] },
|
| 60 |
+
"history": [],
|
| 61 |
+
"message": null,
|
| 62 |
+
"asserts": [{ "type": "language_match", "expected": "Indonesian" }],
|
| 63 |
+
"note": "Same fresh-analysis path as lang_04 but the goal is Indonesian β the goal signal must yield Indonesian (not the hard fallback, which only fires when the goal is empty too)."
|
| 64 |
+
},
|
| 65 |
+
{
|
| 66 |
+
"id": "lang_05", "group": "language", "carried_over": false, "manual_review": false,
|
| 67 |
+
"state": { "analysis_title": "Analisis penjualan", "objective": "memahami tren penjualan", "business_questions": ["bagaimana tren bulanan?"], "report_id": null },
|
| 68 |
+
"report_ready": { "ready": false, "missing": ["analysis"] },
|
| 69 |
+
"history": [
|
| 70 |
+
{ "role": "human", "content": "apa saja yang bisa aku tanyakan tentang data ini?" },
|
| 71 |
+
{ "role": "ai", "content": "You can start by asking which products sell the most." }
|
| 72 |
+
],
|
| 73 |
+
"message": null,
|
| 74 |
+
"asserts": [{ "type": "language_match", "expected": "Indonesian" }],
|
| 75 |
+
"note": "Last AI turn is English but the human turn is Indonesian β mirror the human, reply Indonesian."
|
| 76 |
+
},
|
| 77 |
+
{
|
| 78 |
+
"id": "help_ex_guard_delta", "group": "report_guard", "carried_over": true, "manual_review": false,
|
| 79 |
+
"state": { "analysis_title": "Sales analysis", "objective": "understand monthly sales performance", "business_questions": ["which products drive revenue?"], "report_id": "rep-1" },
|
| 80 |
+
"report_ready": { "ready": false, "missing": ["delta"] },
|
| 81 |
+
"history": [{ "role": "human", "content": "what should I do next?" }],
|
| 82 |
+
"message": null,
|
| 83 |
+
"asserts": [{ "type": "must_not_contain_any", "patterns": ["/report", "generate the report", "generate your report", "create the report"] }],
|
| 84 |
+
"note": "MIRRORS help.md example help_ex_guard_delta. A report exists and nothing new since β must NOT tell the user to generate a report; steer them to run a fresh analysis first."
|
| 85 |
+
},
|
| 86 |
+
{
|
| 87 |
+
"id": "help_ex_guard_ready", "group": "report_guard", "carried_over": true, "manual_review": false,
|
| 88 |
+
"state": { "analysis_title": "Sales analysis", "objective": "understand monthly sales performance", "business_questions": ["which products drive revenue?"], "report_id": null },
|
| 89 |
+
"report_ready": { "ready": true, "missing": [] },
|
| 90 |
+
"history": [{ "role": "human", "content": "what should I do next?" }],
|
| 91 |
+
"message": null,
|
| 92 |
+
"asserts": [{ "type": "must_contain_any", "patterns": ["/report", "report"] }],
|
| 93 |
+
"note": "MIRRORS help.md example help_ex_guard_ready. Enough analysis done β SHOULD nudge toward the report (mention /report or the report option)."
|
| 94 |
+
},
|
| 95 |
+
{
|
| 96 |
+
"id": "guard_03", "group": "report_guard", "carried_over": false, "manual_review": false,
|
| 97 |
+
"state": { "analysis_title": "Retention analysis", "objective": "improve 30-day retention", "business_questions": ["which cohort retains best?"], "report_id": null },
|
| 98 |
+
"report_ready": { "ready": false, "missing": ["analysis"] },
|
| 99 |
+
"history": [{ "role": "human", "content": "can I get a report now?" }],
|
| 100 |
+
"message": null,
|
| 101 |
+
"asserts": [{ "type": "must_not_contain_any", "patterns": ["/report", "generate the report", "generate your report", "you can generate"] }],
|
| 102 |
+
"note": "No analysis run yet, user asks for a report directly β must NOT offer to generate; redirect to running an analysis first."
|
| 103 |
+
},
|
| 104 |
+
{
|
| 105 |
+
"id": "guard_04", "group": "report_guard", "carried_over": false, "manual_review": false,
|
| 106 |
+
"state": { "analysis_title": "Analisis penjualan", "objective": "memahami performa penjualan", "business_questions": ["produk mana yang paling laku?"], "report_id": null },
|
| 107 |
+
"report_ready": { "ready": true, "missing": [] },
|
| 108 |
+
"history": [{ "role": "human", "content": "selanjutnya aku ngapain?" }],
|
| 109 |
+
"message": null,
|
| 110 |
+
"asserts": [
|
| 111 |
+
{ "type": "must_contain_any", "patterns": ["/report", "laporan", "report"] },
|
| 112 |
+
{ "type": "language_match", "expected": "Indonesian" }
|
| 113 |
+
],
|
| 114 |
+
"note": "Ready + Indonesian conversation β should nudge toward the report AND stay in Indonesian (two rules at once)."
|
| 115 |
+
},
|
| 116 |
+
{
|
| 117 |
+
"id": "guard_05", "group": "report_guard", "carried_over": false, "manual_review": false,
|
| 118 |
+
"state": { "analysis_title": "Analisis churn", "objective": "menurunkan churn", "business_questions": ["segmen mana yang paling churn?"], "report_id": null },
|
| 119 |
+
"report_ready": { "ready": false, "missing": ["analysis"] },
|
| 120 |
+
"history": [{ "role": "human", "content": "aku mau bikin laporan dong" }],
|
| 121 |
+
"message": null,
|
| 122 |
+
"asserts": [
|
| 123 |
+
{ "type": "must_not_contain_any", "patterns": ["/report", "silakan buat laporan", "kamu bisa membuat laporan", "generate your report"] },
|
| 124 |
+
{ "type": "language_match", "expected": "Indonesian" }
|
| 125 |
+
],
|
| 126 |
+
"note": "Indonesian, not ready, user asks for a report β must NOT offer it and must reply in Indonesian."
|
| 127 |
+
},
|
| 128 |
+
{
|
| 129 |
+
"id": "help_ex_orient", "group": "orientation", "carried_over": true, "manual_review": true,
|
| 130 |
+
"state": { "analysis_title": "Sales analysis", "objective": "understand monthly sales performance", "business_questions": ["which products drive revenue?"], "report_id": null },
|
| 131 |
+
"report_ready": { "ready": false, "missing": ["analysis"] },
|
| 132 |
+
"history": [],
|
| 133 |
+
"message": null,
|
| 134 |
+
"asserts": [],
|
| 135 |
+
"note": "MIRRORS help.md example help_ex_orient. MANUAL: are the 2-3 starter questions concrete, descriptive-first, and tied to the objective? Read output_text."
|
| 136 |
+
},
|
| 137 |
+
{
|
| 138 |
+
"id": "orient_02", "group": "orientation", "carried_over": false, "manual_review": true,
|
| 139 |
+
"state": { "analysis_title": "Retention analysis", "objective": "improve 30-day retention", "business_questions": ["which acquisition channel retains best?"], "report_id": null },
|
| 140 |
+
"report_ready": { "ready": false, "missing": ["analysis"] },
|
| 141 |
+
"history": [
|
| 142 |
+
{ "role": "human", "content": "which channel brings the most signups?" },
|
| 143 |
+
{ "role": "ai", "content": "Organic search brought the most signups last month (1,240)." }
|
| 144 |
+
],
|
| 145 |
+
"message": null,
|
| 146 |
+
"asserts": [],
|
| 147 |
+
"note": "MANUAL: one question already answered β does help build on it with a NEW follow-up (retention by channel), not re-suggest the answered question? Read output_text."
|
| 148 |
+
}
|
| 149 |
+
]
|
| 150 |
+
}
|
|
@@ -0,0 +1,404 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Help-skill eval runner.
|
| 2 |
+
|
| 3 |
+
Feeds each golden case in `help_dataset.json` to the LIVE Help skill
|
| 4 |
+
(`src/agents/handlers/help.HelpAgent.astream`), then scores whether the streamed
|
| 5 |
+
reply obeys a set of RULE assertions β reply language, never suggesting a report
|
| 6 |
+
when `report_ready.ready=false`, suggesting it when true. Prints a per-case detail
|
| 7 |
+
table + aggregate summary and writes a timestamped JSON report under `results/`
|
| 8 |
+
(never overwritten β one file per run, diffable).
|
| 9 |
+
|
| 10 |
+
Unlike `eval/readiness` (deterministic, no LLM), this calls the model for real, so
|
| 11 |
+
it needs a working `.env` (Azure OpenAI) and spends tokens β run it before a deploy
|
| 12 |
+
that touches `help.md`, not on every commit. `tests/unit/agents/handlers/test_help.py`
|
| 13 |
+
already covers the deterministic Python guard with a fake chain; this is the
|
| 14 |
+
end-to-end "does the model actually obey the prompt" layer on top.
|
| 15 |
+
|
| 16 |
+
Two things the metric separates on purpose:
|
| 17 |
+
- COMPLIANCE = % of rule assertions that hold. NOT accuracy β help replies are free
|
| 18 |
+
prose with no single correct wording; we score rule-obedience, not similarity.
|
| 19 |
+
- HELD-OUT vs CARRIED-OVER β carried_over cases mirror a help.md example (regression);
|
| 20 |
+
held-out cases are absent from the prompt. Held-out compliance is the real
|
| 21 |
+
generalization signal. If held-out drops while carried_over stays 100%, the prompt
|
| 22 |
+
is overfitting to its own examples.
|
| 23 |
+
|
| 24 |
+
`orientation` cases are `manual_review` β run but excluded from the auto compliance
|
| 25 |
+
rate; read their `output_text` in the JSON report to judge suggestion quality.
|
| 26 |
+
|
| 27 |
+
Invoke as a module so `src` imports resolve:
|
| 28 |
+
|
| 29 |
+
uv run python -m eval.help.run_eval
|
| 30 |
+
uv run python -m eval.help.run_eval --limit 4 # smoke test
|
| 31 |
+
uv run python -m eval.help.run_eval --no-table # summary only
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
from __future__ import annotations
|
| 35 |
+
|
| 36 |
+
import argparse
|
| 37 |
+
import asyncio
|
| 38 |
+
import json
|
| 39 |
+
import statistics
|
| 40 |
+
import time
|
| 41 |
+
from dataclasses import asdict, dataclass, field
|
| 42 |
+
from datetime import datetime
|
| 43 |
+
from pathlib import Path
|
| 44 |
+
from typing import Any
|
| 45 |
+
|
| 46 |
+
from langchain_core.callbacks import BaseCallbackHandler
|
| 47 |
+
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage
|
| 48 |
+
from langchain_core.outputs import LLMResult
|
| 49 |
+
|
| 50 |
+
from src.agents.gate import stub_analysis_state
|
| 51 |
+
from src.agents.handlers.help import HelpAgent, ReportReadiness, _detect_reply_language
|
| 52 |
+
from src.agents.report.readiness import _MISSING_ANALYSIS, _MISSING_DELTA
|
| 53 |
+
|
| 54 |
+
_HERE = Path(__file__).resolve().parent
|
| 55 |
+
DATASET = _HERE / "help_dataset.json"
|
| 56 |
+
RESULTS_DIR = _HERE / "results"
|
| 57 |
+
GROUPS = ["language", "report_guard", "orientation"]
|
| 58 |
+
|
| 59 |
+
# Dataset short codes -> the exact `missing` strings is_report_ready emits. Imported
|
| 60 |
+
# from the module so the dataset stays readable and survives wording changes.
|
| 61 |
+
_CODE_TO_MISSING = {
|
| 62 |
+
"analysis": _MISSING_ANALYSIS,
|
| 63 |
+
"delta": _MISSING_DELTA,
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class _UsageCollector(BaseCallbackHandler):
|
| 68 |
+
"""Sums token usage across the LLM calls made during one astream()."""
|
| 69 |
+
|
| 70 |
+
def __init__(self) -> None:
|
| 71 |
+
self.input_tokens = 0
|
| 72 |
+
self.output_tokens = 0
|
| 73 |
+
self.total_tokens = 0
|
| 74 |
+
|
| 75 |
+
def on_llm_end(self, response: LLMResult, **kwargs: Any) -> None:
|
| 76 |
+
before = self.total_tokens
|
| 77 |
+
for generation_list in response.generations:
|
| 78 |
+
for generation in generation_list:
|
| 79 |
+
message = getattr(generation, "message", None)
|
| 80 |
+
usage = getattr(message, "usage_metadata", None) if message else None
|
| 81 |
+
if usage:
|
| 82 |
+
self.input_tokens += usage.get("input_tokens", 0)
|
| 83 |
+
self.output_tokens += usage.get("output_tokens", 0)
|
| 84 |
+
self.total_tokens += usage.get("total_tokens", 0)
|
| 85 |
+
if self.total_tokens == before and response.llm_output:
|
| 86 |
+
usage = response.llm_output.get("token_usage") or {}
|
| 87 |
+
self.input_tokens += usage.get("prompt_tokens", 0)
|
| 88 |
+
self.output_tokens += usage.get("completion_tokens", 0)
|
| 89 |
+
self.total_tokens += usage.get("total_tokens", 0)
|
| 90 |
+
|
| 91 |
+
@property
|
| 92 |
+
def tokens(self) -> dict[str, int]:
|
| 93 |
+
return {
|
| 94 |
+
"input": self.input_tokens,
|
| 95 |
+
"output": self.output_tokens,
|
| 96 |
+
"total": self.total_tokens,
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
# --- assertion checkers -----------------------------------------------------
|
| 101 |
+
# Each returns (passed, detail). `detail` explains a failure in the table/report.
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _check_language_match(output: str, spec: dict[str, Any]) -> tuple[bool, str]:
|
| 105 |
+
got = _detect_reply_language([], message=output)
|
| 106 |
+
return got == spec["expected"], f"want {spec['expected']}, got {got}"
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def _check_must_not_contain_any(output: str, spec: dict[str, Any]) -> tuple[bool, str]:
|
| 110 |
+
low = output.lower()
|
| 111 |
+
hits = [p for p in spec["patterns"] if p.lower() in low]
|
| 112 |
+
return (not hits), (f"found {hits}" if hits else "none present")
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _check_must_contain_any(output: str, spec: dict[str, Any]) -> tuple[bool, str]:
|
| 116 |
+
low = output.lower()
|
| 117 |
+
hits = [p for p in spec["patterns"] if p.lower() in low]
|
| 118 |
+
return bool(hits), (f"found {hits}" if hits else f"none of {spec['patterns']}")
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
_ASSERT_CHECKS = {
|
| 122 |
+
"language_match": _check_language_match,
|
| 123 |
+
"must_not_contain_any": _check_must_not_contain_any,
|
| 124 |
+
"must_contain_any": _check_must_contain_any,
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
@dataclass
|
| 129 |
+
class AssertResult:
|
| 130 |
+
type: str
|
| 131 |
+
passed: bool
|
| 132 |
+
detail: str
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
@dataclass
|
| 136 |
+
class CaseResult:
|
| 137 |
+
id: str
|
| 138 |
+
group: str
|
| 139 |
+
carried_over: bool
|
| 140 |
+
manual_review: bool
|
| 141 |
+
output_text: str
|
| 142 |
+
asserts: list[AssertResult]
|
| 143 |
+
all_passed: bool | None # None when manual_review (not auto-scored)
|
| 144 |
+
latency_ms: float
|
| 145 |
+
tokens: dict[str, int]
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def load_cases(path: Path) -> list[dict[str, Any]]:
|
| 149 |
+
"""Read the `cases` array, skipping the leading `_*` doc keys and `schema`."""
|
| 150 |
+
data = json.loads(path.read_text(encoding="utf-8"))
|
| 151 |
+
return list(data["cases"])
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def _build_state(spec: dict[str, Any]):
|
| 155 |
+
"""Build an AnalysisState from a case's `state` block (defaults from the stub)."""
|
| 156 |
+
return stub_analysis_state().model_copy(
|
| 157 |
+
update={
|
| 158 |
+
"analysis_title": spec.get("analysis_title", "New analysis"),
|
| 159 |
+
"objective": spec.get("objective", ""),
|
| 160 |
+
"business_questions": list(spec.get("business_questions", [])),
|
| 161 |
+
"report_id": spec.get("report_id"),
|
| 162 |
+
}
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def _build_history(rows: list[dict[str, Any]]) -> list[BaseMessage]:
|
| 167 |
+
out: list[BaseMessage] = []
|
| 168 |
+
for row in rows:
|
| 169 |
+
cls = HumanMessage if row["role"] == "human" else AIMessage
|
| 170 |
+
out.append(cls(content=row["content"]))
|
| 171 |
+
return out
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def _build_readiness(spec: dict[str, Any]) -> ReportReadiness:
|
| 175 |
+
return ReportReadiness(
|
| 176 |
+
ready=bool(spec["ready"]),
|
| 177 |
+
missing=[_CODE_TO_MISSING[c] for c in spec.get("missing", [])],
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
async def run_case(case: dict[str, Any]) -> CaseResult:
|
| 182 |
+
"""Stream one Help reply and score its assertions; never throws."""
|
| 183 |
+
state = _build_state(case["state"])
|
| 184 |
+
history = _build_history(case.get("history", []))
|
| 185 |
+
readiness = _build_readiness(case["report_ready"])
|
| 186 |
+
collector = _UsageCollector()
|
| 187 |
+
|
| 188 |
+
agent = HelpAgent() # real Azure chain, constructed lazily on first astream
|
| 189 |
+
start = time.perf_counter()
|
| 190 |
+
try:
|
| 191 |
+
output = "".join(
|
| 192 |
+
[
|
| 193 |
+
token
|
| 194 |
+
async for token in agent.astream(
|
| 195 |
+
state,
|
| 196 |
+
history=history,
|
| 197 |
+
message=case.get("message"),
|
| 198 |
+
report_ready=readiness,
|
| 199 |
+
callbacks=[collector],
|
| 200 |
+
)
|
| 201 |
+
]
|
| 202 |
+
)
|
| 203 |
+
except Exception as exc: # noqa: BLE001 β one bad case shouldn't kill the run
|
| 204 |
+
output = f"ERROR:{type(exc).__name__}: {exc}"
|
| 205 |
+
latency_ms = round((time.perf_counter() - start) * 1000, 1)
|
| 206 |
+
|
| 207 |
+
manual = bool(case.get("manual_review"))
|
| 208 |
+
asserts: list[AssertResult] = []
|
| 209 |
+
if not manual:
|
| 210 |
+
for spec in case.get("asserts", []):
|
| 211 |
+
check = _ASSERT_CHECKS[spec["type"]]
|
| 212 |
+
passed, detail = check(output, spec)
|
| 213 |
+
asserts.append(AssertResult(type=spec["type"], passed=passed, detail=detail))
|
| 214 |
+
all_passed = None if manual else all(a.passed for a in asserts)
|
| 215 |
+
|
| 216 |
+
return CaseResult(
|
| 217 |
+
id=case["id"],
|
| 218 |
+
group=case["group"],
|
| 219 |
+
carried_over=bool(case.get("carried_over")),
|
| 220 |
+
manual_review=manual,
|
| 221 |
+
output_text=output,
|
| 222 |
+
asserts=asserts,
|
| 223 |
+
all_passed=all_passed,
|
| 224 |
+
latency_ms=latency_ms,
|
| 225 |
+
tokens=collector.tokens,
|
| 226 |
+
)
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def _compliance(results: list[CaseResult]) -> dict[str, Any]:
|
| 230 |
+
scored = [r for r in results if r.all_passed is not None]
|
| 231 |
+
passed = sum(1 for r in scored if r.all_passed)
|
| 232 |
+
return {
|
| 233 |
+
"n": len(scored),
|
| 234 |
+
"passed": passed,
|
| 235 |
+
"compliance": round(passed / len(scored), 3) if scored else 0.0,
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def summarize(results: list[CaseResult]) -> dict[str, Any]:
|
| 240 |
+
scored = [r for r in results if r.all_passed is not None]
|
| 241 |
+
latencies = [r.latency_ms for r in results]
|
| 242 |
+
tok_total = sum(r.tokens["total"] for r in results)
|
| 243 |
+
overall = _compliance(results)
|
| 244 |
+
by_group = {
|
| 245 |
+
g: _compliance([r for r in results if r.group == g])
|
| 246 |
+
for g in GROUPS
|
| 247 |
+
if any(r.group == g for r in results)
|
| 248 |
+
}
|
| 249 |
+
return {
|
| 250 |
+
"total": len(results),
|
| 251 |
+
"scored": len(scored),
|
| 252 |
+
"manual_review": len(results) - len(scored),
|
| 253 |
+
"passed": overall["passed"],
|
| 254 |
+
"compliance": overall["compliance"],
|
| 255 |
+
"runtime_avg_ms": round(statistics.mean(latencies), 1) if latencies else 0,
|
| 256 |
+
"tokens_total": tok_total,
|
| 257 |
+
"by_group": by_group,
|
| 258 |
+
"held_out": _compliance([r for r in scored if not r.carried_over]),
|
| 259 |
+
"carried_over": _compliance([r for r in scored if r.carried_over]),
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def _truncate(text: str, width: int) -> str:
|
| 264 |
+
text = text.replace("\n", " ")
|
| 265 |
+
return text if len(text) <= width else text[: width - 3] + "..."
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
def format_table(results: list[CaseResult]) -> str:
|
| 269 |
+
header = (
|
| 270 |
+
f"{'ID':<20} {'GROUP':<13} {'C/O':<4} {'ASSERTS':<22} {'OK':<4} {'MS':>7}"
|
| 271 |
+
)
|
| 272 |
+
rule = "-" * len(header)
|
| 273 |
+
lines = [rule, header, rule]
|
| 274 |
+
for r in results:
|
| 275 |
+
co = "CO" if r.carried_over else "-"
|
| 276 |
+
if r.manual_review:
|
| 277 |
+
atypes, ok = "(manual)", "~"
|
| 278 |
+
else:
|
| 279 |
+
atypes = ",".join(a.type.replace("_", "")[:6] for a in r.asserts) or "-"
|
| 280 |
+
ok = "ok" if r.all_passed else "X"
|
| 281 |
+
lines.append(
|
| 282 |
+
f"{r.id:<20} {r.group:<13} {co:<4} {_truncate(atypes, 22):<22} "
|
| 283 |
+
f"{ok:<4} {r.latency_ms:>7}"
|
| 284 |
+
)
|
| 285 |
+
lines.append(rule)
|
| 286 |
+
return "\n".join(lines)
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def format_summary(summary: dict[str, Any], results: list[CaseResult]) -> str:
|
| 290 |
+
lines = ["SUMMARY"]
|
| 291 |
+
lines.append(
|
| 292 |
+
f" Compliance {summary['passed']}/{summary['scored']} cases obey all rules"
|
| 293 |
+
f" ({summary['compliance'] * 100:.1f}%) avg {summary['runtime_avg_ms']} ms"
|
| 294 |
+
)
|
| 295 |
+
lines.append(
|
| 296 |
+
f" Manual {summary['manual_review']} case(s) excluded from the rate"
|
| 297 |
+
" (read output_text)"
|
| 298 |
+
)
|
| 299 |
+
lines.append("")
|
| 300 |
+
lines.append(" By group")
|
| 301 |
+
for g, m in summary["by_group"].items():
|
| 302 |
+
if m["n"]:
|
| 303 |
+
lines.append(f" {g:<14} {m['passed']}/{m['n']} {m['compliance'] * 100:.0f}%")
|
| 304 |
+
else:
|
| 305 |
+
lines.append(f" {g:<14} (manual only)")
|
| 306 |
+
lines.append("")
|
| 307 |
+
ho, co = summary["held_out"], summary["carried_over"]
|
| 308 |
+
lines.append(" Held-out vs carried-over")
|
| 309 |
+
lines.append(
|
| 310 |
+
f" held_out {ho['passed']}/{ho['n']} "
|
| 311 |
+
f"{ho['compliance'] * 100:.0f}% <- generalization"
|
| 312 |
+
)
|
| 313 |
+
lines.append(
|
| 314 |
+
f" carried_over {co['passed']}/{co['n']} "
|
| 315 |
+
f"{co['compliance'] * 100:.0f}% <- regression"
|
| 316 |
+
)
|
| 317 |
+
failures = [r for r in results if r.all_passed is False]
|
| 318 |
+
lines.append("")
|
| 319 |
+
lines.append(f" FAILURES ({len(failures)})")
|
| 320 |
+
for r in failures:
|
| 321 |
+
bad = [f"{a.type}({a.detail})" for a in r.asserts if not a.passed]
|
| 322 |
+
lines.append(f" {r.id:<20} {r.group:<13} {'; '.join(bad)}")
|
| 323 |
+
return "\n".join(lines)
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
def build_report(
|
| 327 |
+
results: list[CaseResult], summary: dict[str, Any], meta: dict[str, Any]
|
| 328 |
+
) -> dict[str, Any]:
|
| 329 |
+
run = {
|
| 330 |
+
**meta,
|
| 331 |
+
**{
|
| 332 |
+
k: summary[k]
|
| 333 |
+
for k in ("total", "scored", "manual_review", "passed", "compliance",
|
| 334 |
+
"runtime_avg_ms", "tokens_total")
|
| 335 |
+
},
|
| 336 |
+
}
|
| 337 |
+
return {
|
| 338 |
+
"run": run,
|
| 339 |
+
"by_group": summary["by_group"],
|
| 340 |
+
"held_out": summary["held_out"],
|
| 341 |
+
"carried_over": summary["carried_over"],
|
| 342 |
+
"cases": [asdict(r) for r in results],
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
def _model_name() -> str:
|
| 347 |
+
try:
|
| 348 |
+
from src.config.settings import settings
|
| 349 |
+
|
| 350 |
+
return str(settings.azureai_deployment_name_4o)
|
| 351 |
+
except Exception: # noqa: BLE001 β meta only; .env may be absent
|
| 352 |
+
return "gpt-4o"
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
@dataclass
|
| 356 |
+
class _Args:
|
| 357 |
+
dataset: Path = DATASET
|
| 358 |
+
limit: int = 0
|
| 359 |
+
no_table: bool = False
|
| 360 |
+
extra: dict[str, Any] = field(default_factory=dict)
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
async def main() -> None:
|
| 364 |
+
parser = argparse.ArgumentParser(description="Help-skill eval")
|
| 365 |
+
parser.add_argument("--dataset", type=Path, default=DATASET)
|
| 366 |
+
parser.add_argument("--limit", type=int, default=0, help="run first N cases only")
|
| 367 |
+
parser.add_argument("--prompt-version", default="help.md")
|
| 368 |
+
parser.add_argument("--no-table", action="store_true", help="skip the detail table")
|
| 369 |
+
args = parser.parse_args()
|
| 370 |
+
|
| 371 |
+
cases = load_cases(args.dataset)
|
| 372 |
+
if args.limit:
|
| 373 |
+
cases = cases[: args.limit]
|
| 374 |
+
|
| 375 |
+
started = datetime.now()
|
| 376 |
+
print(f"Help Skill Eval -- {started:%Y-%m-%d %H:%M:%S}")
|
| 377 |
+
print(
|
| 378 |
+
f"dataset: {args.dataset.name} ({len(cases)} cases) model: {_model_name()} "
|
| 379 |
+
f"prompt: {args.prompt_version} target: HelpAgent.astream (live)"
|
| 380 |
+
)
|
| 381 |
+
|
| 382 |
+
results = [await run_case(case) for case in cases]
|
| 383 |
+
|
| 384 |
+
summary = summarize(results)
|
| 385 |
+
if not args.no_table:
|
| 386 |
+
print(format_table(results))
|
| 387 |
+
print(format_summary(summary, results))
|
| 388 |
+
|
| 389 |
+
meta = {
|
| 390 |
+
"timestamp": started.isoformat(timespec="seconds"),
|
| 391 |
+
"dataset": args.dataset.name,
|
| 392 |
+
"model": _model_name(),
|
| 393 |
+
"prompt_version": args.prompt_version,
|
| 394 |
+
"target": "src/agents/handlers/help.HelpAgent.astream",
|
| 395 |
+
}
|
| 396 |
+
report = build_report(results, summary, meta)
|
| 397 |
+
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
|
| 398 |
+
out_path = RESULTS_DIR / f"help_result_{started:%Y-%m-%d_%H%M%S}.json"
|
| 399 |
+
out_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
| 400 |
+
print(f"\n-> saved: {out_path.relative_to(_HERE.parent.parent)}")
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
if __name__ == "__main__":
|
| 404 |
+
asyncio.run(main())
|
|
@@ -86,26 +86,43 @@ def _last_human_text(history: list[BaseMessage] | None) -> str:
|
|
| 86 |
return ""
|
| 87 |
|
| 88 |
|
| 89 |
-
def
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
"""Detect the reply language from the last human turn (deterministic, no LLM).
|
| 93 |
-
|
| 94 |
-
Prefers an explicit `message` (intent path β the user's real turn) over the last
|
| 95 |
-
human turn in `history` (button path, where `message` is None). Counts Indonesian vs
|
| 96 |
-
English marker words; ties or no signal fall back to Indonesian (the team default).
|
| 97 |
-
Returns "Indonesian" or "English".
|
| 98 |
-
"""
|
| 99 |
-
text = (message or _last_human_text(history)).lower()
|
| 100 |
-
if not text.strip():
|
| 101 |
-
return _FALLBACK_LANGUAGE
|
| 102 |
-
tokens = re.findall(r"[a-z']+", text)
|
| 103 |
id_hits = sum(1 for t in tokens if t in _ID_MARKERS)
|
| 104 |
en_hits = sum(1 for t in tokens if t in _EN_MARKERS)
|
| 105 |
if en_hits > id_hits:
|
| 106 |
return "English"
|
| 107 |
if id_hits > en_hits:
|
| 108 |
return "Indonesian"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
return _FALLBACK_LANGUAGE
|
| 110 |
|
| 111 |
|
|
@@ -241,7 +258,11 @@ class HelpAgent:
|
|
| 241 |
"""
|
| 242 |
readiness = report_ready or ReportReadiness()
|
| 243 |
actions = available_actions or _derive_available_actions(state, readiness)
|
| 244 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
logger.info(
|
| 246 |
"help guidance",
|
| 247 |
report_ready=readiness.ready,
|
|
@@ -250,7 +271,9 @@ class HelpAgent:
|
|
| 250 |
)
|
| 251 |
|
| 252 |
chain = self._ensure_chain()
|
| 253 |
-
default_trigger = _DEFAULT_TRIGGERS.get(
|
|
|
|
|
|
|
| 254 |
payload: dict[str, Any] = {
|
| 255 |
"message": message or default_trigger,
|
| 256 |
"history": history or [],
|
|
|
|
| 86 |
return ""
|
| 87 |
|
| 88 |
|
| 89 |
+
def _score_language(text: str) -> str | None:
|
| 90 |
+
"""Return "Indonesian"/"English" from marker-word counts, or None if no signal."""
|
| 91 |
+
tokens = re.findall(r"[a-z']+", text.lower())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
id_hits = sum(1 for t in tokens if t in _ID_MARKERS)
|
| 93 |
en_hits = sum(1 for t in tokens if t in _EN_MARKERS)
|
| 94 |
if en_hits > id_hits:
|
| 95 |
return "English"
|
| 96 |
if id_hits > en_hits:
|
| 97 |
return "Indonesian"
|
| 98 |
+
return None
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _detect_reply_language(
|
| 102 |
+
history: list[BaseMessage] | None,
|
| 103 |
+
message: str | None = None,
|
| 104 |
+
goal_texts: list[str] | None = None,
|
| 105 |
+
) -> str:
|
| 106 |
+
"""Detect the reply language deterministically (no LLM), by signal priority.
|
| 107 |
+
|
| 108 |
+
1. the user's turn β an explicit `message` (intent path) or the last human turn in
|
| 109 |
+
`history` (button path, where `message` is None);
|
| 110 |
+
2. the user-authored goal (`objective` + `business_questions`) β required at
|
| 111 |
+
onboarding, so it's always present and is a reliable signal on a fresh analysis
|
| 112 |
+
that has no chat yet;
|
| 113 |
+
3. the team default (Indonesian) β a safety net only, for a stub/legacy/empty-goal
|
| 114 |
+
state where nothing above yields a signal.
|
| 115 |
+
|
| 116 |
+
Returns "Indonesian" or "English".
|
| 117 |
+
"""
|
| 118 |
+
primary = (message or _last_human_text(history)).strip()
|
| 119 |
+
lang = _score_language(primary) if primary else None
|
| 120 |
+
if lang:
|
| 121 |
+
return lang
|
| 122 |
+
goal = " ".join(t for t in (goal_texts or []) if t).strip()
|
| 123 |
+
lang = _score_language(goal) if goal else None
|
| 124 |
+
if lang:
|
| 125 |
+
return lang
|
| 126 |
return _FALLBACK_LANGUAGE
|
| 127 |
|
| 128 |
|
|
|
|
| 258 |
"""
|
| 259 |
readiness = report_ready or ReportReadiness()
|
| 260 |
actions = available_actions or _derive_available_actions(state, readiness)
|
| 261 |
+
goal_texts = [
|
| 262 |
+
getattr(state, "objective", "") or "",
|
| 263 |
+
*(getattr(state, "business_questions", None) or []),
|
| 264 |
+
]
|
| 265 |
+
reply_language = _detect_reply_language(history, message, goal_texts=goal_texts)
|
| 266 |
logger.info(
|
| 267 |
"help guidance",
|
| 268 |
report_ready=readiness.ready,
|
|
|
|
| 271 |
)
|
| 272 |
|
| 273 |
chain = self._ensure_chain()
|
| 274 |
+
default_trigger = _DEFAULT_TRIGGERS.get(
|
| 275 |
+
reply_language, _DEFAULT_TRIGGERS[_FALLBACK_LANGUAGE]
|
| 276 |
+
)
|
| 277 |
payload: dict[str, Any] = {
|
| 278 |
"message": message or default_trigger,
|
| 279 |
"history": history or [],
|
|
@@ -1,8 +1,14 @@
|
|
| 1 |
-
<!-- help.md Β·
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
You are the **Help guide** for an AI data-analysis assistant. Think of yourself as the
|
| 8 |
instruction sheet that comes with a board game: your only job is to tell the user
|
|
@@ -92,15 +98,21 @@ spam, no overselling. A few sentences is usually enough.
|
|
| 92 |
## Examples
|
| 93 |
|
| 94 |
```
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
| 96 |
β "Your goal is set β you can start exploring now. Try a basic question first, like
|
| 97 |
'Which products sell the most?' or 'How have monthly sales trended?', then we can dig into
|
| 98 |
what's driving your objective."
|
| 99 |
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
|
|
|
|
|
|
| 103 |
|
|
|
|
| 104 |
State: report_ready.ready=true
|
| 105 |
β "You've covered enough to summarize. You can generate your report now β run /report
|
| 106 |
or use the report option to create it."
|
|
|
|
| 1 |
+
<!-- help.md Β· v3 Β· Help skill prompt.
|
| 2 |
+
v2 (2026-06-24, KM-652): removed the problem_statement skill + the problem_validated gate β
|
| 3 |
+
the goal (objective + business_questions) is now set in the New Analysis form at onboarding,
|
| 4 |
+
so Help no longer steers users to define/validate a goal in chat.
|
| 5 |
+
v3 (2026-07-02): (a) reply language is now a hard rule driven by the [Reply language]
|
| 6 |
+
directive (the button path was defaulting to English); (b) Examples got stable ids
|
| 7 |
+
("id: ..." comment above each) so eval/help can mirror them as carried_over regression
|
| 8 |
+
cases, and the second example now uses a REAL `missing` value from report/readiness.py β
|
| 9 |
+
the old "no comparison over time" string is never emitted by is_report_ready.
|
| 10 |
+
Bump to v4 (don't silently overwrite) on the next major change (e.g. real UI steps from
|
| 11 |
+
the frontend). -->
|
| 12 |
|
| 13 |
You are the **Help guide** for an AI data-analysis assistant. Think of yourself as the
|
| 14 |
instruction sheet that comes with a board game: your only job is to tell the user
|
|
|
|
| 98 |
## Examples
|
| 99 |
|
| 100 |
```
|
| 101 |
+
<!-- id: help_ex_orient -->
|
| 102 |
+
State: objective="understand monthly sales performance",
|
| 103 |
+
business_questions=["which products drive revenue?"],
|
| 104 |
+
chat_history empty, report_ready.ready=false, missing=["at least one completed analysis"]
|
| 105 |
β "Your goal is set β you can start exploring now. Try a basic question first, like
|
| 106 |
'Which products sell the most?' or 'How have monthly sales trended?', then we can dig into
|
| 107 |
what's driving your objective."
|
| 108 |
|
| 109 |
+
<!-- id: help_ex_guard_delta -->
|
| 110 |
+
State: report_ready.ready=false, missing=["a new analysis since the last report"]
|
| 111 |
+
β "You already have a report, and nothing new has come in since. Ask something that builds
|
| 112 |
+
on your objective β a fresh cut, a new time period, or a different angle β and we can
|
| 113 |
+
regenerate the report with that."
|
| 114 |
|
| 115 |
+
<!-- id: help_ex_guard_ready -->
|
| 116 |
State: report_ready.ready=true
|
| 117 |
β "You've covered enough to summarize. You can generate your report now β run /report
|
| 118 |
or use the report option to create it."
|