Reject invalid classifier refinements and scope slot validators
ci / vet-and-test (pull_request) Failing after 2s
ci / docker-checkout-hostgateway (pull_request) Successful in 2s

Why: Refine could swap handlers or emit out-of-range scores, Ambiguous tops skipped slot extraction, and the process-global validator map coupled independent registries.
Evidence: GOWORK=off go vet ./... && go test -count=1 ./... && go test -race ./... passed, including TestRecognizeClassifierRejectsBadRefinement, TestRecognizeClassifierRebuildsTamperedHandler, TestRecognizeClassifierRefinedTopGetsSlots, and TestScopedSlotValidatorsDoNotInterfere. error-code-system and logging-system advanced to a3ee67b and 49b5d8b (docs-only).
Impact: illegal Refine results stay Ambiguous; a resolved Top is slotted; Options.SlotValidators wins over the deprecated global table.
Refs: I1, I2, I6
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-03 00:47:16 +00:00
co-authored by Cursor
parent 5d5d99e737
commit 3130190acb
10 changed files with 288 additions and 29 deletions
+4 -2
View File
@@ -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)
}
+51 -6
View File
@@ -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 {
+131
View File
@@ -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
}
+28 -5
View File
@@ -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
}
+18 -5
View File
@@ -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
}
+44 -1
View File
@@ -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)
+6 -4
View File
@@ -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")
}
}
}
+4 -4
View File
@@ -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)
}
}