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

+22 -12
View File
@@ -98,6 +98,13 @@ type AdminTidyDryRunResponse struct {
// Truncated 列出被护栏截断的分区标签(扫描/比对/候选上限),供运维放大配额。
// 它**不是**候选;缺席表示本轮没有被截断。
Truncated []string `json:"truncated,omitempty"`
// DedupeGroups / DedupeRowsReducible 是 `dedupe_group`(2026-10-24)的**试算口径**:
// 机械可判重复分成了多少簇、这些簇一共能减少多少 active 行。
// 它们是「先看数、后落库」这条要求的落点 —— 试算零写库,数字就是真跑会产出的。
DedupeGroups int `json:"dedupeGroups"`
DedupeRowsReducible int `json:"dedupeRowsReducible"`
// DedupeTruncated 是本轮被每分区候选上限丢掉的 dedupe 簇数(超限绝不静默)。
DedupeTruncated int `json:"dedupeTruncated"`
// Candidates 恒为非 null 数组:空数组 = 「算过了,这一轮没有建议」,与
// 「没算出来」(那种情况是错误响应)**必须能区分**。
Candidates []AdminTidyDryRunCandidate `json:"candidates"`
@@ -145,18 +152,21 @@ func (s *Server) writeTidyDryRunError(w http.ResponseWriter, err error) {
// 条数与截断标签都来自领域侧已经算好的结果(Candidates 已被每分区上限截断)。
func adminTidyDryRunResponse(result memory.TidyDryRun) AdminTidyDryRunResponse {
out := AdminTidyDryRunResponse{
DryRun: true,
RecallMode: result.RecallMode,
DurationMs: result.DurationMS,
RuleVersion: memory.TidyRuleVersion,
Partitions: result.Stats.Partitions,
Scanned: result.Stats.Scanned,
Compared: result.Stats.Compared,
Pairs: result.Stats.Pairs,
Vetoed: result.Stats.Vetoed,
VetoReasons: result.Stats.VetoReasons,
Truncated: result.Stats.Truncated,
Candidates: make([]AdminTidyDryRunCandidate, 0, len(result.Candidates)),
DryRun: true,
RecallMode: result.RecallMode,
DurationMs: result.DurationMS,
RuleVersion: memory.TidyRuleVersion,
Partitions: result.Stats.Partitions,
Scanned: result.Stats.Scanned,
Compared: result.Stats.Compared,
Pairs: result.Stats.Pairs,
Vetoed: result.Stats.Vetoed,
VetoReasons: result.Stats.VetoReasons,
Truncated: result.Stats.Truncated,
DedupeGroups: result.Stats.DedupeGroups,
DedupeRowsReducible: result.Stats.DedupeRows,
DedupeTruncated: result.Stats.DedupeTruncated,
Candidates: make([]AdminTidyDryRunCandidate, 0, len(result.Candidates)),
}
for _, c := range result.Candidates {
out.Candidates = append(out.Candidates, adminTidyDryRunCandidate(c))
@@ -309,6 +309,8 @@ func adminTidyApplyBatch(batch memory.TidyApplyBatch) AdminTidyApplyBatchRespons
Merged: batch.Merged,
Archived: batch.Archived,
Reclassified: batch.Reclassified,
Deduped: batch.Deduped,
RowsAffected: batch.RowsAffected,
Results: make([]AdminTidyApplyResult, 0, len(batch.Results)),
}
for _, res := range batch.Results {
@@ -53,16 +53,21 @@ type AdminTidyApplyResult struct {
// `applied+skipped+failed` 只覆盖 `limit` 之内处理过的那些 —— 两者不相等时
// `truncated=true`(超限绝不静默)。
type AdminTidyApplyBatchResponse struct {
RunID string `json:"runId,omitempty"`
Limit int `json:"limit"`
Total int `json:"total"`
Truncated bool `json:"truncated"`
Applied int `json:"applied"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
Merged int `json:"merged"`
Archived int `json:"archived"`
Reclassified int `json:"reclassified"`
RunID string `json:"runId,omitempty"`
Limit int `json:"limit"`
Total int `json:"total"`
Truncated bool `json:"truncated"`
Applied int `json:"applied"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
Merged int `json:"merged"`
Archived int `json:"archived"`
Reclassified int `json:"reclassified"`
// Deduped 是成功落地的 `dedupe_group` 簇数(2026-10-24);RowsAffected 是这次
// 真的改动的**记忆行数**。两者分开是因为一簇动多行:「应用了 3 条候选」与
// 「改动了 11 行记忆」在面板上是两个不同的信息(行预算按后者计)。
Deduped int `json:"deduped"`
RowsAffected int `json:"rowsAffected"`
Results []AdminTidyApplyResult `json:"results"`
}
@@ -95,8 +100,14 @@ type AdminTidyRunResponse struct {
// 挡下(同一批数据重复跑)的数量。
CandidatesCreated int `json:"candidatesCreated"`
CandidatesDuplicate int `json:"candidatesDuplicate"`
// CandidatesByAction 是新建候选按动作的分布(merge / archive / reclassify)。
// CandidatesByAction 是新建候选按动作的分布(merge / archive / reclassify /
// dedupe_group,2026-10-24 起新增最后一档)。
CandidatesByAction map[string]int `json:"candidatesByAction"`
// DedupeGroups / DedupeRowsReducible 是 `dedupe_group` 的簇数与可减少行数;
// DedupeTruncated 是被候选上限丢掉的那部分(超限绝不静默)。
DedupeGroups int `json:"dedupeGroups"`
DedupeRowsReducible int `json:"dedupeRowsReducible"`
DedupeTruncated int `json:"dedupeTruncated"`
}
// AdminTidyRejectResponse 是「按动作批量拒绝候选」的回执(契约新增,2026-10-07)。
@@ -96,5 +96,8 @@ func adminTidyRunResponse(stats memory.TidyStats) AdminTidyRunResponse {
CandidatesCreated: stats.Candidates,
CandidatesDuplicate: stats.Duplicates,
CandidatesByAction: byAction,
DedupeGroups: stats.DedupeGroups,
DedupeRowsReducible: stats.DedupeRows,
DedupeTruncated: stats.DedupeTruncated,
}
}