Why: I1 increment one - recognition output becomes a command list for the host router, same-score ties resolve by priority (I5), and CJK substring false positives get an opt-in boundary mode (I3). Evidence: go vet clean; go test ./... all ok, including new command (6), boundary (3), and tie-break tests; intentctl lint smoke run shows boundary hints on short unbounded terms. Existing classifier and ambiguity fixtures repointed to an equal-priority pair; the greet corpus case now expects the I5 tie-break outcome. Impact: Result gains Commands; recognizer derives them at one point (guard/fallback stay empty); MatchSpec gains Boundary (default off, legacy behavior unchanged); lint gains boundary hints. Top/Branch stay for compatibility. Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md I1 WP1/WP4/WP5
266 lines
9.4 KiB
Go
266 lines
9.4 KiB
Go
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)
|
||
res.Commands = deriveCommands(res, 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}
|
||
}
|
||
// 否定只认 excludes_any 子串,不做语义否定;「不要写入」这类说法交给命令层 Cancel。
|
||
matchInput, folded := snap.foldInput(input)
|
||
|
||
// Stage 0:守卫命中即短路,可表达"识别即拒绝"。
|
||
if guardHits := snap.evalGuard(matchInput, 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, matchInput, 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(matchInput, 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 || tieBreakByPriority(hits):
|
||
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}
|
||
}
|
||
|
||
// tieBreakByPriority 报告同分(分差为 0)且前两名 Priority 不同:
|
||
// 此时高 Priority 者直接胜出,不再标歧(I5)。分差小于 margin 但非零时
|
||
// 仍按 ambiguous 处理(margin 语义不变)。
|
||
func tieBreakByPriority(hits []specHit) bool {
|
||
if len(hits) < 2 {
|
||
return false
|
||
}
|
||
return hits[0].score == hits[1].score && hits[0].cs.spec.Priority > hits[1].cs.spec.Priority
|
||
}
|
||
|
||
// 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
|
||
}
|
||
matchInput, folded := s.foldInput(input)
|
||
|
||
for _, idx := range s.guardIdx {
|
||
cs := s.specs[idx]
|
||
o := cs.evaluate(matchInput, 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(matchInput, 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 || tieBreakByPriority(hits):
|
||
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 })
|
||
}
|