Files
Agent-Intent/intent/recognizer_test.go
T
weichen fa512ec03c
ci / vet-and-test (pull_request) Successful in 17s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Add command model, priority tie-break, and boundary matching
Why: I1 increment one - recognition output becomes a command list
for the host router, same-score ties resolve by priority (I5), and
CJK substring false positives get an opt-in boundary mode (I3).

Evidence: go vet clean; go test ./... all ok, including new command
(6), boundary (3), and tie-break tests; intentctl lint smoke run
shows boundary hints on short unbounded terms. Existing classifier
and ambiguity fixtures repointed to an equal-priority pair; the
greet corpus case now expects the I5 tie-break outcome.

Impact: Result gains Commands; recognizer derives them at one point
(guard/fallback stay empty); MatchSpec gains Boundary (default off,
legacy behavior unchanged); lint gains boundary hints. Top/Branch
stay for compatibility.

Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md I1 WP1/WP4/WP5
2026-10-07 07:21:55 +00:00

463 lines
14 KiB
Go

package intent
import (
"context"
"errors"
"math"
"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 TestRecognizeTieBreakPrefersPriority(t *testing.T) {
_, snap := buildDemoRegistry(t)
rec := NewRecognizer(Options{}, nil, nil)
res, err := rec.Recognize(context.Background(), "你好呀", snap)
if err != nil {
t.Fatal(err)
}
// I5:同分(0.7)且 Priority 不同(10 > 5)时高优先者直接胜出,不再标歧。
if res.Branch != BranchMatched || res.Top == nil || res.Top.ID != "demo.greet.hello" {
t.Fatalf("tie-break should match hello: %+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.tie.alpha": 0.9, "demo.tie.beta": 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.tie.alpha" {
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 rewriteClassifier struct {
fn func([]Candidate) []Candidate
}
func (s rewriteClassifier) Refine(ctx context.Context, input string, cands []Candidate) ([]Candidate, error) {
return s.fn(cands), nil
}
func TestRecognizeClassifierRejectsBadRefinement(t *testing.T) {
_, snap := buildDemoRegistry(t)
cases := []struct {
name string
fn func([]Candidate) []Candidate
}{
{"unknown_id", func(cands []Candidate) []Candidate {
out := append([]Candidate(nil), cands...)
out[0].ID = "not.a.real.id"
return out
}},
{"missing_id", func(cands []Candidate) []Candidate {
return cands[:1]
}},
{"duplicate_id", func(cands []Candidate) []Candidate {
return []Candidate{cands[0], cands[0]}
}},
{"confidence_above_one", func(cands []Candidate) []Candidate {
out := append([]Candidate(nil), cands...)
out[0].Confidence = 1.2
return out
}},
{"confidence_negative", func(cands []Candidate) []Candidate {
out := append([]Candidate(nil), cands...)
out[0].Confidence = -0.1
return out
}},
{"confidence_nan", func(cands []Candidate) []Candidate {
out := append([]Candidate(nil), cands...)
out[0].Confidence = math.NaN()
return out
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := NewRecognizer(Options{}, nil, rewriteClassifier{fn: tc.fn})
res, err := rec.Recognize(context.Background(), "打结", snap)
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchAmbiguous || res.Top != nil {
t.Fatalf("expected ambiguous kept: %+v", res)
}
if len(res.Candidates) < 2 || res.Candidates[0].ID != "demo.tie.alpha" {
t.Fatalf("original candidates kept: %+v", res.Candidates)
}
})
}
}
func TestRecognizeClassifierRebuildsTamperedHandler(t *testing.T) {
_, snap := buildDemoRegistry(t)
plain, err := NewRecognizer(Options{}, nil, nil).Recognize(context.Background(), "打结", snap)
if err != nil {
t.Fatal(err)
}
wantHandler := plain.Candidates[0].Handler
rec := NewRecognizer(Options{}, nil, rewriteClassifier{fn: func(cands []Candidate) []Candidate {
out := append([]Candidate(nil), cands...)
for i := range out {
out[i].Handler = "tampered.handler"
if out[i].ID == "demo.tie.alpha" {
out[i].Confidence = 0.95
} else {
out[i].Confidence = 0.2
}
}
return out
}})
res, err := rec.Recognize(context.Background(), "打结", snap)
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchMatched || res.Top == nil || res.Top.ID != "demo.tie.alpha" {
t.Fatalf("expected resolved match: %+v", res)
}
if res.Top.Handler != wantHandler {
t.Fatalf("handler rebuilt from original %q, got %q", wantHandler, res.Top.Handler)
}
}
func TestRecognizeClassifierRefinedTopGetsSlots(t *testing.T) {
b := NewRegistryBuilder(Options{})
if err := b.Register(
IntentSpec{
ID: "demo.slot.a", Domain: "demo", Version: 1,
Matcher: MatchSpec{IncludesAny: []string{"查槽"}},
Handler: "handler.a",
Slots: []SlotSpec{{Key: "n", Pattern: `查槽[: ]*(\d+)`, Group: 1}},
Confidence: ConfidenceSpec{Base: 0.8, Threshold: 0.5},
},
IntentSpec{
ID: "demo.slot.b", Domain: "demo", Version: 1,
Matcher: MatchSpec{IncludesAny: []string{"查槽"}},
Handler: "handler.b",
Confidence: ConfidenceSpec{Base: 0.8, Threshold: 0.5},
},
); err != nil {
t.Fatal(err)
}
reg, err := b.Build()
if err != nil {
t.Fatal(err)
}
snap := reg.Snapshot()
plain, _ := NewRecognizer(Options{}, nil, nil).Recognize(context.Background(), "查槽 7", snap)
if plain.Branch != BranchAmbiguous || plain.Top != nil {
t.Fatalf("expected ambiguous before refine: %+v", plain)
}
rec := NewRecognizer(Options{}, nil, stubClassifier{scores: map[string]float64{"demo.slot.a": 0.95, "demo.slot.b": 0.2}})
res, err := rec.Recognize(context.Background(), "查槽 7", snap)
if err != nil {
t.Fatal(err)
}
if res.Branch != BranchMatched || res.Top == nil || res.Top.ID != "demo.slot.a" {
t.Fatalf("refined top: %+v", res)
}
if res.Top.Slots["n"] != "7" {
t.Fatalf("top slots: %+v", res.Top.Slots)
}
}
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)
}
}