Why: I1 WP2 - one explicit action field outranks the four legacy action keys (Handler, metadata.blueprint, command-id, workflow IntentID) so hosts migrate to a single derivation path. Evidence: go vet clean; go test ./... all ok, including declared action winning the Start command and kind/id validation rejections. Impact: IntentSpec gains Action (command/workflow/blueprint/handler); Candidate carries it through; actionOf prefers it, then blueprint, then Handler, then auto. Legacy keys keep working. Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md I1 WP2
157 lines
5.5 KiB
Go
157 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 从候选推导动作引用:spec.action 显式声明优先,
|
|
// 其后是 blueprint 键、Handler,都没有时回落 auto(宿主裁决)。
|
|
func actionOf(c Candidate) ActionRef {
|
|
if c.Action != nil {
|
|
return *c.Action
|
|
}
|
|
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
|
|
}
|