Why: I1 WP6 - eval needs per-turn command expectations to carry the SetSlot+Start acceptance case, and corpora must reproduce standalone in intentctl (the guard case has been red there since before I1 because the guard spec only existed as a test builtin). Evidence: go vet clean; go test ./... all ok; intentctl eval passes 11/11 on the v1 corpus (guard case fixed via self-declaration) and 1/1 on the new two-turn SetSlot+Start corpus. Impact: new intent/corpus.go (CorpusCase/CorpusTurn/CommandExpect, RunCorpusCase with chained expectations); intentctl eval consumes turns and corpus-declared builtin_specs and domains; report.generate spec and corpus_multiturn.json fixtures added. v1 corpus format and conformance unchanged. Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md I1 WP6
183 lines
5.9 KiB
Go
183 lines
5.9 KiB
Go
package intent
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
)
|
|
|
|
// CorpusCase 是评测用例:v1 单轮(input/expect_*)与 v2 多轮(turns)共用。
|
|
// v1 字段在 Turns 为空时生效,并自动等价于一个单 turn 用例(不含命令断言)。
|
|
type CorpusCase struct {
|
|
Name string `json:"name"`
|
|
Input string `json:"input,omitempty"`
|
|
ExpectBranch string `json:"expect_branch,omitempty"`
|
|
ExpectTop string `json:"expect_top,omitempty"`
|
|
ExpectSlots map[string]string `json:"expect_slots,omitempty"`
|
|
ExpectMissingSlots []string `json:"expect_missing_slots,omitempty"`
|
|
Turns []CorpusTurn `json:"turns,omitempty"`
|
|
}
|
|
|
|
// CorpusTurn 是多轮用例中的一轮:输入与逐轮期望(分支/top/命令列表)。
|
|
type CorpusTurn struct {
|
|
Input string `json:"input"`
|
|
ExpectBranch string `json:"expect_branch,omitempty"`
|
|
ExpectTop string `json:"expect_top,omitempty"`
|
|
ExpectCommands []CommandExpect `json:"expect_commands,omitempty"`
|
|
}
|
|
|
|
// CommandExpect 是对一条命令的期望;只有写出的字段参与断言。
|
|
type CommandExpect struct {
|
|
Kind string `json:"kind,omitempty"`
|
|
ActionKind string `json:"action_kind,omitempty"`
|
|
ActionID string `json:"action_id,omitempty"`
|
|
Slots map[string]string `json:"slots,omitempty"`
|
|
Name string `json:"name,omitempty"`
|
|
Value string `json:"value,omitempty"`
|
|
Missing []string `json:"missing,omitempty"`
|
|
Question string `json:"question,omitempty"`
|
|
Candidates []string `json:"candidates,omitempty"`
|
|
}
|
|
|
|
// TurnResult 一轮的实际结果与差异。
|
|
type TurnResult struct {
|
|
Index int
|
|
Input string
|
|
Branch string
|
|
TopID string
|
|
Commands []Command
|
|
Mismatches []string
|
|
}
|
|
|
|
// CaseResult 一个用例的全部轮次结果。Passed = 所有轮次无差异。
|
|
type CaseResult struct {
|
|
Name string
|
|
Turns []TurnResult
|
|
}
|
|
|
|
// Passed 报告用例是否全部轮次通过。
|
|
func (c CaseResult) Passed() bool {
|
|
for _, t := range c.Turns {
|
|
if len(t.Mismatches) > 0 {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// chainedExpectation 模拟宿主的挂起预期链:上一轮 Top 有必填缺失或非法
|
|
// 槽位时,下一轮挂起该意图(与产品「带槽启动后追问」的行为对应)。
|
|
type chainedExpectation struct {
|
|
next *PendingExpectation
|
|
}
|
|
|
|
func (c *chainedExpectation) Pending(ctx context.Context) *PendingExpectation {
|
|
return c.next
|
|
}
|
|
|
|
// RunCorpusCase 执行一个用例的全部轮次(v1 用例自动包装为单轮)。
|
|
// 识别器按轮次构建在链式预期上,保证 SetSlot+Start 这类组合可在评测中到达。
|
|
func RunCorpusCase(ctx context.Context, snap *Snapshot, tc CorpusCase) CaseResult {
|
|
turns := tc.Turns
|
|
if len(turns) == 0 {
|
|
turns = []CorpusTurn{{Input: tc.Input, ExpectBranch: tc.ExpectBranch, ExpectTop: tc.ExpectTop}}
|
|
}
|
|
exp := &chainedExpectation{}
|
|
rec := NewRecognizer(Options{}, exp, nil)
|
|
out := CaseResult{Name: tc.Name}
|
|
for i, turn := range turns {
|
|
tr := TurnResult{Index: i, Input: turn.Input}
|
|
res, err := rec.Recognize(ctx, turn.Input, snap)
|
|
if err != nil {
|
|
tr.Mismatches = append(tr.Mismatches, "识别错误:"+err.Error())
|
|
out.Turns = append(out.Turns, tr)
|
|
break
|
|
}
|
|
tr.Branch = string(res.Branch)
|
|
if res.Top != nil {
|
|
tr.TopID = res.Top.ID
|
|
}
|
|
tr.Commands = res.Commands
|
|
tr.Mismatches = matchTurn(turn, res)
|
|
out.Turns = append(out.Turns, tr)
|
|
exp.next = nil
|
|
if res.Top != nil && (len(res.Top.MissingRequiredSlots) > 0 || len(res.Top.InvalidSlots) > 0) {
|
|
exp.next = &PendingExpectation{IntentIDs: []string{res.Top.ID}, Reason: "eval chained"}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func matchTurn(turn CorpusTurn, res Result) []string {
|
|
var mm []string
|
|
if turn.ExpectBranch != "" && string(res.Branch) != turn.ExpectBranch {
|
|
mm = append(mm, fmt.Sprintf("branch=%s(期望 %s)", res.Branch, turn.ExpectBranch))
|
|
}
|
|
if turn.ExpectTop != "" {
|
|
got := ""
|
|
if res.Top != nil {
|
|
got = res.Top.ID
|
|
}
|
|
if got != turn.ExpectTop {
|
|
mm = append(mm, fmt.Sprintf("top=%s(期望 %s)", got, turn.ExpectTop))
|
|
}
|
|
}
|
|
if turn.ExpectCommands != nil {
|
|
mm = append(mm, matchCommands(turn.ExpectCommands, res.Commands)...)
|
|
}
|
|
return mm
|
|
}
|
|
|
|
func matchCommands(want []CommandExpect, got []Command) []string {
|
|
if len(want) != len(got) {
|
|
return []string{fmt.Sprintf("commands 数量=%d(期望 %d)", len(got), len(want))}
|
|
}
|
|
var mm []string
|
|
for i, w := range want {
|
|
g := got[i]
|
|
if w.Kind != "" && string(g.Kind) != w.Kind {
|
|
mm = append(mm, fmt.Sprintf("cmd[%d].kind=%s(期望 %s)", i, g.Kind, w.Kind))
|
|
continue
|
|
}
|
|
if w.ActionKind != "" && g.Action.Kind != w.ActionKind {
|
|
mm = append(mm, fmt.Sprintf("cmd[%d].action.kind=%s(期望 %s)", i, g.Action.Kind, w.ActionKind))
|
|
}
|
|
if w.ActionID != "" && g.Action.ID != w.ActionID {
|
|
mm = append(mm, fmt.Sprintf("cmd[%d].action.id=%s(期望 %s)", i, g.Action.ID, w.ActionID))
|
|
}
|
|
for k, v := range w.Slots {
|
|
if g.Slots[k] != v {
|
|
mm = append(mm, fmt.Sprintf("cmd[%d].slots[%s]=%q(期望 %q)", i, k, g.Slots[k], v))
|
|
}
|
|
}
|
|
if w.Name != "" && g.Name != w.Name {
|
|
mm = append(mm, fmt.Sprintf("cmd[%d].name=%s(期望 %s)", i, g.Name, w.Name))
|
|
}
|
|
if w.Value != "" && g.Value != w.Value {
|
|
mm = append(mm, fmt.Sprintf("cmd[%d].value=%s(期望 %s)", i, g.Value, w.Value))
|
|
}
|
|
for _, m := range w.Missing {
|
|
if !containsString(g.Missing, m) {
|
|
mm = append(mm, fmt.Sprintf("cmd[%d].missing 缺 %s", i, m))
|
|
}
|
|
}
|
|
if w.Question != "" && g.Question != w.Question {
|
|
mm = append(mm, fmt.Sprintf("cmd[%d].question=%q(期望 %q)", i, g.Question, w.Question))
|
|
}
|
|
for _, c := range w.Candidates {
|
|
if !containsString(g.Candidates, c) {
|
|
mm = append(mm, fmt.Sprintf("cmd[%d].candidates 缺 %s", i, c))
|
|
}
|
|
}
|
|
}
|
|
return mm
|
|
}
|
|
|
|
func containsString(list []string, s string) bool {
|
|
for _, x := range list {
|
|
if x == s {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|