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 }