From fa512ec03cc641557b6d2c487c336045c0a05168 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 7 Oct 2026 07:21:55 +0000 Subject: [PATCH] Add command model, priority tie-break, and boundary matching Why: I1 increment one - recognition output becomes a command list for the host router, same-score ties resolve by priority (I5), and CJK substring false positives get an opt-in boundary mode (I3). Evidence: go vet clean; go test ./... all ok, including new command (6), boundary (3), and tie-break tests; intentctl lint smoke run shows boundary hints on short unbounded terms. Existing classifier and ambiguity fixtures repointed to an equal-priority pair; the greet corpus case now expects the I5 tie-break outcome. Impact: Result gains Commands; recognizer derives them at one point (guard/fallback stay empty); MatchSpec gains Boundary (default off, legacy behavior unchanged); lint gains boundary hints. Top/Branch stay for compatibility. Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md I1 WP1/WP4/WP5 --- cmd/intentctl/main.go | 37 ++++++- intent/boundary.go | 62 ++++++++++++ intent/boundary_test.go | 115 ++++++++++++++++++++++ intent/command.go | 153 +++++++++++++++++++++++++++++ intent/command_test.go | 164 ++++++++++++++++++++++++++++++++ intent/recognizer.go | 15 ++- intent/recognizer_test.go | 29 +++--- intent/registry.go | 14 ++- intent/result.go | 4 + intent/spec.go | 3 + intent/testdata/corpus.json | 80 +++++++++++++--- intent/testdata/specs/base.json | 96 ++++++++++++++++--- 12 files changed, 726 insertions(+), 46 deletions(-) create mode 100644 intent/boundary.go create mode 100644 intent/boundary_test.go create mode 100644 intent/command.go create mode 100644 intent/command_test.go diff --git a/cmd/intentctl/main.go b/cmd/intentctl/main.go index b6480e0..2f1e444 100644 --- a/cmd/intentctl/main.go +++ b/cmd/intentctl/main.go @@ -106,15 +106,46 @@ func cmdLint(args []string) error { conflicts := snap.Conflicts() if len(conflicts) == 0 { fmt.Println("共享词条提示:无") + } else { + fmt.Printf("共享词条提示(%d 条,非错误,计分与确定性次序会裁决):\n", len(conflicts)) + for _, cf := range conflicts { + fmt.Printf(" %s 与 %s 共享词条 %q\n", cf.A, cf.B, cf.Term) + } + } + // 词界提示(I3):短词条且未开 boundary 的 spec 容易被中文子串误命中 + // (如「配置」命中「配置项」)。提示非错误;boundary: true 可压掉。 + var hints []string + for _, id := range snap.SpecIDs() { + spec, ok := snap.SpecByID(id) + if !ok || spec.Matcher.Boundary { + continue + } + terms := append(append([]string(nil), spec.Matcher.IncludesAny...), spec.Matcher.IncludesAll...) + for _, term := range terms { + if runeLen(term) <= 2 { + hints = append(hints, fmt.Sprintf(" %s 词条 %q 短且未开 boundary(中文子串误命中风险;可考虑 boundary: true)", id, term)) + } + } + } + if len(hints) == 0 { + fmt.Println("词界提示:无") return nil } - fmt.Printf("共享词条提示(%d 条,非错误,计分与确定性次序会裁决):\n", len(conflicts)) - for _, cf := range conflicts { - fmt.Printf(" %s 与 %s 共享词条 %q\n", cf.A, cf.B, cf.Term) + fmt.Printf("词界提示(%d 条,非错误):\n", len(hints)) + for _, h := range hints { + fmt.Println(h) } return nil } +func runeLen(s string) int { + n := 0 + for range s { + n++ + } + return n +} + func cmdExplain(args []string) error { fs := flag.NewFlagSet("explain", flag.ContinueOnError) c := parseCommon(fs) diff --git a/intent/boundary.go b/intent/boundary.go new file mode 100644 index 0000000..435cd9d --- /dev/null +++ b/intent/boundary.go @@ -0,0 +1,62 @@ +package intent + +import ( + "strings" + "unicode" + "unicode/utf8" +) + +// boundedContains 词界匹配。规则分两边: +// - 右缘(必须):命中的右侧不得与词条末字符同类——汉字对汉字、词字符对词字符。 +// 「配置」不命中「配置项」(右邻「项」为汉字)、「write」不命中「rewrites」。 +// - 左缘(仅拉丁词条):汉字结尾的词条不要求左界——中文没有天然词界, +// 「改配置」「系统配置」里的「配置」仍是用户想说那个词; +// 拉丁词条按经典 \b 要求两侧(rewrites 两侧的 write 都不算命中)。 +func boundedContains(haystack, term string) bool { + if term == "" { + return false + } + first, _ := utf8.DecodeRuneInString(term) + last, _ := utf8.DecodeLastRuneInString(term) + checkLeft := isWord(first) && !isHan(first) // 仅拉丁开头的词条要求左界 + for i := strings.Index(haystack, term); i >= 0; { + end := i + len(term) + if (!checkLeft || leftBounded(haystack, i, first)) && rightBounded(haystack, end, last) { + return true + } + j := strings.Index(haystack[i+1:], term) + if j < 0 { + return false + } + i += 1 + j + } + return false +} + +func leftBounded(s string, i int, termFirst rune) bool { + if i == 0 { + return true + } + prev, _ := utf8.DecodeLastRuneInString(s[:i]) + return !sameClass(prev, termFirst) +} + +func rightBounded(s string, end int, termLast rune) bool { + if end >= len(s) { + return true + } + next, _ := utf8.DecodeRuneInString(s[end:]) + return !sameClass(next, termLast) +} + +func sameClass(a, b rune) bool { + return (isHan(a) && isHan(b)) || (isWord(a) && isWord(b)) +} + +func isHan(r rune) bool { + return unicode.Is(unicode.Han, r) +} + +func isWord(r rune) bool { + return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' +} diff --git a/intent/boundary_test.go b/intent/boundary_test.go new file mode 100644 index 0000000..3e1a86a --- /dev/null +++ b/intent/boundary_test.go @@ -0,0 +1,115 @@ +package intent + +import ( + "context" + "testing" +) + +// TestBoundaryCJKFalsePositiveSuppressed:词界开启后,「配置」不命中「配置项」(I3)。 +func TestBoundaryCJKFalsePositiveSuppressed(t *testing.T) { + mk := func(boundary bool) *Snapshot { + b := NewRegistryBuilder(Options{}) + if err := b.Register(IntentSpec{ + ID: "demo.configure", Domain: "demo", Version: 1, + Matcher: MatchSpec{IncludesAny: []string{"配置"}, Boundary: boundary}, + }); err != nil { + t.Fatal(err) + } + reg, err := b.Build() + if err != nil { + t.Fatal(err) + } + return reg.Snapshot() + } + res, err := NewRecognizer(Options{}, nil, nil).Recognize(context.Background(), "配置项在哪里", mk(true)) + if err != nil { + t.Fatal(err) + } + if res.Branch != BranchFallback { + t.Fatalf("boundary on: branch = %s, want fallback", res.Branch) + } + // 词界关闭(旧行为):子串命中依旧,证明差异来自 boundary。 + res, err = NewRecognizer(Options{}, nil, nil).Recognize(context.Background(), "配置项在哪里", mk(false)) + if err != nil { + t.Fatal(err) + } + if res.Branch != BranchMatched { + t.Fatalf("boundary off: branch = %s, want matched", res.Branch) + } + // 词界开启时,独立成词的「配置」照常命中。 + res, err = NewRecognizer(Options{}, nil, nil).Recognize(context.Background(), "我要改配置", mk(true)) + if err != nil { + t.Fatal(err) + } + if res.Branch != BranchMatched { + t.Fatalf("boundary on, standalone term: branch = %s, want matched", res.Branch) + } +} + +// TestBoundaryLatinWordFlank:拉丁词界——write 不命中 rewrites。 +func TestBoundaryLatinWordFlank(t *testing.T) { + b := NewRegistryBuilder(Options{}) + if err := b.Register(IntentSpec{ + ID: "demo.write", Domain: "demo", Version: 1, + Matcher: MatchSpec{IncludesAny: []string{"write"}, Boundary: true}, + }); err != nil { + t.Fatal(err) + } + reg, err := b.Build() + if err != nil { + t.Fatal(err) + } + snap := reg.Snapshot() + res, err := NewRecognizer(Options{}, nil, nil).Recognize(context.Background(), "rewrites everything", snap) + if err != nil { + t.Fatal(err) + } + if res.Branch != BranchFallback { + t.Fatalf("branch = %s, want fallback", res.Branch) + } + res, err = NewRecognizer(Options{}, nil, nil).Recognize(context.Background(), "please write this", snap) + if err != nil { + t.Fatal(err) + } + if res.Branch != BranchMatched { + t.Fatalf("branch = %s, want matched", res.Branch) + } +} + +// TestTieBreakByPriority:同分时高 Priority 胜出(I5);同分同 Priority 仍标歧。 +func TestTieBreakByPriority(t *testing.T) { + mk := func(p1, p2 int) *Snapshot { + b := NewRegistryBuilder(Options{}) + if err := b.Register(IntentSpec{ID: "demo.alpha", Domain: "demo", Version: 1, Priority: p1, Matcher: MatchSpec{IncludesAny: []string{"同分词"}}}); err != nil { + t.Fatal(err) + } + if err := b.Register(IntentSpec{ID: "demo.beta", Domain: "demo", Version: 1, Priority: p2, Matcher: MatchSpec{IncludesAny: []string{"同分词"}}}); err != nil { + t.Fatal(err) + } + reg, err := b.Build() + if err != nil { + t.Fatal(err) + } + return reg.Snapshot() + } + res, err := NewRecognizer(Options{}, nil, nil).Recognize(context.Background(), "同分词", mk(10, 0)) + if err != nil { + t.Fatal(err) + } + if res.Branch != BranchMatched || res.Top == nil || res.Top.ID != "demo.alpha" { + t.Fatalf("tie-break: branch = %s top = %+v", res.Branch, res.Top) + } + // 同分同 Priority:仍 ambiguous。 + res, err = NewRecognizer(Options{}, nil, nil).Recognize(context.Background(), "同分词", mk(5, 5)) + if err != nil { + t.Fatal(err) + } + if res.Branch != BranchAmbiguous { + t.Fatalf("same priority: branch = %s, want ambiguous", res.Branch) + } + // Explain 与裁决同规则。 + tr := mk(10, 0).Explain("同分词") + if tr.Branch != BranchMatched || tr.TopID != "demo.alpha" { + t.Fatalf("explain = %+v", tr) + } +} diff --git a/intent/command.go b/intent/command.go new file mode 100644 index 0000000..7189344 --- /dev/null +++ b/intent/command.go @@ -0,0 +1,153 @@ +package intent + +import ( + "sort" + "strings" +) + +// CommandKind 是一条路由指令的类型。 +type CommandKind string + +const ( + // CommandStart 启动一个动作(流程/命令/蓝图/处理器),携带已抽取槽位。 + CommandStart CommandKind = "start" + // CommandSetSlot 记录一个槽位值(多轮澄清/预期回答的落槽)。 + CommandSetSlot CommandKind = "set_slot" + // CommandChangeSlot 改写一个已有槽位值。 + CommandChangeSlot CommandKind = "change_slot" + // CommandCancel 取消某个进行中的动作(否定语义的命令层表达)。 + CommandCancel CommandKind = "cancel" + // CommandClarify 向用户澄清:缺槽位或候选消歧。 + CommandClarify CommandKind = "clarify" + // CommandChitchat 闲聊(不由规则漏斗产出;留给语义层与宿主映射)。 + CommandChitchat CommandKind = "chitchat" + // CommandHandoff 转人工(不由规则漏斗产出;留给语义层与宿主映射)。 + CommandHandoff CommandKind = "handoff" +) + +// ActionRef 指向一个可执行动作。auto 表示由宿主在命令总线与工作流之间 +// 裁决(与现网「意图 ID 同时是命令 ID 与 workflow IntentID」的现状兼容); +// handler 与 blueprint 来自 spec 字段的显式声明;command/workflow 预留给 +// spec.action 显式声明(WP2)。 +type ActionRef struct { + Kind string `json:"kind"` + ID string `json:"id"` +} + +const ( + ActionAuto = "auto" + ActionHandler = "handler" + ActionBlueprint = "blueprint" + ActionCommand = "command" + ActionWorkflow = "workflow" +) + +// Command 是一条对宿主路由层的指令。一次识别可产出多条命令 +// (如先 SetSlot 后 Start),宿主按序消费。 +type Command struct { + Kind CommandKind `json:"kind"` + Action ActionRef `json:"action,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"` + Scope string `json:"scope,omitempty"` + Target string `json:"target,omitempty"` +} + +// actionOf 从候选推导动作引用:显式 blueprint 键优先于 Handler, +// 都没有时回落 auto(宿主裁决)。 +func actionOf(c Candidate) ActionRef { + if bp := strings.TrimSpace(c.Metadata["blueprint"]); bp != "" { + return ActionRef{Kind: ActionBlueprint, ID: bp} + } + if h := strings.TrimSpace(c.Handler); h != "" { + return ActionRef{Kind: ActionHandler, ID: h} + } + return ActionRef{Kind: ActionAuto, ID: c.ID} +} + +// deriveCommands 把识别结果展开为命令列表(纯函数、确定性)。 +// 规则: +// - guard_hit:不产出命令(守卫语义是「识别即拒绝」,由宿主处理; +// Cancel 命令留给声明 action 的意图与语义层,不在规则漏斗内代为表达)。 +// - expectation_hit:每个已填槽位一条 SetSlot;槽位齐全且合法追加 Start, +// 否则追加 Clarify(缺槽/非法)。 +// - matched:槽位齐全且合法 → 单条 Start;有缺失/非法 → Start(携带已抽槽, +// 宿主可带槽启动并追问,与现网行为一致)+ Clarify。 +// - ambiguous:单条 Clarify(候选消歧,按现有候选序)。 +// - fallback:不产出命令(交给宿主默认通道;Chitchat/Handoff 不由规则漏斗产出)。 +func deriveCommands(res Result, snap *Snapshot) []Command { + if res.Top == nil { + if res.Branch == BranchAmbiguous && len(res.Candidates) > 1 { + ids := make([]string, 0, len(res.Candidates)) + for _, c := range res.Candidates { + ids = append(ids, c.ID) + } + return []Command{{Kind: CommandClarify, Candidates: ids}} + } + return nil + } + top := res.Top + complete := len(top.MissingRequiredSlots) == 0 && len(top.InvalidSlots) == 0 + switch res.Branch { + case BranchExpectationHit: + var cmds []Command + for _, k := range sortedKeys(top.Slots) { + cmds = append(cmds, Command{Kind: CommandSetSlot, Name: k, Value: top.Slots[k]}) + } + if complete { + cmds = append(cmds, Command{Kind: CommandStart, Action: actionOf(*top), Slots: top.Slots}) + } else { + cmds = append(cmds, clarifyOf(*top, snap)) + } + return cmds + case BranchMatched: + start := Command{Kind: CommandStart, Action: actionOf(*top), Slots: top.Slots} + if complete { + return []Command{start} + } + return []Command{start, clarifyOf(*top, snap)} + } + return nil +} + +// clarifyOf 组装澄清命令:Missing 为必填缺失与非法槽位的合并(确定性排序); +// Question 取第一个有 Question 配置的缺失/非法槽位的建议提问(查得到 spec 时)。 +func clarifyOf(top Candidate, snap *Snapshot) Command { + invalid := sortedKeys(top.InvalidSlots) + missing := append(append([]string(nil), top.MissingRequiredSlots...), invalid...) + var question string + if snap != nil { + if spec, ok := snap.SpecByID(top.ID); ok { + asked := map[string]bool{} + for _, k := range missing { + if asked[k] { + continue + } + asked[k] = true + for _, s := range spec.Slots { + if s.Key == k && strings.TrimSpace(s.Question) != "" { + question = s.Question + break + } + } + if question != "" { + break + } + } + } + } + return Command{Kind: CommandClarify, Missing: missing, Question: question} +} + +func sortedKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/intent/command_test.go b/intent/command_test.go new file mode 100644 index 0000000..d0d4d1c --- /dev/null +++ b/intent/command_test.go @@ -0,0 +1,164 @@ +package intent + +import ( + "context" + "testing" +) + +// buildCommandSnap 组装命令测试用快照:一个带必填槽位的意图与一个无槽位意图。 +func buildCommandSnap(t *testing.T) *Snapshot { + t.Helper() + b := NewRegistryBuilder(Options{}) + if err := b.Register(IntentSpec{ + ID: "report.generate", Domain: "report", Version: 1, + Matcher: MatchSpec{IncludesAny: []string{"生成报告"}}, + Slots: []SlotSpec{ + {Key: "topic", Required: true, Pattern: `主题是(.+)`, Question: "要查询哪个主题?"}, + }, + Handler: "reportHandler", + }); err != nil { + t.Fatal(err) + } + if err := b.Register(IntentSpec{ + ID: "report.chat", Domain: "report", Version: 1, + Matcher: MatchSpec{IncludesAny: []string{"闲聊一下"}}, + }); err != nil { + t.Fatal(err) + } + reg, err := b.Build() + if err != nil { + t.Fatal(err) + } + return reg.Snapshot() +} + +func recognize(t *testing.T, snap *Snapshot, input string) Result { + t.Helper() + res, err := NewRecognizer(Options{}, nil, nil).Recognize(context.Background(), input, snap) + if err != nil { + t.Fatal(err) + } + return res +} + +// TestCommandsMatchedFullSlots:槽位齐全 → 单条 Start,ActionRef 取 Handler。 +func TestCommandsMatchedFullSlots(t *testing.T) { + snap := buildCommandSnap(t) + res := recognize(t, snap, "帮我生成报告,主题是电机") + if res.Branch != BranchMatched { + t.Fatalf("branch = %s", res.Branch) + } + if len(res.Commands) != 1 { + t.Fatalf("commands = %+v", res.Commands) + } + cmd := res.Commands[0] + if cmd.Kind != CommandStart || cmd.Action.Kind != ActionHandler || cmd.Action.ID != "reportHandler" { + t.Fatalf("start = %+v", cmd) + } + if cmd.Slots["topic"] != "电机" { + t.Fatalf("slots = %v", cmd.Slots) + } +} + +// TestCommandsMatchedMissingSlot:必填缺失 → Start(带已抽槽)+ Clarify(含建议提问)。 +func TestCommandsMatchedMissingSlot(t *testing.T) { + snap := buildCommandSnap(t) + res := recognize(t, snap, "帮我生成报告") + if len(res.Commands) != 2 { + t.Fatalf("commands = %+v", res.Commands) + } + start, clarify := res.Commands[0], res.Commands[1] + if start.Kind != CommandStart { + t.Fatalf("first = %+v", start) + } + if clarify.Kind != CommandClarify || len(clarify.Missing) != 1 || clarify.Missing[0] != "topic" { + t.Fatalf("clarify = %+v", clarify) + } + if clarify.Question != "要查询哪个主题?" { + t.Fatalf("question = %q", clarify.Question) + } +} + +// TestCommandsExpectationSetSlotPlusStart:预期命中 → SetSlot + Start(验收形态)。 +func TestCommandsExpectationSetSlotPlusStart(t *testing.T) { + snap := buildCommandSnap(t) + exp := listExpectation{ids: []string{"report.generate"}} + res, err := NewRecognizer(Options{}, exp, nil).Recognize(context.Background(), "生成报告,主题是电机", snap) + if err != nil { + t.Fatal(err) + } + if res.Branch != BranchExpectationHit { + t.Fatalf("branch = %s", res.Branch) + } + var setSlot, start *Command + for i := range res.Commands { + switch res.Commands[i].Kind { + case CommandSetSlot: + setSlot = &res.Commands[i] + case CommandStart: + start = &res.Commands[i] + } + } + if setSlot == nil || start == nil { + t.Fatalf("commands = %+v", res.Commands) + } + if setSlot.Name != "topic" || setSlot.Value != "电机" { + t.Fatalf("set_slot = %+v", *setSlot) + } + if start.Action.Kind != ActionHandler || start.Slots["topic"] != "电机" { + t.Fatalf("start = %+v", *start) + } +} + +// TestCommandsAmbiguousClarify:标歧 → 单条 Clarify 携带候选 ID。 +func TestCommandsAmbiguousClarify(t *testing.T) { + b := NewRegistryBuilder(Options{}) + if err := b.Register(IntentSpec{ID: "demo.alpha", Domain: "demo", Version: 1, Matcher: MatchSpec{IncludesAny: []string{"模糊词"}}}); err != nil { + t.Fatal(err) + } + if err := b.Register(IntentSpec{ID: "demo.beta", Domain: "demo", Version: 1, Matcher: MatchSpec{IncludesAny: []string{"模糊词"}}}); err != nil { + t.Fatal(err) + } + reg, err := b.Build() + if err != nil { + t.Fatal(err) + } + res := recognize(t, reg.Snapshot(), "模糊词") + if res.Branch != BranchAmbiguous { + t.Fatalf("branch = %s", res.Branch) + } + if len(res.Commands) != 1 || res.Commands[0].Kind != CommandClarify { + t.Fatalf("commands = %+v", res.Commands) + } + if len(res.Commands[0].Candidates) != 2 { + t.Fatalf("candidates = %v", res.Commands[0].Candidates) + } +} + +// TestCommandsFallbackEmpty:fallback 不产出命令。 +func TestCommandsFallbackEmpty(t *testing.T) { + snap := buildCommandSnap(t) + res := recognize(t, snap, "完全不相关的一句话") + if res.Branch != BranchFallback || len(res.Commands) != 0 { + t.Fatalf("fallback commands = %+v", res.Commands) + } +} + +// TestActionRefDerivation:blueprint 键优先于 Handler;都没有回落 auto。 +func TestActionRefDerivation(t *testing.T) { + if got := actionOf(Candidate{ID: "x", Metadata: map[string]string{"blueprint": "bp-1"}, Handler: "h"}); got.Kind != ActionBlueprint || got.ID != "bp-1" { + t.Fatalf("blueprint = %+v", got) + } + if got := actionOf(Candidate{ID: "x", Handler: "h"}); got.Kind != ActionHandler || got.ID != "h" { + t.Fatalf("handler = %+v", got) + } + if got := actionOf(Candidate{ID: "x"}); got.Kind != ActionAuto || got.ID != "x" { + t.Fatalf("auto = %+v", got) + } +} + +type listExpectation struct{ ids []string } + +func (s listExpectation) Pending(ctx context.Context) *PendingExpectation { + return &PendingExpectation{IntentIDs: s.ids} +} diff --git a/intent/recognizer.go b/intent/recognizer.go index f101148..e3f0a84 100644 --- a/intent/recognizer.go +++ b/intent/recognizer.go @@ -46,6 +46,7 @@ func (r *FunnelRecognizer) Recognize(ctx context.Context, input string, snap *Sn } start := time.Now() res := r.recognize(ctx, input, snap) + res.Commands = deriveCommands(res, snap) topID, conf := "", 0.0 if res.Top != nil { topID, conf = res.Top.ID, res.Top.Confidence @@ -89,7 +90,7 @@ func (r *FunnelRecognizer) recognize(ctx context.Context, input string, snap *Sn switch { case len(hits) == 0: return Result{Input: input, Branch: BranchFallback, SnapshotRevision: snap.rev} - case len(hits) == 1 || hits[0].score-hits[1].score >= snap.margin: + case len(hits) == 1 || hits[0].score-hits[1].score >= snap.margin || tieBreakByPriority(hits): cands := candidatesOf(trimHits(hits, snap.topN), input, true) top := cands[0] return Result{Input: input, Branch: BranchMatched, Top: &top, Candidates: cands, SnapshotRevision: snap.rev} @@ -118,6 +119,16 @@ func (r *FunnelRecognizer) recognize(ctx context.Context, input string, snap *Sn return Result{Input: input, Branch: BranchAmbiguous, Candidates: cands, SnapshotRevision: snap.rev} } +// tieBreakByPriority 报告同分(分差为 0)且前两名 Priority 不同: +// 此时高 Priority 者直接胜出,不再标歧(I5)。分差小于 margin 但非零时 +// 仍按 ambiguous 处理(margin 语义不变)。 +func tieBreakByPriority(hits []specHit) bool { + if len(hits) < 2 { + return false + } + return hits[0].score == hits[1].score && hits[0].cs.spec.Priority > hits[1].cs.spec.Priority +} + // validateRefined 要求分类器返回与原候选完全相同的 ID 集合且无重复。 // 置信度必须是有限值并落在 [0,1];越界判为不合法,不截断。 // 除 Confidence 外的字段一律按 ID 从原候选重建,分类器改写的 Handler 等不生效。 @@ -236,7 +247,7 @@ func (s *Snapshot) Explain(input string) Trace { switch { case len(hits) == 0: trace.Branch = BranchFallback - case len(hits) == 1 || hits[0].score-hits[1].score >= s.margin: + case len(hits) == 1 || hits[0].score-hits[1].score >= s.margin || tieBreakByPriority(hits): trace.Branch = BranchMatched trace.TopID = hits[0].cs.spec.ID default: diff --git a/intent/recognizer_test.go b/intent/recognizer_test.go index f473c95..35da55a 100644 --- a/intent/recognizer_test.go +++ b/intent/recognizer_test.go @@ -112,20 +112,21 @@ func TestRecognizePatternBonus(t *testing.T) { } } -func TestRecognizeAmbiguousAndPriorityOrder(t *testing.T) { +func TestRecognizeTieBreakPrefersPriority(t *testing.T) { _, snap := buildDemoRegistry(t) rec := NewRecognizer(Options{}, nil, nil) res, err := rec.Recognize(context.Background(), "你好呀", snap) if err != nil { t.Fatal(err) } - if res.Branch != BranchAmbiguous || res.Top != nil { - t.Fatalf("expected ambiguous without top: %+v", res) + // I5:同分(0.7)且 Priority 不同(10 > 5)时高优先者直接胜出,不再标歧。 + if res.Branch != BranchMatched || res.Top == nil || res.Top.ID != "demo.greet.hello" { + t.Fatalf("tie-break should match hello: %+v", res) } if len(res.Candidates) < 2 { t.Fatalf("expected >=2 candidates: %+v", res.Candidates) } - // 同分按 priority 降序:hello(10) 在 hi(5) 前。 + // 候选次序仍按 priority 降序:hello(10) 在 hi(5) 前。 if res.Candidates[0].ID != "demo.greet.hello" || res.Candidates[1].ID != "demo.greet.hi" { t.Fatalf("priority order: %+v", res.Candidates) } @@ -151,13 +152,13 @@ func (s stubClassifier) Refine(ctx context.Context, input string, cands []Candid func TestRecognizeClassifierResolvesAmbiguity(t *testing.T) { _, snap := buildDemoRegistry(t) - cls := stubClassifier{scores: map[string]float64{"demo.greet.hello": 0.9, "demo.greet.hi": 0.3}} + cls := stubClassifier{scores: map[string]float64{"demo.tie.alpha": 0.9, "demo.tie.beta": 0.3}} rec := NewRecognizer(Options{}, nil, cls) - res, err := rec.Recognize(context.Background(), "你好呀", snap) + res, err := rec.Recognize(context.Background(), "打结", snap) if err != nil { t.Fatal(err) } - if res.Branch != BranchMatched || res.Top == nil || res.Top.ID != "demo.greet.hello" { + if res.Branch != BranchMatched || res.Top == nil || res.Top.ID != "demo.tie.alpha" { t.Fatalf("classifier should resolve: %+v", res) } if !strings.Contains(res.Top.Reason, "refined_by=classifier") { @@ -168,7 +169,7 @@ func TestRecognizeClassifierResolvesAmbiguity(t *testing.T) { func TestRecognizeClassifierFailureKeepsAmbiguous(t *testing.T) { _, snap := buildDemoRegistry(t) rec := NewRecognizer(Options{}, nil, stubClassifier{err: errors.New("llm down")}) - res, err := rec.Recognize(context.Background(), "你好呀", snap) + res, err := rec.Recognize(context.Background(), "打结", snap) if err != nil { t.Fatalf("classifier failure must not fail recognition: %v", err) } @@ -221,14 +222,14 @@ func TestRecognizeClassifierRejectsBadRefinement(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { rec := NewRecognizer(Options{}, nil, rewriteClassifier{fn: tc.fn}) - res, err := rec.Recognize(context.Background(), "你好呀", snap) + res, err := rec.Recognize(context.Background(), "打结", snap) if err != nil { t.Fatal(err) } if res.Branch != BranchAmbiguous || res.Top != nil { t.Fatalf("expected ambiguous kept: %+v", res) } - if len(res.Candidates) < 2 || res.Candidates[0].ID != "demo.greet.hello" { + if len(res.Candidates) < 2 || res.Candidates[0].ID != "demo.tie.alpha" { t.Fatalf("original candidates kept: %+v", res.Candidates) } }) @@ -237,7 +238,7 @@ func TestRecognizeClassifierRejectsBadRefinement(t *testing.T) { func TestRecognizeClassifierRebuildsTamperedHandler(t *testing.T) { _, snap := buildDemoRegistry(t) - plain, err := NewRecognizer(Options{}, nil, nil).Recognize(context.Background(), "你好呀", snap) + plain, err := NewRecognizer(Options{}, nil, nil).Recognize(context.Background(), "打结", snap) if err != nil { t.Fatal(err) } @@ -246,7 +247,7 @@ func TestRecognizeClassifierRebuildsTamperedHandler(t *testing.T) { out := append([]Candidate(nil), cands...) for i := range out { out[i].Handler = "tampered.handler" - if out[i].ID == "demo.greet.hello" { + if out[i].ID == "demo.tie.alpha" { out[i].Confidence = 0.95 } else { out[i].Confidence = 0.2 @@ -254,11 +255,11 @@ func TestRecognizeClassifierRebuildsTamperedHandler(t *testing.T) { } return out }}) - res, err := rec.Recognize(context.Background(), "你好呀", snap) + res, err := rec.Recognize(context.Background(), "打结", snap) if err != nil { t.Fatal(err) } - if res.Branch != BranchMatched || res.Top == nil || res.Top.ID != "demo.greet.hello" { + if res.Branch != BranchMatched || res.Top == nil || res.Top.ID != "demo.tie.alpha" { t.Fatalf("expected resolved match: %+v", res) } if res.Top.Handler != wantHandler { diff --git a/intent/registry.go b/intent/registry.go index aa0d770..6fc058b 100644 --- a/intent/registry.go +++ b/intent/registry.go @@ -139,19 +139,19 @@ func (cs *compiledSpec) evaluate(input, folded string) matchOutcome { hay = folded } for _, t := range cs.termsEx { - if strings.Contains(hay, t) { + if cs.contains(hay, t) { return matchOutcome{reject: "excluded_term:" + t} } } for _, t := range cs.termsAll { - if !strings.Contains(hay, t) { + if !cs.contains(hay, t) { return matchOutcome{reject: "missing_includes_all:" + t} } } var hitTerms, hitPat []string positive := false for _, t := range cs.termsAny { - if strings.Contains(hay, t) { + if cs.contains(hay, t) { positive = true hitTerms = append(hitTerms, t) } @@ -176,6 +176,14 @@ func (cs *compiledSpec) evaluate(input, folded string) matchOutcome { return matchOutcome{score: math.Round(score*10000) / 10000, passed: score >= cs.threshold, hitTerms: hitTerms, hitPat: hitPat} } +// contains 按 spec 的 boundary 配置选择子串或词界匹配(I3)。 +func (cs *compiledSpec) contains(hay, term string) bool { + if cs.spec.Matcher.Boundary { + return boundedContains(hay, term) + } + return strings.Contains(hay, term) +} + func compileSpec(spec IntentSpec, guard bool, index int, scoped map[string]SlotValidator, nfkc bool) (*compiledSpec, error) { m := spec.Matcher fold := func(terms []string) []string { diff --git a/intent/result.go b/intent/result.go index ee55a77..2e8b21b 100644 --- a/intent/result.go +++ b/intent/result.go @@ -47,6 +47,10 @@ type Result struct { Top *Candidate Candidates []Candidate SnapshotRevision int64 + // Commands 是把本结果展开的路由指令列表(I1 命令化识别); + // 可能为空(fallback / guard_hit / 宿主默认通道)。Branch 与 Top 保留, + // 供诊断与尚未迁移到命令的消费方使用。 + Commands []Command } // ScoredSpec 诊断用:单个 spec 在某个阶段的得分与命中明细。 diff --git a/intent/spec.go b/intent/spec.go index 98c415b..2ab0369 100644 --- a/intent/spec.go +++ b/intent/spec.go @@ -25,12 +25,15 @@ const ( // 命中条件:excludes_any 全不命中 && includes_all 全命中 &&(includes_any 至少一词命中 // 或任一 pattern 命中;仅配置 includes_all 时以全命中为准)。 // case_sensitive 只作用于 includes/excludes 词表;pattern 自行用 (?i) 表达大小写。 +// boundary 为真时 includes/excludes 词表走词界匹配(命中两侧不得与词条边缘 +// 同类:汉字对汉字、词字符对词字符),用于压住中文子串误命中(I3);默认 false 保持旧行为。 type MatchSpec struct { IncludesAny []string `json:"includes_any,omitempty"` IncludesAll []string `json:"includes_all,omitempty"` ExcludesAny []string `json:"excludes_any,omitempty"` Patterns []string `json:"patterns,omitempty"` CaseSensitive bool `json:"case_sensitive,omitempty"` + Boundary bool `json:"boundary,omitempty"` } // SlotSpec 槽位抽取规则。group 未填默认取 1(第一个捕获组)。 diff --git a/intent/testdata/corpus.json b/intent/testdata/corpus.json index f498fa3..0d01b9e 100644 --- a/intent/testdata/corpus.json +++ b/intent/testdata/corpus.json @@ -1,14 +1,72 @@ { "cases": [ - {"name": "status_with_slot", "input": "查询任务 42 的进度", "expect_branch": "matched", "expect_top": "demo.run.status_query", "expect_slots": {"run_id": "42"}}, - {"name": "status_term_only", "input": "帮我看下进度", "expect_branch": "matched", "expect_top": "demo.run.status_query"}, - {"name": "guard_config_query", "input": "查询系统配置", "expect_branch": "guard_hit", "expect_top": "guard.config_change"}, - {"name": "negated_write_readonly", "input": "怎么配置查询进度", "expect_branch": "fallback"}, - {"name": "restart_task", "input": "把任务重启一下", "expect_branch": "matched", "expect_top": "demo.run.restart"}, - {"name": "greet_ambiguous", "input": "你好呀", "expect_branch": "ambiguous"}, - {"name": "cancel_missing_required_slot", "input": "取消一下", "expect_branch": "matched", "expect_top": "demo.run.cancel", "expect_missing_slots": ["run_id"]}, - {"name": "case_fold_deploy", "input": "please DEPLOY it", "expect_branch": "matched", "expect_top": "demo.misc.deploy"}, - {"name": "casual_fallback", "input": "今天天气不错", "expect_branch": "fallback"}, - {"name": "blank_fallback", "input": " ", "expect_branch": "fallback"} + { + "name": "status_with_slot", + "input": "查询任务 42 的进度", + "expect_branch": "matched", + "expect_top": "demo.run.status_query", + "expect_slots": { + "run_id": "42" + } + }, + { + "name": "status_term_only", + "input": "帮我看下进度", + "expect_branch": "matched", + "expect_top": "demo.run.status_query" + }, + { + "name": "guard_config_query", + "input": "查询系统配置", + "expect_branch": "guard_hit", + "expect_top": "guard.config_change" + }, + { + "name": "negated_write_readonly", + "input": "怎么配置查询进度", + "expect_branch": "fallback" + }, + { + "name": "restart_task", + "input": "把任务重启一下", + "expect_branch": "matched", + "expect_top": "demo.run.restart" + }, + { + "name": "greet_tiebreak_priority", + "input": "你好呀", + "expect_branch": "matched", + "expect_top": "demo.greet.hello" + }, + { + "name": "cancel_missing_required_slot", + "input": "取消一下", + "expect_branch": "matched", + "expect_top": "demo.run.cancel", + "expect_missing_slots": [ + "run_id" + ] + }, + { + "name": "case_fold_deploy", + "input": "please DEPLOY it", + "expect_branch": "matched", + "expect_top": "demo.misc.deploy" + }, + { + "name": "casual_fallback", + "input": "今天天气不错", + "expect_branch": "fallback" + }, + { + "name": "blank_fallback", + "input": " ", + "expect_branch": "fallback" + }, + { + "name": "tie_ambiguous", + "input": "打结", + "expect_branch": "ambiguous" + } ] -} +} \ No newline at end of file diff --git a/intent/testdata/specs/base.json b/intent/testdata/specs/base.json index 8b9d042..d651077 100644 --- a/intent/testdata/specs/base.json +++ b/intent/testdata/specs/base.json @@ -6,24 +6,49 @@ "version": 1, "description": "查询任务运行进度", "matcher": { - "includes_any": ["进度", "状态"], - "excludes_any": ["配置"] + "includes_any": [ + "进度", + "状态" + ], + "excludes_any": [ + "配置" + ] }, "slots": [ - {"key": "run_id", "required": false, "pattern": "(?:run|任务)[:# ]*([A-Za-z0-9-]+)", "group": 1} + { + "key": "run_id", + "required": false, + "pattern": "(?:run|任务)[:# ]*([A-Za-z0-9-]+)", + "group": 1 + } ], - "confidence": {"base": 0.8, "threshold": 0.7}, + "confidence": { + "base": 0.8, + "threshold": 0.7 + }, "risk": "low", "handler": "taskrun.query_status", - "metadata": {"channel": "dialogue"} + "metadata": { + "channel": "dialogue" + } }, { "id": "demo.run.restart", "domain": "demo", "version": 1, "description": "重启任务", - "matcher": {"includes_any": ["重启"], "includes_all": ["任务"]}, - "confidence": {"base": 0.75, "threshold": 0.7}, + "matcher": { + "includes_any": [ + "重启" + ], + "includes_all": [ + "任务" + ] + }, + "confidence": { + "base": 0.75, + "threshold": 0.7 + }, "risk": "medium", "requires_confirm": true, "handler": "taskrun.restart" @@ -33,9 +58,18 @@ "domain": "demo", "version": 1, "description": "取消任务", - "matcher": {"includes_any": ["取消"]}, + "matcher": { + "includes_any": [ + "取消" + ] + }, "slots": [ - {"key": "run_id", "required": true, "pattern": "(?:run|任务)[:# ]*([A-Za-z0-9-]+)", "group": 1} + { + "key": "run_id", + "required": true, + "pattern": "(?:run|任务)[:# ]*([A-Za-z0-9-]+)", + "group": 1 + } ], "handler": "taskrun.cancel" }, @@ -43,23 +77,59 @@ "id": "demo.greet.hello", "domain": "demo", "version": 1, - "matcher": {"includes_any": ["你好"]}, + "matcher": { + "includes_any": [ + "你好" + ] + }, "priority": 10 }, { "id": "demo.greet.hi", "domain": "demo", "version": 1, - "matcher": {"includes_any": ["你好"]}, + "matcher": { + "includes_any": [ + "你好" + ] + }, "priority": 5 }, { "id": "demo.misc.deploy", "domain": "demo", "version": 1, - "matcher": {"includes_any": ["deploy", "发布"], "case_sensitive": false}, + "matcher": { + "includes_any": [ + "deploy", + "发布" + ], + "case_sensitive": false + }, "handler": "deploy.run" + }, + { + "id": "demo.tie.alpha", + "domain": "demo", + "version": 1, + "matcher": { + "includes_any": [ + "打结" + ] + }, + "priority": 5 + }, + { + "id": "demo.tie.beta", + "domain": "demo", + "version": 1, + "matcher": { + "includes_any": [ + "打结" + ] + }, + "priority": 5 } ], "disable": [] -} +} \ No newline at end of file