diff --git a/deps/error-code-system b/deps/error-code-system index dbeed2f..a3ee67b 160000 --- a/deps/error-code-system +++ b/deps/error-code-system @@ -1 +1 @@ -Subproject commit dbeed2f1b67eb417ce089e7033a228578fbbeb03 +Subproject commit a3ee67bba269e1276c14e22c026171ecf2cf6190 diff --git a/deps/logging-system b/deps/logging-system index 2c5716f..49b5d8b 160000 --- a/deps/logging-system +++ b/deps/logging-system @@ -1 +1 @@ -Subproject commit 2c5716fdcffbc4696fc0371afc70f39c44174201 +Subproject commit 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7 diff --git a/intent/classifier.go b/intent/classifier.go index cb47665..54aa76f 100644 --- a/intent/classifier.go +++ b/intent/classifier.go @@ -7,7 +7,9 @@ import "context" // 返回错误时识别保持 Ambiguous,不会使 Recognize 失败(识别确定性不受实现影响)。 // 宿主应保证 Refine 幂等且无副作用:同样的输入与候选应得到同样的重排结果。 type Classifier interface { - // Refine 对候选重打分/重排。返回的切片应包含与输入相同的一组候选 - // (可更新 Confidence 与顺序);快照信息已随候选 ID 携带。 + // Refine 对候选重打分/重排。返回的 ID 集合必须与输入完全相同且不能重复; + // Confidence 必须是有限值并落在 [0,1],越界会被拒绝而不是截断。 + // 除 Confidence 以外的字段会被按 ID 从原候选重建,改写不生效。 + // 校验失败时识别保持 Ambiguous。 Refine(ctx context.Context, input string, candidates []Candidate) ([]Candidate, error) } diff --git a/intent/recognizer.go b/intent/recognizer.go index 5d708da..0ef23ae 100644 --- a/intent/recognizer.go +++ b/intent/recognizer.go @@ -2,6 +2,8 @@ package intent import ( "context" + "fmt" + "math" "sort" "strings" "time" @@ -92,7 +94,7 @@ func (r *FunnelRecognizer) recognize(ctx context.Context, input string, snap *Sn return Result{Input: input, Branch: BranchMatched, Top: &top, Candidates: cands, SnapshotRevision: snap.rev} } - // Ambiguous:可选 L2 分类器重排;失败保持 Ambiguous,不影响识别确定性承诺的失败语义。 + // Ambiguous:可选 L2 分类器重排;失败或结果不合法都保持 Ambiguous。 cands := candidatesOf(trimHits(hits, snap.topN), input, false) if r.cls != nil { refined, err := r.cls.Refine(ctx, input, cands) @@ -100,16 +102,59 @@ func (r *FunnelRecognizer) recognize(ctx context.Context, input string, snap *Sn r.log.Module("intent").WithContext(ctx).Warn("intent classifier failed; keep ambiguous", "error", err.Error()) return Result{Input: input, Branch: BranchAmbiguous, Candidates: cands, SnapshotRevision: snap.rev} } - if top, ok := adjudicateRefined(refined, snap); ok { - cands = refined - top.Reason = top.Reason + "; refined_by=classifier" - return Result{Input: input, Branch: BranchMatched, Top: &top, Candidates: cands, SnapshotRevision: snap.rev} + rebuilt, err := validateRefined(cands, refined) + if err != nil { + r.log.Module("intent").WithContext(ctx).Warn("intent classifier result rejected; keep ambiguous", "error", err.Error()) + return Result{Input: input, Branch: BranchAmbiguous, Candidates: cands, SnapshotRevision: snap.rev} } - cands = refined + if top, ok := adjudicateRefined(rebuilt, snap); ok { + snap.fillTopSlots(&top, input) + top.Reason = top.Reason + "; refined_by=classifier" + return Result{Input: input, Branch: BranchMatched, Top: &top, Candidates: rebuilt, SnapshotRevision: snap.rev} + } + cands = rebuilt } return Result{Input: input, Branch: BranchAmbiguous, Candidates: cands, SnapshotRevision: snap.rev} } +// validateRefined 要求分类器返回与原候选完全相同的 ID 集合且无重复。 +// 置信度必须是有限值并落在 [0,1];越界判为不合法,不截断。 +// 除 Confidence 外的字段一律按 ID 从原候选重建,分类器改写的 Handler 等不生效。 +func validateRefined(original, refined []Candidate) ([]Candidate, error) { + byID := make(map[string]Candidate, len(original)) + for _, c := range original { + if _, dup := byID[c.ID]; dup { + return nil, fmt.Errorf("duplicate original id %s", c.ID) + } + byID[c.ID] = c + } + if len(refined) != len(original) { + return nil, fmt.Errorf("refined candidate count %d != %d", len(refined), len(original)) + } + seen := make(map[string]struct{}, len(refined)) + out := make([]Candidate, len(refined)) + for i, got := range refined { + if _, dup := seen[got.ID]; dup { + return nil, fmt.Errorf("duplicate refined id %s", got.ID) + } + seen[got.ID] = struct{}{} + src, ok := byID[got.ID] + if !ok { + return nil, fmt.Errorf("unknown refined id %s", got.ID) + } + if !validConfidence(got.Confidence) { + return nil, fmt.Errorf("confidence out of range for %s", got.ID) + } + src.Confidence = got.Confidence + out[i] = src + } + return out, nil +} + +func validConfidence(v float64) bool { + return !math.IsNaN(v) && !math.IsInf(v, 0) && v >= 0 && v <= 1 +} + // adjudicateRefined 对分类器结果重排后应用同一裁决规则;分差仍贴近则返回 false。 func adjudicateRefined(refined []Candidate, snap *Snapshot) (Candidate, bool) { if len(refined) == 0 { diff --git a/intent/recognizer_test.go b/intent/recognizer_test.go index 27d21ee..f473c95 100644 --- a/intent/recognizer_test.go +++ b/intent/recognizer_test.go @@ -3,6 +3,7 @@ package intent import ( "context" "errors" + "math" "os" "reflect" "strings" @@ -176,6 +177,136 @@ func TestRecognizeClassifierFailureKeepsAmbiguous(t *testing.T) { } } +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.greet.hello" { + 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.greet.hello" { + 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.greet.hello" { + 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 } diff --git a/intent/registry.go b/intent/registry.go index eb9b30c..02fe307 100644 --- a/intent/registry.go +++ b/intent/registry.go @@ -35,6 +35,9 @@ type Options struct { MaxSlotsPerSpec int // Log 可选日志器;nil 时使用 Nop。识别日志不记录原始输入。 Log *logsys.Logger + // SlotValidators 作用域槽位校验器。构建与校验时先查此表,查不到再回落 + // 已弃用的全局表(RegisterSlotValidator)。不同 Registry 的同名校验器互不影响。 + SlotValidators map[string]SlotValidator } // Options 字段默认值。 @@ -70,6 +73,13 @@ func (o Options) normalized() Options { if n.Log == nil { n.Log = logsys.Nop() } + if len(n.SlotValidators) > 0 { + cp := make(map[string]SlotValidator, len(n.SlotValidators)) + for k, v := range n.SlotValidators { + cp[k] = v + } + n.SlotValidators = cp + } return n } @@ -163,7 +173,7 @@ func (cs *compiledSpec) evaluate(input, folded string) matchOutcome { return matchOutcome{score: math.Round(score*10000) / 10000, passed: score >= cs.threshold, hitTerms: hitTerms, hitPat: hitPat} } -func compileSpec(spec IntentSpec, guard bool, index int) (*compiledSpec, error) { +func compileSpec(spec IntentSpec, guard bool, index int, scoped map[string]SlotValidator) (*compiledSpec, error) { m := spec.Matcher fold := func(terms []string) []string { out := make([]string, len(terms)) @@ -205,7 +215,7 @@ func compileSpec(spec IntentSpec, guard bool, index int) (*compiledSpec, error) cs.patterns[i] = re } for _, s := range spec.Slots { - cs2, err := compileSlot(s) + cs2, err := compileSlot(s, scoped) if err != nil { return nil, err.(*Error).with("id", spec.ID) } @@ -411,6 +421,19 @@ func (h specHit) candidate(input string, withSlots bool) Candidate { return c } +// fillTopSlots 给裁决后的 Top 补抽槽位。Ambiguous 路径建候选时不抽槽位, +// 分类器选定 Top 之后才知道该抽哪一条。 +func (s *Snapshot) fillTopSlots(c *Candidate, input string) { + idx, ok := s.byID[c.ID] + if !ok { + return + } + slots, missing, invalid := s.specs[idx].extractSlots(input) + c.Slots = slots + c.MissingRequiredSlots = missing + c.InvalidSlots = invalid +} + func candidatesOf(hits []specHit, input string, topSlots bool) []Candidate { out := make([]Candidate, 0, len(hits)) for i, h := range hits { @@ -440,7 +463,7 @@ func (b *RegistryBuilder) Register(specs ...IntentSpec) error { if b.seen[spec.ID] { return newError(InvalidSpec, "内置 spec id 重复", "id", spec.ID) } - if err := validateSpec(spec, lim); err != nil { + if err := validateSpec(spec, lim, b.opts.SlotValidators); err != nil { return err } b.seen[spec.ID] = true @@ -574,7 +597,7 @@ func buildSnapshot(opts Options, baseline []IntentSpec, entries []overlayEntry, continue } spec := e.spec - if err := validateSpec(spec, lim); err != nil { + if err := validateSpec(spec, lim, opts.SlotValidators); err != nil { return nil, err.(*Error).with("source", e.source) } if idx, ok := pos[spec.ID]; ok { @@ -616,7 +639,7 @@ func buildSnapshot(opts Options, baseline []IntentSpec, entries []overlayEntry, snap.disabled = append(snap.disabled, spec.ID) continue } - cs, err := compileSpec(spec, guards[spec.Domain], nextIndex) + cs, err := compileSpec(spec, guards[spec.Domain], nextIndex, opts.SlotValidators) if err != nil { return nil, err } diff --git a/intent/slots.go b/intent/slots.go index 894d529..95959b4 100644 --- a/intent/slots.go +++ b/intent/slots.go @@ -11,13 +11,18 @@ type SlotValidator func(value string) error var slotValidators sync.Map // name → SlotValidator -// RegisterSlotValidator 注册具名校验器;spec 通过 SlotSpec.Validator 引用。 +// RegisterSlotValidator 注册进程级具名校验器;spec 通过 SlotSpec.Validator 引用。 // 必须在快照构建前注册(构建期会校验名字存在)。 +// +// Deprecated: 校验器应随 Registry 隔离。请改用 Options.SlotValidators。 +// 全局表只作回落:构建与校验先查作用域表,查不到再查这里。 func RegisterSlotValidator(name string, fn SlotValidator) { slotValidators.Store(name, fn) } -// LookupSlotValidator 按名查找已注册校验器。 +// LookupSlotValidator 按名查找已注册的全局校验器。 +// +// Deprecated: 请改用 Options.SlotValidators。本函数只服务全局回落。 func LookupSlotValidator(name string) (SlotValidator, bool) { fn, ok := slotValidators.Load(name) if !ok { @@ -27,6 +32,14 @@ func LookupSlotValidator(name string) (SlotValidator, bool) { return v, ok } +// resolveSlotValidator 先查作用域表,查不到再回落已弃用的全局表。 +func resolveSlotValidator(scoped map[string]SlotValidator, name string) (SlotValidator, bool) { + if fn, ok := scoped[name]; ok && fn != nil { + return fn, true + } + return LookupSlotValidator(name) +} + // 内置归一化步骤:trim / lower / upper。 var builtinNormalizers = map[string]func(string) string{ "trim": strings.TrimSpace, @@ -105,7 +118,7 @@ func (cs *compiledSpec) extractSlots(input string) (slots map[string]string, mis return slots, missing, invalid } -func compileSlot(spec SlotSpec) (compiledSlot, error) { +func compileSlot(spec SlotSpec, scoped map[string]SlotValidator) (compiledSlot, error) { re, err := regexp.Compile(spec.Pattern) if err != nil { return compiledSlot{}, newError(InvalidSpec, "slot pattern 编译失败", @@ -136,11 +149,11 @@ func compileSlot(spec SlotSpec) (compiledSlot, error) { cs.formatRe = fre } if spec.Validator != "" { - fn, ok := LookupSlotValidator(spec.Validator) + fn, ok := resolveSlotValidator(scoped, spec.Validator) if !ok { return compiledSlot{}, newError(InvalidSpec, "slot validator 未注册", "field", "slots", "key", spec.Key, "validator", spec.Validator, - "hint", "先调用 intent.RegisterSlotValidator 再构建快照") + "hint", "在 Options.SlotValidators 登记,或调用已弃用的 intent.RegisterSlotValidator") } cs.validator = fn } diff --git a/intent/slots_catalog_test.go b/intent/slots_catalog_test.go index 95fde6f..87fca9d 100644 --- a/intent/slots_catalog_test.go +++ b/intent/slots_catalog_test.go @@ -67,6 +67,49 @@ func TestSlotNormalizeAndValidateChain(t *testing.T) { } } +func TestScopedSlotValidatorsDoNotInterfere(t *testing.T) { + spec := slotSpecWith(func(s *IntentSpec) { + s.Slots = []SlotSpec{{ + Key: "run_id", Required: true, + Pattern: `(?:run|任务)[:# ]*([A-Za-z0-9-]+)`, Group: 1, + Validator: "shared", + }} + }) + reject := SlotValidator(func(v string) error { + if v == "42" { + return errors.New("rejected by a") + } + return nil + }) + accept := SlotValidator(func(string) error { return nil }) + build := func(fn SlotValidator) *Snapshot { + t.Helper() + b := NewRegistryBuilder(Options{SlotValidators: map[string]SlotValidator{"shared": fn}}) + if err := b.Register(spec); err != nil { + t.Fatal(err) + } + reg, err := b.Build() + if err != nil { + t.Fatal(err) + } + return reg.Snapshot() + } + snapA := build(reject) + snapB := build(accept) + rec := NewRecognizer(Options{}, nil, nil) + resA, _ := rec.Recognize(context.Background(), "查询任务 42 的进度", snapA) + resB, _ := rec.Recognize(context.Background(), "查询任务 42 的进度", snapB) + if resA.Top == nil || resA.Top.InvalidSlots["run_id"] == "" { + t.Fatalf("registry A should reject 42: %+v", resA.Top) + } + if resB.Top == nil || resB.Top.Slots["run_id"] != "42" { + t.Fatalf("registry B should accept 42: %+v", resB.Top) + } + if len(resB.Top.InvalidSlots) != 0 { + t.Fatalf("registry B invalid: %+v", resB.Top.InvalidSlots) + } +} + func TestSlotEnumAndFormat(t *testing.T) { b := NewRegistryBuilder(Options{}) if err := b.Register(slotSpecWith(func(s *IntentSpec) { @@ -112,7 +155,7 @@ func TestSlotSpecValidationRejections(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { spec := slotSpecWith(func(s *IntentSpec) { s.Slots = []SlotSpec{tc.slot} }) - err := validateSpec(spec, lim) + err := validateSpec(spec, lim, nil) var e *Error if !errors.As(err, &e) || e.Code != InvalidSpec { t.Fatalf("expected invalid_spec, got %v", err) diff --git a/intent/spec.go b/intent/spec.go index 9dcf338..98c415b 100644 --- a/intent/spec.go +++ b/intent/spec.go @@ -51,7 +51,8 @@ type SlotSpec struct { Format string `json:"format,omitempty"` // Normalize 归一化步骤,按序应用:trim | lower | upper。 Normalize []string `json:"normalize,omitempty"` - // Validator 宿主经 RegisterSlotValidator 注册的校验器名。 + // Validator 槽位校验器名。优先解析 Options.SlotValidators,查不到再回落 + // 已弃用的全局表(RegisterSlotValidator)。 Validator string `json:"validator,omitempty"` } @@ -95,7 +96,8 @@ type limits struct { } // validateSpec 校验单条 spec 的结构合法性,错误可定位到字段。limits 为零值时跳过数量上限。 -func validateSpec(spec IntentSpec, lim limits) error { +// scoped 为 Options.SlotValidators;查不到名字时回落已弃用的全局表。 +func validateSpec(spec IntentSpec, lim limits, scoped map[string]SlotValidator) error { if spec.Domain == "" { return newError(InvalidSpec, "domain 为空", "field", "domain") } @@ -181,8 +183,8 @@ func validateSpec(spec IntentSpec, lim limits) error { } } if s.Validator != "" { - if _, ok := LookupSlotValidator(s.Validator); !ok { - return newError(InvalidSpec, "slot validator 未注册", "field", fmt.Sprintf("slots[%d].validator", i), "key", s.Key, "validator", s.Validator, "hint", "先调用 intent.RegisterSlotValidator 再注册/加载 spec") + if _, ok := resolveSlotValidator(scoped, s.Validator); !ok { + return newError(InvalidSpec, "slot validator 未注册", "field", fmt.Sprintf("slots[%d].validator", i), "key", s.Key, "validator", s.Validator, "hint", "在 Options.SlotValidators 登记,或调用已弃用的 intent.RegisterSlotValidator") } } } diff --git a/intent/spec_test.go b/intent/spec_test.go index 665bf9e..949f550 100644 --- a/intent/spec_test.go +++ b/intent/spec_test.go @@ -16,14 +16,14 @@ func validSpec() IntentSpec { func TestValidateSpecAcceptsValid(t *testing.T) { lim := Options{}.normalized().limits() - if err := validateSpec(validSpec(), lim); err != nil { + if err := validateSpec(validSpec(), lim, nil); err != nil { t.Fatalf("valid spec rejected: %v", err) } } func TestValidateSpecDefaultsNoLimits(t *testing.T) { // 零值 limits 跳过数量上限(供意图较少的宿主直接校验)。 - if err := validateSpec(validSpec(), limits{}); err != nil { + if err := validateSpec(validSpec(), limits{}, nil); err != nil { t.Fatalf("valid spec rejected without limits: %v", err) } } @@ -73,7 +73,7 @@ func TestValidateSpecRejections(t *testing.T) { t.Run(tc.name, func(t *testing.T) { spec := validSpec() tc.mut(&spec) - err := validateSpec(spec, lim) + err := validateSpec(spec, lim, nil) if err == nil { t.Fatalf("expected rejection") } @@ -96,7 +96,7 @@ func TestValidateSpecIncludesAllOnly(t *testing.T) { // 仅 includes_all 也是合法正向条件。 spec := validSpec() spec.Matcher = MatchSpec{IncludesAll: []string{"任务", "进度"}} - if err := validateSpec(spec, Options{}.normalized().limits()); err != nil { + if err := validateSpec(spec, Options{}.normalized().limits(), nil); err != nil { t.Fatalf("includes_all-only spec rejected: %v", err) } }