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 ./... 全绿。
84 lines
3.4 KiB
Go
84 lines
3.4 KiB
Go
package memory
|
||
|
||
import (
|
||
"sort"
|
||
"time"
|
||
)
|
||
|
||
// 本文件是 `dedupe_group` 的**候选构造**侧:从一簇成员里挑出可并入的行、
|
||
// 并把簇转成候选(落库形状与 merge 候选同构,因此面板/应用/回滚三条路都不用改)。
|
||
// 它从 tidy_dedupe.go 拆出来只为守 AGENTS §10 的文件行数上限(≤500 行),
|
||
// 判据与注释**逐字未动**;簇形成在 tidy_dedupe.go,元信息查询在 tidy_dedupe_meta.go。
|
||
|
||
// dedupeAbsorbable 从一簇成员里挑出**可以并入存活行**的那些(逐条过 §5.2 的安全门)。
|
||
//
|
||
// 数值与否定是对**存活行**比(不是两两全比):簇的语义是「这些行说的是同一件事」,
|
||
// 而同一性由存活行代表;任何一条与存活行数值/否定不一致的成员,说明它讲的是
|
||
// **另一个事实**(§5.2-2/§5.2-3),因此它**不进这一簇**(留待人工),而不是让整簇作废。
|
||
//
|
||
// 前缀簇还要过相似度下限(见 dedupeGroupPrefixSimilarity);tag 集簇不需要 ——
|
||
// 完全相同的 tag 集本身就是强证据,而前缀簇的「同开头」弱得多。
|
||
func dedupeAbsorbable(rule string, survivor tidyDedupeMember, members []tidyDedupeMember) []tidyDedupeMember {
|
||
out := make([]tidyDedupeMember, 0, len(members))
|
||
for _, it := range members {
|
||
if it.item.ID == survivor.item.ID {
|
||
continue
|
||
}
|
||
if !sameNumbers(it.item.Content, survivor.item.Content) {
|
||
continue
|
||
}
|
||
if !sameNegations(it.item.Content, survivor.item.Content) {
|
||
continue
|
||
}
|
||
if rule == tidyDedupeRulePrefix &&
|
||
lexicalSimilarity(it.item.Content, survivor.item.Content) < dedupeGroupPrefixSimilarity {
|
||
continue
|
||
}
|
||
out = append(out, it)
|
||
}
|
||
// 被吸收行的顺序也必须是确定的(幂等键按 sorted(source_ids) 算,但证据里的
|
||
// dedupe_members 会原样落库,顺序抖动会让同一个簇每次产出不同的证据文本)。
|
||
sort.Slice(out, func(i, j int) bool { return out[i].item.ID < out[j].item.ID })
|
||
return out
|
||
}
|
||
|
||
// dedupeGroupCandidate 把一个簇转成候选。
|
||
//
|
||
// 落库字段与 merge 候选同构,因此既有面板/应用/回滚三条路都不用改形状:
|
||
// `source_ids` = 被吸收行(**可多条**,`applyTidyDedupeGroup` 逐条软合并),
|
||
// `target_id` = 存活行,`merged_content` = 存活行正文(**绝不改正文**)。
|
||
func dedupeGroupCandidate(cl tidyDedupeCluster, now time.Time) TidyCandidate {
|
||
absorbedIDs := make([]string, 0, len(cl.Absorbed))
|
||
members := make([]string, 0, len(cl.Absorbed))
|
||
for _, it := range cl.Absorbed {
|
||
absorbedIDs = append(absorbedIDs, it.ID)
|
||
members = append(members, it.Content)
|
||
}
|
||
return TidyCandidate{
|
||
ID: mustNewTidyID(),
|
||
PersonaID: cl.Survivor.PersonaID,
|
||
Scope: cl.Survivor.Scope,
|
||
ProjectID: cl.Survivor.ProjectID,
|
||
Kind: cl.Survivor.Kind,
|
||
Action: TidyDedupeGroup,
|
||
SourceIDs: absorbedIDs,
|
||
TargetID: cl.Survivor.ID,
|
||
ProposedContent: cl.Survivor.Content,
|
||
MergedContent: cl.Survivor.Content,
|
||
Reason: tidyReasonDedupeGroup,
|
||
Evidence: TidyEvidence{
|
||
Containment: "none",
|
||
ContentA: cl.Survivor.Content,
|
||
DedupeRule: cl.Rule,
|
||
DedupeKey: cl.Key,
|
||
ClusterSize: len(cl.Absorbed) + 1,
|
||
KeptReason: cl.KeptReason,
|
||
DedupeMembers: members,
|
||
KindA: string(cl.Survivor.Kind),
|
||
},
|
||
RuleVersion: TidyRuleVersion,
|
||
Status: tidyCandidatePending,
|
||
CreatedAt: now,
|
||
}
|
||
}
|