[NOTICKET] eval(intent): follow-up cases with chat history
Browse files- run_eval.py sent every case without history, so follow-up routing was never tested
- New case_history(): an optional `history` (user/ai turns, oldest first) goes to the
router like the app's chat history; cases without it run as before
- 7 followup_* rows: analysis follow-ups -> structured_flow, "detailnya?" after a
document answer -> unstructured_flow, column/source questions stay check
- followup_01 uses the real chat-1 history before Test demo Q2 (a short made-up
history did not reproduce the miss); followup_02 is "sitenya mana aja?"
- Old router prompt 61/64 (both fail), new prompt 63/64; unstructured_04 predates this
- Dataset schema/_next_layer and README note the new `history` field
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- eval/intent/README.md +4 -0
- eval/intent/intent_dataset.json +90 -2
- eval/intent/run_eval.py +18 -1
|
@@ -45,6 +45,10 @@ eval runs fully without Langfuse configured.
|
|
| 45 |
needed group plus extras — safe, the planner still sees what it needs), **absent**
|
| 46 |
(fail-open — safe), **wrong** (a needed group missing — the planner cannot reach the tool).
|
| 47 |
`stray` counts hints on non-structured intents.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
### Content-filter blocks count as `out_of_scope` passes
|
| 50 |
|
|
|
|
| 45 |
needed group plus extras — safe, the planner still sees what it needs), **absent**
|
| 46 |
(fail-open — safe), **wrong** (a needed group missing — the planner cannot reach the tool).
|
| 47 |
`stray` counts hints on non-structured intents.
|
| 48 |
+
- **follow-ups** (added 2026-11-03) — a case may carry `history` (earlier `user`/`ai` turns,
|
| 49 |
+
oldest first); the runner sends it to the router as the app's chat history. The
|
| 50 |
+
`followup_*` rows check that a short follow-up ("unitnya yg mana aja?", "detailnya?")
|
| 51 |
+
takes the route of the answer it follows instead of reading as an inventory question.
|
| 52 |
|
| 53 |
### Content-filter blocks count as `out_of_scope` passes
|
| 54 |
|
|
@@ -1,14 +1,15 @@
|
|
| 1 |
{
|
| 2 |
"_about": "Golden intent dataset for the reworked 6-intent router (E3 eval — runs against the live LLM, not the mocked unit tests). Each of the 6 intents has 6-7 DISTINCT scenarios (not EN/ID translation pairs of one scenario) balanced across English + Indonesian, since users code-switch (technical terms often stay English: revenue, churn, upload, base_price). `carried_over` rows mirror the old intent_router.md examples for regression; the rest are new. `id` is a stable per-case handle so timestamped run files (eval_result_<ts>.json) can be diffed case-by-case across runs — it is NOT the run timestamp (that lives in the filename). `lang` enables per-language correctness scoring (aggregate, not matched-pairs).",
|
| 3 |
"_grounding": "structured_flow + structured `check` rows are grounded in Sofhia's real test files: online_vs_offline_learning_dataset.csv (cols: Learning_Mode, Subject, Study_Hours, Retention_Score, Focus_Level, Exam_Score) and xl_knowledge_large.xlsx (telco product catalog: product_name, category, base_price, is_active, region_restriction...). unstructured_flow + document `check` rows are grounded in the IoT connectivity indicative-price PDF and the Internet of Things DOCX. IMPORTANT: routing labels are decided from the question PHRASING, not from which file holds the answer (the router has no catalog access) — so structured rows are clearly analytical (avg/correlation/count) and unstructured rows are clearly explanatory (jelaskan/summarize/features), with no price-lookup collisions. Rifqi's DB-grounded rows can be merged into this same file for variety; owner: Rifqi + Sofhia.",
|
| 4 |
-
"_next_layer": "Not in this seed: (1) deliberate near-boundary cases (chat-vs-help, check-vs-structured); (2) follow-up/contextual handling — decide whether follow-ups route to `chat` or are out of scope.",
|
| 5 |
"schema": {
|
| 6 |
"id": "stable per-case handle, <intent>_<NN>",
|
| 7 |
"message": "the user utterance fed to the router",
|
| 8 |
"expected_intent": "one of: chat | help | check | unstructured_flow | structured_flow | out_of_scope",
|
| 9 |
"lang": "en | id",
|
| 10 |
"carried_over": "true if mirrored from the pre-rework intent_router.md examples",
|
| 11 |
-
"expected_group_hint": "structured_flow only: list of ACCEPTABLE group sets (e.g. [[\"evaluate\"], [\"explore\"]] when a question scopes two valid ways). Absent on every other intent = expects no hint. Scored separately from intent: exact | absent (fail-open, safe) | wrong (added 2026-09-23)"
|
|
|
|
| 12 |
},
|
| 13 |
"cases": [
|
| 14 |
{
|
|
@@ -424,6 +425,93 @@
|
|
| 424 |
"expected_group_hint": [["timeseries"], ["explore"]],
|
| 425 |
"lang": "en",
|
| 426 |
"carried_over": false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 427 |
}
|
| 428 |
]
|
| 429 |
}
|
|
|
|
| 1 |
{
|
| 2 |
"_about": "Golden intent dataset for the reworked 6-intent router (E3 eval — runs against the live LLM, not the mocked unit tests). Each of the 6 intents has 6-7 DISTINCT scenarios (not EN/ID translation pairs of one scenario) balanced across English + Indonesian, since users code-switch (technical terms often stay English: revenue, churn, upload, base_price). `carried_over` rows mirror the old intent_router.md examples for regression; the rest are new. `id` is a stable per-case handle so timestamped run files (eval_result_<ts>.json) can be diffed case-by-case across runs — it is NOT the run timestamp (that lives in the filename). `lang` enables per-language correctness scoring (aggregate, not matched-pairs).",
|
| 3 |
"_grounding": "structured_flow + structured `check` rows are grounded in Sofhia's real test files: online_vs_offline_learning_dataset.csv (cols: Learning_Mode, Subject, Study_Hours, Retention_Score, Focus_Level, Exam_Score) and xl_knowledge_large.xlsx (telco product catalog: product_name, category, base_price, is_active, region_restriction...). unstructured_flow + document `check` rows are grounded in the IoT connectivity indicative-price PDF and the Internet of Things DOCX. IMPORTANT: routing labels are decided from the question PHRASING, not from which file holds the answer (the router has no catalog access) — so structured rows are clearly analytical (avg/correlation/count) and unstructured rows are clearly explanatory (jelaskan/summarize/features), with no price-lookup collisions. Rifqi's DB-grounded rows can be merged into this same file for variety; owner: Rifqi + Sofhia.",
|
| 4 |
+
"_next_layer": "Not in this seed: (1) deliberate near-boundary cases (chat-vs-help, check-vs-structured); (2) follow-up/contextual handling — decide whether follow-ups route to `chat` or are out of scope. UPDATE 2026-11-03: (2) is now covered by the `followup_*` rows — a short follow-up takes the route of the answer it follows (Test demo Q2).",
|
| 5 |
"schema": {
|
| 6 |
"id": "stable per-case handle, <intent>_<NN>",
|
| 7 |
"message": "the user utterance fed to the router",
|
| 8 |
"expected_intent": "one of: chat | help | check | unstructured_flow | structured_flow | out_of_scope",
|
| 9 |
"lang": "en | id",
|
| 10 |
"carried_over": "true if mirrored from the pre-rework intent_router.md examples",
|
| 11 |
+
"expected_group_hint": "structured_flow only: list of ACCEPTABLE group sets (e.g. [[\"evaluate\"], [\"explore\"]] when a question scopes two valid ways). Absent on every other intent = expects no hint. Scored separately from intent: exact | absent (fail-open, safe) | wrong (added 2026-09-23)",
|
| 12 |
+
"history": "optional: earlier turns, oldest first, as [{\"role\": \"user\" | \"ai\", \"content\": ...}] — sent to the router like the app's chat history (added 2026-11-03)"
|
| 13 |
},
|
| 14 |
"cases": [
|
| 15 |
{
|
|
|
|
| 425 |
"expected_group_hint": [["timeseries"], ["explore"]],
|
| 426 |
"lang": "en",
|
| 427 |
"carried_over": false
|
| 428 |
+
},
|
| 429 |
+
{
|
| 430 |
+
"id": "followup_01",
|
| 431 |
+
"message": "unitnya yg mana aja?",
|
| 432 |
+
"expected_intent": "structured_flow",
|
| 433 |
+
"expected_group_hint": [["explore"]],
|
| 434 |
+
"lang": "id",
|
| 435 |
+
"carried_over": false,
|
| 436 |
+
"history": [
|
| 437 |
+
{"role": "user", "content": "datanya lengkap ga? ada yg kosong atau aneh?"},
|
| 438 |
+
{"role": "ai", "content": "Ya, datanya **lengkap untuk kolom yang diperiksa**: 1.080 baris, 17 kolom, **tanpa nilai kosong** dan **tanpa duplikasi baris**. Namun ada **nilai anomali** pada sebagian baris.\n\n| Ringkasan | Hasil |\n|---|---:|\n| Baris | 1.080 |\n| Kolom | 17 |\n| Nilai kosong | 0 |\n| Duplikasi baris | 0 |\n| Baris anomali | 127 |\n| Tingkat anomali | 11,759% |\n\n**Temuan utama**\n- Kolom `site_code` dan `section` konstan: masing-masing hanya berisi `ADT` dan `OB HAULER`.\n- Kolom numerik yang diperiksa semuanya terisi, tetapi beberapa memiliki nilai ekstrem; misalnya `pa` memiliki 68 outlier, `mttr` 80, `mtbs` 55, `mtbf` 73, `breakdown_duration_sm` 72, dan `breakdown_duration_usm` 70.\n- Dari 1.080 baris, 127 terdeteksi sebagai anomali.\n\n**Catatan**\n- Karena cakupan yang terlihat hanya untuk `ADT · OB HAULER · 2026-06 s.d. 2026-09`, kesimpulan ini berlaku untuk rentang tersebut saja.\n- `pa_target` juga bervariasi (87, 88,15, dan 90), jadi target tidak sepenuhnya seragam di seluruh baris.\n\n_Cakupan: section OB HAULER · site_code ADT · year 2026 · month 6–9_"},
|
| 439 |
+
{"role": "user", "content": "gambaran umum PA sama MTBS OB HAULER ADT gimana?"},
|
| 440 |
+
{"role": "ai", "content": "Ringkasan PA dan MTBS untuk OB HAULER di site ADT menunjukkan PA rata-rata **88,537%** dengan median **91,05%**, sedangkan MTBS rata-rata **43,379** dengan median **35,1**.\n\n| Metrik | Jumlah data | Rata-rata | Median | Modus | Simpangan baku | Q1 | Q3 | Minimum | Maksimum |\n|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|\n| PA | 2.379 | 88,537% | 91,05% | 92,5% | 9,913 | 85,085% | 95,13% | 11,5% | 100,0% |\n| MTBS | 2.379 | 43,379 | 35,1 | 21,7 | 30,525 | 24,633 | 52,72 | 0,9 | 307,15 |\n\nSebaran PA cenderung tinggi dan miring ke kiri, sementara MTBS lebih menyebar dan miring ke kanan. Ada **1 nilai MTBS kosong** dari 2.379 baris.\n\n_Cakupan: section OB HAULER · site_code ADT_"},
|
| 441 |
+
{"role": "user", "content": "model apa yg PA nya paling jelek?"},
|
| 442 |
+
{"role": "ai", "content": "Model dengan PA terendah di OB HAULER ADT selama Juni–September 2026 adalah **HD465-7** dengan **76,129%**.\n\n| Model | Avg PA | n_periods | last_month |\n|---|---:|---:|---:|\n| HD465-7 | 76,129% | 19 | 9 |\n| 777E | 86,048% | 609 | 9 |\n| HD785-7 | 88,853% | 452 | 9 |\n\n_Cakupan: section OB HAULER · site_code ADT · year 2026 · month 6–9_"},
|
| 443 |
+
{"role": "user", "content": "buatin grafiknya"},
|
| 444 |
+
{"role": "ai", "content": "Berikut ringkasan PA per model untuk **OB HAULER** di **ADT** selama **Juni–September 2026**. Model dengan PA terendah adalah **HD465-7** dengan **76,129%**; dua model lainnya lebih tinggi, yaitu **777E** (**86,048%**) dan **HD785-7** (**88,853%**). Grafik batang di bawah jawaban sudah menyorot model dengan PA terendah.\n\n| Model | PA rata-rata |\n|---|---:|\n| HD465-7 | 76,129% |\n| 777E | 86,048% |\n| HD785-7 | 88,853% |\n\n_Cakupan: section OB HAULER · site_code ADT · year 2026 · month 6–9_"}
|
| 445 |
+
]
|
| 446 |
+
},
|
| 447 |
+
{
|
| 448 |
+
"id": "followup_02",
|
| 449 |
+
"message": "sitenya mana aja?",
|
| 450 |
+
"expected_intent": "structured_flow",
|
| 451 |
+
"expected_group_hint": [["explore"]],
|
| 452 |
+
"lang": "id",
|
| 453 |
+
"carried_over": false,
|
| 454 |
+
"history": [
|
| 455 |
+
{"role": "user", "content": "model apa yg PA nya paling jelek?"},
|
| 456 |
+
{"role": "ai", "content": "Model dengan PA terendah: HD465-7 76,13%, 777E 86,05%, HD785-7 88,85%."}
|
| 457 |
+
]
|
| 458 |
+
},
|
| 459 |
+
{
|
| 460 |
+
"id": "followup_03",
|
| 461 |
+
"message": "subject-nya yang mana aja?",
|
| 462 |
+
"expected_intent": "structured_flow",
|
| 463 |
+
"expected_group_hint": [["explore"]],
|
| 464 |
+
"lang": "id",
|
| 465 |
+
"carried_over": false,
|
| 466 |
+
"history": [
|
| 467 |
+
{"role": "user", "content": "rata-rata Exam_Score per Learning_Mode berapa?"},
|
| 468 |
+
{"role": "ai", "content": "Rata-rata Exam_Score: Offline 74,8, Online 69,3."}
|
| 469 |
+
]
|
| 470 |
+
},
|
| 471 |
+
{
|
| 472 |
+
"id": "followup_04",
|
| 473 |
+
"message": "which products?",
|
| 474 |
+
"expected_intent": "structured_flow",
|
| 475 |
+
"expected_group_hint": [["explore"]],
|
| 476 |
+
"lang": "en",
|
| 477 |
+
"carried_over": false,
|
| 478 |
+
"history": [
|
| 479 |
+
{"role": "user", "content": "Which category has the highest average base_price?"},
|
| 480 |
+
{"role": "ai", "content": "Enterprise has the highest average base_price (Rp 2.4 M), then Business (Rp 1.1 M)."}
|
| 481 |
+
]
|
| 482 |
+
},
|
| 483 |
+
{
|
| 484 |
+
"id": "followup_05",
|
| 485 |
+
"message": "detailnya?",
|
| 486 |
+
"expected_intent": "unstructured_flow",
|
| 487 |
+
"lang": "id",
|
| 488 |
+
"carried_over": false,
|
| 489 |
+
"history": [
|
| 490 |
+
{"role": "user", "content": "apa key feature dari iot connectivity?"},
|
| 491 |
+
{"role": "ai", "content": "Menurut dokumen IoT: konektivitas NB-IoT dan LTE-M, manajemen SIM terpusat, dan pemantauan perangkat real-time."}
|
| 492 |
+
]
|
| 493 |
+
},
|
| 494 |
+
{
|
| 495 |
+
"id": "followup_06",
|
| 496 |
+
"message": "kolomnya apa aja?",
|
| 497 |
+
"expected_intent": "check",
|
| 498 |
+
"lang": "id",
|
| 499 |
+
"carried_over": false,
|
| 500 |
+
"history": [
|
| 501 |
+
{"role": "user", "content": "data apa aja yang aku punya?"},
|
| 502 |
+
{"role": "ai", "content": "Kamu punya 2 sumber data: online_vs_offline_learning_dataset.csv dan xl_knowledge_large.xlsx."}
|
| 503 |
+
]
|
| 504 |
+
},
|
| 505 |
+
{
|
| 506 |
+
"id": "followup_07",
|
| 507 |
+
"message": "What data sources do I have?",
|
| 508 |
+
"expected_intent": "check",
|
| 509 |
+
"lang": "en",
|
| 510 |
+
"carried_over": false,
|
| 511 |
+
"history": [
|
| 512 |
+
{"role": "user", "content": "Which category has the highest average base_price?"},
|
| 513 |
+
{"role": "ai", "content": "Enterprise has the highest average base_price (Rp 2.4 M), then Business (Rp 1.1 M)."}
|
| 514 |
+
]
|
| 515 |
}
|
| 516 |
]
|
| 517 |
}
|
|
@@ -32,6 +32,7 @@ from pathlib import Path
|
|
| 32 |
from typing import Any
|
| 33 |
|
| 34 |
from langchain_core.callbacks import BaseCallbackHandler
|
|
|
|
| 35 |
from langchain_core.outputs import LLMResult
|
| 36 |
|
| 37 |
from src.agents.orchestration import OrchestratorAgent
|
|
@@ -150,6 +151,20 @@ def load_cases(path: Path) -> list[dict[str, Any]]:
|
|
| 150 |
return list(data["cases"])
|
| 151 |
|
| 152 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
@dataclass
|
| 154 |
class _LangfuseCtx:
|
| 155 |
"""Optional Langfuse sink — one session groups all cases of a run."""
|
|
@@ -220,7 +235,9 @@ async def run_case(
|
|
| 220 |
correct: bool
|
| 221 |
hint_got: list[str] | None = None
|
| 222 |
try:
|
| 223 |
-
decision = await agent.classify(
|
|
|
|
|
|
|
| 224 |
got = decision.intent
|
| 225 |
correct = got == expected
|
| 226 |
hint_got = list(decision.group_hint) if decision.group_hint else None
|
|
|
|
| 32 |
from typing import Any
|
| 33 |
|
| 34 |
from langchain_core.callbacks import BaseCallbackHandler
|
| 35 |
+
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage
|
| 36 |
from langchain_core.outputs import LLMResult
|
| 37 |
|
| 38 |
from src.agents.orchestration import OrchestratorAgent
|
|
|
|
| 151 |
return list(data["cases"])
|
| 152 |
|
| 153 |
|
| 154 |
+
def case_history(case: dict[str, Any]) -> list[BaseMessage]:
|
| 155 |
+
"""The case's optional `history` (oldest first) as the messages the app sends.
|
| 156 |
+
|
| 157 |
+
Mirrors `load_history`: `user` turns become HumanMessage, `ai` turns AIMessage.
|
| 158 |
+
Cases without `history` classify the message alone, as before.
|
| 159 |
+
"""
|
| 160 |
+
return [
|
| 161 |
+
HumanMessage(content=turn["content"])
|
| 162 |
+
if turn["role"] == "user"
|
| 163 |
+
else AIMessage(content=turn["content"])
|
| 164 |
+
for turn in case.get("history", [])
|
| 165 |
+
]
|
| 166 |
+
|
| 167 |
+
|
| 168 |
@dataclass
|
| 169 |
class _LangfuseCtx:
|
| 170 |
"""Optional Langfuse sink — one session groups all cases of a run."""
|
|
|
|
| 235 |
correct: bool
|
| 236 |
hint_got: list[str] | None = None
|
| 237 |
try:
|
| 238 |
+
decision = await agent.classify(
|
| 239 |
+
case["message"], case_history(case), callbacks=callbacks
|
| 240 |
+
)
|
| 241 |
got = decision.intent
|
| 242 |
correct = got == expected
|
| 243 |
hint_got = list(decision.group_hint) if decision.group_hint else None
|