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
508 lines
18 KiB
Go
508 lines
18 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, 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
|
||
}
|