Files
Agent-Intent/intent/recognizer.go
T
weichenandCursor 3130190acb
ci / vet-and-test (pull_request) Failing after 2s
ci / docker-checkout-hostgateway (pull_request) Successful in 2s
Reject invalid classifier refinements and scope slot validators
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>
2026-10-03 00:47:16 +00:00

254 lines
8.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package intent
import (
"context"
"fmt"
"math"
"sort"
"strings"
"time"
"logging-system/logsys"
)
// Recognizer 是识别的公开端口(Query 语义:纯函数式、无副作用)。
// error 只表示系统级失败(nil 快照、输入超长);BranchFallback 是正常结果。
type Recognizer interface {
Recognize(ctx context.Context, input string, snap *Snapshot) (Result, error)
}
// FunnelRecognizer 四级漏斗实现:guard → expectation → rules → adjudicate,
// 可选注入挂起预期提供者与 Ambiguous 分支的 L2 分类器。
type FunnelRecognizer struct {
log *logsys.Logger
exp ExpectationProvider
cls Classifier
}
// NewRecognizer 构建漏斗识别器。opts 仅使用 Log 字段(阈值类参数在快照内);
// exp / cls 可为 nil(对应阶段不启用)。
func NewRecognizer(opts Options, exp ExpectationProvider, cls Classifier) *FunnelRecognizer {
o := opts.normalized()
return &FunnelRecognizer{log: o.Log, exp: exp, cls: cls}
}
// Recognize 识别一次输入。确定性:同输入 + 同快照 → 同结果(日志中的耗时除外)。
func (r *FunnelRecognizer) Recognize(ctx context.Context, input string, snap *Snapshot) (Result, error) {
if snap == nil {
err := newError(InvalidArgument, "snapshot 为 nil")
r.log.Module("intent").WithContext(ctx).Error("intent recognize rejected", err)
return Result{}, err
}
if len(input) > snap.maxInput {
err := newError(InvalidArgument, "输入超过长度上限", "actual_len", len(input), "max_len", snap.maxInput)
r.log.Module("intent").WithContext(ctx).Error("intent recognize rejected", err)
return Result{}, err
}
start := time.Now()
res := r.recognize(ctx, input, snap)
topID, conf := "", 0.0
if res.Top != nil {
topID, conf = res.Top.ID, res.Top.Confidence
}
r.log.Module("intent").WithContext(ctx).Info("intent recognized",
"branch", string(res.Branch),
"top_id", topID,
"confidence", conf,
"snapshot_revision", res.SnapshotRevision,
"duration_ms", time.Since(start).Milliseconds())
return res, nil
}
func (r *FunnelRecognizer) recognize(ctx context.Context, input string, snap *Snapshot) Result {
if strings.TrimSpace(input) == "" {
return Result{Input: input, Branch: BranchFallback, SnapshotRevision: snap.rev}
}
folded := strings.ToLower(input)
// Stage 0:守卫命中即短路,可表达"识别即拒绝"。
if guardHits := snap.evalGuard(input, folded); len(guardHits) > 0 {
cands := candidatesOf(trimHits(guardHits, snap.topN), input, true)
top := cands[0]
return Result{Input: input, Branch: BranchGuardHit, Top: &top, Candidates: cands, SnapshotRevision: snap.rev}
}
// Stage 1:会话挂起预期优先于一般规则(宿主可选注入)。
if r.exp != nil {
if pending := r.exp.Pending(ctx); pending != nil && len(pending.IntentIDs) > 0 {
if hits := snap.evalIDs(pending.IntentIDs, input, folded); len(hits) > 0 {
cands := candidatesOf(trimHits(hits, snap.topN), input, true)
top := cands[0]
return Result{Input: input, Branch: BranchExpectationHit, Top: &top, Candidates: cands, SnapshotRevision: snap.rev}
}
}
}
// Stage 2 + 3:规则计分与置信度裁决。
hits := snap.evalRules(input, folded)
switch {
case len(hits) == 0:
return Result{Input: input, Branch: BranchFallback, SnapshotRevision: snap.rev}
case len(hits) == 1 || hits[0].score-hits[1].score >= snap.margin:
cands := candidatesOf(trimHits(hits, snap.topN), input, true)
top := cands[0]
return Result{Input: input, Branch: BranchMatched, Top: &top, Candidates: cands, SnapshotRevision: snap.rev}
}
// Ambiguous:可选 L2 分类器重排;失败或结果不合法都保持 Ambiguous。
cands := candidatesOf(trimHits(hits, snap.topN), input, false)
if r.cls != nil {
refined, err := r.cls.Refine(ctx, input, cands)
if err != nil {
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}
}
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}
}
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 {
return Candidate{}, false
}
sorted := append([]Candidate(nil), refined...)
sortCandidates(sorted)
if len(sorted) > 1 && sorted[0].Confidence-sorted[1].Confidence < snap.margin {
return Candidate{}, false
}
return sorted[0], true
}
func sortCandidates(cands []Candidate) {
sort.SliceStable(cands, func(i, j int) bool {
a, b := cands[i], cands[j]
if a.Confidence != b.Confidence {
return a.Confidence > b.Confidence
}
if a.Priority != b.Priority {
return a.Priority > b.Priority
}
return a.ID < b.ID
})
}
func trimHits(hits []specHit, n int) []specHit {
if len(hits) > n {
return hits[:n]
}
return hits
}
// Explain 输出单条输入的裁决轨迹(guard 与 rules 全量、确定性、无识别器状态)。
// 这是配置规范要求的只读诊断;intentctl explain 在此之上构建。
func (s *Snapshot) Explain(input string) Trace {
trace := Trace{InputLen: len(input), AmbiguityMargin: s.margin}
if strings.TrimSpace(input) == "" {
trace.Branch = BranchFallback
return trace
}
folded := strings.ToLower(input)
for _, idx := range s.guardIdx {
cs := s.specs[idx]
o := cs.evaluate(input, folded)
if o.passed {
trace.Guard = append(trace.Guard, ScoredSpec{ID: cs.spec.ID, Score: o.score, HitTerms: o.hitTerms, HitPatterns: o.hitPat})
} else {
trace.GuardRejected = append(trace.GuardRejected, RejectedSpec{ID: cs.spec.ID, Reason: o.reject})
}
}
sortScored(trace.Guard)
sortRejected(trace.GuardRejected)
if len(trace.Guard) > 0 {
trace.Branch = BranchGuardHit
trace.TopID = trace.Guard[0].ID
return trace
}
var hits []specHit
for _, cs := range s.specs {
if cs.guard {
continue
}
o := cs.evaluate(input, folded)
if o.passed {
hits = append(hits, specHit{cs: cs, score: o.score, terms: o.hitTerms, pats: o.hitPat})
trace.Rules = append(trace.Rules, ScoredSpec{ID: cs.spec.ID, Score: o.score, HitTerms: o.hitTerms, HitPatterns: o.hitPat})
} else {
trace.RulesRejected = append(trace.RulesRejected, RejectedSpec{ID: cs.spec.ID, Reason: o.reject})
}
}
sortHits(hits)
sortScored(trace.Rules)
sortRejected(trace.RulesRejected)
switch {
case len(hits) == 0:
trace.Branch = BranchFallback
case len(hits) == 1 || hits[0].score-hits[1].score >= s.margin:
trace.Branch = BranchMatched
trace.TopID = hits[0].cs.spec.ID
default:
trace.Branch = BranchAmbiguous
}
return trace
}
func sortScored(ss []ScoredSpec) {
sort.SliceStable(ss, func(i, j int) bool { return ss[i].ID < ss[j].ID })
}
func sortRejected(rs []RejectedSpec) {
sort.SliceStable(rs, func(i, j int) bool { return rs[i].ID < rs[j].ID })
}