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()
|
conflicts := snap.Conflicts()
|
||||||
if len(conflicts) == 0 {
|
if len(conflicts) == 0 {
|
||||||
fmt.Println("共享词条提示:无")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
fmt.Printf("共享词条提示(%d 条,非错误,计分与确定性次序会裁决):\n", len(conflicts))
|
fmt.Printf("词界提示(%d 条,非错误):\n", len(hints))
|
||||||
for _, cf := range conflicts {
|
for _, h := range hints {
|
||||||
fmt.Printf(" %s 与 %s 共享词条 %q\n", cf.A, cf.B, cf.Term)
|
fmt.Println(h)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runeLen(s string) int {
|
||||||
|
n := 0
|
||||||
|
for range s {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
func cmdExplain(args []string) error {
|
func cmdExplain(args []string) error {
|
||||||
fs := flag.NewFlagSet("explain", flag.ContinueOnError)
|
fs := flag.NewFlagSet("explain", flag.ContinueOnError)
|
||||||
c := parseCommon(fs)
|
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()
|
start := time.Now()
|
||||||
res := r.recognize(ctx, input, snap)
|
res := r.recognize(ctx, input, snap)
|
||||||
|
res.Commands = deriveCommands(res, snap)
|
||||||
topID, conf := "", 0.0
|
topID, conf := "", 0.0
|
||||||
if res.Top != nil {
|
if res.Top != nil {
|
||||||
topID, conf = res.Top.ID, res.Top.Confidence
|
topID, conf = res.Top.ID, res.Top.Confidence
|
||||||
@@ -89,7 +90,7 @@ func (r *FunnelRecognizer) recognize(ctx context.Context, input string, snap *Sn
|
|||||||
switch {
|
switch {
|
||||||
case len(hits) == 0:
|
case len(hits) == 0:
|
||||||
return Result{Input: input, Branch: BranchFallback, SnapshotRevision: snap.rev}
|
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)
|
cands := candidatesOf(trimHits(hits, snap.topN), input, true)
|
||||||
top := cands[0]
|
top := cands[0]
|
||||||
return Result{Input: input, Branch: BranchMatched, Top: &top, Candidates: cands, SnapshotRevision: snap.rev}
|
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}
|
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 集合且无重复。
|
// validateRefined 要求分类器返回与原候选完全相同的 ID 集合且无重复。
|
||||||
// 置信度必须是有限值并落在 [0,1];越界判为不合法,不截断。
|
// 置信度必须是有限值并落在 [0,1];越界判为不合法,不截断。
|
||||||
// 除 Confidence 外的字段一律按 ID 从原候选重建,分类器改写的 Handler 等不生效。
|
// 除 Confidence 外的字段一律按 ID 从原候选重建,分类器改写的 Handler 等不生效。
|
||||||
@@ -236,7 +247,7 @@ func (s *Snapshot) Explain(input string) Trace {
|
|||||||
switch {
|
switch {
|
||||||
case len(hits) == 0:
|
case len(hits) == 0:
|
||||||
trace.Branch = BranchFallback
|
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.Branch = BranchMatched
|
||||||
trace.TopID = hits[0].cs.spec.ID
|
trace.TopID = hits[0].cs.spec.ID
|
||||||
default:
|
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)
|
_, snap := buildDemoRegistry(t)
|
||||||
rec := NewRecognizer(Options{}, nil, nil)
|
rec := NewRecognizer(Options{}, nil, nil)
|
||||||
res, err := rec.Recognize(context.Background(), "你好呀", snap)
|
res, err := rec.Recognize(context.Background(), "你好呀", snap)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if res.Branch != BranchAmbiguous || res.Top != nil {
|
// I5:同分(0.7)且 Priority 不同(10 > 5)时高优先者直接胜出,不再标歧。
|
||||||
t.Fatalf("expected ambiguous without top: %+v", res)
|
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 {
|
if len(res.Candidates) < 2 {
|
||||||
t.Fatalf("expected >=2 candidates: %+v", res.Candidates)
|
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" {
|
if res.Candidates[0].ID != "demo.greet.hello" || res.Candidates[1].ID != "demo.greet.hi" {
|
||||||
t.Fatalf("priority order: %+v", res.Candidates)
|
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) {
|
func TestRecognizeClassifierResolvesAmbiguity(t *testing.T) {
|
||||||
_, snap := buildDemoRegistry(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)
|
rec := NewRecognizer(Options{}, nil, cls)
|
||||||
res, err := rec.Recognize(context.Background(), "你好呀", snap)
|
res, err := rec.Recognize(context.Background(), "打结", snap)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)
|
t.Fatalf("classifier should resolve: %+v", res)
|
||||||
}
|
}
|
||||||
if !strings.Contains(res.Top.Reason, "refined_by=classifier") {
|
if !strings.Contains(res.Top.Reason, "refined_by=classifier") {
|
||||||
@@ -168,7 +169,7 @@ func TestRecognizeClassifierResolvesAmbiguity(t *testing.T) {
|
|||||||
func TestRecognizeClassifierFailureKeepsAmbiguous(t *testing.T) {
|
func TestRecognizeClassifierFailureKeepsAmbiguous(t *testing.T) {
|
||||||
_, snap := buildDemoRegistry(t)
|
_, snap := buildDemoRegistry(t)
|
||||||
rec := NewRecognizer(Options{}, nil, stubClassifier{err: errors.New("llm down")})
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("classifier failure must not fail recognition: %v", err)
|
t.Fatalf("classifier failure must not fail recognition: %v", err)
|
||||||
}
|
}
|
||||||
@@ -221,14 +222,14 @@ func TestRecognizeClassifierRejectsBadRefinement(t *testing.T) {
|
|||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
rec := NewRecognizer(Options{}, nil, rewriteClassifier{fn: tc.fn})
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if res.Branch != BranchAmbiguous || res.Top != nil {
|
if res.Branch != BranchAmbiguous || res.Top != nil {
|
||||||
t.Fatalf("expected ambiguous kept: %+v", res)
|
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)
|
t.Fatalf("original candidates kept: %+v", res.Candidates)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -237,7 +238,7 @@ func TestRecognizeClassifierRejectsBadRefinement(t *testing.T) {
|
|||||||
|
|
||||||
func TestRecognizeClassifierRebuildsTamperedHandler(t *testing.T) {
|
func TestRecognizeClassifierRebuildsTamperedHandler(t *testing.T) {
|
||||||
_, snap := buildDemoRegistry(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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -246,7 +247,7 @@ func TestRecognizeClassifierRebuildsTamperedHandler(t *testing.T) {
|
|||||||
out := append([]Candidate(nil), cands...)
|
out := append([]Candidate(nil), cands...)
|
||||||
for i := range out {
|
for i := range out {
|
||||||
out[i].Handler = "tampered.handler"
|
out[i].Handler = "tampered.handler"
|
||||||
if out[i].ID == "demo.greet.hello" {
|
if out[i].ID == "demo.tie.alpha" {
|
||||||
out[i].Confidence = 0.95
|
out[i].Confidence = 0.95
|
||||||
} else {
|
} else {
|
||||||
out[i].Confidence = 0.2
|
out[i].Confidence = 0.2
|
||||||
@@ -254,11 +255,11 @@ func TestRecognizeClassifierRebuildsTamperedHandler(t *testing.T) {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}})
|
}})
|
||||||
res, err := rec.Recognize(context.Background(), "你好呀", snap)
|
res, err := rec.Recognize(context.Background(), "打结", snap)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)
|
t.Fatalf("expected resolved match: %+v", res)
|
||||||
}
|
}
|
||||||
if res.Top.Handler != wantHandler {
|
if res.Top.Handler != wantHandler {
|
||||||
|
|||||||
+11
-3
@@ -139,19 +139,19 @@ func (cs *compiledSpec) evaluate(input, folded string) matchOutcome {
|
|||||||
hay = folded
|
hay = folded
|
||||||
}
|
}
|
||||||
for _, t := range cs.termsEx {
|
for _, t := range cs.termsEx {
|
||||||
if strings.Contains(hay, t) {
|
if cs.contains(hay, t) {
|
||||||
return matchOutcome{reject: "excluded_term:" + t}
|
return matchOutcome{reject: "excluded_term:" + t}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, t := range cs.termsAll {
|
for _, t := range cs.termsAll {
|
||||||
if !strings.Contains(hay, t) {
|
if !cs.contains(hay, t) {
|
||||||
return matchOutcome{reject: "missing_includes_all:" + t}
|
return matchOutcome{reject: "missing_includes_all:" + t}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var hitTerms, hitPat []string
|
var hitTerms, hitPat []string
|
||||||
positive := false
|
positive := false
|
||||||
for _, t := range cs.termsAny {
|
for _, t := range cs.termsAny {
|
||||||
if strings.Contains(hay, t) {
|
if cs.contains(hay, t) {
|
||||||
positive = true
|
positive = true
|
||||||
hitTerms = append(hitTerms, t)
|
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}
|
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) {
|
func compileSpec(spec IntentSpec, guard bool, index int, scoped map[string]SlotValidator, nfkc bool) (*compiledSpec, error) {
|
||||||
m := spec.Matcher
|
m := spec.Matcher
|
||||||
fold := func(terms []string) []string {
|
fold := func(terms []string) []string {
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ type Result struct {
|
|||||||
Top *Candidate
|
Top *Candidate
|
||||||
Candidates []Candidate
|
Candidates []Candidate
|
||||||
SnapshotRevision int64
|
SnapshotRevision int64
|
||||||
|
// Commands 是把本结果展开的路由指令列表(I1 命令化识别);
|
||||||
|
// 可能为空(fallback / guard_hit / 宿主默认通道)。Branch 与 Top 保留,
|
||||||
|
// 供诊断与尚未迁移到命令的消费方使用。
|
||||||
|
Commands []Command
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScoredSpec 诊断用:单个 spec 在某个阶段的得分与命中明细。
|
// ScoredSpec 诊断用:单个 spec 在某个阶段的得分与命中明细。
|
||||||
|
|||||||
@@ -25,12 +25,15 @@ const (
|
|||||||
// 命中条件:excludes_any 全不命中 && includes_all 全命中 &&(includes_any 至少一词命中
|
// 命中条件:excludes_any 全不命中 && includes_all 全命中 &&(includes_any 至少一词命中
|
||||||
// 或任一 pattern 命中;仅配置 includes_all 时以全命中为准)。
|
// 或任一 pattern 命中;仅配置 includes_all 时以全命中为准)。
|
||||||
// case_sensitive 只作用于 includes/excludes 词表;pattern 自行用 (?i) 表达大小写。
|
// case_sensitive 只作用于 includes/excludes 词表;pattern 自行用 (?i) 表达大小写。
|
||||||
|
// boundary 为真时 includes/excludes 词表走词界匹配(命中两侧不得与词条边缘
|
||||||
|
// 同类:汉字对汉字、词字符对词字符),用于压住中文子串误命中(I3);默认 false 保持旧行为。
|
||||||
type MatchSpec struct {
|
type MatchSpec struct {
|
||||||
IncludesAny []string `json:"includes_any,omitempty"`
|
IncludesAny []string `json:"includes_any,omitempty"`
|
||||||
IncludesAll []string `json:"includes_all,omitempty"`
|
IncludesAll []string `json:"includes_all,omitempty"`
|
||||||
ExcludesAny []string `json:"excludes_any,omitempty"`
|
ExcludesAny []string `json:"excludes_any,omitempty"`
|
||||||
Patterns []string `json:"patterns,omitempty"`
|
Patterns []string `json:"patterns,omitempty"`
|
||||||
CaseSensitive bool `json:"case_sensitive,omitempty"`
|
CaseSensitive bool `json:"case_sensitive,omitempty"`
|
||||||
|
Boundary bool `json:"boundary,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SlotSpec 槽位抽取规则。group 未填默认取 1(第一个捕获组)。
|
// SlotSpec 槽位抽取规则。group 未填默认取 1(第一个捕获组)。
|
||||||
|
|||||||
Vendored
+69
-11
@@ -1,14 +1,72 @@
|
|||||||
{
|
{
|
||||||
"cases": [
|
"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": "status_with_slot",
|
||||||
{"name": "guard_config_query", "input": "查询系统配置", "expect_branch": "guard_hit", "expect_top": "guard.config_change"},
|
"input": "查询任务 42 的进度",
|
||||||
{"name": "negated_write_readonly", "input": "怎么配置查询进度", "expect_branch": "fallback"},
|
"expect_branch": "matched",
|
||||||
{"name": "restart_task", "input": "把任务重启一下", "expect_branch": "matched", "expect_top": "demo.run.restart"},
|
"expect_top": "demo.run.status_query",
|
||||||
{"name": "greet_ambiguous", "input": "你好呀", "expect_branch": "ambiguous"},
|
"expect_slots": {
|
||||||
{"name": "cancel_missing_required_slot", "input": "取消一下", "expect_branch": "matched", "expect_top": "demo.run.cancel", "expect_missing_slots": ["run_id"]},
|
"run_id": "42"
|
||||||
{"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_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,
|
"version": 1,
|
||||||
"description": "查询任务运行进度",
|
"description": "查询任务运行进度",
|
||||||
"matcher": {
|
"matcher": {
|
||||||
"includes_any": ["进度", "状态"],
|
"includes_any": [
|
||||||
"excludes_any": ["配置"]
|
"进度",
|
||||||
|
"状态"
|
||||||
|
],
|
||||||
|
"excludes_any": [
|
||||||
|
"配置"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"slots": [
|
"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",
|
"risk": "low",
|
||||||
"handler": "taskrun.query_status",
|
"handler": "taskrun.query_status",
|
||||||
"metadata": {"channel": "dialogue"}
|
"metadata": {
|
||||||
|
"channel": "dialogue"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "demo.run.restart",
|
"id": "demo.run.restart",
|
||||||
"domain": "demo",
|
"domain": "demo",
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"description": "重启任务",
|
"description": "重启任务",
|
||||||
"matcher": {"includes_any": ["重启"], "includes_all": ["任务"]},
|
"matcher": {
|
||||||
"confidence": {"base": 0.75, "threshold": 0.7},
|
"includes_any": [
|
||||||
|
"重启"
|
||||||
|
],
|
||||||
|
"includes_all": [
|
||||||
|
"任务"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"confidence": {
|
||||||
|
"base": 0.75,
|
||||||
|
"threshold": 0.7
|
||||||
|
},
|
||||||
"risk": "medium",
|
"risk": "medium",
|
||||||
"requires_confirm": true,
|
"requires_confirm": true,
|
||||||
"handler": "taskrun.restart"
|
"handler": "taskrun.restart"
|
||||||
@@ -33,9 +58,18 @@
|
|||||||
"domain": "demo",
|
"domain": "demo",
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"description": "取消任务",
|
"description": "取消任务",
|
||||||
"matcher": {"includes_any": ["取消"]},
|
"matcher": {
|
||||||
|
"includes_any": [
|
||||||
|
"取消"
|
||||||
|
]
|
||||||
|
},
|
||||||
"slots": [
|
"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"
|
"handler": "taskrun.cancel"
|
||||||
},
|
},
|
||||||
@@ -43,23 +77,59 @@
|
|||||||
"id": "demo.greet.hello",
|
"id": "demo.greet.hello",
|
||||||
"domain": "demo",
|
"domain": "demo",
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"matcher": {"includes_any": ["你好"]},
|
"matcher": {
|
||||||
|
"includes_any": [
|
||||||
|
"你好"
|
||||||
|
]
|
||||||
|
},
|
||||||
"priority": 10
|
"priority": 10
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "demo.greet.hi",
|
"id": "demo.greet.hi",
|
||||||
"domain": "demo",
|
"domain": "demo",
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"matcher": {"includes_any": ["你好"]},
|
"matcher": {
|
||||||
|
"includes_any": [
|
||||||
|
"你好"
|
||||||
|
]
|
||||||
|
},
|
||||||
"priority": 5
|
"priority": 5
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "demo.misc.deploy",
|
"id": "demo.misc.deploy",
|
||||||
"domain": "demo",
|
"domain": "demo",
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"matcher": {"includes_any": ["deploy", "发布"], "case_sensitive": false},
|
"matcher": {
|
||||||
|
"includes_any": [
|
||||||
|
"deploy",
|
||||||
|
"发布"
|
||||||
|
],
|
||||||
|
"case_sensitive": false
|
||||||
|
},
|
||||||
"handler": "deploy.run"
|
"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": []
|
"disable": []
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user