Files
Agent-Intent/intent/recognizer_test.go
T
weichen b58595fb38 Implement the intent core
Details (original message):
Implement the intent core, conformance suite, catalog alignment, examples, and the intentctl diagnostics CLI.
2026-09-29 15:35:18 +00:00

331 lines
10 KiB
Go

package intent
import (
"context"
"errors"
"os"
"reflect"
"strings"
"testing"
)
// buildDemoRegistry 按消费方典型形态组装:守卫内容 Go 内置(受保护域),
// 业务意图来自 JSON 配置目录。
func buildDemoRegistry(t *testing.T) (*Registry, *Snapshot) {
t.Helper()
b := newTestBuilder()
if err := b.Register(demoGuardSpec()); err != nil {
t.Fatal(err)
}
reg, err := b.BuildFS(context.Background(), os.DirFS("testdata"), "specs")
if err != nil {
t.Fatalf("build: %v", err)
}
return reg, reg.Snapshot()
}
func TestRecognizeGuardHit(t *testing.T) {
_, snap := buildDemoRegistry(t)
rec := NewRecognizer(Options{}, nil, nil)
res, err := rec.Recognize(context.Background(), "帮我改一下系统网关地址", snap)
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchGuardHit || res.Top == nil || res.Top.ID != "guard.config_change" {
t.Fatalf("guard result: %+v", res)
}
if res.Top.Risk != RiskHigh || res.Top.Handler != "refuse.and_guide" {
t.Fatalf("guard candidate: %+v", res.Top)
}
if len(res.Candidates) == 0 || res.Candidates[0].ID != "guard.config_change" {
t.Fatalf("guard candidates: %+v", res.Candidates)
}
}
func TestRecognizeMatchedWithSlot(t *testing.T) {
_, snap := buildDemoRegistry(t)
rec := NewRecognizer(Options{}, nil, nil)
res, err := rec.Recognize(context.Background(), "查询任务 42 的进度", snap)
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchMatched || res.Top == nil || res.Top.ID != "demo.run.status_query" {
t.Fatalf("matched result: %+v", res)
}
if res.Top.Slots["run_id"] != "42" {
t.Fatalf("slots: %+v", res.Top.Slots)
}
if res.Top.Handler != "taskrun.query_status" || res.Top.Risk != RiskLow {
t.Fatalf("candidate metadata: %+v", res.Top)
}
if res.Top.Reason == "" {
t.Fatal("reason empty")
}
}
func TestRecognizeExcludedTermFallsBack(t *testing.T) {
_, snap := buildDemoRegistry(t)
rec := NewRecognizer(Options{}, nil, nil)
// 硬负例:否定写 + 只读式提问不得误触(含"配置"排除词,也不是守卫词)。
res, err := rec.Recognize(context.Background(), "怎么配置查询进度", snap)
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchFallback || res.Top != nil {
t.Fatalf("expected fallback, got %+v", res)
}
}
func TestRecognizePatternBonus(t *testing.T) {
b := NewRegistryBuilder(Options{})
if err := b.Register(IntentSpec{
ID: "demo.deploy.check",
Domain: "demo",
Version: 1,
Matcher: MatchSpec{
IncludesAny: []string{"部署"},
Patterns: []string{"(?:执行|跑).{0,4}部署"},
},
Confidence: ConfidenceSpec{Base: 0.7, PatternBonus: 0.2, Threshold: 0.75},
}); err != nil {
t.Fatal(err)
}
reg, err := b.Build()
if err != nil {
t.Fatal(err)
}
rec := NewRecognizer(Options{}, nil, nil)
// 仅词条命中:base 0.7 < threshold 0.75,不过阈。
res, _ := rec.Recognize(context.Background(), "部署", reg.Snapshot())
if res.Branch != BranchFallback {
t.Fatalf("term-only should stay below threshold: %+v", res)
}
// 词条 + 模式命中:0.7 + 0.2 = 0.9 过阈。
res, _ = rec.Recognize(context.Background(), "帮我执行一下部署", reg.Snapshot())
if res.Branch != BranchMatched || res.Top.ID != "demo.deploy.check" {
t.Fatalf("pattern bonus should pass threshold: %+v", res)
}
if res.Top.Confidence != 0.9 {
t.Fatalf("confidence: %v", res.Top.Confidence)
}
}
func TestRecognizeAmbiguousAndPriorityOrder(t *testing.T) {
_, snap := buildDemoRegistry(t)
rec := NewRecognizer(Options{}, nil, nil)
res, err := rec.Recognize(context.Background(), "你好呀", snap)
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchAmbiguous || res.Top != nil {
t.Fatalf("expected ambiguous without top: %+v", res)
}
if len(res.Candidates) < 2 {
t.Fatalf("expected >=2 candidates: %+v", res.Candidates)
}
// 同分按 priority 降序:hello(10) 在 hi(5) 前。
if res.Candidates[0].ID != "demo.greet.hello" || res.Candidates[1].ID != "demo.greet.hi" {
t.Fatalf("priority order: %+v", res.Candidates)
}
}
type stubClassifier struct {
scores map[string]float64
err error
}
func (s stubClassifier) Refine(ctx context.Context, input string, cands []Candidate) ([]Candidate, error) {
if s.err != nil {
return nil, s.err
}
out := append([]Candidate(nil), cands...)
for i := range out {
if v, ok := s.scores[out[i].ID]; ok {
out[i].Confidence = v
}
}
return out, nil
}
func TestRecognizeClassifierResolvesAmbiguity(t *testing.T) {
_, snap := buildDemoRegistry(t)
cls := stubClassifier{scores: map[string]float64{"demo.greet.hello": 0.9, "demo.greet.hi": 0.3}}
rec := NewRecognizer(Options{}, nil, cls)
res, err := rec.Recognize(context.Background(), "你好呀", snap)
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchMatched || res.Top == nil || res.Top.ID != "demo.greet.hello" {
t.Fatalf("classifier should resolve: %+v", res)
}
if !strings.Contains(res.Top.Reason, "refined_by=classifier") {
t.Fatalf("reason missing refine marker: %q", res.Top.Reason)
}
}
func TestRecognizeClassifierFailureKeepsAmbiguous(t *testing.T) {
_, snap := buildDemoRegistry(t)
rec := NewRecognizer(Options{}, nil, stubClassifier{err: errors.New("llm down")})
res, err := rec.Recognize(context.Background(), "你好呀", snap)
if err != nil {
t.Fatalf("classifier failure must not fail recognition: %v", err)
}
if res.Branch != BranchAmbiguous || res.Top != nil {
t.Fatalf("expected ambiguous kept: %+v", res)
}
}
type stubExpectation struct {
pending *PendingExpectation
}
func (s stubExpectation) Pending(ctx context.Context) *PendingExpectation { return s.pending }
func TestRecognizeExpectationHit(t *testing.T) {
_, snap := buildDemoRegistry(t)
// 无挂起预期时该输入落在规则歧义(0.8 vs 0.75,差 0.05 < margin 0.1)。
plain := NewRecognizer(Options{}, nil, nil)
res, _ := plain.Recognize(context.Background(), "任务重启 进度", snap)
if res.Branch != BranchAmbiguous {
t.Fatalf("expected ambiguous without expectation: %+v", res)
}
// 宿主声明挂起预期后,同输入确定命中预期意图。
exp := stubExpectation{pending: &PendingExpectation{IntentIDs: []string{"demo.run.restart"}, Reason: "确认重启"}}
rec := NewRecognizer(Options{}, exp, nil)
res, err := rec.Recognize(context.Background(), "任务重启 进度", snap)
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchExpectationHit || res.Top == nil || res.Top.ID != "demo.run.restart" {
t.Fatalf("expectation result: %+v", res)
}
}
func TestRecognizeRequiredSlotMissing(t *testing.T) {
_, snap := buildDemoRegistry(t)
rec := NewRecognizer(Options{}, 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.run.cancel" {
t.Fatalf("matched: %+v", res)
}
if len(res.Top.MissingRequiredSlots) != 1 || res.Top.MissingRequiredSlots[0] != "run_id" {
t.Fatalf("missing slots: %+v", res.Top.MissingRequiredSlots)
}
}
func TestRecognizeCaseFolding(t *testing.T) {
_, snap := buildDemoRegistry(t)
rec := NewRecognizer(Options{}, nil, nil)
res, err := rec.Recognize(context.Background(), "please DEPLOY it", snap)
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchMatched || res.Top.ID != "demo.misc.deploy" {
t.Fatalf("case fold: %+v", res)
}
}
func TestRecognizeEmptyInputFallsBack(t *testing.T) {
_, snap := buildDemoRegistry(t)
rec := NewRecognizer(Options{}, nil, nil)
for _, in := range []string{"", " ", "\t\n"} {
res, err := rec.Recognize(context.Background(), in, snap)
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchFallback || res.Top != nil {
t.Fatalf("input %q: %+v", in, res)
}
}
}
func TestRecognizeNilSnapshot(t *testing.T) {
rec := NewRecognizer(Options{}, nil, nil)
_, err := rec.Recognize(context.Background(), "x", nil)
var e *Error
if !errors.As(err, &e) || e.Code != InvalidArgument {
t.Fatalf("expected sys.invalid_argument, got %v", err)
}
}
func TestRecognizeOverlongInput(t *testing.T) {
// 输入上限随快照构建(阈值类参数在快照内),识别器不单独限制。
b := NewRegistryBuilder(Options{MaxInputLen: 16})
reg, err := b.Build()
if err != nil {
t.Fatal(err)
}
rec := NewRecognizer(Options{}, nil, nil)
_, err = rec.Recognize(context.Background(), "这一段输入超过了十六字节的长度上限用于验证", reg.Snapshot())
var e *Error
if !errors.As(err, &e) || e.Code != InvalidArgument {
t.Fatalf("expected sys.invalid_argument, got %v", err)
}
if e.Fields["max_len"] != 16 {
t.Fatalf("fields: %v", e.Fields)
}
}
func TestRecognizeDeterministic(t *testing.T) {
_, snap := buildDemoRegistry(t)
rec := NewRecognizer(Options{}, nil, nil)
for _, in := range []string{"查询任务 42 的进度", "你好呀", "怎么配置查询进度", "帮我改一下系统网关地址"} {
first, err := rec.Recognize(context.Background(), in, snap)
if err != nil {
t.Fatal(err)
}
second, err := rec.Recognize(context.Background(), in, snap)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(first, second) {
t.Fatalf("nondeterministic result for %q", in)
}
trace1, trace2 := snap.Explain(in), snap.Explain(in)
if !reflect.DeepEqual(trace1, trace2) {
t.Fatalf("nondeterministic explain for %q", in)
}
}
}
func TestExplainTrace(t *testing.T) {
_, snap := buildDemoRegistry(t)
trace := snap.Explain("怎么配置查询进度")
if trace.Branch != BranchFallback {
t.Fatalf("branch: %+v", trace)
}
found := false
for _, r := range trace.RulesRejected {
if r.ID == "demo.run.status_query" && len(r.Reason) > len("excluded_term:") && r.Reason[:len("excluded_term:")] == "excluded_term:" {
found = true
}
}
if !found {
t.Fatalf("excluded rejection missing: %+v", trace.RulesRejected)
}
trace = snap.Explain("帮我改一下系统网关地址")
if trace.Branch != BranchGuardHit || trace.TopID != "guard.config_change" || len(trace.Guard) != 1 {
t.Fatalf("guard trace: %+v", trace)
}
}
func TestRuleSegmenter(t *testing.T) {
seg := NewRuleSegmenter()
got := seg.Segment("查询任务进度,然后把结果发给我。谢谢!")
want := []string{"查询任务进度", "然后把结果发给我", "谢谢"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("segment: %v", got)
}
if got := seg.Segment(" "); len(got) != 1 || got[0] != " " {
t.Fatalf("blank input should return as-is: %v", got)
}
}