Merge pull request 'Add command model, priority tie-break, and boundary matching (I1 WP1/WP4/WP5)' (#6) from feature/agent-stack-i1 into main
ci / vet-and-test (push) Successful in 17s
ci / docker-checkout-hostgateway (push) Successful in 0s

This commit was merged in pull request #6.
This commit is contained in:
2026-10-07 07:28:19 +00:00
12 changed files with 726 additions and 46 deletions
+33 -2
View File
@@ -106,13 +106,44 @@ func cmdLint(args []string) error {
conflicts := snap.Conflicts()
if len(conflicts) == 0 {
fmt.Println("共享词条提示:无")
return nil
}
} 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(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 {
+62
View File
@@ -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 == '_'
}
+115
View File
@@ -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)
}
}
+153
View File
@@ -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
}
+164
View File
@@ -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}
}
+13 -2
View File
@@ -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:
+15 -14
View File
@@ -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 {
+11 -3
View File
@@ -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 {
+4
View File
@@ -47,6 +47,10 @@ type Result struct {
Top *Candidate
Candidates []Candidate
SnapshotRevision int64
// Commands 是把本结果展开的路由指令列表(I1 命令化识别);
// 可能为空(fallback / guard_hit / 宿主默认通道)。Branch 与 Top 保留,
// 供诊断与尚未迁移到命令的消费方使用。
Commands []Command
}
// ScoredSpec 诊断用:单个 spec 在某个阶段的得分与命中明细。
+3
View File
@@ -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(第一个捕获组)。
+68 -10
View File
@@ -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"
}
]
}
+82 -12
View File
@@ -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,22 +77,58 @@
"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": []