From 765a7e717737dcbe0d684ece3b7e2b38923c53d5 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 21 Oct 2026 15:41:31 +0000 Subject: [PATCH] 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 --- intent/catalog.go | 7 +- intent/fold.go | 89 +++++- intent/generator.go | 8 +- intent/recognizer.go | 198 ++++++++----- intent/registry.go | 33 +-- intent/slots.go | 50 ++++ intent/u5_isolation_generated_test.go | 381 ++++++++++++++++++++++++++ 7 files changed, 678 insertions(+), 88 deletions(-) create mode 100644 intent/u5_isolation_generated_test.go diff --git a/intent/catalog.go b/intent/catalog.go index 4f47aee..2c28e05 100644 --- a/intent/catalog.go +++ b/intent/catalog.go @@ -33,6 +33,7 @@ type CatalogSpec struct { // Catalog 返回能力目录:全部启用的非守卫 spec,按合并序(确定性)。 // 守卫域 spec 是拦截器而非可调用能力,不进目录;需要全量时用 SpecIDs/SpecByID。 +// 返回值为深拷贝:调用方改切片/枚举/metadata 不会改到快照。 func (s *Snapshot) Catalog() []CatalogSpec { out := make([]CatalogSpec, 0, len(s.specs)) for _, cs := range s.specs { @@ -55,7 +56,7 @@ func (s *Snapshot) Catalog() []CatalogSpec { for _, sl := range cs.slots { view.Slots = append(view.Slots, CatalogSlot{ Key: sl.key, Required: sl.required, Label: sl.label, Question: sl.question, - Enum: sl.enum, Format: specSlotFormat(cs.spec, sl.key), + Enum: cloneStrings(sl.enum), Format: specSlotFormat(cs.spec, sl.key), }) } out = append(out, view) @@ -124,7 +125,7 @@ func specSlotFormat(spec IntentSpec, key string) string { // CatalogTopK 取目录的前 k 条:按 Priority 降序、ID 升序(确定性排序; // 排序口径是 priority,不是语义相关度——相关度排序由宿主经 examples 自行计算)。 -// k <= 0 时等同 Catalog() 全量。 +// k <= 0 时等同 Catalog() 全量。返回值与 Catalog 一样是深拷贝,不共享底层数组。 func (s *Snapshot) CatalogTopK(k int) []CatalogSpec { all := s.Catalog() if k <= 0 || len(all) <= k { @@ -141,5 +142,5 @@ func (s *Snapshot) CatalogTopK(k int) []CatalogSpec { } return all[i].ID < all[j].ID }) - return all[:k] + return cloneCatalogSpecs(all[:k]) } diff --git a/intent/fold.go b/intent/fold.go index c75d2fb..7a939c6 100644 --- a/intent/fold.go +++ b/intent/fold.go @@ -34,6 +34,26 @@ func cloneStrings(in []string) []string { return append([]string(nil), in...) } +func cloneStringMap(in map[string]string) map[string]string { + if in == nil { + return nil + } + out := make(map[string]string, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + +func cloneActionRef(in *ActionRef) *ActionRef { + if in == nil { + return nil + } + cp := *in + return &cp +} + +// cloneIntentSpec 深拷贝 IntentSpec,切断切片/map/指针与调用方或快照内部的共享。 func cloneIntentSpec(in IntentSpec) IntentSpec { out := in out.Matcher.IncludesAny = cloneStrings(in.Matcher.IncludesAny) @@ -47,15 +67,76 @@ func cloneIntentSpec(in IntentSpec) IntentSpec { out.Slots[i].Normalize = cloneStrings(in.Slots[i].Normalize) } } - if in.Metadata != nil { - out.Metadata = make(map[string]string, len(in.Metadata)) - for k, v := range in.Metadata { - out.Metadata[k] = v + out.Metadata = cloneStringMap(in.Metadata) + out.Action = cloneActionRef(in.Action) + out.Examples = cloneStrings(in.Examples) + return out +} + +func cloneCatalogSlot(in CatalogSlot) CatalogSlot { + out := in + out.Enum = cloneStrings(in.Enum) + return out +} + +func cloneCatalogSpec(in CatalogSpec) CatalogSpec { + out := in + out.Metadata = cloneStringMap(in.Metadata) + if in.Slots != nil { + out.Slots = make([]CatalogSlot, len(in.Slots)) + for i := range in.Slots { + out.Slots[i] = cloneCatalogSlot(in.Slots[i]) } } return out } +func cloneCatalogSpecs(in []CatalogSpec) []CatalogSpec { + if in == nil { + return nil + } + out := make([]CatalogSpec, len(in)) + for i := range in { + out[i] = cloneCatalogSpec(in[i]) + } + return out +} + +func cloneOptions(in Options) Options { + out := in + out.ProtectedDomains = cloneStrings(in.ProtectedDomains) + out.GuardDomains = cloneStrings(in.GuardDomains) + if in.SlotValidators != nil { + cp := make(map[string]SlotValidator, len(in.SlotValidators)) + for k, v := range in.SlotValidators { + cp[k] = v + } + out.SlotValidators = cp + } + return out +} + +func cloneCandidate(in Candidate) Candidate { + out := in + out.Metadata = cloneStringMap(in.Metadata) + out.Slots = cloneStringMap(in.Slots) + out.MissingRequiredSlots = cloneStrings(in.MissingRequiredSlots) + out.InvalidSlots = cloneStringMap(in.InvalidSlots) + out.Action = cloneActionRef(in.Action) + return out +} + +func cloneCandidates(in []Candidate) []Candidate { + if in == nil { + return nil + } + out := make([]Candidate, len(in)) + for i := range in { + out[i] = cloneCandidate(in[i]) + } + return out +} + // catalogMetadataAllow 是目录与 Markdown 允许带出的 metadata 键。 // handler 仍留在 CatalogSpec 供执行器按名分派,但不写入 Markdown。 var catalogMetadataAllow = map[string]bool{ diff --git a/intent/generator.go b/intent/generator.go index ca213a4..34ed418 100644 --- a/intent/generator.go +++ b/intent/generator.go @@ -7,10 +7,10 @@ import "context" // 只在规则与语义都未命中(fallback)时被调用;实现自担延迟与成本。 // 返回错误时识别保持 fallback,不会使 Recognize 失败。 // -// 库侧校验与 deriveCommands 同款:start 命令的 ActionRef 必须可解析—— -// auto/command/workflow/blueprint 的目标必须在 catalog 的 spec ID 集合内, -// handler 的目标必须在 catalog 的 Handler 集合内;槽位键必须在目标 spec 的 -// 槽位键集合内(可解析时)。生成器幻觉一律 fail-closed 回 fallback。 +// 库侧校验:start 的 ActionRef 必须能从同一快照唯一解析回权威 spec +// (spec ID、Action 别名、或唯一 handler 反查);槽位键必须在目标 spec +// 键集内,随后跑与规则路径同款的必填/枚举/format 校验并挂上元数据。 +// 解析失败或未知键一律 fail-closed 回 fallback。 type CommandGenerator interface { // Generate 基于输入与能力目录产出命令列表(catalog 由调用方按 top-k 给)。 Generate(ctx context.Context, input string, catalog []CatalogSpec) ([]Command, error) diff --git a/intent/recognizer.go b/intent/recognizer.go index 64590a8..6f07960 100644 --- a/intent/recognizer.go +++ b/intent/recognizer.go @@ -120,7 +120,8 @@ func (r *FunnelRecognizer) recognize(ctx context.Context, input string, snap *Sn } } if r.cls != nil { - refined, err := r.cls.Refine(ctx, input, cands) + // 传入候选副本:分类器就地改写不会污染原候选(失败时仍返回未篡改集)。 + 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} @@ -175,10 +176,11 @@ func validateRefined(original, refined []Candidate) ([]Candidate, error) { if !ok { return nil, fmt.Errorf("unknown refined id %s", got.ID) } - if !validConfidence(got.Confidence) { + norm, ok := normalizeScore(got.Confidence) + if !ok { return nil, fmt.Errorf("confidence out of range for %s", got.ID) } - src.Confidence = got.Confidence + src.Confidence = norm out[i] = src } return out, nil @@ -188,6 +190,15 @@ 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 { @@ -289,6 +300,7 @@ func sortRejected(rs []RejectedSpec) { // 规则:候选必须来自当前候选集(构造上不可能引入未知 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 @@ -300,14 +312,21 @@ func (r *FunnelRecognizer) semanticRerank(ctx context.Context, input string, can if !ok || len(spec.Examples) == 0 { continue } - s, err := r.sem.Score(ctx, input, spec.Examples) + 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 } - if s >= r.thr { - above = append(above, scored{cand: c, score: s}) + 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 { @@ -327,9 +346,9 @@ func (r *FunnelRecognizer) semanticRerank(ctx context.Context, input string, can } // generateCommands 经模型命令生成端口兜底(I2)。校验: -// start 的 ActionRef 可解析(spec ID 集或 Handler 集内)、命令种类受限、 -// 槽位键在目标 spec 的键集内(可解析时);全部通过则把首个 start 的目标 -// spec 合成 Top(refined_by=generator 标注),任一项不过则保持 fallback。 +// 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) @@ -337,7 +356,7 @@ func (r *FunnelRecognizer) generateCommands(ctx context.Context, input string, s 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 { + 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 } @@ -346,16 +365,14 @@ func (r *FunnelRecognizer) generateCommands(ctx context.Context, input string, s 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} + 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 @@ -364,46 +381,53 @@ func (r *FunnelRecognizer) generateCommands(ctx context.Context, input string, s // 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)。 -func validateGenerated(cmds []Command, catalog []CatalogSpec) error { +// ActionRef 经快照唯一解析;槽位未知键在此拒绝(与规则路径「键集外」同款 fail-closed)。 +func validateGenerated(cmds []Command, snap *Snapshot) 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) + cs, err := snap.resolveActionRef(c.Action) + if err != nil { + return fmt.Errorf("cmd[%d] start: %w", i, err) } - 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) - } + 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: @@ -414,18 +438,70 @@ func validateGenerated(cmds []Command, catalog []CatalogSpec) error { 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 - } +// 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.ID == ref.ID { - return cs, true + if cs.spec.Handler != handler { + continue } + if found != nil { + return nil, fmt.Errorf("handler %q 对应多个 spec(歧义)", handler) + } + found = cs } - return CatalogSpec{}, false + 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 } diff --git a/intent/registry.go b/intent/registry.go index 7805e8d..f133806 100644 --- a/intent/registry.go +++ b/intent/registry.go @@ -60,7 +60,7 @@ const ( ) func (o Options) normalized() Options { - n := o + n := cloneOptions(o) if n.TopN <= 0 { n.TopN = DefaultTopN } @@ -85,13 +85,6 @@ func (o Options) normalized() Options { if n.Log == nil { n.Log = logsys.Nop() } - if len(n.SlotValidators) > 0 { - cp := make(map[string]SlotValidator, len(n.SlotValidators)) - for k, v := range n.SlotValidators { - cp[k] = v - } - n.SlotValidators = cp - } return n } @@ -203,7 +196,7 @@ func compileSpec(spec IntentSpec, guard bool, index int, scoped map[string]SlotV return out } cs := &compiledSpec{ - spec: spec, + spec: cloneIntentSpec(spec), termsAny: fold(m.IncludesAny), termsAll: fold(m.IncludesAll), termsEx: fold(m.ExcludesAny), @@ -481,6 +474,7 @@ func NewRegistryBuilder(opts Options) *RegistryBuilder { } // Register 登记内置基线 spec,立即校验;重复 id 报 InvalidSpec。 +// 登记时深拷贝:调用方之后改入参不会影响已登记定义。 func (b *RegistryBuilder) Register(specs ...IntentSpec) error { lim := b.opts.limits() for _, spec := range specs { @@ -491,7 +485,7 @@ func (b *RegistryBuilder) Register(specs ...IntentSpec) error { return err } b.seen[spec.ID] = true - b.builtin = append(b.builtin, spec) + b.builtin = append(b.builtin, cloneIntentSpec(spec)) } return nil } @@ -526,9 +520,13 @@ func (b *RegistryBuilder) buildWithSource(fsys fs.FS, roots []string) (*Registry if err != nil { return nil, err } + builtin := make([]IntentSpec, len(b.builtin)) + for i, spec := range b.builtin { + builtin[i] = cloneIntentSpec(spec) + } r := &Registry{ - opts: b.opts, - builtin: append([]IntentSpec(nil), b.builtin...), + opts: cloneOptions(b.opts), + builtin: builtin, fsys: fsys, roots: append([]string(nil), roots...), hasSource: fsys != nil, @@ -553,8 +551,8 @@ type Registry struct { // Snapshot 返回当前生效快照。永不为 nil。 func (r *Registry) Snapshot() *Snapshot { return r.snap.Load() } -// Options 返回生效选项(已填默认值)。 -func (r *Registry) Options() Options { return r.opts } +// Options 返回生效选项(已填默认值)的深拷贝:改切片/校验器表不影响 Registry。 +func (r *Registry) Options() Options { return cloneOptions(r.opts) } // Reload 重读配置来源并构建新快照;成功后原子替换并返回新快照。 // 任一步失败保留旧快照,revision 不变,返回带来源与原因的错误。 @@ -590,7 +588,10 @@ func buildSnapshot(opts Options, baseline []IntentSpec, entries []overlayEntry, guards := opts.guardSet() lim := opts.limits() - ordered := append([]IntentSpec(nil), baseline...) + ordered := make([]IntentSpec, len(baseline)) + for i, spec := range baseline { + ordered[i] = cloneIntentSpec(spec) + } pos := make(map[string]int, len(ordered)) sources := make(map[string]string, len(ordered)) for i, spec := range ordered { @@ -620,7 +621,7 @@ func buildSnapshot(opts Options, baseline []IntentSpec, entries []overlayEntry, sources[e.id] = e.source + " (disabled)" continue } - spec := e.spec + spec := cloneIntentSpec(e.spec) if err := validateSpec(spec, lim, opts.SlotValidators); err != nil { return nil, err.(*Error).with("source", e.source) } diff --git a/intent/slots.go b/intent/slots.go index fc3f186..a0412b5 100644 --- a/intent/slots.go +++ b/intent/slots.go @@ -167,6 +167,56 @@ func (cs *compiledSpec) extractSlots(input string) (slots map[string]string, mis return slots, missing, invalid } +// validateProvidedSlots 校验调用方直接给出的槽位表(生成器路径)。 +// 与 extractSlots 同款结果形态:归一化 → format/enum/validator;必填缺失进 missing; +// 捕获到但不合法进 invalid;未知键进 invalid(reason=unknown slot key)。 +// 非法或未知键的值不写入 slots。 +func (cs *compiledSpec) validateProvidedSlots(provided map[string]string) (slots map[string]string, missing []string, invalid map[string]string) { + byKey := make(map[string]*compiledSlot, len(cs.slots)) + for i := range cs.slots { + byKey[cs.slots[i].key] = &cs.slots[i] + } + for k, raw := range provided { + s, ok := byKey[k] + if !ok { + if invalid == nil { + invalid = map[string]string{} + } + invalid[k] = "unknown slot key" + continue + } + v := normalizeSlotValue(raw, s.normalize) + reason, valid := s.validate(v) + if !valid { + if invalid == nil { + invalid = map[string]string{} + } + invalid[k] = reason + continue + } + if slots == nil { + slots = map[string]string{} + } + slots[k] = v + } + for _, s := range cs.slots { + if !s.required { + continue + } + if _, ok := slots[s.key]; ok { + continue + } + // 必填且已标 invalid 的不重复进 missing(与 extractSlots 同款)。 + if invalid != nil { + if _, bad := invalid[s.key]; bad { + continue + } + } + missing = append(missing, s.key) + } + return slots, missing, invalid +} + func compileSlot(spec SlotSpec, scoped map[string]SlotValidator) (compiledSlot, error) { re, err := regexp.Compile(spec.Pattern) if err != nil { diff --git a/intent/u5_isolation_generated_test.go b/intent/u5_isolation_generated_test.go new file mode 100644 index 0000000..1731e6f --- /dev/null +++ b/intent/u5_isolation_generated_test.go @@ -0,0 +1,381 @@ +package intent + +import ( + "context" + "math" + "testing" +) + +// TestU5RegisterMutationDoesNotAffectSnapshot:登记后改原 spec 不影响 SpecByID/Catalog。 +func TestU5RegisterMutationDoesNotAffectSnapshot(t *testing.T) { + spec := IntentSpec{ + ID: "demo.iso.reg", Domain: "demo", Version: 1, + Matcher: MatchSpec{IncludesAny: []string{"隔离登记"}}, + Risk: RiskHigh, + RequiresConfirm: true, + Handler: "iso.handler", + Examples: []string{"示例甲"}, + Action: &ActionRef{Kind: ActionWorkflow, ID: "wf-iso"}, + Slots: []SlotSpec{{ + Key: "env", Pattern: `env[: ]*(\w+)`, Group: 1, + Enum: []string{"prod", "dev"}, Normalize: []string{"trim"}, + }}, + Metadata: map[string]string{"guidance": "keep", "token": "secret"}, + } + b := NewRegistryBuilder(Options{ProtectedDomains: []string{"demo"}, GuardDomains: []string{"guard"}}) + if err := b.Register(spec); err != nil { + t.Fatal(err) + } + // 登记后篡改原对象与嵌套可变字段。 + spec.Risk = RiskLow + spec.RequiresConfirm = false + spec.Handler = "tampered" + spec.Examples[0] = "被改" + spec.Action.ID = "wf-tampered" + spec.Slots[0].Enum[0] = "evil" + spec.Metadata["guidance"] = "mutated" + spec.Matcher.IncludesAny[0] = "被改词" + + reg, err := b.Build() + if err != nil { + t.Fatal(err) + } + snap := reg.Snapshot() + got, ok := snap.SpecByID("demo.iso.reg") + if !ok { + t.Fatal("missing spec") + } + if got.Risk != RiskHigh || !got.RequiresConfirm || got.Handler != "iso.handler" { + t.Fatalf("register isolation broken: %+v", got) + } + if got.Examples[0] != "示例甲" || got.Action == nil || got.Action.ID != "wf-iso" { + t.Fatalf("nested isolation broken: examples=%v action=%+v", got.Examples, got.Action) + } + if got.Slots[0].Enum[0] != "prod" || got.Metadata["guidance"] != "keep" { + t.Fatalf("slot/meta isolation broken: %+v", got) + } + cat := snap.Catalog() + if len(cat) != 1 || cat[0].Handler != "iso.handler" || cat[0].Slots[0].Enum[0] != "prod" { + t.Fatalf("catalog isolation after register mutate: %+v", cat) + } + opts := reg.Options() + opts.ProtectedDomains[0] = "hacked" + opts2 := reg.Options() + if opts2.ProtectedDomains[0] != "demo" { + t.Fatalf("Options isolation broken: %v", opts2.ProtectedDomains) + } +} + +// TestU5ReturnedSpecMutationDoesNotAffectRegistry:改返回值不影响快照。 +func TestU5ReturnedSpecMutationDoesNotAffectRegistry(t *testing.T) { + b := NewRegistryBuilder(Options{}) + if err := b.Register(IntentSpec{ + ID: "demo.iso.ret", Domain: "demo", Version: 1, + Matcher: MatchSpec{IncludesAny: []string{"隔离返回"}, Patterns: []string{`隔离返回`}}, + Examples: []string{"例"}, + Action: &ActionRef{Kind: ActionCommand, ID: "cmd.iso"}, + Slots: []SlotSpec{{Key: "x", Pattern: `(x)`, Enum: []string{"a", "b"}}}, + Metadata: map[string]string{"guidance": "g"}, + }); err != nil { + t.Fatal(err) + } + reg, err := b.Build() + if err != nil { + t.Fatal(err) + } + snap := reg.Snapshot() + got, _ := snap.SpecByID("demo.iso.ret") + got.Examples[0] = "mut" + got.Action.ID = "cmd.mut" + got.Slots[0].Enum[0] = "z" + got.Metadata["guidance"] = "mut" + got.Matcher.Patterns[0] = "mut" + + again, _ := snap.SpecByID("demo.iso.ret") + if again.Examples[0] != "例" || again.Action.ID != "cmd.iso" || again.Slots[0].Enum[0] != "a" { + t.Fatalf("SpecByID leak: %+v", again) + } + if again.Metadata["guidance"] != "g" || again.Matcher.Patterns[0] != `隔离返回` { + t.Fatalf("SpecByID matcher/meta leak: %+v", again) + } + + cat := snap.Catalog() + cat[0].Slots[0].Enum[0] = "z" + cat[0].Metadata["guidance"] = "mut" + cat2 := snap.Catalog() + if cat2[0].Slots[0].Enum[0] != "a" || cat2[0].Metadata["guidance"] != "g" { + t.Fatalf("Catalog leak: %+v", cat2) + } +} + +// TestU5GeneratedMatchesRuleValidationAndMetadata:同 action+slots 时生成路径与规则路径校验/元数据一致。 +func TestU5GeneratedMatchesRuleValidationAndMetadata(t *testing.T) { + spec := IntentSpec{ + ID: "report.generate", Domain: "report", Version: 1, + Matcher: MatchSpec{IncludesAny: []string{"生成报告"}}, + Slots: []SlotSpec{{ + Key: "topic", Required: true, Pattern: `主题是(.+)`, Group: 1, + Enum: []string{"电机", "泵"}, Normalize: []string{"trim"}, + }}, + Risk: RiskMedium, + RequiresConfirm: true, + Handler: "reportHandler", + Action: &ActionRef{Kind: ActionWorkflow, ID: "wf-report"}, + Metadata: map[string]string{"guidance": "ask topic", "blueprint": "bp"}, + } + b := NewRegistryBuilder(Options{}) + if err := b.Register(spec); err != nil { + t.Fatal(err) + } + reg, err := b.Build() + if err != nil { + t.Fatal(err) + } + snap := reg.Snapshot() + rec := NewRecognizer(Options{}, nil, nil) + rule, err := rec.Recognize(context.Background(), "生成报告 主题是电机", snap) + if err != nil { + t.Fatal(err) + } + if rule.Branch != BranchMatched || rule.Top == nil { + t.Fatalf("rule: %+v", rule) + } + + gen := stubGenerator{cmds: []Command{ + {Kind: CommandStart, Action: ActionRef{Kind: ActionWorkflow, ID: "wf-report"}, + Slots: map[string]string{"topic": "电机"}}, + }} + genRec := NewRecognizer(Options{Generator: gen}, nil, nil) + out, err := genRec.Recognize(context.Background(), "完全不相关的一句话", snap) + if err != nil { + t.Fatal(err) + } + if out.Branch != BranchMatched || out.Top == nil { + t.Fatalf("generated: %+v", out) + } + if out.Top.ID != rule.Top.ID || out.Top.Risk != rule.Top.Risk || out.Top.RequiresConfirm != rule.Top.RequiresConfirm { + t.Fatalf("metadata mismatch rule=%+v gen=%+v", rule.Top, out.Top) + } + if out.Top.Handler != rule.Top.Handler { + t.Fatalf("handler mismatch rule=%q gen=%q", rule.Top.Handler, out.Top.Handler) + } + if out.Top.Action == nil || rule.Top.Action == nil || out.Top.Action.Kind != rule.Top.Action.Kind || out.Top.Action.ID != rule.Top.Action.ID { + t.Fatalf("action mismatch rule=%+v gen=%+v", rule.Top.Action, out.Top.Action) + } + if out.Top.Slots["topic"] != rule.Top.Slots["topic"] { + t.Fatalf("slots mismatch rule=%v gen=%v", rule.Top.Slots, out.Top.Slots) + } + if len(out.Top.MissingRequiredSlots) != 0 || len(out.Top.InvalidSlots) != 0 { + t.Fatalf("unexpected missing/invalid: %+v", out.Top) + } +} + +// TestU5GeneratedSlotValidationStable:缺必填/非法枚举/未知键/重复 handler 在生成路径稳定处理。 +func TestU5GeneratedSlotValidationStable(t *testing.T) { + mk := func(t *testing.T, specs ...IntentSpec) *Snapshot { + t.Helper() + b := NewRegistryBuilder(Options{}) + if err := b.Register(specs...); err != nil { + t.Fatal(err) + } + reg, err := b.Build() + if err != nil { + t.Fatal(err) + } + return reg.Snapshot() + } + base := IntentSpec{ + ID: "report.generate", Domain: "report", Version: 1, + Matcher: MatchSpec{IncludesAny: []string{"生成报告"}}, + Slots: []SlotSpec{{ + Key: "topic", Required: true, Pattern: `主题是(.+)`, + Enum: []string{"电机", "泵"}, + }}, + Risk: RiskHigh, RequiresConfirm: true, Handler: "reportHandler", + Action: &ActionRef{Kind: ActionCommand, ID: "cmd.report"}, + } + + t.Run("missing_required", func(t *testing.T) { + snap := mk(t, base) + rec := NewRecognizer(Options{Generator: stubGenerator{cmds: []Command{ + {Kind: CommandStart, Action: ActionRef{Kind: ActionCommand, ID: "cmd.report"}, Slots: map[string]string{}}, + }}}, nil, nil) + res, err := rec.Recognize(context.Background(), "无关输入", snap) + if err != nil { + t.Fatal(err) + } + if res.Branch != BranchMatched || res.Top == nil { + t.Fatalf("want matched with missing: %+v", res) + } + if len(res.Top.MissingRequiredSlots) != 1 || res.Top.MissingRequiredSlots[0] != "topic" { + t.Fatalf("missing: %+v", res.Top.MissingRequiredSlots) + } + if res.Top.Risk != RiskHigh || !res.Top.RequiresConfirm { + t.Fatalf("metadata lost: %+v", res.Top) + } + }) + + t.Run("invalid_enum", func(t *testing.T) { + snap := mk(t, base) + rec := NewRecognizer(Options{Generator: stubGenerator{cmds: []Command{ + {Kind: CommandStart, Action: ActionRef{Kind: ActionHandler, ID: "reportHandler"}, + Slots: map[string]string{"topic": "涡轮"}}, + }}}, nil, nil) + res, err := rec.Recognize(context.Background(), "无关输入", snap) + if err != nil { + t.Fatal(err) + } + if res.Branch != BranchMatched || res.Top == nil { + t.Fatalf("want matched with invalid: %+v", res) + } + if res.Top.InvalidSlots["topic"] == "" { + t.Fatalf("invalid slots: %+v", res.Top.InvalidSlots) + } + if res.Top.Slots["topic"] != "" { + t.Fatalf("invalid value must not fill slots: %+v", res.Top.Slots) + } + }) + + t.Run("unknown_slot", func(t *testing.T) { + snap := mk(t, base) + rec := NewRecognizer(Options{Generator: stubGenerator{cmds: []Command{ + {Kind: CommandStart, Action: ActionRef{Kind: ActionWorkflow, ID: "report.generate"}, + Slots: map[string]string{"bogus": "v"}}, + }}}, nil, nil) + res, err := rec.Recognize(context.Background(), "无关输入", snap) + if err != nil { + t.Fatal(err) + } + if res.Branch != BranchFallback || res.Top != nil { + t.Fatalf("unknown slot must fallback: %+v", res) + } + }) + + t.Run("duplicate_handler", func(t *testing.T) { + a := base + a.ID = "report.a" + a.Handler = "shared" + bSpec := base + bSpec.ID = "report.b" + bSpec.Handler = "shared" + bSpec.Action = &ActionRef{Kind: ActionCommand, ID: "cmd.b"} + snap := mk(t, a, bSpec) + rec := NewRecognizer(Options{Generator: stubGenerator{cmds: []Command{ + {Kind: CommandStart, Action: ActionRef{Kind: ActionHandler, ID: "shared"}, + Slots: map[string]string{"topic": "电机"}}, + }}}, nil, nil) + res, err := rec.Recognize(context.Background(), "无关输入", snap) + if err != nil { + t.Fatal(err) + } + if res.Branch != BranchFallback || res.Top != nil { + t.Fatalf("ambiguous handler must fallback: %+v", res) + } + }) +} + +// TestU5GeneratedActionAliasRoutesToSpec:Action 指向不同 command ID 时仍解析到权威 spec。 +func TestU5GeneratedActionAliasRoutesToSpec(t *testing.T) { + b := NewRegistryBuilder(Options{}) + if err := b.Register(IntentSpec{ + ID: "report.generate", Domain: "report", Version: 1, + Matcher: MatchSpec{IncludesAny: []string{"生成报告"}}, + Slots: []SlotSpec{{Key: "topic", Pattern: `(.+)`}}, + Action: &ActionRef{Kind: ActionCommand, ID: "cmd.other-id"}, + Handler: "h", + Risk: RiskLow, + }); err != nil { + t.Fatal(err) + } + reg, err := b.Build() + if err != nil { + t.Fatal(err) + } + rec := NewRecognizer(Options{Generator: stubGenerator{cmds: []Command{ + {Kind: CommandStart, Action: ActionRef{Kind: ActionCommand, ID: "cmd.other-id"}, + Slots: map[string]string{"topic": "x"}}, + }}}, nil, nil) + res, err := rec.Recognize(context.Background(), "无关输入", reg.Snapshot()) + if err != nil { + t.Fatal(err) + } + if res.Branch != BranchMatched || res.Top == nil || res.Top.ID != "report.generate" { + t.Fatalf("alias resolve: %+v", res) + } + if res.Top.Action == nil || res.Top.Action.ID != "cmd.other-id" { + t.Fatalf("action metadata: %+v", res.Top.Action) + } +} + +// TestU5ClassifierAndSemanticRejectBadScores:非有限/越界分数不得变成 matched。 +func TestU5ClassifierAndSemanticRejectBadScores(t *testing.T) { + snap := ambiguousFixture(t, []string{"查运行进度"}, []string{"闲聊问候"}) + + t.Run("classifier_inf", func(t *testing.T) { + rec := NewRecognizer(Options{}, nil, rewriteClassifier{fn: func(cands []Candidate) []Candidate { + out := append([]Candidate(nil), cands...) + out[0].Confidence = math.Inf(1) + out[1].Confidence = 0.1 + return out + }}) + res, err := rec.Recognize(context.Background(), "模糊词", snap) + if err != nil { + t.Fatal(err) + } + if res.Branch != BranchAmbiguous || res.Top != nil { + t.Fatalf("inf must stay ambiguous: %+v", res) + } + }) + + t.Run("classifier_in_place_mutation", func(t *testing.T) { + rec := NewRecognizer(Options{}, nil, rewriteClassifier{fn: func(cands []Candidate) []Candidate { + cands[0].Handler = "tampered" + cands[0].Confidence = 0.99 + cands[1].Confidence = 0.1 + return cands + }}) + res, err := rec.Recognize(context.Background(), "模糊词", snap) + if err != nil { + t.Fatal(err) + } + if res.Branch != BranchMatched || res.Top == nil { + t.Fatalf("expected matched: %+v", res) + } + if res.Top.Handler == "tampered" { + t.Fatalf("classifier in-place mutation leaked into top: %+v", res.Top) + } + }) + + t.Run("semantic_nan", func(t *testing.T) { + rec := NewRecognizer(Options{Semantic: fakeScorer{scores: map[string]float64{"查运行进度": math.NaN()}}}, nil, nil) + res, err := rec.Recognize(context.Background(), "模糊词", snap) + if err != nil { + t.Fatal(err) + } + if res.Branch != BranchAmbiguous || res.Top != nil { + t.Fatalf("nan semantic must stay ambiguous: %+v", res) + } + }) + + t.Run("semantic_above_one", func(t *testing.T) { + rec := NewRecognizer(Options{Semantic: fakeScorer{scores: map[string]float64{"查运行进度": 1.5}}}, nil, nil) + res, err := rec.Recognize(context.Background(), "模糊词", snap) + if err != nil { + t.Fatal(err) + } + if res.Branch != BranchAmbiguous || res.Top != nil { + t.Fatalf("out-of-range semantic must stay ambiguous: %+v", res) + } + }) + + t.Run("semantic_inf_not_matched", func(t *testing.T) { + rec := NewRecognizer(Options{Semantic: fakeScorer{scores: map[string]float64{"查运行进度": math.Inf(1)}}}, nil, nil) + res, err := rec.Recognize(context.Background(), "模糊词", snap) + if err != nil { + t.Fatal(err) + } + if res.Branch != BranchAmbiguous || res.Top != nil { + t.Fatalf("inf semantic must stay ambiguous: %+v", res) + } + }) +}