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
235 lines
7.8 KiB
Go
235 lines
7.8 KiB
Go
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
|
||
}
|