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) } }