package memory import ( "context" "fmt" "sort" "strings" ) // 本文件是记忆整理**轮末自动落地**的策略与执行面。动因是主人 2026-10-07 的口径: // 「那么多 link 是干什么的?该删就删,怎么这个记忆整理机制这么保守呢」—— // 整理不能永远只产建议,规则层已经判死的高置信动作就该自己落地。 // // ⚠️ 与 design/23 的既有口径冲突,且**是有意为之**:§7 原写「自动应用默认关闭; // 即便将来开启,也只允许 apply 低风险动作(如 reclassify),merge 与 archive 永远 // 人工确认」,§13 开放点 5 的处置是「全部人工应用(无自动 apply)」。主人本次的 // 「该删就删」(2026-10-07)与同日追加的「全自动挡吧」覆盖那两条;design 文档需同步 // (交付报告已列出建议补的几行)。 // // 五条护栏,缺一不可: // // 1. **可自动的类别只有三类**,判据是纯函数 tidyAutoApplyEligible: // - `merge` = 字面重复 ∨(**事实集合等价** ∧ 相似度 ≥ 0.95); // - `archive` = 理由为 one_off_event **且不像习惯**(design/23 §5.2-1:一次性 // 事件 ≠ 长期习惯,习惯类绝不能被钟点误判归档 —— 这条守卫是红线,不许动); // - `reclassify` = 理由为 kind_mismatch ∧ **只**要求改分类列(2026-10-07 追加 // 口径「全自动挡吧」)。它只改 memory_items.kind 一列:**不动正文、不删行、 // 不影响任何召回内容**(检索读的是 content/向量,kind 只是分类标记), // 因此与 merge(软合并)/ archive(软删)同级安全。 // 2. **只走既有 apply 路径**(tidy_apply.go):软动作(只改 status/merged_*/kind, // 绝不改正文、绝不删行)、候选状态条件更新(幂等)、失败隔离、可 revert。 // 3. **每轮上限**(cfg.AutoApplyPerRound):三类动作**同占一个池子**,超限如实报 // Truncated + 真总数,绝不静默少做;下一轮继续。 // 4. **审计**:走 design/23 §8 的 `memory.tidy.apply` 族,result 带 `auto=true` // 以区分「人点的」与「整理器自己落的」;只带计数与理由分布,不带任何记忆正文。 // 5. **一键回到只产建议**:`$DATA_DIR/config/memory-tidy.json` 的 // `{"autoApply": false}`(不加 env 键 —— `.env` 已冻结,design/13 §2.1)。 // // 🔴 仍然人工 / **绝不自动**的清单(写在这里以免后来者扩大范围): // - 跨分区、跨角色:候选与目标行分区不一致 ⇒ tidySkipPartitionMismatch; // - 受限来源(群聊 / 陌生人,design/23 §5.2-9 + §6)⇒ tidySkipSourceRestricted; // - **任何改正文**:改内容的动作根本不在这套动作闭集里,三类自动动作的落库 // UPDATE 也各自只写白名单列(status / merged_* / kind),正文列永不出现; // - **低置信 merge**:规则层 VetoLowConfidence 一票否决 ⇒ 根本不成候选; // - **非 one_off_event 理由的归档**(理由变了说明规则变了 = 拿不准,交人工); // - 🔴 **archive 的「习惯类不归档」守卫不动**(design/23 §5.2-1):习惯类会因正文 // 带钟点被误判成一次性事件,归档 = 从检索里消失 ⇒ 风险是「她忘掉主人的作息 // 习惯」。它不在任何自动档授权内,改它就是偏离。 // tidyAutoApplyVerb 是自动落地的审计动作名。它与网关侧 HTTP 触发那条同族 // (gateway.tidyApplyAction 逐字同值):动作名是 design/23 §8 的契约,不是实现细节, // 因此两处必须是同一个字符串(跨包各写一份,改则两处一起改)。 const tidyAutoApplyVerb = "memory.tidy.apply" // 自动档的**理由标签**:进日志/审计,机器可读(面板与排障按它聚合)。 const ( // tidyAutoReasonLiteralDuplicate 表示两侧正文逐字相同(归一化后)。 tidyAutoReasonLiteralDuplicate = "literal_duplicate" // tidyAutoReasonEquivalentFacts 表示事实集合等价(双向包含)且相似度 ≥ 0.95。 tidyAutoReasonEquivalentFacts = "fact_equivalent_high_similarity" // tidyAutoReasonOneOffEvent 表示一次性事件(含绝对日期/时刻且不像习惯)。 tidyAutoReasonOneOffEvent = "one_off_event" // tidyAutoReasonKindMismatch 表示 kind 误分类修正(design/23 §5.1 T0-6)。 // 它**刻意与候选理由同值**(tidyReasonKindMismatch):这条动作的自动档与人工档 // 是同一个口径(只改分类列),没有第二个理由标签可引入 —— 写成别名而不是复制 // 一个字面量,是为了让「两处必须一致」由编译器保证。 tidyAutoReasonKindMismatch = tidyReasonKindMismatch ) // TidyAutoApplyBatch 是轮末自动档的一趟回执(进日志、审计与统计)。 // // EligibleTotal 是命中自动判据的**真总数**(含因每轮上限没处理的)—— 与扫描/候选 // 上限同一口径:Truncated 为真时,面板看到的落地数**少于**判据命中的数。 type TidyAutoApplyBatch struct { EligibleTotal int Limit int Truncated bool // ScanTruncated 报告**读取**待定候选时撞上了 maxTidyAutoApplyScan:此时判据 // 只看过了最新的一批,可能还有命中判据的候选没被看到。它与 Truncated(每轮 // 上限截断)是两件事,所以分开报 —— 原因不同,运维动作也不同。 ScanTruncated bool Applied int Skipped int Failed int Merged int Archived int // Reclassified 是成功落地的 reclassify 条数(2026-10-07「全自动挡吧」起纳入 // 自动档)。它与 Merged/Archived 同口径:只数**成功落地**的,跳过的计入 Skipped。 Reclassified int // ByReason 是**成功落地**的条数按自动理由分解(跳过的计不计:它们没落地)。 ByReason map[string]int } // normalizedAutoLimit 归一轮末自动档的每轮上限:非正取 defaultTidyAutoApplyPerRound, // 超过 maxTidyApplyBatchLimit 时钳到它 —— 与批量应用共用同一个硬顶,因为两者都是 // 「一次批量改记忆」的动作,不该有两套上限。 func (o TidyApplyOptions) normalizedAutoLimit() int { if o.Limit <= 0 { return defaultTidyAutoApplyPerRound } if o.Limit > maxTidyApplyBatchLimit { return maxTidyApplyBatchLimit } return o.Limit } // AutoApplyTidyCandidates 跑一趟轮末自动落地:从**全部待定候选**里挑出命中自动 // 判据的那些,按每轮上限逐条落地。 // // 为什么是「全部待定」而不是「本轮产出的」:整理是周期任务,积压的候选不会自己消失 // (活库曾积压 501 条 link + 56 条 merge 全 pending)。每轮都把判据命中的捞一遍, // 幂等由候选状态的条件更新保证,于是积压会被逐轮排干,而不会因为「不是这一轮产的」 // 就永远躺着。上限仍然是每轮一次,因此不会出现「一次点击改几百条」。 func (s *Store) AutoApplyTidyCandidates(ctx context.Context, opts TidyApplyOptions) (TidyAutoApplyBatch, error) { limit := opts.normalizedAutoLimit() pending, err := s.listTidyCandidates(ctx, tidyCandidatePending, "", maxTidyAutoApplyScan) if err != nil { return TidyAutoApplyBatch{}, fmt.Errorf("memory/tidy: auto apply list pending: %w", err) } selected, reasons, total := selectAutoEligible(pending, limit) batch := TidyAutoApplyBatch{ EligibleTotal: total, Limit: limit, Truncated: total > len(selected), ScanTruncated: len(pending) >= maxTidyAutoApplyScan, ByReason: make(map[string]int), } for i, cand := range selected { if err := ctx.Err(); err != nil { // 上下文取消时**已落地的保持已落地**,未做的留给下一轮(与批量应用同口径)。 break } res, err := s.applyCandidate(ctx, cand, opts) if err != nil { res = tidyFailed(TidyApplyResult{ CandidateID: cand.ID, Action: cand.Action, TargetID: cand.TargetID, AffectedIDs: []string{}, }, err) } tallyTidyAutoApply(&batch, res, reasons[i]) } return batch, nil } // selectAutoEligible 挑出自动档要处理的候选(输入顺序即优先序 = 最新优先),最多 // limit 条;返回的 total 是命中判据的**真总数**(不受 limit 影响),reasons 与 // selected 一一对应。total > len(selected) 即「被每轮上限截断」。 func selectAutoEligible(cands []TidyCandidate, limit int) (selected []TidyCandidate, reasons []string, total int) { selected = make([]TidyCandidate, 0, limit) reasons = make([]string, 0, limit) for _, c := range cands { ok, reason := tidyAutoApplyEligible(c) if !ok { continue } total++ if len(selected) >= limit { continue } selected = append(selected, c) reasons = append(reasons, reason) } return selected, reasons, total } // tidyAutoApplyEligible 是**自动落地的唯一判据**:纯函数、零 IO、确定性,因此可以 // 逐条断言(含反例),也不依赖候选此刻是否还在库。 // // 读的是候选自带的证据(ContentA/ContentB/Containment/Similarity/Reason),不是 // 此刻的记忆行 —— 因此「判据」与「行是否还在册」是两件事:后者由 apply 路径的 // 分区校验与 `AND status='active'` 条件更新兜住(过时的候选如实 skip)。 // // 低置信刻意**不在这里判**:候选的**存在本身**就证明它过了 §5.2-6 的低置信否决门 // (tidy_rules.go 的 judgePair 是产候选的唯一路径),因此库里不可能存在低置信的 // merge 候选 —— 在这里再查一遍会是一条永远不生效的死代码。 func tidyAutoApplyEligible(c TidyCandidate) (bool, string) { switch c.Action { case TidyMerge: if len(c.SourceIDs) != 1 || strings.TrimSpace(c.TargetID) == "" { // 形状不完整(老候选可能有):不自动,交给 apply 路径如实 skip。 return false, "" } if literalDuplicate(c.Evidence.ContentA, c.Evidence.ContentB) { return true, tidyAutoReasonLiteralDuplicate } if containmentKind(c.Evidence.Containment) == containBoth && c.Evidence.Similarity >= tidyAutoMergeMinSimilarity { return true, tidyAutoReasonEquivalentFacts } return false, "" case TidyArchive: if len(c.SourceIDs) != 1 { return false, "" } if c.Reason != tidyReasonOneOffEvent { // 只认这一个归档理由:理由变了说明规则变了,那属于「拿不准」。 return false, "" } if looksLikeHabit(candidateSubjectContent(c)) { // design/23 §5.2-1:一次性事件 ≠ 长期习惯。习惯类(「常在深夜 23:30 // 后还醒着」)会因为正文里带钟点而被判成一次性事件 —— 归档它是错的, // 因此这一条守卫是自动档的**红线**,不是优化。 return false, "" } return true, tidyAutoReasonOneOffEvent case TidyReclassify: // 2026-10-07 追加口径「全自动挡吧」:reclassify 只改 kind(分类字段), // **不动正文、不删行、不影响召回内容** ⇒ 与 merge/archive 同级安全。 // // 边界写在**读字段的那一处**(tidyAutoReclassifyKind),以免后来者顺手扩大: // 本档只采纳它的分类要求。候选上任何正文/合并后正文(ProposedContent / // MergedContent / ContentA / ContentB)、任何指向第二条行的 target_id、 // 任何分区或时间字段,一概不读、不采纳 —— reclassify 的授权范围只有 // 「分类列」,它不能改正文、不能改作用域;落地路径的 UPDATE 也只写 kind。 if _, ok := tidyAutoReclassifyKind(c); ok { return true, tidyAutoReasonKindMismatch } return false, "" default: // link / keep 都不自动(它们本来也没有执行语义)。 return false, "" } } // tidyAutoReclassifyKind 报告一条候选是否**只**要求改分类,并给出它要求改成的分类。 // 四个条件缺一不可: // // 1. **形状**:恰 1 个 source、且 target_id 为空。reclassify 的主体只有一行; // 指着第二条行的「改成什么」不是分类修正(那是 merge 的形状),拒。 // 2. **理由**必须是 kind_mismatch —— 与 archive 只认 one_off_event 同一手法: // 理由变了说明规则变了,那属于「拿不准」,交人工。 // 3. ProposedKind 必须是**合法分类**(memory 只有 relationship / world_event)。 // 4. 候选自带的「改前 kind」(KindA)必须合法、且与建议值**不同**。这一条是 // **可 revert 护栏的抓手**:回滚靠 KindA 把 kind 写回去(tidy_apply_store.go 的 // undoTidyReclassifySQL),KindA 缺失/非法的候选一旦自动落地就**没法回滚**, // 那违反「自动落地的每一条都能 revert」;同理 KindA == ProposedKind 的候选 // 无事可做,让它每轮都占一次上限(又永远跳过)是纯噪声。 // // 除 ProposedKind / KindA 外,候选上的字段一概不参与判定(尤其 ProposedContent // 之类的正文:reclassify 无权改正文 ⇒ 一律不采纳)。 func tidyAutoReclassifyKind(c TidyCandidate) (Kind, bool) { if len(c.SourceIDs) != 1 || strings.TrimSpace(c.SourceIDs[0]) == "" || strings.TrimSpace(c.TargetID) != "" { return "", false } if c.Reason != tidyReasonKindMismatch { return "", false } proposed := Kind(c.Evidence.ProposedKind) previous := Kind(c.Evidence.KindA) if !proposed.valid() || !previous.valid() || previous == proposed { return "", false } return proposed, true } // literalDuplicate 报告两条正文是否逐字相同(只去掉首尾空白)。 // 大小写**不**折叠、内部空白**不**压缩:「逐字相同」就该逐字比。 func literalDuplicate(a, b string) bool { ta, tb := strings.TrimSpace(a), strings.TrimSpace(b) return ta != "" && ta == tb } // candidateSubjectContent 取一条候选「讲的是哪条记忆」的正文:归档候选记在 // ProposedContent(= 原文),其余记在证据的 ContentA(对/条各自的原文)。 func candidateSubjectContent(c TidyCandidate) string { if strings.TrimSpace(c.ProposedContent) != "" { return c.ProposedContent } return c.Evidence.ContentA } // looksLikeHabit 报告一段记忆正文看起来是不是**反复发生的习惯/规律**(而不是 // 一次性事件)。刻意**偏宽**:多一条进人工队列,远小于把主人的作息习惯当成一次性 // 事件归档掉的代价(归档虽可 revert,但主人不会知道要 revert 什么)。 func looksLikeHabit(content string) bool { normalized := strings.ToLower(strings.TrimSpace(content)) for _, marker := range tidyHabitMarkers() { if strings.Contains(normalized, marker) { return true } } return false } // tidyHabitMarkers 是「反复发生」的信号词。它是函数而不是包级变量 // (AGENTS §10 禁包级全局变量;同 tidySystemSessions 的手法)。 // // 刻意**不含光杆「常」**:它在中文里到处都是(「非常」「不正常」),且「常在深夜」 // 这类真信号已由「常在」覆盖 —— 判据宁可用长词命中,也不要把绝大多数一次性事件 // 都挡在自动档之外(那会让自动档等于没开)。 func tidyHabitMarkers() []string { return []string{ "习惯", "经常", "常常", "常年", "常态", "常在", "总是", "反复", "长期", "一直", "持续", "例行", "惯例", "每逢", "每天", "每日", "每晚", "每周", "每月", "每次", "作息", "规律", "平时", "平常", "一向", "向来", "通常", "大多", "有时", "偶尔", "偏向", "偏好", "倾向于", "喜欢", } } // tallyTidyAutoApply 把一条落地结果计入自动档回执(跳过的与失败的都不算「落地」, // 因此不进 ByReason —— 理由分布要回答的是「自动做成的那些是什么理由」)。 func tallyTidyAutoApply(batch *TidyAutoApplyBatch, res TidyApplyResult, reason string) { switch res.Status { case TidyApplyApplied: batch.Applied++ batch.ByReason[reason]++ switch res.Action { case TidyMerge: batch.Merged++ case TidyArchive: batch.Archived++ case TidyReclassify: // 2026-10-07 起 reclassify 也在自动档里,因此它必须有独立的计数位: // 少了这一格,自动改了 kind 的那些会**静默消失**在 Applied 总数里, // 审计与日志就再也分不清「自动档做了什么」。 batch.Reclassified++ } case TidyApplyFailed: batch.Failed++ default: batch.Skipped++ } } // autoApply 是 RunOnce 的轮末自动档入口。它在开关关闭时**什么都不做**(连查询都不 // 发),因此「回到只产建议」是一次配置改动而不是一次代码回退。 // // 失败不让整轮失败(与候选写入失败同口径):记 stats.Errors + WARN —— 这一轮的 // 价值(候选与留痕)已经拿到了,不该因为自动档而回一份错误。 func (t *Tidier) autoApply(ctx context.Context, stats *TidyStats) TidyAutoApplyBatch { if !t.cfg.autoApplyEnabled() { return TidyAutoApplyBatch{} } batch, err := t.deps.Store.AutoApplyTidyCandidates(ctx, TidyApplyOptions{ Actor: tidyAuditWho, Limit: t.cfg.AutoApplyPerRound, }) if err != nil { stats.Errors++ t.log.Warn("memory tidy auto apply failed", "error", err) return TidyAutoApplyBatch{} } stats.AutoEligible = batch.EligibleTotal stats.AutoApplied = batch.Applied stats.AutoMerged = batch.Merged stats.AutoArchived = batch.Archived stats.AutoReclassified = batch.Reclassified stats.AutoTruncated = batch.Truncated if batch.Failed > 0 { stats.Errors += batch.Failed t.log.Warn("memory tidy auto apply had failures", "failed", batch.Failed, "applied", batch.Applied, "skipped", batch.Skipped) } if batch.Truncated { t.log.Warn("memory tidy auto apply truncated by per-round limit", "eligible_total", batch.EligibleTotal, "limit", batch.Limit) } if batch.ScanTruncated { // 待定候选多到读不完:判据只看过了最新的一批。**绝不静默**(与扫描上限同口径)。 t.log.Warn("memory tidy auto apply hit the pending-candidate scan limit", "scan_limit", maxTidyAutoApplyScan, "pending", maxTidyAutoApplyScan) } return batch } // auditAutoApply 写一条自动落地的审计。审计只带**计数与理由分布**,不带任何记忆 // 正文(design/23 §8);没接审计出口时退到一条带 source 的日志,绝不静默。 func (t *Tidier) auditAutoApply(batch TidyAutoApplyBatch) { if !t.cfg.autoApplyEnabled() { return } result := fmt.Sprintf( "ok auto=true applied=%d merged=%d archived=%d reclassified=%d skipped=%d failed=%d eligible_total=%d limit=%d truncated=%t scan_truncated=%t by_reason=%s", batch.Applied, batch.Merged, batch.Archived, batch.Reclassified, batch.Skipped, batch.Failed, batch.EligibleTotal, batch.Limit, batch.Truncated, batch.ScanTruncated, tidyAutoReasonLabel(batch.ByReason)) hook := t.deps.Audit if hook == nil { t.log.Info("memory tidy auto apply audit (no sink configured)", "what", tidyAutoApplyVerb, "result", result) return } hook(tidyAuditWho, tidyAutoApplyVerb, result) } // tidyAutoReasonLabel 把自动理由分布渲染成稳定字符串(按理由排序), // 与 tidyReasonsLabel 同一手法。 func tidyAutoReasonLabel(reasons map[string]int) string { if len(reasons) == 0 { return "none" } keys := make([]string, 0, len(reasons)) for k := range reasons { keys = append(keys, k) } sort.Strings(keys) parts := make([]string, 0, len(keys)) for _, k := range keys { parts = append(parts, fmt.Sprintf("%s=%d", k, reasons[k])) } return strings.Join(parts, ",") }