Files
Agent-System-Backend/internal/intentgate/gate_test.go
T

360 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}
// 生成路径与规则路径同款:库层对生成槽位跑全量权威校验(candidateFromGenerated
// → validateProvidedSlots:enum/format/validator 失败进 Invalid,必填未给进 Missing),
// 并按权威 spec 合成 risk/confirm/action;宿主只做投影,不重复裁决、不二次拦。
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 res.Complete() {
t.Fatalf("非法枚举值不得算作完整:%+v", res)
}
if len(res.Missing) != 0 {
// 必填且非法只进 Invalid,不重复进 Missing。
t.Fatalf("非法槽位不应重复计入缺失:%+v", res.Missing)
}
if len(res.Invalid) != 1 {
t.Fatalf("生成路径非法槽位详情:%+v", res.Invalid)
}
d := res.Invalid[0]
if d.Key != "mode" || !d.Invalid || d.Reason != "value not in enum" || d.Question != "要哪种模式?" {
t.Fatalf("生成路径非法原因/提问未透传:%+v", d)
}
if _, leaked := res.Slots["mode"]; leaked {
t.Fatalf("非法值不得进入已校验槽位表:%+v", res.Slots)
}
if res.Slots["topic"] != "核能" {
t.Fatalf("生成路径合法槽位丢失:%+v", res.Slots)
}
// 风险/确认/动作声明按 modeSpec 合成(生成路径与规则路径一致)。
if res.Risk != "high" || !res.RequiresConfirm {
t.Fatalf("生成路径风险元数据:%+v", res)
}
if res.Action == nil || res.Action.Kind != ActionCommand || res.Action.ID != "demo.mode" {
t.Fatalf("生成路径动作声明:%+v", res.Action)
}
// 合法槽位:matched、完整、无缺陷,元数据同样齐备。
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"] != "摘要" || len(clean.Invalid) != 0 {
t.Fatalf("生成路径合法槽位:%+v", clean)
}
if clean.Risk != "high" || !clean.RequiresConfirm || clean.Action == nil {
t.Fatalf("生成路径合法槽位的风险元数据:%+v", clean)
}
// 未知槽位键仍由库层 fail-closed 拦住(整单回 fallback,不带 Top)。
g.SetGenerator(stubGenerator{cmds: []Command{{
Kind: CommandStart,
Action: ActionRef{Kind: ActionCommand, ID: "demo.mode"},
Slots: map[string]string{"unknown_key": "值"},
}}})
unknown := g.Recognize(context.Background(), "跟昨天一样")
if unknown.Branch != "fallback" || unknown.ID != "" || len(unknown.Slots) != 0 {
t.Fatalf("未知槽位键必须 fail-closed:%+v", unknown)
}
}
// 必填槽位缺失与非法是两条独立通道:缺失走 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)
}
}