Files
Agent-Intent/intent/recognizer.go
T
weichen 40c33d5edc
ci / vet-and-test (push) Failing after 1s
ci / docker-checkout-hostgateway (push) Successful in 3s
Implement the intent core, conformance suite, catalog alignment, examples, and the intentctl diagnostics CLI.
2026-09-29 15:35:18 +00:00

209 lines
7.1 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"
"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}
}
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}
}
cands = refined
}
return Result{Input: input, Branch: BranchAmbiguous, Candidates: cands, SnapshotRevision: snap.rev}
}
// 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 })
}