sofhiaazzhr Claude Opus 4.8 commited on
Commit
cbdbefb
Β·
1 Parent(s): 781b93c

[NOTICKET] feat(eval): add help-skill eval harness + goal-based language fallback

Browse files

Live-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 ADDED
@@ -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.
eval/help/__init__.py ADDED
File without changes
eval/help/help_dataset.json ADDED
@@ -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
+ }
eval/help/run_eval.py ADDED
@@ -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())
src/agents/handlers/help.py CHANGED
@@ -86,26 +86,43 @@ def _last_human_text(history: list[BaseMessage] | None) -> str:
86
  return ""
87
 
88
 
89
- def _detect_reply_language(
90
- history: list[BaseMessage] | None, message: str | None = None
91
- ) -> str:
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
- reply_language = _detect_reply_language(history, message)
 
 
 
 
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(reply_language, _DEFAULT_TRIGGERS[_FALLBACK_LANGUAGE])
 
 
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 [],
src/config/prompts/help.md CHANGED
@@ -1,8 +1,14 @@
1
- <!-- help.md Β· v2 Β· Help skill prompt. v2 (2026-06-24, KM-652): removed the problem_statement
2
- skill + the problem_validated gate β€” the goal (objective + business_questions) is now set
3
- in the New Analysis form at onboarding, so Help no longer steers users to define/validate a
4
- goal in chat. Bump to v3 (don't silently overwrite) on the next major change (e.g. real UI
5
- steps from the frontend). -->
 
 
 
 
 
 
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
- State: chat_history nearly empty
 
 
 
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
- State: report_ready.ready=false, missing=["no comparison over time"]
101
- β†’ "Good progress. Before a report, it's worth looking at change over time β€” try asking
102
- 'How does this quarter compare to last?' Once we have that, we can put the report together."
 
 
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."