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
326 lines
12 KiB
Go
326 lines
12 KiB
Go
package intentgate
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"strings"
|
||
"testing"
|
||
|
||
agentintent "agent-intent/intent"
|
||
)
|
||
|
||
func raw(t *testing.T, v any) json.RawMessage {
|
||
t.Helper()
|
||
b, err := json.Marshal(v)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return b
|
||
}
|
||
|
||
// spec 采用 agent-intent overlay 形态(matcher 嵌套)。
|
||
func demoSpec(t *testing.T) json.RawMessage {
|
||
return raw(t, map[string]any{
|
||
"id": "demo.show", "domain": "demo", "version": 1,
|
||
"description": "示例意图",
|
||
"matcher": map[string]any{"includes_any": []string{"示例", "演示"}},
|
||
})
|
||
}
|
||
|
||
func TestGateMatchAndFallback(t *testing.T) {
|
||
g := New()
|
||
if _, id := g.Match(context.Background(), "任意"); id != "" || g.Enabled() {
|
||
t.Error("empty gate should passthrough")
|
||
}
|
||
if err := g.Rebuild(context.Background(), []json.RawMessage{demoSpec(t)}); err != nil {
|
||
t.Fatalf("rebuild: %v", err)
|
||
}
|
||
if !g.Enabled() || g.SpecCount() != 1 {
|
||
t.Fatalf("enabled=%v specs=%d", g.Enabled(), g.SpecCount())
|
||
}
|
||
branch, top := g.Match(context.Background(), "来个示例看看")
|
||
if branch != "matched" || top != "demo.show" {
|
||
t.Errorf("want matched/demo_intent, got %s/%s", branch, top)
|
||
}
|
||
branch, _ = g.Match(context.Background(), "完全无关的一句话")
|
||
if branch != "fallback" {
|
||
t.Errorf("want fallback, got %s", branch)
|
||
}
|
||
}
|
||
|
||
// fail-closed:坏 spec 保留上一快照。
|
||
func TestGateFailClosed(t *testing.T) {
|
||
g := New()
|
||
if err := g.Rebuild(context.Background(), []json.RawMessage{demoSpec(t)}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := g.Rebuild(context.Background(), []json.RawMessage{json.RawMessage(`{"id":`)}); err == nil {
|
||
t.Error("broken spec should error")
|
||
}
|
||
if !g.Enabled() || g.SpecCount() != 1 {
|
||
t.Error("previous snapshot must be kept on failed rebuild")
|
||
}
|
||
// 空重建 = 显式清空(卸载全部插件意图)。
|
||
if err := g.Rebuild(context.Background(), nil); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if g.Enabled() {
|
||
t.Error("empty rebuild should disable gate")
|
||
}
|
||
}
|
||
|
||
func TestRecognizeKeepsSlots(t *testing.T) {
|
||
g := New()
|
||
spec := raw(t, map[string]any{
|
||
"id": "demo.brief", "domain": "demo", "version": 1,
|
||
"matcher": map[string]any{"includes_any": []string{"简报"}},
|
||
"slots": []map[string]any{{
|
||
"key": "topic", "required": true, "pattern": `主题[::]\s*(\S+)`,
|
||
"question": "主题是什么?",
|
||
}},
|
||
})
|
||
if err := g.Rebuild(context.Background(), []json.RawMessage{spec}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
miss := g.Recognize(context.Background(), "写一份简报")
|
||
if miss.Branch != "matched" || miss.ID != "demo.brief" || len(miss.Missing) != 1 || miss.Missing[0].Question != "主题是什么?" {
|
||
t.Fatalf("%+v", miss)
|
||
}
|
||
hit := g.Recognize(context.Background(), "主题:核能 写一份简报")
|
||
if hit.Slots["topic"] != "核能" || len(hit.Missing) != 0 {
|
||
t.Fatalf("%+v", hit)
|
||
}
|
||
if branch, id := g.Match(context.Background(), "写一份简报"); branch != "matched" || id != "demo.brief" {
|
||
t.Fatalf("Match wrapper = %s %s", branch, id)
|
||
}
|
||
}
|
||
|
||
// ---- I2 慢通道接线(默认关;SetSemantic/SetGenerator 开启)----
|
||
|
||
// stubScorer 固定返回脚本分(按 examples[0] 查表)。
|
||
type stubScorer struct{ scores map[string]float64 }
|
||
|
||
func (s stubScorer) Score(_ context.Context, _ string, examples []string) (float64, error) {
|
||
if len(examples) == 0 {
|
||
return 0, nil
|
||
}
|
||
return s.scores[examples[0]], nil
|
||
}
|
||
|
||
// stubGenerator 固定返回脚本命令。
|
||
type stubGenerator struct{ cmds []Command }
|
||
|
||
func (g stubGenerator) Generate(_ context.Context, _ string, _ []agentintent.CatalogSpec) ([]agentintent.Command, error) {
|
||
return g.cmds, nil
|
||
}
|
||
|
||
func ambiguousOverlay(t *testing.T) []json.RawMessage {
|
||
t.Helper()
|
||
mk := func(id string, ex []string) json.RawMessage {
|
||
return raw(t, map[string]any{
|
||
"id": id, "domain": "demo", "version": 1, "priority": 5,
|
||
"matcher": map[string]any{"includes_any": []string{"模糊词"}},
|
||
"examples": ex,
|
||
})
|
||
}
|
||
return []json.RawMessage{mk("demo.alpha", []string{"查运行进度"}), mk("demo.beta", []string{"闲聊问候"})}
|
||
}
|
||
|
||
// 语义层:歧义被过阈打分裁决为 matched(默认关时不启用)。
|
||
func TestGateSemanticResolvesAmbiguous(t *testing.T) {
|
||
g := New()
|
||
if err := g.Rebuild(context.Background(), ambiguousOverlay(t)); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// 默认(未接线):规则层歧义原样透出。
|
||
if branch, _ := g.Match(context.Background(), "模糊词"); branch != "ambiguous" {
|
||
t.Fatalf("默认关:branch = %s", branch)
|
||
}
|
||
g.SetSemantic(stubScorer{scores: map[string]float64{"查运行进度": 0.95, "闲聊问候": 0.4}}, 0)
|
||
branch, top := g.Match(context.Background(), "模糊词")
|
||
if branch != "matched" || top != "demo.alpha" {
|
||
t.Fatalf("语义裁决:%s/%s", branch, top)
|
||
}
|
||
// 关闭后回到歧义。
|
||
g.SetSemantic(nil, 0)
|
||
if branch, _ := g.Match(context.Background(), "模糊词"); branch != "ambiguous" {
|
||
t.Fatalf("关闭语义层:branch = %s", branch)
|
||
}
|
||
}
|
||
|
||
// 生成层:fallback 时产出合法 start 命令 → matched + Commands。
|
||
func TestGateGeneratorProducesCommands(t *testing.T) {
|
||
g := New()
|
||
overlay := []json.RawMessage{raw(t, map[string]any{
|
||
"id": "report.generate", "domain": "report", "version": 1,
|
||
"description": "生成报告",
|
||
"matcher": map[string]any{"includes_any": []string{"生成报告"}},
|
||
"handler": "reportHandler",
|
||
})}
|
||
if err := g.Rebuild(context.Background(), overlay); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
g.SetGenerator(stubGenerator{cmds: []Command{{
|
||
Kind: CommandStart,
|
||
Action: ActionRef{Kind: ActionHandler, ID: "reportHandler"},
|
||
}}})
|
||
res := g.Recognize(context.Background(), "完全不相关的一句话")
|
||
if res.Branch != "matched" || res.ID != "report.generate" {
|
||
t.Fatalf("生成器命中:%+v", res)
|
||
}
|
||
if len(res.Commands) != 1 || res.Commands[0].Kind != CommandStart {
|
||
t.Fatalf("commands: %+v", res.Commands)
|
||
}
|
||
// 关闭后回到 fallback。
|
||
g.SetGenerator(nil)
|
||
if branch, _ := g.Match(context.Background(), "完全不相关的一句话"); branch != "fallback" {
|
||
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)
|
||
}
|
||
}
|