Files
Agent-Intent/intent/corpus.go
T
weichen 1d0fcbd1ba
ci / vet-and-test (pull_request) Successful in 18s
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
Add the multi-turn eval format and self-describing corpora
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
2026-10-07 08:12:50 +00:00

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
}