package memory import ( "crypto/sha256" "encoding/hex" "fmt" "sort" "strings" "time" ) // 本文件是记忆整理的**候选**侧:候选与证据的落库形状、由判定结果构造候选、 // 以及候选的幂等键。它从 tidy.go 拆出来,让编排(扫描→召回→判定→落库) // 保持在一屏内(AGENTS §10 文件 ≤500 行)。 // TidyEvidence 是一条候选的判定证据。design/23 §7 要求 diff 预览能展示 // 「A 原文 / B 原文 / 合并后内容 / A 被 B 包含的证据 / 命中或未命中的否决规则」, // 因此这四项都在这里。它装的是主人自己的记忆正文,只落本机库、只走 owner-only // 的读侧(design/23 §11)。 type TidyEvidence struct { // Similarity 是召回用的余弦相似度(只用来召回,不是判定依据)。 Similarity float64 `json:"similarity"` // RecallMode 记录本轮相似度的来源(embedding / lexical),便于运维判读。 RecallMode string `json:"recall_mode,omitempty"` // Containment 是包含判定结论:a_subset_b / b_subset_a / both / none。 Containment string `json:"containment,omitempty"` // ContainmentEvidence 是「A ⊆ B」的逐要素证据(缺哪一项就列哪一项)。 ContainmentEvidence []string `json:"containment_evidence,omitempty"` // VetoReason 是命中的否决规则(未命中时为空)。 VetoReason string `json:"veto_reason,omitempty"` // KindA/KindB/ProposedKind 供 reclassify 候选展示 kind 误分类建议。 KindA string `json:"kind_a,omitempty"` KindB string `json:"kind_b,omitempty"` ProposedKind string `json:"proposed_kind,omitempty"` // ContentA/ContentB 是两条原文,供面板 diff 预览。 ContentA string `json:"content_a,omitempty"` ContentB string `json:"content_b,omitempty"` // 以下是 dedupe_group(2026-10-24)的**簇信息**:判据、簇键、簇大小、存活规则, // 以及被吸收行的正文列表(面板要能一次看清「这一簇吞了哪几条」——merge 候选 // 只有一对,ContentA/ContentB 装得下;dedupe_group 一簇最多十来条,装不下)。 // // 🔴 回滚**不需要**这些字段:回滚靠 SourceIDs + TargetID(被吸收行的 // merged_into 必须等于 target),与 merge 候选完全同一套。簇信息只服务审计。 DedupeRule string `json:"dedupe_rule,omitempty"` DedupeKey string `json:"dedupe_key,omitempty"` ClusterSize int `json:"cluster_size,omitempty"` KeptReason string `json:"kept_reason,omitempty"` DedupeMembers []string `json:"dedupe_members,omitempty"` } // TidyCandidate 是一条整理建议(落库形状,design/23 §10)。 type TidyCandidate struct { ID string PersonaID string Scope Scope ProjectID string Kind Kind Action TidyAction SourceIDs []string TargetID string // RunID 是产出这条候选的那一轮整理(切片 TIDY-1 补的列:批量应用的抓手)。 // 幂等跳过的候选保留**首次**产它的那一轮,不被后来的轮次改写。 RunID string ProposedContent string MergedContent string Reason string Evidence TidyEvidence RuleVersion string ScoreMin float64 ScoreMax float64 Status string IdempotencyKey string CreatedAt time.Time } // tidyVerdict 是一条候选对的判定结果。 type tidyVerdict struct { Action TidyAction Veto TidyVetoReason Target *MemoryItem Containment containment Reason string } // 候选与判定结论的理由标签(落 memory_tidy_candidates.reason;`tidyAutoApplyEligible` // 会把其中两条读回来当自动落地的判据)。抽成常量是因为它们各有两处以上使用: // 「三次复制必须抽取」(AGENTS §10)。 const ( // tidyReasonSimilarNotContained 是「相似但互不包含」的判定结论。2026-10-07 起 // 这种对**不再产候选**(判 keep,见 tidy_rules.go),它只留在判定结果与日志里。 tidyReasonSimilarNotContained = "similar_but_not_contained" // tidyReasonFactSubset 是包含式合并(A ⊆ B,把 A 并入 B)的理由。 tidyReasonFactSubset = "fact_subset" // tidyReasonOneOffEvent 是一次性事件的归档理由,**也是自动档唯一认的归档理由**: // 理由不同(例如将来新增的归档类别)就不在新口径的授权范围内,交人工。 tidyReasonOneOffEvent = "one_off_event" // tidyReasonKindMismatch 是 kind 误分类的理由(design/23 §5.1 T0-6)。2026-10-07 // 「全自动挡吧」起它**也是自动档认的 reclassify 理由**(判据还要形状完整 + // 可回滚的改前 kind,见 tidy_auto.go 的 tidyAutoReclassifyKind)—— 只改分类列, // 不碰正文。 tidyReasonKindMismatch = "kind_mismatch" ) // archiveCandidate 为一次性事件产一条归档建议(design/23 §5.1 T0-5)。 func archiveCandidate(it MemoryItem, now time.Time) TidyCandidate { return TidyCandidate{ ID: mustNewTidyID(), PersonaID: it.PersonaID, Scope: it.Scope, ProjectID: it.ProjectID, Kind: it.Kind, Action: TidyArchive, SourceIDs: []string{it.ID}, ProposedContent: it.Content, Reason: tidyReasonOneOffEvent, Evidence: TidyEvidence{ Containment: "none", ContentA: it.Content, KindA: string(it.Kind), }, RuleVersion: TidyRuleVersion, Status: tidyCandidatePending, CreatedAt: now, } } // buildCandidates 把一条判定结果转成候选:merge 需要一条存活条目(超集), // kind 不一致时附带一条 reclassify 建议(design/23 §5.1 T0-6)。 func buildCandidates(pr tidyPair, v tidyVerdict, now time.Time) []TidyCandidate { evidence := TidyEvidence{ Similarity: pr.score, RecallMode: pr.mode, Containment: string(v.Containment.Kind), ContainmentEvidence: v.Containment.Missing, KindA: string(pr.a.Kind), KindB: string(pr.b.Kind), ContentA: pr.a.Content, ContentB: pr.b.Content, } base := TidyCandidate{ ID: mustNewTidyID(), PersonaID: pr.a.PersonaID, Scope: pr.a.Scope, ProjectID: pr.a.ProjectID, Evidence: evidence, RuleVersion: TidyRuleVersion, Status: tidyCandidatePending, ScoreMin: pr.score, ScoreMax: pr.score, CreatedAt: now, } if v.Action != TidyMerge || v.Target == nil { // 非 merge 的判定结论**不产候选**:link 没有执行语义(design/23 §14 的待 // 细化项,见 tidy_rules.go 的 containNone 分支),keep 按 §5.2-10 就是 // 「什么都不做」。返回空切片而不是一条应用不了的候选 —— 后者只会污染待办。 return nil } absorbed, target := pr.a, *v.Target if v.Target.ID == pr.a.ID { absorbed, target = pr.b, pr.a } base.Action = TidyMerge base.SourceIDs = []string{absorbed.ID} base.TargetID = target.ID base.Kind = target.Kind base.ProposedContent = absorbed.Content base.MergedContent = target.Content base.Reason = v.Reason out := []TidyCandidate{base} if absorbed.Kind != target.Kind { // kind 误分类:以存活条目(超集)的 kind 为建议值(design/23 §5.1 T0-6)。 // 2026-10-07「全自动挡吧」起,形状完整的这类候选由**轮末自动档**直接落地 // (只改分类列;判据见 tidy_auto.go),不完整的仍交人工。 re := base re.ID = mustNewTidyID() re.Action = TidyReclassify re.SourceIDs = []string{absorbed.ID} re.TargetID = "" re.ProposedContent = "" re.MergedContent = "" re.Reason = tidyReasonKindMismatch re.Evidence.ProposedKind = string(target.Kind) // KindA 在 reclassify 候选上**重新定义**为「被改那一行当前的 kind」: // merge 候选的 KindA/KindB 是一对各自的 kind(供 diff 预览),而 // reclassify 候选只有一个主体(absorbed),回滚必须知道它原来的 kind, // 否则 revert 会拿另一条行的 kind 去覆盖(design/23 §8 的可逆口径)。 re.Evidence.KindA = string(absorbed.Kind) out = append(out, re) } return out } // tidyIdempotencyKey 由 design/23 §9 的幂等键口径算出: // hash(persona_id, scope, project_id, sorted(source_ids), action, rule_version)。 // 规则不变时同一批数据重复运行产出同一个键,因此候选绝不重复落库。 func tidyIdempotencyKey(c TidyCandidate) string { ids := append([]string(nil), c.SourceIDs...) sort.Strings(ids) parts := []string{ c.PersonaID, string(c.Scope), c.ProjectID, strings.Join(ids, ","), string(c.Action), c.RuleVersion, } sum := sha256.Sum256([]byte(strings.Join(parts, "\x00"))) return hex.EncodeToString(sum[:]) } // mustNewTidyID 生成候选 id。它复用与记忆条目同一个随机源;熵源损坏时回退到 // 时间戳,而不是让整理整轮失败(候选 id 只需要在本库唯一)。 func mustNewTidyID() string { id, err := newID() if err != nil { return fmt.Sprintf("tidy-%d", time.Now().UnixNano()) } return id }