Why: I2 layering - between rules and the L2 classifier there is now a deterministic host-scored semantic layer (rules then semantic then model), and fallback can be rescued by a validated command generator instead of going straight to the default channel. Evidence: go vet clean; full suite ok, including ten new tests (rerank resolve/below-threshold/tie/error-then-L2, generator valid/invalid/error, catalog top-k). Impact: IntentSpec gains Examples; Options gains Semantic (with threshold), Generator; ambiguous candidates rerank through host scores before L2; fallback validates generated commands (action/slots/kind fail-closed) and synthesizes Top with slots; generated commands are no longer overwritten by deriveCommands; CatalogTopK by priority. Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md I2
432 lines
15 KiB
Go
432 lines
15 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 分类器。
|
||
// 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, 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 })
|
||
}
|
||
|
||
// semanticRerank 用宿主打分器对歧义候选按 examples 重排(I2)。
|
||
// 规则:候选必须来自当前候选集(构造上不可能引入未知 ID);
|
||
// 只取相似度 ≥ 阈值的候选——唯一过阈或前两名分差 ≥ margin 才判 matched,
|
||
// 否则保持 ambiguous(与规则裁决同款语义)。打分错误一律记日志并保持 ambiguous。
|
||
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
|
||
}
|
||
s, err := r.sem.Score(ctx, input, spec.Examples)
|
||
if err != nil {
|
||
r.log.Module("intent").WithContext(ctx).Warn("intent semantic scorer failed; keep ambiguous",
|
||
"error", err.Error(), "id", c.ID)
|
||
continue
|
||
}
|
||
if s >= r.thr {
|
||
above = append(above, scored{cand: c, score: s})
|
||
}
|
||
}
|
||
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 ID 集或 Handler 集内)、命令种类受限、
|
||
// 槽位键在目标 spec 的键集内(可解析时);全部通过则把首个 start 的目标
|
||
// spec 合成 Top(refined_by=generator 标注),任一项不过则保持 fallback。
|
||
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, catalog); 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
|
||
}
|
||
if spec, ok := generatedSpecOf(c.Action, catalog); ok {
|
||
top := Candidate{
|
||
ID: spec.ID, Domain: spec.Domain, Version: spec.Version,
|
||
Confidence: 0, Reason: "generated_by=command_generator",
|
||
Slots: c.Slots,
|
||
}
|
||
res.Top = &top
|
||
res.Branch = BranchMatched
|
||
res.Candidates = []Candidate{top}
|
||
}
|
||
break
|
||
}
|
||
return res, true
|
||
}
|
||
|
||
// DefaultGeneratorCatalogK 生成器目录默认 top-k。
|
||
const DefaultGeneratorCatalogK = 8
|
||
|
||
// validateGenerated 校验生成命令(I2;与 spec 级校验同款 fail-closed)。
|
||
func validateGenerated(cmds []Command, catalog []CatalogSpec) error {
|
||
if len(cmds) == 0 {
|
||
return fmt.Errorf("empty command list")
|
||
}
|
||
specIDs := map[string]bool{}
|
||
handlers := map[string]bool{}
|
||
slotKeys := map[string]map[string]bool{}
|
||
for _, cs := range catalog {
|
||
specIDs[cs.ID] = true
|
||
if cs.Handler != "" {
|
||
handlers[cs.Handler] = true
|
||
}
|
||
keys := map[string]bool{}
|
||
for _, sl := range cs.Slots {
|
||
keys[sl.Key] = true
|
||
}
|
||
slotKeys[cs.ID] = keys
|
||
}
|
||
for i, c := range cmds {
|
||
switch c.Kind {
|
||
case CommandStart:
|
||
ref := c.Action
|
||
switch ref.Kind {
|
||
case ActionAuto, ActionCommand, ActionWorkflow, ActionBlueprint:
|
||
if !specIDs[ref.ID] {
|
||
return fmt.Errorf("cmd[%d] start: action.id %q 不在目录内", i, ref.ID)
|
||
}
|
||
case ActionHandler:
|
||
if !handlers[ref.ID] {
|
||
return fmt.Errorf("cmd[%d] start: handler %q 不在目录内", i, ref.ID)
|
||
}
|
||
default:
|
||
return fmt.Errorf("cmd[%d] start: 未知 action.kind %q", i, ref.Kind)
|
||
}
|
||
if keys, ok := slotKeys[ref.ID]; ok {
|
||
for k := range c.Slots {
|
||
if !keys[k] {
|
||
return fmt.Errorf("cmd[%d] start: 槽位 %q 不在 %s 的槽位键集内", i, k, ref.ID)
|
||
}
|
||
}
|
||
}
|
||
case CommandClarify, CommandCancel, CommandSetSlot, CommandChangeSlot, CommandChitchat, CommandHandoff:
|
||
default:
|
||
return fmt.Errorf("cmd[%d]: 未知命令种类 %q", i, c.Kind)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// generatedSpecOf 把 start 的 ActionRef 解析回目录 spec(handler 键按 Handler 反查)。
|
||
func generatedSpecOf(ref ActionRef, catalog []CatalogSpec) (CatalogSpec, bool) {
|
||
for _, cs := range catalog {
|
||
if ref.Kind == ActionHandler {
|
||
if cs.Handler == ref.ID {
|
||
return cs, true
|
||
}
|
||
continue
|
||
}
|
||
if cs.ID == ref.ID {
|
||
return cs, true
|
||
}
|
||
}
|
||
return CatalogSpec{}, false
|
||
}
|