feat(memory): dedupe_group 候选动作 + 留存闸组合口径与 N6/N7 时点规则(M34)

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 ./... 全绿。
This commit is contained in:
AskaEth committed 2026-10-25 00:08:46 +08:00
1 parent 210c8b999f
commit 6a3f3a329a
20 files changed
+2353 -403

No files matched your search

@@ -0,0 +1,65 @@
package memory
import (
"context"
"fmt"
"strings"
)
// 本文件是 `dedupe_group` 的**元信息查询**:按分区取出「合并次数」与「是否临时层」
// 两列 —— 它们是存活规则的次要判据与一条数据安全护栏(临时行绝不进簇)。
// 从 tidy_dedupe.go 拆出来只为守 AGENTS §10 的文件行数上限(≤500 行),
// 注释与判据**逐字未动**。
// dedupeItemMetaSQL 取一个分区内每条记忆的**分簇所需元信息**:合并次数
// (存活规则的次要判据)+ 是否是临时层(`expires_at` 非空)。
//
// 两列都只读、只走分区条件,与 tidy 分区扫描同一组谓词(不含 archived/merged)。
const dedupeItemMetaSQL = `SELECT id, merge_count, expires_at FROM memory_items
WHERE persona_id = ? AND scope = ? AND project_id = ? AND status = ?`
// tidyDedupeMeta 是一条记忆的额外元信息(`MemoryItem` 的读侧 SELECT 不含这两列)。
type tidyDedupeMeta struct {
// mergeCount 是存活规则的次要判据(合并次数多的留)。
mergeCount int
// temporary 报告这条记忆在**临时层**(`expires_at` 非空,design/03 §9.6)。
//
// 🔴 临时层行**绝不进簇**,两个方向都不许:让一条长期事实并进一个 7 天后会被
// 后台**物理删除**(design/23 §19)的存活行,等于把那条事实一起删掉 ——
// 被吸收行会永远停在 `status='merged'` 且 `merged_into` 指向一个不存在的 id。
// 临时行本身也不值得合并:它的寿命本来就只剩几天。
temporary bool
}
// dedupeItemMeta 读出一个分区的 id → 元信息映射。
//
// 失败方向上它是**可降级**的(调用方在失败时用空映射继续分簇):存活行的第一判据
// 是「内容最长」,merge_count 只在前者并列时决定胜负。但**降级会让「临时行」这个
// 判断失效** —— 因此调用方在降级时必须记 WARN,并且降级路径下 `temporary` 恒为
// false 意味着「可能把临时行当长期行」;这是**宁可少做也不误删**的反面,
// 所以这里把降级方向定成「继续分簇 + WARN」,而不是「整个分区不分簇」:
// 判错的方向是「多留一条冗余」(可 revert),不是「丢一条记忆」。
func (s *Store) dedupeItemMeta(ctx context.Context, p tidyPartition) (map[string]tidyDedupeMeta, error) {
rows, err := s.db.QueryContext(ctx, dedupeItemMetaSQL,
p.personaID, string(p.scope), p.projectID, string(tidyStatusActive))
if err != nil {
return nil, fmt.Errorf("memory/tidy: dedupe item meta: %w", err)
}
defer func() { _ = rows.Close() }()
out := make(map[string]tidyDedupeMeta, 64)
for rows.Next() {
var (
id string
mergeCnt int
expiresAt string
)
if err := rows.Scan(&id, &mergeCnt, &expiresAt); err != nil {
return nil, fmt.Errorf("memory/tidy: scan dedupe item meta: %w", err)
}
out[id] = tidyDedupeMeta{mergeCount: mergeCnt, temporary: strings.TrimSpace(expiresAt) != ""}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("memory/tidy: dedupe item meta rows: %w", err)
}
return out, nil
}