Files
Agent-Intent/intent/recognizer.go
weichen 765a7e7177 Isolate snapshots and validate generated commands
Why: F07/F08 - registry and catalog accessors shared caller-mutable
nested state, and the generation fallback skipped full slot validation,
risk metadata and mis-keyed handler slot tables.
Evidence: intent package tests pass incl. new isolation/generated cases
(verify-u5.json).
Impact: no exported signature change; rule and generated paths now share
authoritative validation.
Refs: feature/agent-stack-audit-remediation-b1 U5
2026-10-21 15:41:31 +00:00

508 lines
18 KiB
Go
Raw Permalink 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 分类器。
// I2 的分层:歧义先经语义重排(确定性宿主打分)再到 L2 分类器(模型)。
type FunnelRecognizer struct {
log *logsys.Logger
exp ExpectationProvider
cls Classifier
sem SemanticScorer
thr float64
gen CommandGenerator
}
// NewRecognizer 构建漏斗识别器。opts 仅使用 Log 字段(阈值类参数在快照内);
// exp / cls 可为 nil(对应阶段不启用)。语义与生成端口经 Options 注入(I2)。
func NewRecognizer(opts Options, exp ExpectationProvider, cls Classifier) *FunnelRecognizer {
o := opts.normalized()
return &FunnelRecognizer{log: o.Log, exp: exp, cls: cls,
sem: o.Semantic, thr: o.SemanticThreshold, gen: o.Generator}
}
// 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)
// 生成端口已经给出经过校验的命令列表时不再用规则展开覆盖(I2)。
if res.Commands == nil {
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:
// I2:规则与语义都未命中时,可选经模型命令生成端口兜底(校验同款)。
if r.gen != nil {
if res, ok := r.generateCommands(ctx, input, snap); ok {
return res
}
}
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:先语义重排(I2 确定性宿主打分),失败或不达阈才到 L2 分类器。
cands := candidatesOf(trimHits(hits, snap.topN), input, false)
if r.sem != nil {
if top, ok := r.semanticRerank(ctx, input, cands, snap); ok {
snap.fillTopSlots(&top, input)
top.Reason = top.Reason + "; refined_by=semantic"
return Result{Input: input, Branch: BranchMatched, Top: &top, Candidates: cands, SnapshotRevision: snap.rev}
}
}
if r.cls != nil {
// 传入候选副本:分类器就地改写不会污染原候选(失败时仍返回未篡改集)。
refined, err := r.cls.Refine(ctx, input, cloneCandidates(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)
}
norm, ok := normalizeScore(got.Confidence)
if !ok {
return nil, fmt.Errorf("confidence out of range for %s", got.ID)
}
src.Confidence = norm
out[i] = src
}
return out, nil
}
func validConfidence(v float64) bool {
return !math.IsNaN(v) && !math.IsInf(v, 0) && v >= 0 && v <= 1
}
// normalizeScore 拒绝非有限/越界分数,并通过与规则计分同款的定点舍入后再交给阈值比较,
// 防止篡改或噪声分数绕过阈值进入 matched。
func normalizeScore(v float64) (float64, bool) {
if !validConfidence(v) {
return 0, false
}
return math.Round(v*10000) / 10000, true
}
// 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 })
}
// semanticRerank 用宿主打分器对歧义候选按 examples 重排(I2)。
// 规则:候选必须来自当前候选集(构造上不可能引入未知 ID);
// 只取相似度 ≥ 阈值的候选——唯一过阈或前两名分差 ≥ margin 才判 matched,
// 否则保持 ambiguous(与规则裁决同款语义)。打分错误一律记日志并保持 ambiguous。
// examples 以副本传入打分器;非有限/越界分数拒绝;定点舍入后再与阈值比较。
func (r *FunnelRecognizer) semanticRerank(ctx context.Context, input string, cands []Candidate, snap *Snapshot) (Candidate, bool) {
type scored struct {
cand Candidate
score float64
}
var above []scored
for _, c := range cands {
spec, ok := snap.SpecByID(c.ID)
if !ok || len(spec.Examples) == 0 {
continue
}
examples := cloneStrings(spec.Examples)
s, err := r.sem.Score(ctx, input, examples)
if err != nil {
r.log.Module("intent").WithContext(ctx).Warn("intent semantic scorer failed; keep ambiguous",
"error", err.Error(), "id", c.ID)
continue
}
norm, ok := normalizeScore(s)
if !ok {
r.log.Module("intent").WithContext(ctx).Warn("intent semantic score rejected; keep ambiguous",
"id", c.ID, "score", s)
continue
}
if norm >= r.thr {
above = append(above, scored{cand: c, score: norm})
}
}
if len(above) == 0 {
return Candidate{}, false
}
for i := 0; i+1 < len(above); i++ {
for j := i + 1; j < len(above); j++ {
if above[j].score > above[i].score {
above[i], above[j] = above[j], above[i]
}
}
}
if len(above) == 1 || above[0].score-above[1].score >= snap.margin {
return above[0].cand, true
}
return Candidate{}, false
}
// generateCommands 经模型命令生成端口兜底(I2)。校验:
// start 的 ActionRef 必须能从同一快照唯一解析回权威 spec(含 Action 别名与
// handler→spec 反查)、命令种类受限、槽位键合法;解析成功后对生成槽位跑与
// 规则路径同款的全量校验并挂上 risk/confirm/action 元数据。任一项不过则保持 fallback(无 Top)。
func (r *FunnelRecognizer) generateCommands(ctx context.Context, input string, snap *Snapshot) (Result, bool) {
catalog := snap.CatalogTopK(DefaultGeneratorCatalogK)
cmds, err := r.gen.Generate(ctx, input, catalog)
if err != nil {
r.log.Module("intent").WithContext(ctx).Warn("intent command generator failed; keep fallback", "error", err.Error())
return Result{}, false
}
if err := validateGenerated(cmds, snap); err != nil {
r.log.Module("intent").WithContext(ctx).Warn("intent generator output rejected; keep fallback", "error", err.Error())
return Result{}, false
}
res := Result{Input: input, Branch: BranchFallback, Commands: cmds, SnapshotRevision: snap.rev}
for _, c := range cmds {
if c.Kind != CommandStart {
continue
}
cs, err := snap.resolveActionRef(c.Action)
if err != nil || cs == nil {
break
}
top := candidateFromGenerated(cs, c.Slots)
res.Top = &top
res.Branch = BranchMatched
res.Candidates = []Candidate{top}
break
}
return res, true
}
// DefaultGeneratorCatalogK 生成器目录默认 top-k。
const DefaultGeneratorCatalogK = 8
// candidateFromGenerated 用权威 spec 元数据 + 生成槽位的全量校验合成 Top(与规则路径同款字段)。
func candidateFromGenerated(cs *compiledSpec, provided map[string]string) Candidate {
c := Candidate{
ID: cs.spec.ID,
Domain: cs.spec.Domain,
Version: cs.spec.Version,
Confidence: 0,
Priority: cs.spec.Priority,
Risk: cs.spec.Risk,
RequiresConfirm: cs.spec.RequiresConfirm,
Handler: cs.spec.Handler,
Reason: "generated_by=command_generator",
}
if c.Risk == "" {
c.Risk = RiskLow
}
if cs.spec.Action != nil {
ref := *cs.spec.Action
c.Action = &ref
}
if len(cs.spec.Metadata) > 0 {
c.Metadata = cloneStringMap(cs.spec.Metadata)
}
c.Slots, c.MissingRequiredSlots, c.InvalidSlots = cs.validateProvidedSlots(provided)
return c
}
// validateGenerated 校验生成命令(I2;与 spec 级校验同款 fail-closed)。
// ActionRef 经快照唯一解析;槽位未知键在此拒绝(与规则路径「键集外」同款 fail-closed)。
func validateGenerated(cmds []Command, snap *Snapshot) error {
if len(cmds) == 0 {
return fmt.Errorf("empty command list")
}
for i, c := range cmds {
switch c.Kind {
case CommandStart:
cs, err := snap.resolveActionRef(c.Action)
if err != nil {
return fmt.Errorf("cmd[%d] start: %w", i, err)
}
keys := map[string]bool{}
for _, sl := range cs.slots {
keys[sl.key] = true
}
for k := range c.Slots {
if !keys[k] {
return fmt.Errorf("cmd[%d] start: 槽位 %q 不在 %s 的槽位键集内", i, k, cs.spec.ID)
}
}
case CommandClarify, CommandCancel, CommandSetSlot, CommandChangeSlot, CommandChitchat, CommandHandoff:
default:
return fmt.Errorf("cmd[%d]: 未知命令种类 %q", i, c.Kind)
}
}
return nil
}
// resolveActionRef 把 start 的 ActionRef 解析回快照内唯一权威 compiledSpec。
// ActionCommand/Workflow/Blueprint/Auto:先按 spec ID,再按 spec.Action 声明的别名;
// ActionHandler:经 handler→spec 反查,重名拒绝。
func (s *Snapshot) resolveActionRef(ref ActionRef) (*compiledSpec, error) {
switch ref.Kind {
case ActionHandler:
return s.resolveByHandler(ref.ID)
case ActionAuto, ActionCommand, ActionWorkflow, ActionBlueprint:
return s.resolveBySpecOrAlias(ref)
default:
return nil, fmt.Errorf("未知 action.kind %q", ref.Kind)
}
}
func (s *Snapshot) resolveByHandler(handler string) (*compiledSpec, error) {
if strings.TrimSpace(handler) == "" {
return nil, fmt.Errorf("handler 为空")
}
var found *compiledSpec
for _, cs := range s.specs {
if cs.guard {
continue
}
if cs.spec.Handler != handler {
continue
}
if found != nil {
return nil, fmt.Errorf("handler %q 对应多个 spec(歧义)", handler)
}
found = cs
}
if found == nil {
return nil, fmt.Errorf("handler %q 不在快照内", handler)
}
return found, nil
}
func (s *Snapshot) resolveBySpecOrAlias(ref ActionRef) (*compiledSpec, error) {
if strings.TrimSpace(ref.ID) == "" {
return nil, fmt.Errorf("action.id 为空")
}
var found *compiledSpec
for _, cs := range s.specs {
if cs.guard {
continue
}
match := cs.spec.ID == ref.ID
if !match && cs.spec.Action != nil && cs.spec.Action.Kind == ref.Kind && cs.spec.Action.ID == ref.ID {
match = true
}
// ActionAuto 也接受「合法别名」:显式 Action 的 ID 与 kind 匹配任一声明。
if !match && ref.Kind == ActionAuto && cs.spec.Action != nil && cs.spec.Action.ID == ref.ID {
match = true
}
if !match {
continue
}
if found != nil && found != cs {
return nil, fmt.Errorf("action.id %q 对应多个 spec(歧义)", ref.ID)
}
found = cs
}
if found == nil {
return nil, fmt.Errorf("action.id %q 不在快照内", ref.ID)
}
return found, nil
}