Files
Agent-Intent/intent/recognizer.go
T
agent-exec-transport 7f3ff6a187 Make intent matching negation-aware and slots fail closed
Why: the A3 baseline exposed 6 mis-executions - semantic negations
(不要/别/不用/先不要 + action term) matched as positive intents - and
3 invalid-slot cases (multi-candidate values, charset violations,
symbols-only) that started without clarification.
Evidence: recall corpus mis_exec now 0; no regression in
expired_confirmation, generation_consistency or overall top1
(verify-misexec.json, exit=0).
Impact: compiledSpec.evaluate per-hit negation check; adjudicate
specificity tie-break; slot extraction multi-value/charset invalidity.
Refs: feature/recall-misexec-fixes
2026-10-23 03:55:47 +00:00

538 lines
19 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 分类器。
// 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 整体排除;includes_any/pattern 命中前紧邻否定词
// (negationTokens)的那次命中不算正向命中。
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 || resolveTie(hits, input):
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}
}
// resolveTie 裁决与第一名同分(分差为 0)的候选组(I5 + 特异度)。
// 组内按(对 input 成功抽取的槽位数降序、Priority 降序、合并序)排名;排名第一者须
// 胜过组内每一个其余候选才直接胜出、不再标歧,此时把它移到 hits 首位并返回 true。
// a 胜过 b 当且仅当二者 Priority 不同,且 a 抽到的槽位更多、或槽位数相同而 Priority 更高:
// 寒暄(高 Priority、无槽位)让位于抽到槽位的动作;Priority 相同的两个动作仍按
// ambiguous 澄清(多意图输入),候选次序不变。分差小于 margin 但非零时仍按
// ambiguous 处理(margin 语义不变)。
func resolveTie(hits []specHit, input string) bool {
if len(hits) < 2 || hits[0].score != hits[1].score {
return false
}
n := 2
for n < len(hits) && hits[n].score == hits[0].score {
n++
}
filled := make(map[*compiledSpec]int, n)
for _, h := range hits[:n] {
slots, _, _ := h.cs.extractSlots(input)
filled[h.cs] = len(slots)
}
ranked := append([]specHit(nil), hits[:n]...)
sort.SliceStable(ranked, func(i, j int) bool {
if a, b := filled[ranked[i].cs], filled[ranked[j].cs]; a != b {
return a > b
}
return lessHit(ranked[i], ranked[j])
})
best := ranked[0]
for _, other := range ranked[1:] {
pa, pb := best.cs.spec.Priority, other.cs.spec.Priority
fa, fb := filled[best.cs], filled[other.cs]
if pa == pb || fa < fb || (fa == fb && pa < pb) {
return false
}
}
copy(hits[:n], ranked)
return true
}
// 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 || resolveTie(hits, input):
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
}