M27 体检(DevDocs/audit/记忆库价值体检-2026-10-24.md)两条根治项:
1) 新增 `dedupe_group` 候选动作(不放大 containNone):
- 簇只认「同 scope+kind+完全相同 tag 集」或「同 scope+kind+前 12 字相同」
(后者附加词项余弦 ≥0.70 与同 kind 收窄);
- 每簇保留超集:内容最长 > merge_count 大 > created_at 早 > id;
- 安全门与既有合并路径同源:受限来源/绝对时刻/时效状态/低置信一律不进簇,
数值与否定与存活行不一致的成员被逐条剔出;临时层行(expires_at 非空)
两个方向都不进簇(存活行被清扫会给被吸收行留悬空 merged_into);
- 已被 archive/merge 候选覆盖的条目不重复出第二条待办(抑制);
- 落库形态与 merge 同构:source_ids 多条 + target_id,应用为整簇原子的软合并,
回滚复用 revertTidyMerge(逐条复原);
- 批量应用按**行**计上限 200 行/批(maxTidyApplyRowsPerBatch),超限如实 truncated;
- 不进自动档(人工/批量点);试算(DryRun)零写库给出簇数与可减少行数。
2) 留存闸改组合口径(治根)+ N6/N7:
- 护栏②从「含习惯/约定词就一票放行」改为「锚点词 **且** 含具体约束
(数字/禁忌/频次规律)」;无约束的锚点交给更窄的规则判;
- N1/N3/N4/N5 这几条词项规则改为「无锚点时才开火」(`主人喜欢说晚安`
这类偏好不再因寒暄词被判临时);
- 新增 N6(指示性时点,句首前 12 字内)与 N7(进行中状态),安全阀:习惯词、
硬性长期事实词、数值/约束、寒暄/情绪词一律按回去;
- 命中即第五态 defer,`expires_at = 判定时刻 + tempTtlSec`(默认 7 天,
已落在 $DATA_DIR/config/memory-tidy.json,零新 .env 键);**只开新增侧**,
存量一条不补写(用例钉住)。
契约(加性,不改既有字段语义):tidy dry-run/run 回执加 dedupeGroups /
dedupeRowsReducible / dedupeTruncated;批量应用回执加 deduped / rowsAffected。
实测(生产库只读 mode=ro,active 3667):
- 闸:改前 2 条(0.05%) → 改后 89 条(2.43%):n6 58 / n7 29 / n1 2;
89 条里 30 条含锚点词,逐条人读均为「约定明天中午见」「约定下周留个午后」
这类到期即失效的安排(唯一可议:1 条把「中午互动的日常」一起带进临时层)。
- dedupe_group:281 簇 / 可减 594 行(16.2% of active);tag_set 265 / prefix12 16;
簇大小 2:170 3:48 4:27 5:10 6:10 7:7 8:2 9:2 10:1。
验收:go build ./... + go vet ./... + go test ./... 全绿。
111 lines
4.7 KiB
Go
111 lines
4.7 KiB
Go
package memory
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"errors"
|
||
"time"
|
||
)
|
||
|
||
// 本文件是 `dedupe_group`(2026-10-24,M34)的**应用与回滚**执行面。
|
||
//
|
||
// 它与 merge 共用同一套语义(design/23 §8 的软合并):被吸收行
|
||
// `status='merged' + merged_into=<存活 id>`,存活行 `merged_from` 追加、`merge_count`
|
||
// 累加;**绝不改正文、绝不删行**。因此回滚直接复用 `revertTidyMerge`(它本来就按
|
||
// `SourceIDs` 逐条复原,不是为「一对一」写的)。
|
||
//
|
||
// 与单条 merge 的两点差别、以及它们的理由:
|
||
//
|
||
// 1. **一条候选吸收多行**(一簇 N 条)。整簇在**一个事务**里落,任何一条不在册
|
||
// ⇒ 整簇回滚(`errTidyItemNotActive`),不会出现「吞了一半」的中间态 ——
|
||
// 半吞的簇最坏:面板显示 applied,但被吞行里有一半还在 active 集合里,
|
||
// 回滚也无从判断该复原哪几条。
|
||
// 2. **按行计的分批上限**(`maxTidyApplyRowsPerBatch`)。一条候选动 N 行,若只按
|
||
// 「候选条数」限批,一次批量应用可能在一条命令里改上千行记忆 —— 这正是体检
|
||
// 报告 §7.2 要求分批的理由(「第 2 批必须分批 + 每批留 run_id」)。行预算在
|
||
// `ApplyTidyCandidates` 里统一执行,超限如实报 `truncated`。
|
||
//
|
||
// 🔴 **不进自动档**:`tidyAutoApplyEligible` 的 switch 只认 merge/archive/reclassify,
|
||
// 默认分支返回 false,因此 dedupe_group 永远只走人工/批量点(测试钉着这一条)。
|
||
|
||
// maxTidyApplyRowsPerBatch 是一次批量应用**最多改动的记忆行数**(dedupe_group 按
|
||
// 被吸收行计)。200 的来源是体检报告 §7 第 2 批的建议值:一簇平均 3~4 行,
|
||
// 200 行 ≈ 50 簇,一屏可核对;它比候选条数上限(100)**更严**的场合是「簇很大」,
|
||
// 这正是需要护栏的那种数据。
|
||
const maxTidyApplyRowsPerBatch = 200
|
||
|
||
// tidyApplyRowCost 返回一条候选**会改动的记忆行数**,供批量应用的按行预算使用。
|
||
// 只有 dedupe_group 是多行动作;其余三种动作恒为 1(merge/archive/reclassify 各碰一行)。
|
||
func tidyApplyRowCost(c TidyCandidate) int {
|
||
if c.Action == TidyDedupeGroup && len(c.SourceIDs) > 0 {
|
||
return len(c.SourceIDs)
|
||
}
|
||
return 1
|
||
}
|
||
|
||
// applyTidyDedupeGroup 把一簇被吸收行软合并进存活行。
|
||
//
|
||
// 前置校验与 merge 逐条同源:分区硬门、只读来源硬门、目标行必须存在且与候选同分区。
|
||
// 任何一条不通过都如实 skip(不假装做过),且**一行都不写**。
|
||
func (s *Store) applyTidyDedupeGroup(ctx context.Context, cand TidyCandidate, opts TidyApplyOptions, now time.Time) (TidyApplyResult, error) {
|
||
base := TidyApplyResult{
|
||
CandidateID: cand.ID, Action: cand.Action, TargetID: cand.TargetID, AffectedIDs: []string{},
|
||
}
|
||
if len(cand.SourceIDs) == 0 || cand.TargetID == "" {
|
||
return skipTidyApply(base, tidySkipInvalidCandidate), nil
|
||
}
|
||
survivor, err := s.Get(ctx, cand.TargetID)
|
||
if errors.Is(err, ErrNotFound) {
|
||
return skipTidyApply(base, tidySkipSourceMissing), nil
|
||
}
|
||
if err != nil {
|
||
return base, err
|
||
}
|
||
if skip := tidyPartitionMismatch(cand, survivor); skip != "" {
|
||
return skipTidyApply(base, skip), nil
|
||
}
|
||
if nonOwnerSource(survivor.SourceConversation) {
|
||
// 只读来源(群聊/陌生人)绝不改写(design/23 §5.2-9/§6):纵深防御,
|
||
// 规则层本就把这类条目排除在分簇之外。
|
||
return skipTidyApply(base, tidySkipSourceRestricted), nil
|
||
}
|
||
absorbed := make([]MemoryItem, 0, len(cand.SourceIDs))
|
||
for _, id := range cand.SourceIDs {
|
||
if id == cand.TargetID {
|
||
return skipTidyApply(base, tidySkipInvalidCandidate), nil
|
||
}
|
||
it, gerr := s.Get(ctx, id)
|
||
if errors.Is(gerr, ErrNotFound) {
|
||
return skipTidyApply(base, tidySkipSourceMissing), nil
|
||
}
|
||
if gerr != nil {
|
||
return base, gerr
|
||
}
|
||
if skip := tidyPartitionMismatch(cand, it); skip != "" {
|
||
return skipTidyApply(base, skip), nil
|
||
}
|
||
if nonOwnerSource(it.SourceConversation) {
|
||
return skipTidyApply(base, tidySkipSourceRestricted), nil
|
||
}
|
||
absorbed = append(absorbed, it)
|
||
}
|
||
err = s.tidyApplyTx(ctx, cand, opts, now, func(ctx context.Context, tx *sql.Tx) error {
|
||
for _, it := range absorbed {
|
||
if err := markTidyAbsorbed(ctx, tx, it.ID, survivor.ID, now); err != nil {
|
||
return err
|
||
}
|
||
// 逐条 bump:bumpSurvivor 在事务内**重读**存活行的 merge_count,
|
||
// 因此连续调用会把 N 条都记进 merged_from、把计数加 N 次。
|
||
if err := bumpSurvivor(ctx, tx, survivor, it, now); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
})
|
||
affected := make([]string, 0, len(absorbed))
|
||
for _, it := range absorbed {
|
||
affected = append(affected, it.ID)
|
||
}
|
||
return finishTidyApply(base, affected, err, tidySkipSourceNotActive)
|
||
}
|