Why: I2 acceptance - the evaluator must emit metrics over a multiturn fixture no smaller than the existing corpus, not just print branch names. Evidence: go vet clean; full suite ok; intentctl eval prints precision=1.000 recall=1.000 (TP=12 FP=0 FN=0) over the 11-case multiturn fixture; MetricsOf unit test pins TP/FP/FN counting including wrong-match counting as both. Impact: CorpusMetrics + MetricsOf (expectation_hit counts as a hit); intentctl eval prints the metrics line after the summary; corpus_multiturn.json expanded to 11 cases (chained expectation, tie-break, ambiguous, fallback, handler action, clarify paths). Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md I2
128 lines
3.9 KiB
Go
128 lines
3.9 KiB
Go
package intent
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"os"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// TestEvalMultiTurnCorpus 是 I1 验收载体:v2 多轮语料含「SetSlot 加 Start」
|
||
// 用例并通过(链式预期把第二轮转进 expectation_hit)。
|
||
func TestEvalMultiTurnCorpus(t *testing.T) {
|
||
data, err := os.ReadFile("testdata/corpus_multiturn.json")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var doc struct {
|
||
Cases []CorpusCase `json:"cases"`
|
||
}
|
||
if err := json.Unmarshal(data, &doc); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(doc.Cases) == 0 {
|
||
t.Fatal("empty corpus")
|
||
}
|
||
_, snap := buildDemoRegistry(t)
|
||
for _, tc := range doc.Cases {
|
||
result := RunCorpusCase(context.Background(), snap, tc)
|
||
if !result.Passed() {
|
||
for _, tr := range result.Turns {
|
||
for _, mm := range tr.Mismatches {
|
||
t.Errorf("case %s turn %d (%q): %s", tc.Name, tr.Index, tr.Input, mm)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestCorpusV1WrapsAsSingleTurn:v1 单轮形态经同一执行器等价运行(兼容承诺)。
|
||
func TestCorpusV1WrapsAsSingleTurn(t *testing.T) {
|
||
_, snap := buildDemoRegistry(t)
|
||
tc := CorpusCase{Name: "v1", Input: "你好呀", ExpectBranch: "matched", ExpectTop: "demo.greet.hello"}
|
||
result := RunCorpusCase(context.Background(), snap, tc)
|
||
if !result.Passed() {
|
||
t.Fatalf("v1 wrap: %+v", result.Turns)
|
||
}
|
||
if len(result.Turns) != 1 || result.Turns[0].Branch != "matched" {
|
||
t.Fatalf("turns = %+v", result.Turns)
|
||
}
|
||
}
|
||
|
||
// TestMatchCommandsFindsMismatches:命令断言的差异定位(kind/action/slot/name)。
|
||
func TestMatchCommandsFindsMismatches(t *testing.T) {
|
||
got := []Command{
|
||
{Kind: CommandStart, Action: ActionRef{Kind: ActionHandler, ID: "h"}, Slots: map[string]string{"topic": "电机"}},
|
||
}
|
||
mm := matchCommands([]CommandExpect{
|
||
{Kind: "start", ActionKind: ActionHandler, ActionID: "other"},
|
||
}, got)
|
||
if len(mm) != 1 || !strings.Contains(mm[0], "action.id") {
|
||
t.Fatalf("mm = %v", mm)
|
||
}
|
||
mm = matchCommands([]CommandExpect{
|
||
{Kind: "start", Slots: map[string]string{"topic": "水泵"}},
|
||
}, got)
|
||
if len(mm) != 1 || !strings.Contains(mm[0], "slots") {
|
||
t.Fatalf("mm = %v", mm)
|
||
}
|
||
mm = matchCommands([]CommandExpect{
|
||
{Kind: "start", ActionKind: ActionHandler, ActionID: "h", Slots: map[string]string{"topic": "电机"}},
|
||
}, got)
|
||
if len(mm) != 0 {
|
||
t.Fatalf("mm = %v", mm)
|
||
}
|
||
if mm := matchCommands([]CommandExpect{{Kind: "start"}, {Kind: "clarify"}}, got); len(mm) != 1 {
|
||
t.Fatalf("count mismatch = %v", mm)
|
||
}
|
||
}
|
||
|
||
// TestMetricsOfCounts:指标按意图标签计 TP/FP/FN(I2 验收的指标形态)。
|
||
func TestMetricsOfCounts(t *testing.T) {
|
||
_, snap := buildDemoRegistry(t)
|
||
data, err := os.ReadFile("testdata/corpus_multiturn.json")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var doc struct {
|
||
Cases []CorpusCase `json:"cases"`
|
||
}
|
||
if err := json.Unmarshal(data, &doc); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var results []CaseResult
|
||
for _, tc := range doc.Cases {
|
||
results = append(results, RunCorpusCase(context.Background(), snap, tc))
|
||
}
|
||
m := MetricsOf(doc.Cases, results)
|
||
// 通过的夹具:指标应当全 TP(precision=recall=1)。
|
||
if m.FP != 0 || m.FN != 0 {
|
||
t.Fatalf("metrics = %+v", m)
|
||
}
|
||
p, pok := m.Precision()
|
||
r, rok := m.Recall()
|
||
if !pok || !rok || p != 1.0 || r != 1.0 {
|
||
t.Fatalf("precision=%v recall=%v (m=%+v)", p, r, m)
|
||
}
|
||
if m.TP < 8 {
|
||
t.Fatalf("TP = %d, want >= 8(夹具内带 top 期望的轮次)", m.TP)
|
||
}
|
||
|
||
// 手工构造一个 FP+FN 用例验证计数方向。
|
||
bad := []CorpusCase{{Name: "bad", Turns: []CorpusTurn{
|
||
{Input: "查询任务 42 的进度", ExpectBranch: "matched", ExpectTop: "demo.greet.hello"},
|
||
}}}
|
||
got := []CaseResult{RunCorpusCase(context.Background(), snap, bad[0])}
|
||
m2 := MetricsOf(bad, got)
|
||
// 实际 matched status_query 但期望 greet.hello → 1 FP + 1 FN。
|
||
if m2.TP != 0 || m2.FP != 1 || m2.FN != 1 {
|
||
t.Fatalf("bad metrics = %+v", m2)
|
||
}
|
||
p2, _ := m2.Precision()
|
||
r2, _ := m2.Recall()
|
||
if p2 != 0 || r2 != 0 {
|
||
t.Fatalf("p=%v r=%v, want 0/0", p2, r2)
|
||
}
|
||
}
|