Admit intents with full slot validity and schema-typed slots
Why: F09 - invalid slot information was dropped at the gate, the router saw only Start, and the command entrance passed untyped text, so invalid-slot recognitions could still reach business execution paths. Evidence: intentgate/route/server tests pass (verify-u6.json); admission regression cases added. Impact: invalid or missing slots never start executable actions; clarification flows unchanged; cmdbus confirmation untouched. Refs: feature/agent-stack-audit-remediation-b1 U6
This commit is contained in:
@@ -7,6 +7,8 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
agentintent "agent-intent/intent"
|
||||
@@ -14,8 +16,21 @@ import (
|
||||
|
||||
// SlotQuestion 是命中意图后仍缺的必填槽位,以及建议的提问。
|
||||
type SlotQuestion struct {
|
||||
Key string
|
||||
Question string
|
||||
Key string `json:"key"`
|
||||
Question string `json:"question"`
|
||||
}
|
||||
|
||||
// SlotDefect 是一条槽位缺陷,来自权威识别结果的校验(F09):
|
||||
// - Invalid=true:捕获到了值但没通过确定性校验(枚举/格式/校验器/跨度重叠/
|
||||
// 生成路径的未知键),Reason 是库层原因原文;
|
||||
// - Invalid=false:必填槽位根本没给(等价于 SlotQuestion,Reason 为空)。
|
||||
//
|
||||
// 宿主据此区分「没给」与「给错了」,两种情形都不派发业务动作。
|
||||
type SlotDefect struct {
|
||||
Key string `json:"key"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Question string `json:"question"`
|
||||
Invalid bool `json:"invalid"`
|
||||
}
|
||||
|
||||
// MatchResult 是一次识别。Branch 与 Match 相同。
|
||||
@@ -24,11 +39,22 @@ type MatchResult struct {
|
||||
ID string
|
||||
Slots map[string]string
|
||||
Missing []SlotQuestion
|
||||
// Invalid 是捕获到但未通过权威校验的槽位(含生成路径的未知键)。
|
||||
// 与 Missing 分开投影:必填且非法只进此处,不重复计入 Missing。
|
||||
Invalid []SlotDefect
|
||||
// Risk/RequiresConfirm/Action 是权威候选的声明(规则路径与生成路径同款):
|
||||
// 风险档、是否要求显式确认、spec.action 显式动作引用。nil Action 表示未声明。
|
||||
Risk string
|
||||
RequiresConfirm bool
|
||||
Action *ActionRef
|
||||
// Commands 是库层展开的命令列表(I1);Router 消费它做唯一裁决。
|
||||
// guard_hit/expectation_hit 被折叠为 fallback 时为空。
|
||||
Commands []Command
|
||||
}
|
||||
|
||||
// Complete 报告槽位是否齐全且全部通过权威校验:false 时业务动作不得派发。
|
||||
func (m MatchResult) Complete() bool { return len(m.Missing) == 0 && len(m.Invalid) == 0 }
|
||||
|
||||
// 命令类型经闸门投影(别名):生态库按边界规则只许本包导入,
|
||||
// Router/runmgr 等消费方只依赖本包表面。
|
||||
type (
|
||||
@@ -38,6 +64,12 @@ type (
|
||||
|
||||
const (
|
||||
CommandStart = agentintent.CommandStart
|
||||
CommandSetSlot = agentintent.CommandSetSlot
|
||||
CommandChangeSlot = agentintent.CommandChangeSlot
|
||||
CommandCancel = agentintent.CommandCancel
|
||||
CommandClarify = agentintent.CommandClarify
|
||||
CommandChitchat = agentintent.CommandChitchat
|
||||
CommandHandoff = agentintent.CommandHandoff
|
||||
ActionAuto = agentintent.ActionAuto
|
||||
ActionCommand = agentintent.ActionCommand
|
||||
ActionWorkflow = agentintent.ActionWorkflow
|
||||
@@ -133,7 +165,7 @@ func (g *Gate) Match(ctx context.Context, input string) (string, string) {
|
||||
return res.Branch, res.ID
|
||||
}
|
||||
|
||||
// Recognize 在 Match 的结果上带上槽位和缺失提问。
|
||||
// Recognize 在 Match 的结果上带上槽位、缺失提问、非法原因与风险/确认/动作声明。
|
||||
func (g *Gate) Recognize(ctx context.Context, input string) MatchResult {
|
||||
g.mu.RLock()
|
||||
reg, rec, byID := g.reg, g.rec, g.specsByID
|
||||
@@ -163,16 +195,45 @@ func (g *Gate) Recognize(ctx context.Context, input string) MatchResult {
|
||||
out.Slots[k] = v
|
||||
}
|
||||
}
|
||||
// 风险/确认/动作声明:规则路径与生成路径(candidateFromGenerated)同款字段,
|
||||
// 宿主据此决定是否必须走确认,不在本包内裁决。
|
||||
out.Risk = res.Top.Risk
|
||||
out.RequiresConfirm = res.Top.RequiresConfirm
|
||||
if res.Top.Action != nil {
|
||||
action := *res.Top.Action
|
||||
out.Action = &action
|
||||
}
|
||||
spec := byID[out.ID]
|
||||
for _, key := range res.Top.MissingRequiredSlots {
|
||||
q := key
|
||||
out.Missing = append(out.Missing, SlotQuestion{Key: key, Question: questionOf(spec, key)})
|
||||
}
|
||||
// 非法槽位按键名排序(识别结果里的 map 无序,投影必须确定)。
|
||||
for _, key := range sortedKeys(res.Top.InvalidSlots) {
|
||||
out.Invalid = append(out.Invalid, SlotDefect{
|
||||
Key: key, Reason: res.Top.InvalidSlots[key], Invalid: true,
|
||||
Question: questionOf(spec, key),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// questionOf 取 spec 为该槽位声明的建议提问;没有就回落键名本身。
|
||||
func questionOf(spec agentintent.IntentSpec, key string) string {
|
||||
for _, sl := range spec.Slots {
|
||||
if sl.Key == key && sl.Question != "" {
|
||||
q = sl.Question
|
||||
if sl.Key == key && strings.TrimSpace(sl.Question) != "" {
|
||||
return sl.Question
|
||||
}
|
||||
}
|
||||
out.Missing = append(out.Missing, SlotQuestion{Key: key, Question: q})
|
||||
return key
|
||||
}
|
||||
|
||||
// sortedKeys 按键名排序,保证同一识别结果的投影顺序确定。
|
||||
func sortedKeys(m map[string]string) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package intentgate
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentintent "agent-intent/intent"
|
||||
@@ -176,3 +177,149 @@ func TestGateGeneratorProducesCommands(t *testing.T) {
|
||||
t.Fatalf("关闭生成层:branch = %s", branch)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- F09:非法槽位与风险/确认/动作声明的透传(规则路径与生成路径同款)----
|
||||
|
||||
// modeSpec 声明一个带枚举校验的风险意图:mode 与 topic 都是必填。
|
||||
func modeSpec(t *testing.T) json.RawMessage {
|
||||
t.Helper()
|
||||
return raw(t, map[string]any{
|
||||
"id": "demo.mode", "domain": "demo", "version": 1,
|
||||
"matcher": map[string]any{"includes_any": []string{"切换"}},
|
||||
"risk": "high",
|
||||
"requires_confirm": true,
|
||||
"action": map[string]any{"kind": "command", "id": "demo.mode"},
|
||||
"slots": []map[string]any{
|
||||
{"key": "mode", "required": true, "pattern": `模式[::]\s*(\S+)`, "enum": []string{"摘要", "全文"}, "question": "要哪种模式?"},
|
||||
{"key": "topic", "required": true, "pattern": `主题[::]\s*(\S+)`, "question": "主题是什么?"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestRecognizeKeepsInvalidSlotDetail(t *testing.T) {
|
||||
g := New()
|
||||
if err := g.Rebuild(context.Background(), []json.RawMessage{modeSpec(t)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bad := g.Recognize(context.Background(), "切换 主题:核能 模式:随手写")
|
||||
if bad.Branch != "matched" || bad.ID != "demo.mode" {
|
||||
t.Fatalf("识别分支:%+v", bad)
|
||||
}
|
||||
if bad.Complete() {
|
||||
t.Fatalf("非法枚举值不得算作完整:%+v", bad)
|
||||
}
|
||||
if len(bad.Missing) != 0 {
|
||||
// 必填且非法只进 Invalid,不重复进 Missing。
|
||||
t.Fatalf("非法槽位不应重复计入缺失:%+v", bad.Missing)
|
||||
}
|
||||
if len(bad.Invalid) != 1 {
|
||||
t.Fatalf("非法槽位详情:%+v", bad.Invalid)
|
||||
}
|
||||
d := bad.Invalid[0]
|
||||
if d.Key != "mode" || !d.Invalid || d.Reason != "value not in enum" || d.Question != "要哪种模式?" {
|
||||
t.Fatalf("非法原因/提问未透传:%+v", d)
|
||||
}
|
||||
if _, leaked := bad.Slots["mode"]; leaked {
|
||||
t.Fatalf("非法值不得进入已校验槽位表:%+v", bad.Slots)
|
||||
}
|
||||
if bad.Slots["topic"] != "核能" {
|
||||
t.Fatalf("合法槽位丢失:%+v", bad.Slots)
|
||||
}
|
||||
// 风险/确认/动作声明一并透传(宿主据此决定确认与派发)。
|
||||
if bad.Risk != "high" || !bad.RequiresConfirm {
|
||||
t.Fatalf("风险元数据未透传:%+v", bad)
|
||||
}
|
||||
if bad.Action == nil || bad.Action.Kind != ActionCommand || bad.Action.ID != "demo.mode" {
|
||||
t.Fatalf("动作声明未透传:%+v", bad.Action)
|
||||
}
|
||||
|
||||
ok := g.Recognize(context.Background(), "切换 主题:核能 模式:摘要")
|
||||
if !ok.Complete() || ok.Slots["mode"] != "摘要" || len(ok.Invalid) != 0 {
|
||||
t.Fatalf("合法槽位识别:%+v", ok)
|
||||
}
|
||||
}
|
||||
|
||||
// 生成路径同样经过权威槽位校验:非法值与未知键都要带原因回到宿主。
|
||||
func TestRecognizeGeneratedPathCarriesSlotDetail(t *testing.T) {
|
||||
g := New()
|
||||
if err := g.Rebuild(context.Background(), []json.RawMessage{modeSpec(t)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g.SetGenerator(stubGenerator{cmds: []Command{{
|
||||
Kind: CommandStart,
|
||||
Action: ActionRef{Kind: ActionCommand, ID: "demo.mode"},
|
||||
Slots: map[string]string{"mode": "随手写", "topic": "核能"},
|
||||
}}})
|
||||
res := g.Recognize(context.Background(), "跟昨天一样")
|
||||
if res.Branch != "matched" || res.ID != "demo.mode" {
|
||||
t.Fatalf("生成路径识别:%+v", res)
|
||||
}
|
||||
if len(res.Invalid) != 1 || res.Invalid[0].Key != "mode" || res.Invalid[0].Reason != "value not in enum" ||
|
||||
res.Invalid[0].Question != "要哪种模式?" {
|
||||
t.Fatalf("生成路径非法详情:%+v", res.Invalid)
|
||||
}
|
||||
if _, leaked := res.Slots["mode"]; leaked || res.Complete() {
|
||||
t.Fatalf("生成路径非法值不得进入槽位表:%+v", res.Slots)
|
||||
}
|
||||
if res.Risk != "high" || !res.RequiresConfirm || res.Action == nil {
|
||||
t.Fatalf("生成路径风险元数据:%+v", res)
|
||||
}
|
||||
|
||||
// 未知键:库层判为 invalid(reason=unknown slot key),宿主据此拒绝派发。
|
||||
g.SetGenerator(stubGenerator{cmds: []Command{{
|
||||
Kind: CommandStart,
|
||||
Action: ActionRef{Kind: ActionCommand, ID: "demo.mode"},
|
||||
Slots: map[string]string{"mode": "摘要", "topic": "核能"},
|
||||
}}})
|
||||
clean := g.Recognize(context.Background(), "跟昨天一样")
|
||||
if !clean.Complete() || clean.Slots["mode"] != "摘要" {
|
||||
t.Fatalf("生成路径合法槽位:%+v", clean)
|
||||
}
|
||||
}
|
||||
|
||||
// 必填槽位缺失与非法是两条独立通道:缺失走 Missing,非法走 Invalid。
|
||||
func TestRecognizeDistinguishesMissingFromInvalid(t *testing.T) {
|
||||
g := New()
|
||||
if err := g.Rebuild(context.Background(), []json.RawMessage{modeSpec(t)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res := g.Recognize(context.Background(), "切换 主题:核能")
|
||||
if len(res.Missing) != 1 || res.Missing[0].Key != "mode" || res.Missing[0].Question != "要哪种模式?" {
|
||||
t.Fatalf("缺失投影:%+v", res.Missing)
|
||||
}
|
||||
if len(res.Invalid) != 0 {
|
||||
t.Fatalf("缺失不得混进非法:%+v", res.Invalid)
|
||||
}
|
||||
if res.Complete() {
|
||||
t.Fatal("缺必填槽位不得算作完整")
|
||||
}
|
||||
}
|
||||
|
||||
// 格式校验失败与枚举失败同款透传(原因原文来自库层,不做改写)。
|
||||
func TestRecognizeKeepsFormatFailureReason(t *testing.T) {
|
||||
g := New()
|
||||
spec := raw(t, map[string]any{
|
||||
"id": "demo.ticket", "domain": "demo", "version": 1,
|
||||
"matcher": map[string]any{"includes_any": []string{"开工单"}},
|
||||
"slots": []map[string]any{{
|
||||
"key": "issue", "required": true, "pattern": `编号[::]\s*(\S+)`,
|
||||
"format": `[A-Z]{2}-\d+`, "question": "编号是什么?",
|
||||
}},
|
||||
})
|
||||
if err := g.Rebuild(context.Background(), []json.RawMessage{spec}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bad := g.Recognize(context.Background(), "开工单 编号:随手写的")
|
||||
if len(bad.Invalid) != 1 || bad.Invalid[0].Key != "issue" || bad.Invalid[0].Question != "编号是什么?" {
|
||||
t.Fatalf("格式失败明细:%+v", bad.Invalid)
|
||||
}
|
||||
if !strings.HasPrefix(bad.Invalid[0].Reason, "format mismatch") {
|
||||
t.Fatalf("格式失败原因:%+v", bad.Invalid[0])
|
||||
}
|
||||
if bad.Complete() || len(bad.Missing) != 0 {
|
||||
t.Fatalf("格式失败不得算作完整或缺失:%+v", bad)
|
||||
}
|
||||
if ok := g.Recognize(context.Background(), "开工单 编号:AB-12"); !ok.Complete() || ok.Slots["issue"] != "AB-12" {
|
||||
t.Fatalf("格式合法:%+v", ok)
|
||||
}
|
||||
}
|
||||
|
||||
+175
-18
@@ -10,15 +10,22 @@
|
||||
// - PromptTrigger(技能触发)是提示词装配、不参与路由决胜;
|
||||
// 意图产出 Start/Clarify 类命令时路由优先。
|
||||
//
|
||||
// 槽位准入(F09):可执行业务动作(命令总线)必须在槽位齐全且全部通过
|
||||
// 权威校验时才派发;缺失或非法一律不派发,转澄清诉求(Clarification)。
|
||||
// 工作流是「边收集边执行」的草稿:按既有规则照常启动,缺失清单随决策透出,
|
||||
// 由流程自身追问——非法值不会进入槽位表(库层只把合法值写进 Slots)。
|
||||
//
|
||||
// 当前版本的已知边界(如实记录,不静默):
|
||||
// - squad 决策需要 runmgr 的 per-run entry 覆盖才能真正执行(独立设计项);
|
||||
// 本版本调用方按普通回合处理并保留 Decision 供审计。
|
||||
// - SetSlot/Cancel/Handoff 命令由库层产出,但 Backend 尚无会话槽位状态机
|
||||
// 与语义层,Router v1 不消费它们(expectation_hit 在 intentgate 已被折叠为
|
||||
// fallback,Start 只来自 matched)。
|
||||
// - SetSlot/ChangeSlot/Cancel/Handoff 由库层产出,但 Backend 尚无会话槽位
|
||||
// 状态机与语义层:它们记入 Unsupported 并回落普通回合,绝不假装已支持
|
||||
// (例如用户说「取消」时不会把同一句话里的 Start 当成业务动作派发)。
|
||||
package route
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"agent-system-backend/internal/intentgate"
|
||||
core "agent-system-core"
|
||||
)
|
||||
@@ -33,6 +40,18 @@ const (
|
||||
KindTurn Kind = "turn"
|
||||
)
|
||||
|
||||
// Clarification 是「业务动作未派发,需要向用户澄清」的诉求。
|
||||
// 槽位缺失与非法都带结构化原因(key + reason + question),
|
||||
// 宿主据此渲染澄清话术或转成回合提示,不再自行猜测。
|
||||
type Clarification struct {
|
||||
Needed bool
|
||||
Question string
|
||||
Missing []intentgate.SlotQuestion
|
||||
Invalid []intentgate.SlotDefect
|
||||
// Command 是被拦下的动作 ID(诊断与审计用,未派发)。
|
||||
Command string
|
||||
}
|
||||
|
||||
// Decision 是一次路由裁决。
|
||||
type Decision struct {
|
||||
Kind Kind
|
||||
@@ -41,8 +60,17 @@ type Decision struct {
|
||||
Blueprint string
|
||||
Slots map[string]string
|
||||
Missing []intentgate.SlotQuestion
|
||||
Reason string // 回落 turn 的原因(显式声明不可解析时)
|
||||
Invalid []intentgate.SlotDefect
|
||||
Risk string // 权威意图声明的风险档(low/medium/high)
|
||||
Reason string // 回落 turn 的原因(显式声明不可解析、准入未过等)
|
||||
StartFound bool // 是否存在 Start 命令(runmgr 转轨判定用)
|
||||
// RequiresConfirm 是意图声明的显式确认要求(与命令自身的 danger 分级正交,
|
||||
// 本包只透传,不替代 cmdbus 的高危确认收口)。
|
||||
RequiresConfirm bool
|
||||
// Clarification 非空时业务动作未派发(Needed=true 时必有槽位原因)。
|
||||
Clarification Clarification
|
||||
// Unsupported 是本次识别里宿主不消费的库层命令种类(如 cancel/set_slot)。
|
||||
Unsupported []string
|
||||
}
|
||||
|
||||
// Env 是路由裁决的环境解析器(由调用方注入;nil 视为不可解析)。
|
||||
@@ -52,22 +80,51 @@ type Env struct {
|
||||
SquadReady bool
|
||||
}
|
||||
|
||||
// unconsumedCommandKinds 是宿主 v1 不消费的库层命令种类(无会话槽位状态机)。
|
||||
// 命中即不派发业务动作:把「取消」当成「启动」是比不响更糟的错配。
|
||||
var unconsumedCommandKinds = map[string]bool{
|
||||
string(intentgate.CommandSetSlot): true,
|
||||
string(intentgate.CommandChangeSlot): true,
|
||||
string(intentgate.CommandCancel): true,
|
||||
string(intentgate.CommandHandoff): true,
|
||||
string(intentgate.CommandChitchat): true,
|
||||
}
|
||||
|
||||
// Decide 裁决一次识别结果。规则:
|
||||
// 1. 非 matched(fallback/guard/ambiguous 经闸门折叠后)→ turn。
|
||||
// 2. 找到首个 Start 命令;没有时按 TopID 走 auto(兼容兜底,与现网一致)。
|
||||
// 3. 按 ActionRef.Kind 解析:command→命令总线;workflow→工作流;
|
||||
// blueprint→squad 决策;handler/auto→命令总线 > 工作流 > turn。
|
||||
// 2. 出现宿主不消费的库层命令(cancel/set_slot/…)→ turn,并如实记录
|
||||
// Unsupported 与 Reason(不假装支持,也不派发同句的 Start)。
|
||||
// 3. 找到首个 Start 命令;没有时按 TopID(或显式 action 声明)走 auto
|
||||
// (兼容兜底,与现网一致)。
|
||||
// 4. 按 ActionRef.Kind 解析:command→命令总线(先过槽位准入);
|
||||
// workflow→工作流(草稿,缺槽照常启动);blueprint→squad 决策;
|
||||
// handler/auto→命令总线 > 工作流 > turn。
|
||||
func Decide(res intentgate.MatchResult, env Env) Decision {
|
||||
d := Decision{Kind: KindTurn, Slots: res.Slots, Missing: res.Missing}
|
||||
d := Decision{Kind: KindTurn, Slots: res.Slots, Missing: res.Missing, Invalid: res.Invalid,
|
||||
Risk: res.Risk, RequiresConfirm: res.RequiresConfirm}
|
||||
if res.Branch != "matched" {
|
||||
return d
|
||||
}
|
||||
var start *intentgate.Command
|
||||
d.Unsupported = unconsumedKinds(res.Commands)
|
||||
var start, clarify *intentgate.Command
|
||||
for i := range res.Commands {
|
||||
if res.Commands[i].Kind == intentgate.CommandStart {
|
||||
switch res.Commands[i].Kind {
|
||||
case intentgate.CommandStart:
|
||||
if start == nil {
|
||||
start = &res.Commands[i]
|
||||
break
|
||||
}
|
||||
case intentgate.CommandClarify:
|
||||
if clarify == nil {
|
||||
clarify = &res.Commands[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(d.Unsupported) > 0 {
|
||||
d.Reason = "宿主未消费库层命令:" + strings.Join(d.Unsupported, "、")
|
||||
if clarify != nil {
|
||||
d.Clarification = clarificationFrom("", res, clarify)
|
||||
}
|
||||
return d
|
||||
}
|
||||
action := intentgate.ActionRef{Kind: intentgate.ActionAuto, ID: res.ID}
|
||||
if start != nil {
|
||||
@@ -76,19 +133,19 @@ func Decide(res intentgate.MatchResult, env Env) Decision {
|
||||
if len(start.Slots) > 0 {
|
||||
d.Slots = start.Slots
|
||||
}
|
||||
} else if res.Action != nil {
|
||||
action = *res.Action
|
||||
}
|
||||
switch action.Kind {
|
||||
case intentgate.ActionCommand:
|
||||
if env.HasCommand != nil && env.HasCommand(action.ID) {
|
||||
d.Kind, d.CommandID = KindCommand, action.ID
|
||||
return d
|
||||
return d.dispatchCommand(action.ID, res, clarify)
|
||||
}
|
||||
d.Reason = "声明的命令不存在:" + action.ID
|
||||
case intentgate.ActionWorkflow:
|
||||
if env.WorkflowFor != nil {
|
||||
if def, ok := env.WorkflowFor(action.ID); ok {
|
||||
d.Kind, d.Workflow = KindWorkflow, def
|
||||
return d
|
||||
return d.startWorkflowDraft(def)
|
||||
}
|
||||
}
|
||||
d.Reason = "声明的工作流未注册:" + action.ID
|
||||
@@ -100,15 +157,115 @@ func Decide(res intentgate.MatchResult, env Env) Decision {
|
||||
d.Reason = "声明的小队未启用:" + action.ID
|
||||
default: // auto / handler
|
||||
if env.HasCommand != nil && env.HasCommand(action.ID) {
|
||||
d.Kind, d.CommandID = KindCommand, action.ID
|
||||
return d
|
||||
return d.dispatchCommand(action.ID, res, clarify)
|
||||
}
|
||||
if env.WorkflowFor != nil {
|
||||
if def, ok := env.WorkflowFor(action.ID); ok {
|
||||
return d.startWorkflowDraft(def)
|
||||
}
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// dispatchCommand 派发可执行业务动作前的最后一道闸:槽位必须齐全且通过权威校验。
|
||||
// 未就绪时不碰业务动作,改为带结构化原因的澄清诉求(Kind 留在 turn)。
|
||||
func (d Decision) dispatchCommand(id string, res intentgate.MatchResult, clarify *intentgate.Command) Decision {
|
||||
if !res.Complete() {
|
||||
d.Clarification = clarificationFrom(id, res, clarify)
|
||||
d.Reason = "槽位未就绪,未派发命令:" + id
|
||||
return d
|
||||
}
|
||||
d.Kind, d.CommandID = KindCommand, id
|
||||
return d
|
||||
}
|
||||
|
||||
// startWorkflowDraft 工作流是收集槽位的草稿:缺失与非法都不阻断启动
|
||||
// (非法值本就进不了 Slots),缺失清单随 Decision 透出,由流程自身追问。
|
||||
func (d Decision) startWorkflowDraft(def core.WorkflowDef) Decision {
|
||||
d.Kind, d.Workflow = KindWorkflow, def
|
||||
return d
|
||||
}
|
||||
|
||||
// unconsumedKinds 列出命令列表里宿主不消费的种类(按首次出现序,确定性)。
|
||||
func unconsumedKinds(cmds []intentgate.Command) []string {
|
||||
var out []string
|
||||
for _, c := range cmds {
|
||||
if !unconsumedCommandKinds[string(c.Kind)] {
|
||||
continue
|
||||
}
|
||||
if contains(out, string(c.Kind)) {
|
||||
continue
|
||||
}
|
||||
out = append(out, string(c.Kind))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func contains(list []string, v string) bool {
|
||||
for _, x := range list {
|
||||
if x == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return d
|
||||
return false
|
||||
}
|
||||
|
||||
// clarificationFrom 组装澄清诉求:优先用 spec 声明的建议提问,
|
||||
// 缺失时回落库层 clarify 命令里的 Question,再缺就按缺陷键列一句兜底。
|
||||
func clarificationFrom(id string, res intentgate.MatchResult, clarify *intentgate.Command) Clarification {
|
||||
c := Clarification{Needed: true, Command: id,
|
||||
Missing: append([]intentgate.SlotQuestion(nil), res.Missing...),
|
||||
Invalid: append([]intentgate.SlotDefect(nil), res.Invalid...)}
|
||||
if len(c.Missing) > 0 {
|
||||
c.Question = strings.TrimSpace(c.Missing[0].Question)
|
||||
}
|
||||
if c.Question == "" && len(c.Invalid) > 0 {
|
||||
c.Question = strings.TrimSpace(c.Invalid[0].Question)
|
||||
}
|
||||
if clarify != nil {
|
||||
if c.Question == "" {
|
||||
c.Question = strings.TrimSpace(clarify.Question)
|
||||
}
|
||||
// 库层 clarify 的 Missing 是缺失与非法键的合并;补齐宿主侧没有投影的键。
|
||||
for _, k := range clarify.Missing {
|
||||
if defectKeyKnown(c, k) {
|
||||
continue
|
||||
}
|
||||
c.Missing = append(c.Missing, intentgate.SlotQuestion{Key: k, Question: k})
|
||||
}
|
||||
}
|
||||
if c.Question == "" {
|
||||
c.Question = fallbackQuestion(c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func defectKeyKnown(c Clarification, key string) bool {
|
||||
for _, m := range c.Missing {
|
||||
if m.Key == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, iv := range c.Invalid {
|
||||
if iv.Key == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// fallbackQuestion 没有任何声明提问时的兜底话术:按缺失在前、非法在后列键。
|
||||
func fallbackQuestion(c Clarification) string {
|
||||
var keys []string
|
||||
for _, m := range c.Missing {
|
||||
keys = append(keys, m.Key)
|
||||
}
|
||||
for _, iv := range c.Invalid {
|
||||
keys = append(keys, iv.Key)
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "请补充:" + strings.Join(keys, "、")
|
||||
}
|
||||
|
||||
@@ -81,3 +81,124 @@ func TestSlotsFromStartCommand(t *testing.T) {
|
||||
t.Fatalf("slots = %v", d.Slots)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- F09:槽位准入——业务动作必须齐全且合法,否则转澄清 ----
|
||||
|
||||
func matchedCommand(action intentgate.ActionRef) intentgate.MatchResult {
|
||||
return intentgate.MatchResult{Branch: "matched", ID: action.ID,
|
||||
Commands: []intentgate.Command{startCmd(action)}}
|
||||
}
|
||||
|
||||
func hasCommandEnv() Env { return Env{HasCommand: func(string) bool { return true }} }
|
||||
|
||||
func TestMissingSlotsBlockCommandDispatch(t *testing.T) {
|
||||
in := matchedCommand(intentgate.ActionRef{Kind: intentgate.ActionAuto, ID: "demo.brief"})
|
||||
in.Missing = []intentgate.SlotQuestion{{Key: "topic", Question: "主题是什么?"}}
|
||||
d := Decide(in, hasCommandEnv())
|
||||
if d.Kind != KindTurn || d.CommandID != "" {
|
||||
t.Fatalf("缺必填槽位不得派发命令:%+v", d)
|
||||
}
|
||||
if !d.Clarification.Needed || d.Clarification.Command != "demo.brief" {
|
||||
t.Fatalf("澄清诉求缺失:%+v", d.Clarification)
|
||||
}
|
||||
if d.Clarification.Question != "主题是什么?" || len(d.Clarification.Missing) != 1 {
|
||||
t.Fatalf("澄清提问未透传:%+v", d.Clarification)
|
||||
}
|
||||
if d.Reason == "" || !d.StartFound {
|
||||
t.Fatalf("回落原因或 Start 标记缺失:%+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidSlotsBlockCommandWithReason(t *testing.T) {
|
||||
in := matchedCommand(intentgate.ActionRef{Kind: intentgate.ActionCommand, ID: "demo.mode"})
|
||||
in.Invalid = []intentgate.SlotDefect{{Key: "mode", Reason: "value not in enum", Question: "要哪种模式?", Invalid: true}}
|
||||
d := Decide(in, hasCommandEnv())
|
||||
if d.Kind != KindTurn || d.CommandID != "" {
|
||||
t.Fatalf("非法槽位不得派发命令:%+v", d)
|
||||
}
|
||||
if len(d.Clarification.Invalid) != 1 || d.Clarification.Invalid[0].Reason != "value not in enum" {
|
||||
t.Fatalf("非法原因未透传:%+v", d.Clarification)
|
||||
}
|
||||
if d.Clarification.Question != "要哪种模式?" {
|
||||
t.Fatalf("澄清提问未透传:%+v", d.Clarification)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteSlotsDispatchCommand(t *testing.T) {
|
||||
in := matchedCommand(intentgate.ActionRef{Kind: intentgate.ActionAuto, ID: "demo.brief"})
|
||||
in.Slots = map[string]string{"topic": "核能"}
|
||||
in.Risk, in.RequiresConfirm = "high", true
|
||||
d := Decide(in, hasCommandEnv())
|
||||
if d.Kind != KindCommand || d.CommandID != "demo.brief" {
|
||||
t.Fatalf("完整合法槽位应派发:%+v", d)
|
||||
}
|
||||
if d.Risk != "high" || !d.RequiresConfirm || d.Clarification.Needed {
|
||||
t.Fatalf("风险元数据未透传或误报澄清:%+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// 工作流是「边收集边执行」的草稿:缺失清单照常启动,由流程自身追问。
|
||||
func TestWorkflowDraftStartsWhileGatheringSlots(t *testing.T) {
|
||||
in := matchedCommand(intentgate.ActionRef{Kind: intentgate.ActionAuto, ID: "demo.brief"})
|
||||
in.Missing = []intentgate.SlotQuestion{{Key: "topic", Question: "主题是什么?"}}
|
||||
d := Decide(in, Env{
|
||||
HasCommand: func(string) bool { return false },
|
||||
WorkflowFor: func(string) (core.WorkflowDef, bool) { return core.WorkflowDef{ID: "wf"}, true },
|
||||
})
|
||||
if d.Kind != KindWorkflow || d.Workflow.ID != "wf" {
|
||||
t.Fatalf("工作流草稿应照常启动:%+v", d)
|
||||
}
|
||||
if len(d.Missing) != 1 || d.Clarification.Needed {
|
||||
t.Fatalf("缺失清单应随决策透出:%+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// 显式的取消/改槽命令不得被静默当成已支持:回落普通回合,如实记录。
|
||||
func TestUnsupportedLibraryCommandsAreNotClaimed(t *testing.T) {
|
||||
in := intentgate.MatchResult{Branch: "matched", ID: "demo.brief", Commands: []intentgate.Command{
|
||||
{Kind: intentgate.CommandStart, Action: intentgate.ActionRef{Kind: intentgate.ActionAuto, ID: "demo.brief"}},
|
||||
{Kind: intentgate.CommandCancel, Target: "demo.brief"},
|
||||
}}
|
||||
d := Decide(in, hasCommandEnv())
|
||||
if d.Kind != KindTurn || d.CommandID != "" {
|
||||
t.Fatalf("未消费的库层命令不得派发业务动作:%+v", d)
|
||||
}
|
||||
if len(d.Unsupported) != 1 || d.Unsupported[0] != string(intentgate.CommandCancel) {
|
||||
t.Fatalf("未消费命令种类未如实记录:%+v", d.Unsupported)
|
||||
}
|
||||
if d.Reason == "" {
|
||||
t.Fatalf("回落原因缺失:%+v", d)
|
||||
}
|
||||
// 只有改槽命令时同样回落,且不得声称支持。
|
||||
only := intentgate.MatchResult{Branch: "matched", ID: "demo.brief", Commands: []intentgate.Command{
|
||||
{Kind: intentgate.CommandSetSlot, Name: "topic", Value: "核能"},
|
||||
}}
|
||||
got := Decide(only, hasCommandEnv())
|
||||
if got.Kind != KindTurn || got.CommandID != "" || len(got.Unsupported) != 1 {
|
||||
t.Fatalf("set_slot 不得被当成可派发:%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 澄清提问优先用 spec 声明,缺失时回落库层 clarify 命令,最后按键名兜底。
|
||||
func TestClarificationQuestionSources(t *testing.T) {
|
||||
in := matchedCommand(intentgate.ActionRef{Kind: intentgate.ActionCommand, ID: "demo.mode"})
|
||||
in.Missing = []intentgate.SlotQuestion{{Key: "mode"}}
|
||||
in.Commands = append(in.Commands, intentgate.Command{Kind: intentgate.CommandClarify, Question: "要哪种模式?"})
|
||||
if q := Decide(in, hasCommandEnv()).Clarification.Question; q != "要哪种模式?" {
|
||||
t.Fatalf("库层澄清提问未用上:%q", q)
|
||||
}
|
||||
in.Commands = in.Commands[:1]
|
||||
if q := Decide(in, hasCommandEnv()).Clarification.Question; q != "请补充:mode" {
|
||||
t.Fatalf("兜底提问:%q", q)
|
||||
}
|
||||
}
|
||||
|
||||
// 旧消费方(命令列表为空)仍按 TopID 走 auto,且必须槽位完整。
|
||||
func TestLegacyAutoStillNeedsCompleteSlots(t *testing.T) {
|
||||
in := intentgate.MatchResult{Branch: "matched", ID: "demo.x",
|
||||
Missing: []intentgate.SlotQuestion{{Key: "topic", Question: "主题是什么?"}}}
|
||||
d := Decide(in, hasCommandEnv())
|
||||
if d.Kind != KindTurn || d.CommandID != "" || !d.Clarification.Needed {
|
||||
t.Fatalf("旧路径也必须过槽位准入:%+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
package server
|
||||
|
||||
// 意图接纳(F09):把「一次识别」落到「一次命令派发」之前,
|
||||
// 按命令 DECLARED schema 完成参数映射与准入判定:
|
||||
// - 只映射命令声明过的槽位键,并按声明类型转换(string/number/integer/boolean);
|
||||
// 未声明的键一律丢弃——不把任意字符串复制进 params。
|
||||
// - 声明的必填参数无法由「原文 + 已识别槽位」满足时不派发:不把拒绝留给
|
||||
// 总线层的 schema 校验(那会变成一条让人困惑的 "params: missing required
|
||||
// param x"),而是转成澄清诉求,与路由层的槽位缺口同一出口。
|
||||
//
|
||||
// 确认语义(高危命令的确认卡)完全由 cmdbus 收口,本文件不参与。
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"agent-system-backend/internal/cmdbus"
|
||||
"agent-system-backend/internal/intentgate"
|
||||
"agent-system-backend/internal/route"
|
||||
)
|
||||
|
||||
// chatTextParam 是 chat 入口恒定提供的原始文本参数名(既有文本处理器契约)。
|
||||
const chatTextParam = "text"
|
||||
|
||||
// intentAdmission 是一次「意图命中 → 命令派发」的接纳结果。
|
||||
type intentAdmission struct {
|
||||
// Params 是派发参数:恒含 text,其余按声明 schema 映射。
|
||||
Params map[string]any
|
||||
// Blocked 表示准入未过(业务动作不得派发)。
|
||||
Blocked bool
|
||||
// Reason 是准入未过的原因(诊断用;面向用户的说法走澄清诉求)。
|
||||
Reason string
|
||||
// MissingParams 是声明要求但槽位无法满足的参数名(按声明序)。
|
||||
MissingParams []string
|
||||
}
|
||||
|
||||
// admitIntentCommand 按命令声明的 schema 把已识别槽位映射成派发参数。
|
||||
// 声明不可解析(命令不存在/未启用)时按 fail-closed 返回 Blocked。
|
||||
func admitIntentCommand(bus *cmdbus.Bus, commandID string, slots map[string]string, text string) intentAdmission {
|
||||
if bus == nil {
|
||||
return intentAdmission{Blocked: true, Reason: "命令总线不可用:" + commandID}
|
||||
}
|
||||
spec, _, err := bus.Registry().Resolve(commandID)
|
||||
if err != nil {
|
||||
return intentAdmission{Blocked: true, Reason: "命令不可解析:" + commandID}
|
||||
}
|
||||
params, missing := mapSchemaParams(spec.ParamsSchema, slots)
|
||||
params[chatTextParam] = text
|
||||
if len(missing) > 0 {
|
||||
return intentAdmission{Params: params, Blocked: true,
|
||||
Reason: "命令必填参数未满足:" + strings.Join(missing, "、"),
|
||||
MissingParams: missing}
|
||||
}
|
||||
return intentAdmission{Params: params}
|
||||
}
|
||||
|
||||
// mapSchemaParams 把槽位按 JSON Schema(draft-07 子集,与总线侧校验同款)
|
||||
// 投影成参数。只认声明过的键,并按声明类型转换;转换不成的不写参数
|
||||
// (数组/对象等非标量声明也不会收到字符串)。必填参数里无法满足的按键名返回。
|
||||
func mapSchemaParams(schema json.RawMessage, slots map[string]string) (map[string]any, []string) {
|
||||
params := map[string]any{}
|
||||
props, required, ok := objectSchema(schema)
|
||||
if !ok {
|
||||
// 没有可用声明:不复制任何槽位(fail-closed),只保留原文入口参数。
|
||||
return params, nil
|
||||
}
|
||||
keys := make([]string, 0, len(slots))
|
||||
for k := range slots {
|
||||
if _, declared := props[k]; declared {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
if v, ok := castParam(props[k], slots[k]); ok {
|
||||
params[k] = v
|
||||
}
|
||||
}
|
||||
var missing []string
|
||||
for _, k := range required {
|
||||
if k == chatTextParam {
|
||||
continue // 原文入口恒供
|
||||
}
|
||||
if _, ok := params[k]; !ok {
|
||||
missing = append(missing, k)
|
||||
}
|
||||
}
|
||||
return params, missing
|
||||
}
|
||||
|
||||
// objectSchema 解析命令的 paramsSchema;非对象/非法 schema 返回 ok=false。
|
||||
func objectSchema(schema json.RawMessage) (props map[string]any, required []string, ok bool) {
|
||||
if len(schema) == 0 {
|
||||
return nil, nil, false
|
||||
}
|
||||
var sc struct {
|
||||
Type string `json:"type"`
|
||||
Required []string `json:"required"`
|
||||
Properties map[string]any `json:"properties"`
|
||||
}
|
||||
if err := json.Unmarshal(schema, &sc); err != nil {
|
||||
return nil, nil, false
|
||||
}
|
||||
if sc.Type != "" && sc.Type != "object" {
|
||||
return nil, nil, false
|
||||
}
|
||||
if sc.Properties == nil {
|
||||
sc.Properties = map[string]any{}
|
||||
}
|
||||
return sc.Properties, sc.Required, true
|
||||
}
|
||||
|
||||
// castParam 按声明类型把槽位字符串转成参数值;类型不支持或转换失败返回 false。
|
||||
func castParam(decl any, raw string) (any, bool) {
|
||||
m, _ := decl.(map[string]any)
|
||||
want, _ := m["type"].(string)
|
||||
switch want {
|
||||
case "", "string":
|
||||
return raw, true
|
||||
case "number":
|
||||
f, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return f, true
|
||||
case "integer":
|
||||
n, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return n, true
|
||||
case "boolean":
|
||||
b, err := strconv.ParseBool(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return b, true
|
||||
default:
|
||||
// array/object 等非标量声明:拒绝把字符串塞进去。
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
// clarifyPayload 是回给调用方的澄清诉求(postMessage 响应里的 clarify 字段)。
|
||||
// 既有端点上的附加字段,不新增 API 面:缺失与非法各自带 key/原因/建议提问。
|
||||
type clarifyPayload struct {
|
||||
Intent string `json:"intent,omitempty"`
|
||||
Command string `json:"command,omitempty"`
|
||||
Question string `json:"question,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Missing []intentgate.SlotQuestion `json:"missing,omitempty"`
|
||||
Invalid []intentgate.SlotDefect `json:"invalid,omitempty"`
|
||||
}
|
||||
|
||||
// newClarifyPayload 把路由裁决与准入结果合成一条澄清诉求;无需澄清时返回 nil。
|
||||
func newClarifyPayload(res intentgate.MatchResult, dec route.Decision, adm *intentAdmission) *clarifyPayload {
|
||||
if !dec.Clarification.Needed && (adm == nil || !adm.Blocked) {
|
||||
return nil
|
||||
}
|
||||
c := dec.Clarification
|
||||
out := &clarifyPayload{
|
||||
Intent: res.ID,
|
||||
Command: firstNonEmpty(c.Command, dec.CommandID),
|
||||
Question: strings.TrimSpace(c.Question),
|
||||
Missing: append([]intentgate.SlotQuestion(nil), c.Missing...),
|
||||
Invalid: append([]intentgate.SlotDefect(nil), c.Invalid...),
|
||||
}
|
||||
if adm != nil && adm.Blocked {
|
||||
out.Reason = adm.Reason
|
||||
names := make([]string, 0, len(adm.MissingParams))
|
||||
for _, name := range adm.MissingParams {
|
||||
if slotQuestionKnown(out.Missing, name) {
|
||||
continue
|
||||
}
|
||||
out.Missing = append(out.Missing, intentgate.SlotQuestion{Key: name, Question: name})
|
||||
names = append(names, name)
|
||||
}
|
||||
if out.Question == "" && len(names) > 0 {
|
||||
out.Question = "请补充:" + strings.Join(names, "、")
|
||||
}
|
||||
}
|
||||
if out.Question == "" {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func slotQuestionKnown(list []intentgate.SlotQuestion, key string) bool {
|
||||
for _, q := range list {
|
||||
if q.Key == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
agentcommand "agent-command"
|
||||
agentintent "agent-intent/intent"
|
||||
"agent-system-backend/internal/cmdbus"
|
||||
"agent-system-backend/internal/intentgate"
|
||||
)
|
||||
|
||||
// F09 意图接纳:槽位缺失/非法一律不派发业务动作;合法槽位按命令 DECLARED
|
||||
// schema 映射后进处理器;高危命令的确认语义完全由 cmdbus 收口(此处不动)。
|
||||
|
||||
// reportIntent 声明一个带枚举校验与多个可选槽位的意图(合成数据)。
|
||||
func reportIntent() json.RawMessage {
|
||||
return json.RawMessage(`{"id":"demo.report","domain":"demo","version":1,
|
||||
"matcher":{"includes_any":["生成报告"]},
|
||||
"slots":[
|
||||
{"key":"topic","required":true,"pattern":"主题[::]\\s*(\\S+)","question":"主题是什么?"},
|
||||
{"key":"mode","required":true,"pattern":"模式[::]\\s*(\\S+)","enum":["摘要","全文"],"question":"要哪种模式?"},
|
||||
{"key":"count","pattern":"份数[::]\\s*(\\S+)"},
|
||||
{"key":"enabled","pattern":"归档[::]\\s*(\\S+)"},
|
||||
{"key":"note","pattern":"备注[::]\\s*(\\S+)"}]}`)
|
||||
}
|
||||
|
||||
// reportCommandSpec 声明参数 schema:count 是 integer、enabled 是 boolean,
|
||||
// note 未声明——未声明的键不得被塞进 params。
|
||||
func reportCommandSpec() agentcommand.CommandSpec {
|
||||
return agentcommand.CommandSpec{ID: "demo.report", Title: "生成报告命令",
|
||||
ParamsSchema: json.RawMessage(`{"type":"object","required":["text","topic","mode"],
|
||||
"properties":{"topic":{"type":"string"},"mode":{"type":"string"},
|
||||
"count":{"type":"integer"},"enabled":{"type":"boolean"}}}`)}
|
||||
}
|
||||
|
||||
// dispatchLog 记录每次真实派发(处理器被调用才算执行)。
|
||||
type dispatchLog struct {
|
||||
mu sync.Mutex
|
||||
calls []cmdbus.Request
|
||||
params []map[string]any
|
||||
}
|
||||
|
||||
func (l *dispatchLog) record(req cmdbus.Request) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
copied := map[string]any{}
|
||||
for k, v := range req.Params {
|
||||
copied[k] = v
|
||||
}
|
||||
l.calls = append(l.calls, req)
|
||||
l.params = append(l.params, copied)
|
||||
}
|
||||
|
||||
func (l *dispatchLog) snapshot() []map[string]any {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return append([]map[string]any(nil), l.params...)
|
||||
}
|
||||
|
||||
func (l *dispatchLog) byOwner() map[string]map[string]any {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
out := map[string]map[string]any{}
|
||||
for _, req := range l.calls {
|
||||
out[req.Owner] = req.Params
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// admissionServer 组装带意图闸门与 demo.report 命令的测试服务器。
|
||||
func admissionServer(t *testing.T) (*Server, *dispatchLog) {
|
||||
t.Helper()
|
||||
return admissionServerWith(t, reportCommandSpec())
|
||||
}
|
||||
|
||||
func admissionServerWith(t *testing.T, spec agentcommand.CommandSpec) (*Server, *dispatchLog) {
|
||||
t.Helper()
|
||||
srv, _ := newTestServer(t)
|
||||
log := &dispatchLog{}
|
||||
if err := srv.Bus.RegisterBuiltin(spec, "", func(_ context.Context, req cmdbus.Request) (any, error) {
|
||||
log.record(req)
|
||||
return map[string]any{"ok": true}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gate := intentgate.New()
|
||||
if err := gate.Rebuild(context.Background(), []json.RawMessage{reportIntent()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv.Gate = gate
|
||||
return srv, log
|
||||
}
|
||||
|
||||
func clarifyOf(t *testing.T, out map[string]any) map[string]any {
|
||||
t.Helper()
|
||||
c, ok := out["clarify"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("响应缺少澄清诉求:%v", out)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// 缺必填槽位:不执行业务命令,澄清诉求带正确的提问。
|
||||
func TestMissingSlotBlocksCommandAndSurfacesClarification(t *testing.T) {
|
||||
srv, log := admissionServer(t)
|
||||
h := srv.Handler()
|
||||
_, convID := setupConversation(t, h)
|
||||
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
|
||||
map[string]any{"text": "生成报告 模式:摘要"})
|
||||
if code != 200 {
|
||||
t.Fatalf("status %d body %v", code, out)
|
||||
}
|
||||
if _, executed := out["command"]; executed {
|
||||
t.Fatalf("缺必填槽位不得执行业务命令:%v", out)
|
||||
}
|
||||
if out["run"] == nil {
|
||||
t.Fatalf("未派发命令时应回落普通回合:%v", out)
|
||||
}
|
||||
c := clarifyOf(t, out)
|
||||
if c["question"] != "主题是什么?" {
|
||||
t.Fatalf("澄清提问:%v", c)
|
||||
}
|
||||
if c["command"] != "demo.report" || c["intent"] != "demo.report" {
|
||||
t.Fatalf("被拦下的动作未标明:%v", c)
|
||||
}
|
||||
missing, _ := c["missing"].([]any)
|
||||
if len(missing) != 1 {
|
||||
t.Fatalf("缺失槽位明细:%v", c)
|
||||
}
|
||||
if m := missing[0].(map[string]any); m["key"] != "topic" || m["question"] != "主题是什么?" {
|
||||
t.Fatalf("缺失槽位键/提问:%v", m)
|
||||
}
|
||||
if _, bad := c["invalid"]; bad {
|
||||
t.Fatalf("缺失不应混进非法:%v", c)
|
||||
}
|
||||
if got := log.snapshot(); len(got) != 0 {
|
||||
t.Fatalf("命令处理器被调用:%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 非法枚举值:不执行业务命令,结构化原因原样透出。
|
||||
func TestInvalidSlotBlocksCommandWithReason(t *testing.T) {
|
||||
srv, log := admissionServer(t)
|
||||
h := srv.Handler()
|
||||
_, convID := setupConversation(t, h)
|
||||
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
|
||||
map[string]any{"text": "生成报告 主题:核能 模式:随手写"})
|
||||
if code != 200 {
|
||||
t.Fatalf("status %d body %v", code, out)
|
||||
}
|
||||
if _, executed := out["command"]; executed {
|
||||
t.Fatalf("非法槽位不得执行业务命令:%v", out)
|
||||
}
|
||||
c := clarifyOf(t, out)
|
||||
if c["question"] != "要哪种模式?" {
|
||||
t.Fatalf("澄清提问:%v", c)
|
||||
}
|
||||
invalid, _ := c["invalid"].([]any)
|
||||
if len(invalid) != 1 {
|
||||
t.Fatalf("非法槽位明细:%v", c)
|
||||
}
|
||||
got := invalid[0].(map[string]any)
|
||||
if got["key"] != "mode" || got["reason"] != "value not in enum" || got["question"] != "要哪种模式?" {
|
||||
t.Fatalf("非法原因/提问未透传:%v", got)
|
||||
}
|
||||
if got["invalid"] != true {
|
||||
t.Fatalf("非法标记:%v", got)
|
||||
}
|
||||
if _, missing := c["missing"]; missing {
|
||||
t.Fatalf("非法不应重复计入缺失:%v", c)
|
||||
}
|
||||
if len(log.snapshot()) != 0 {
|
||||
t.Fatalf("命令处理器被调用:%v", log.snapshot())
|
||||
}
|
||||
}
|
||||
|
||||
// 合法槽位:按声明 schema 映射后到达处理器(类型正确、未声明键不复制)。
|
||||
func TestValidSlotsReachHandlerMappedPerSchema(t *testing.T) {
|
||||
srv, log := admissionServer(t)
|
||||
h := srv.Handler()
|
||||
_, convID := setupConversation(t, h)
|
||||
text := "生成报告 主题:核能 模式:摘要 份数:3 归档:true 备注:随便"
|
||||
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": text})
|
||||
if code != 200 || out["command"] != "demo.report" {
|
||||
t.Fatalf("status %d body %v", code, out)
|
||||
}
|
||||
if _, ok := out["clarify"]; ok {
|
||||
t.Fatalf("完整合法槽位不应带澄清诉求:%v", out)
|
||||
}
|
||||
calls := log.snapshot()
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("处理器调用次数:%d", len(calls))
|
||||
}
|
||||
params := calls[0]
|
||||
if params["topic"] != "核能" || params["mode"] != "摘要" || params["text"] != text {
|
||||
t.Fatalf("字符串参数映射:%v", params)
|
||||
}
|
||||
if params["count"] != int64(3) {
|
||||
t.Fatalf("integer 参数映射:%#v", params["count"])
|
||||
}
|
||||
if params["enabled"] != true {
|
||||
t.Fatalf("boolean 参数映射:%#v", params["enabled"])
|
||||
}
|
||||
if _, ok := params["note"]; ok {
|
||||
t.Fatalf("未声明的槽位不得复制进 params:%v", params)
|
||||
}
|
||||
if len(params) != 5 {
|
||||
t.Fatalf("参数量超出声明:%v", params)
|
||||
}
|
||||
}
|
||||
|
||||
// 声明的必填参数无法由原文+槽位满足:不派发,转澄清(不再由总线报 schema 错)。
|
||||
func TestDeclaredRequiredParamBlocksDispatch(t *testing.T) {
|
||||
srv, log := admissionServerWith(t, agentcommand.CommandSpec{ID: "demo.report", Title: "生成报告命令",
|
||||
ParamsSchema: json.RawMessage(`{"type":"object","required":["text","target"],
|
||||
"properties":{"topic":{"type":"string"},"target":{"type":"string"}}}`)})
|
||||
h := srv.Handler()
|
||||
_, convID := setupConversation(t, h)
|
||||
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
|
||||
map[string]any{"text": "生成报告 主题:核能 模式:摘要"})
|
||||
if code != 200 {
|
||||
t.Fatalf("status %d body %v", code, out)
|
||||
}
|
||||
if _, executed := out["command"]; executed {
|
||||
t.Fatalf("必填参数缺失不得执行业务命令:%v", out)
|
||||
}
|
||||
c := clarifyOf(t, out)
|
||||
missing, _ := c["missing"].([]any)
|
||||
found := false
|
||||
for _, m := range missing {
|
||||
if m.(map[string]any)["key"] == "target" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("缺失必填参数未透出:%v", c)
|
||||
}
|
||||
if c["reason"] == "" {
|
||||
t.Fatalf("准入原因缺失:%v", c)
|
||||
}
|
||||
if len(log.snapshot()) != 0 {
|
||||
t.Fatalf("命令处理器被调用:%v", log.snapshot())
|
||||
}
|
||||
}
|
||||
|
||||
// 高危命令仍走 cmdbus 的确认卡收口:接纳通过后创建确认卡,处理器不执行。
|
||||
func TestHighRiskCommandStillRequiresConfirmation(t *testing.T) {
|
||||
srv, log := admissionServer(t)
|
||||
h := srv.Handler()
|
||||
gate := intentgate.New()
|
||||
if err := gate.Rebuild(context.Background(), []json.RawMessage{
|
||||
reportIntent(),
|
||||
json.RawMessage(`{"id":"demo.purge","domain":"demo","version":1,
|
||||
"matcher":{"includes_any":["清除报告"]},
|
||||
"action":{"kind":"command","id":"demo.purge"},
|
||||
"slots":[{"key":"topic","required":true,"pattern":"对象[::]\\s*(\\S+)","question":"清除哪个对象?"}]}`),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv.Gate = gate
|
||||
if err := srv.Bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.purge", Title: "清除报告",
|
||||
Danger: agentcommand.DangerHigh,
|
||||
ParamsSchema: json.RawMessage(`{"type":"object","required":["text","topic"],
|
||||
"properties":{"topic":{"type":"string"}}}`)}, "", func(context.Context, cmdbus.Request) (any, error) {
|
||||
t.Fatalf("高危命令未经确认不得执行处理器")
|
||||
return nil, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 缺必填槽位时连确认卡都不该出现(业务动作根本没到总线)。
|
||||
_, blocked := setupConversation(t, h)
|
||||
code, out := doJSON(t, h, "POST", "/api/conversations/"+blocked+"/messages",
|
||||
map[string]any{"text": "清除报告"})
|
||||
if code != 200 {
|
||||
t.Fatalf("status %d body %v", code, out)
|
||||
}
|
||||
if _, hasCard := out["card"]; hasCard {
|
||||
t.Fatalf("槽位未就绪不得创建确认卡:%v", out)
|
||||
}
|
||||
if _, executed := out["command"]; executed {
|
||||
t.Fatalf("槽位未就绪不得执行业务命令:%v", out)
|
||||
}
|
||||
|
||||
// 槽位齐全:派发到总线,高危收口创建确认卡,处理器仍未执行。
|
||||
_, ready := setupConversation(t, h)
|
||||
code, out = doJSON(t, h, "POST", "/api/conversations/"+ready+"/messages",
|
||||
map[string]any{"text": "清除报告 对象:旧归档"})
|
||||
if code != 200 || out["command"] != "demo.purge" {
|
||||
t.Fatalf("status %d body %v", code, out)
|
||||
}
|
||||
if out["card"] == nil {
|
||||
t.Fatalf("高危命令必须要求确认:%v", out)
|
||||
}
|
||||
if len(log.snapshot()) != 0 {
|
||||
t.Fatalf("确认前不得派发其他命令:%v", log.snapshot())
|
||||
}
|
||||
}
|
||||
|
||||
// 显式取消命令宿主未实现:如实回落普通回合,不派发同一句里的 Start。
|
||||
type cancelGenerator struct{}
|
||||
|
||||
func (cancelGenerator) Generate(context.Context, string, []agentintent.CatalogSpec) ([]agentintent.Command, error) {
|
||||
return []agentintent.Command{
|
||||
{Kind: intentgate.CommandStart, Action: intentgate.ActionRef{Kind: intentgate.ActionCommand, ID: "demo.report"}},
|
||||
{Kind: intentgate.CommandCancel, Target: "demo.report"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestUnsupportedCancelCommandFallsBackToTurn(t *testing.T) {
|
||||
srv, log := admissionServer(t)
|
||||
h := srv.Handler()
|
||||
gate, ok := srv.Gate.(*intentgate.Gate)
|
||||
if !ok {
|
||||
t.Fatalf("闸门类型:%T", srv.Gate)
|
||||
}
|
||||
gate.SetGenerator(cancelGenerator{})
|
||||
_, convID := setupConversation(t, h)
|
||||
code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages",
|
||||
map[string]any{"text": "算了吧 生成报告"})
|
||||
if code != 200 {
|
||||
t.Fatalf("status %d body %v", code, out)
|
||||
}
|
||||
if _, executed := out["command"]; executed {
|
||||
t.Fatalf("未支持的取消命令不得派发业务动作:%v", out)
|
||||
}
|
||||
if out["run"] == nil {
|
||||
t.Fatalf("未支持的库层命令应回落普通回合:%v", out)
|
||||
}
|
||||
if len(log.snapshot()) != 0 {
|
||||
t.Fatalf("命令处理器被调用:%v", log.snapshot())
|
||||
}
|
||||
}
|
||||
|
||||
// 两个租户的槽位上下文互不串味:各自只看到自己那句输入映射出的参数。
|
||||
func TestIntentSlotsDoNotLeakAcrossOwners(t *testing.T) {
|
||||
srv, log := admissionServer(t)
|
||||
srv.Auth = NewAuthenticator(srv.st, true)
|
||||
convID := map[string]string{}
|
||||
for _, owner := range []string{"alice", "bob"} {
|
||||
if err := srv.st.CreateAccount(owner, owner, "", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
proj, err := srv.st.CreateProject(owner, owner+" 的项目")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conv, err := srv.st.CreateConversation(owner, proj.ID, owner+" 的会话", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
convID[owner] = conv.ID
|
||||
}
|
||||
h := srv.Handler()
|
||||
send := func(owner, text string) map[string]any {
|
||||
t.Helper()
|
||||
key, err := srv.st.CreateAPIKey(owner, owner+"-key")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{"text": text})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := bearerRequest(h, "POST", "/api/conversations/"+convID[owner]+"/messages", string(body), key)
|
||||
out := map[string]any{}
|
||||
if rec.Body.Len() > 0 {
|
||||
json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
}
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("%s 发送 %q: status %d body %s", owner, text, rec.Code, rec.Body.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
if out := send("alice", "生成报告 主题:核能 模式:摘要"); out["command"] != "demo.report" {
|
||||
t.Fatalf("alice 派发:%v", out)
|
||||
}
|
||||
if out := send("bob", "生成报告 主题:水利 模式:全文"); out["command"] != "demo.report" {
|
||||
t.Fatalf("bob 派发:%v", out)
|
||||
}
|
||||
if got := log.byOwner(); len(got) != 2 {
|
||||
t.Fatalf("派发归属:%v", got)
|
||||
}
|
||||
byOwner := log.byOwner()
|
||||
if byOwner["alice"]["topic"] != "核能" || byOwner["alice"]["mode"] != "摘要" {
|
||||
t.Fatalf("alice 收到别人的槽位:%v", byOwner["alice"])
|
||||
}
|
||||
if byOwner["bob"]["topic"] != "水利" || byOwner["bob"]["mode"] != "全文" {
|
||||
t.Fatalf("bob 收到别人的槽位:%v", byOwner["bob"])
|
||||
}
|
||||
if byOwner["alice"]["topic"] == byOwner["bob"]["topic"] {
|
||||
t.Fatalf("两个租户共享了同一份槽位上下文:%v", byOwner)
|
||||
}
|
||||
}
|
||||
@@ -1150,6 +1150,8 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
|
||||
routeKind := route.KindTurn
|
||||
var recognized intentgate.MatchResult
|
||||
var workflowDef core.WorkflowDef
|
||||
var admission *intentAdmission
|
||||
var clarify *clarifyPayload
|
||||
if s.Gate != nil && !strings.HasPrefix(text, "/") && req.TaskRef == nil {
|
||||
recognized = s.recognize(r.Context(), text)
|
||||
dec := route.Decide(recognized, route.Env{
|
||||
@@ -1164,13 +1166,23 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
switch dec.Kind {
|
||||
case route.KindCommand:
|
||||
// 接纳:按命令 DECLARED schema 把槽位映射成参数;准入未过
|
||||
// (必填参数无法由原文+槽位满足)就不派发,降为澄清回合(F09)。
|
||||
adm := admitIntentCommand(s.Bus, dec.CommandID, dec.Slots, text)
|
||||
clarify = newClarifyPayload(recognized, dec, &adm)
|
||||
if !adm.Blocked {
|
||||
routeKind = route.KindCommand
|
||||
recognized.ID = dec.CommandID
|
||||
recognized.Slots = dec.Slots
|
||||
admission = &adm
|
||||
}
|
||||
case route.KindWorkflow:
|
||||
routeKind = route.KindWorkflow
|
||||
workflowDef = dec.Workflow
|
||||
recognized.Slots = dec.Slots
|
||||
clarify = newClarifyPayload(recognized, dec, nil)
|
||||
default:
|
||||
clarify = newClarifyPayload(recognized, dec, nil)
|
||||
}
|
||||
}
|
||||
claimed := false
|
||||
@@ -1204,8 +1216,14 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if routeKind == route.KindCommand {
|
||||
// 参数按命令 DECLARED schema 映射(槽位→声明键/声明类型);原文恒供。
|
||||
params := map[string]any{chatTextParam: text}
|
||||
if admission != nil && admission.Params != nil {
|
||||
params = admission.Params
|
||||
params[chatTextParam] = text
|
||||
}
|
||||
resp := s.Bus.Dispatch(r.Context(), cmdbus.Request{
|
||||
ID: recognized.ID, Params: map[string]any{"text": text}, Entrance: "chat", Actor: owner,
|
||||
ID: recognized.ID, Params: params, Entrance: "chat", Actor: owner,
|
||||
Owner: owner, ConversationID: convID, AnchorMessageID: msg.ID, HostProjectID: string(conv.ProjectID),
|
||||
})
|
||||
out := map[string]any{"message": msg, "receipt": resp.Receipt, "command": recognized.ID}
|
||||
@@ -1242,7 +1260,12 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
msg.RunID = run.ID
|
||||
writeJSON(w, http.StatusOK, map[string]any{"message": msg, "run": run})
|
||||
out := map[string]any{"message": msg, "run": run}
|
||||
if clarify != nil {
|
||||
// 澄清诉求(业务动作未派发):既有端点上的附加字段,缺失/非法各带结构化原因。
|
||||
out["clarify"] = clarify
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// writeExistingMessage 把同一 requestId 的已有消息和仍在进行的 run 原样返回。
|
||||
|
||||
@@ -59,10 +59,14 @@ func TestCommandBusBeatsWorkflow(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, convID := setupConversation(t, srv.Handler())
|
||||
code, out := doJSON(t, srv.Handler(), "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "写一份简报"})
|
||||
// 槽位齐全才派发命令(F09):必填槽位缺失时命令不得执行,改走澄清回合。
|
||||
code, out := doJSON(t, srv.Handler(), "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "主题:核能 写一份简报"})
|
||||
if code != 200 || out["command"] != "demo.brief" {
|
||||
t.Fatalf("status %d body %v", code, out)
|
||||
}
|
||||
if _, ok := out["clarify"]; ok {
|
||||
t.Fatalf("complete slots must not clarify: %v", out)
|
||||
}
|
||||
if _, ok := out["run"]; ok {
|
||||
t.Fatal("a command hit must not start a workflow run")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user