Details (original message): Implement the intent core, conformance suite, catalog alignment, examples, and the intentctl diagnostics CLI.
331 lines
10 KiB
Go
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)
|
|
}
|
|
}
|