Add command model, priority tie-break, and boundary matching (I1 WP1/WP4/WP5) #6
+34
-3
@@ -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)
|
||||
|
||||
@@ -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 == '_'
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -47,6 +47,10 @@ type Result struct {
|
||||
Top *Candidate
|
||||
Candidates []Candidate
|
||||
SnapshotRevision int64
|
||||
// Commands 是把本结果展开的路由指令列表(I1 命令化识别);
|
||||
// 可能为空(fallback / guard_hit / 宿主默认通道)。Branch 与 Top 保留,
|
||||
// 供诊断与尚未迁移到命令的消费方使用。
|
||||
Commands []Command
|
||||
}
|
||||
|
||||
// ScoredSpec 诊断用:单个 spec 在某个阶段的得分与命中明细。
|
||||
|
||||
@@ -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(第一个捕获组)。
|
||||
|
||||
Vendored
+69
-11
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Vendored
+83
-13
@@ -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": []
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user