Why: I1 increment one - recognition output becomes a command list for the host router, same-score ties resolve by priority (I5), and CJK substring false positives get an opt-in boundary mode (I3). Evidence: go vet clean; go test ./... all ok, including new command (6), boundary (3), and tie-break tests; intentctl lint smoke run shows boundary hints on short unbounded terms. Existing classifier and ambiguity fixtures repointed to an equal-priority pair; the greet corpus case now expects the I5 tie-break outcome. Impact: Result gains Commands; recognizer derives them at one point (guard/fallback stay empty); MatchSpec gains Boundary (default off, legacy behavior unchanged); lint gains boundary hints. Top/Branch stay for compatibility. Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md I1 WP1/WP4/WP5
154 lines
5.5 KiB
Go
154 lines
5.5 KiB
Go
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
|
||
}
|