Files
Agent-Intent/intent/i2_test.go
T
weichen 89a43abf87
ci / vet-and-test (pull_request) Successful in 15s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Add the semantic rerank port, examples, and the command generator port
Why: I2 layering - between rules and the L2 classifier there is now
a deterministic host-scored semantic layer (rules then semantic then
model), and fallback can be rescued by a validated command
generator instead of going straight to the default channel.

Evidence: go vet clean; full suite ok, including ten new tests
(rerank resolve/below-threshold/tie/error-then-L2, generator
valid/invalid/error, catalog top-k).

Impact: IntentSpec gains Examples; Options gains Semantic (with
threshold), Generator; ambiguous candidates rerank through host
scores before L2; fallback validates generated commands
(action/slots/kind fail-closed) and synthesizes Top with slots;
generated commands are no longer overwritten by deriveCommands;
CatalogTopK by priority.

Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md I2
2026-10-07 10:56:38 +00:00

235 lines
7.8 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 intent
import (
"context"
"errors"
"strings"
"testing"
)
// fakeScorer 按 examples 里是否包含特定子串给分(测试用确定性打分器)。
type fakeScorer struct {
scores map[string]float64 // key: examples[0]
err error
}
func (f fakeScorer) Score(ctx context.Context, input string, examples []string) (float64, error) {
if f.err != nil {
return 0, f.err
}
if len(examples) == 0 {
return 0, nil
}
if s, ok := f.scores[examples[0]]; ok {
return s, nil
}
return 0, nil
}
func ambiguousFixture(t *testing.T, exA, exB []string) *Snapshot {
t.Helper()
b := NewRegistryBuilder(Options{})
if err := b.Register(IntentSpec{ID: "demo.alpha", Domain: "demo", Version: 1, Priority: 5,
Matcher: MatchSpec{IncludesAny: []string{"模糊词"}}, Examples: exA}); err != nil {
t.Fatal(err)
}
if err := b.Register(IntentSpec{ID: "demo.beta", Domain: "demo", Version: 1, Priority: 5,
Matcher: MatchSpec{IncludesAny: []string{"模糊词"}}, Examples: exB}); err != nil {
t.Fatal(err)
}
reg, err := b.Build()
if err != nil {
t.Fatal(err)
}
return reg.Snapshot()
}
// TestSemanticRerankResolvesAmbiguous:语义过阈且分差够 → matched(I2 分层)。
func TestSemanticRerankResolvesAmbiguous(t *testing.T) {
snap := ambiguousFixture(t, []string{"查运行进度"}, []string{"闲聊问候"})
rec := NewRecognizer(Options{Semantic: fakeScorer{scores: map[string]float64{"查运行进度": 0.95, "闲聊问候": 0.4}}}, nil, nil)
res, err := rec.Recognize(context.Background(), "模糊词", snap)
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchMatched || res.Top == nil || res.Top.ID != "demo.alpha" {
t.Fatalf("semantic rerank: %+v", res)
}
if !strings.Contains(res.Top.Reason, "refined_by=semantic") {
t.Fatalf("reason = %q", res.Top.Reason)
}
}
// TestSemanticBelowThresholdKeepsAmbiguous:全部不达阈 → 保持 ambiguous。
func TestSemanticBelowThresholdKeepsAmbiguous(t *testing.T) {
snap := ambiguousFixture(t, []string{"查运行进度"}, []string{"闲聊问候"})
rec := NewRecognizer(Options{Semantic: fakeScorer{scores: map[string]float64{"查运行进度": 0.5}}}, nil, nil)
res, err := rec.Recognize(context.Background(), "模糊词", snap)
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchAmbiguous {
t.Fatalf("below threshold: branch = %s", res.Branch)
}
}
// TestSemanticTieKeepsAmbiguous:两个过阈但分差不足 → 保持 ambiguous。
func TestSemanticTieKeepsAmbiguous(t *testing.T) {
snap := ambiguousFixture(t, []string{"查运行进度"}, []string{"查运行状态"})
rec := NewRecognizer(Options{Semantic: fakeScorer{scores: map[string]float64{"查运行进度": 0.9, "查运行状态": 0.89}}}, nil, nil)
res, err := rec.Recognize(context.Background(), "模糊词", snap)
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchAmbiguous {
t.Fatalf("tie: branch = %s", res.Branch)
}
}
// TestSemanticErrorKeepsAmbiguousAndCallsL2Next:打分错误 → ambiguous,且 L2 接管。
func TestSemanticErrorKeepsAmbiguousAndCallsL2Next(t *testing.T) {
snap := ambiguousFixture(t, []string{"查运行进度"}, nil)
called := false
cls := rewriteClassifier{fn: func(c []Candidate) []Candidate {
called = true
out := append([]Candidate(nil), c...)
out[0].Confidence = 0.99
out[1].Confidence = 0.1
return out
}}
rec := NewRecognizer(Options{Semantic: fakeScorer{err: errors.New("embed down")}}, nil, cls)
res, err := rec.Recognize(context.Background(), "模糊词", snap)
if err != nil {
t.Fatal(err)
}
if !called {
t.Fatal("L2 should be consulted after semantic failure")
}
if res.Branch != BranchMatched || res.Top.ID != "demo.alpha" {
t.Fatalf("L2 fallback after semantic error: %+v", res)
}
}
// TestGeneratorValidStartBecomesMatched:合法 start 命令 → matched + 合成 Top(I2)。
func TestGeneratorValidStartBecomesMatched(t *testing.T) {
b := NewRegistryBuilder(Options{})
if err := b.Register(IntentSpec{ID: "report.generate", Domain: "report", Version: 1,
Matcher: MatchSpec{IncludesAny: []string{"生成报告"}},
Slots: []SlotSpec{{Key: "topic", Pattern: `主题是(.+)`}},
Handler: "reportHandler"}); err != nil {
t.Fatal(err)
}
reg, err := b.Build()
if err != nil {
t.Fatal(err)
}
gen := stubGenerator{cmds: []Command{
{Kind: CommandStart, Action: ActionRef{Kind: ActionWorkflow, ID: "report.generate"},
Slots: map[string]string{"topic": "电机"}},
}}
rec := NewRecognizer(Options{Generator: gen}, nil, nil)
res, err := rec.Recognize(context.Background(), "完全不相关的一句话", reg.Snapshot())
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchMatched || res.Top == nil || res.Top.ID != "report.generate" {
t.Fatalf("generated: %+v", res)
}
if len(res.Commands) != 1 || res.Commands[0].Kind != CommandStart || res.Commands[0].Slots["topic"] != "电机" {
t.Fatalf("commands = %+v", res.Commands)
}
}
// TestGeneratorInvalidOutputFallsBack:幻觉动作/槽位 → 保持 fallback(fail-closed)。
func TestGeneratorInvalidOutputFallsBack(t *testing.T) {
b := NewRegistryBuilder(Options{})
if err := b.Register(IntentSpec{ID: "report.generate", Domain: "report", Version: 1,
Matcher: MatchSpec{IncludesAny: []string{"生成报告"}},
Slots: []SlotSpec{{Key: "topic", Pattern: `(.+)`}}}); err != nil {
t.Fatal(err)
}
reg, err := b.Build()
if err != nil {
t.Fatal(err)
}
snap := reg.Snapshot()
cases := map[string][]Command{
"unknown spec id": {
{Kind: CommandStart, Action: ActionRef{Kind: ActionWorkflow, ID: "ghost.flow"}},
},
"unknown kind": {
{Kind: CommandStart, Action: ActionRef{Kind: "bogus", ID: "report.generate"}},
},
"unknown slot key": {
{Kind: CommandStart, Action: ActionRef{Kind: ActionWorkflow, ID: "report.generate"}, Slots: map[string]string{"bogus": "v"}},
},
"unknown cmd kind": {
{Kind: "teleport"},
},
"empty": {},
}
for name, cmds := range cases {
rec := NewRecognizer(Options{Generator: stubGenerator{cmds: cmds}}, nil, nil)
res, err := rec.Recognize(context.Background(), "完全不相关的一句话", snap)
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchFallback {
t.Fatalf("%s: branch = %s, want fallback", name, res.Branch)
}
}
}
// TestGeneratorErrorKeepsFallback:生成器错误 → 保持 fallback。
func TestGeneratorErrorKeepsFallback(t *testing.T) {
reg, err := NewRegistryBuilder(Options{}).Build()
if err != nil {
t.Fatal(err)
}
rec := NewRecognizer(Options{Generator: stubGenerator{err: errors.New("model down")}}, nil, nil)
res, err := rec.Recognize(context.Background(), "完全不相关的一句话", reg.Snapshot())
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchFallback {
t.Fatalf("branch = %s", res.Branch)
}
}
// TestCatalogTopK:按 priority 降序取 k(确定性口径)。
func TestCatalogTopK(t *testing.T) {
b := NewRegistryBuilder(Options{})
if err := b.Register(IntentSpec{ID: "demo.low", Domain: "demo", Version: 1, Priority: 1, Matcher: MatchSpec{IncludesAny: []string{"a"}}}); err != nil {
t.Fatal(err)
}
if err := b.Register(IntentSpec{ID: "demo.high", Domain: "demo", Version: 1, Priority: 9, Matcher: MatchSpec{IncludesAny: []string{"b"}}}); err != nil {
t.Fatal(err)
}
if err := b.Register(IntentSpec{ID: "demo.mid", Domain: "demo", Version: 1, Priority: 5, Matcher: MatchSpec{IncludesAny: []string{"c"}}}); err != nil {
t.Fatal(err)
}
reg, err := b.Build()
if err != nil {
t.Fatal(err)
}
top := reg.Snapshot().CatalogTopK(2)
if len(top) != 2 || top[0].ID != "demo.high" || top[1].ID != "demo.mid" {
t.Fatalf("topk = %+v", top)
}
if all := reg.Snapshot().CatalogTopK(0); len(all) != 3 {
t.Fatalf("k=0 should be full catalog: %d", len(all))
}
}
type stubGenerator struct {
cmds []Command
err error
}
func (s stubGenerator) Generate(ctx context.Context, input string, catalog []CatalogSpec) ([]Command, error) {
if s.err != nil {
return nil, s.err
}
return s.cmds, nil
}