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 ./... 全绿。
71 lines
2.2 KiB
Go
71 lines
2.2 KiB
Go
package memory
|
||
|
||
import "errors"
|
||
|
||
// 本文件是记忆整理应用路径的**结果收敛**:把一条结果计入回执、把事务结果收敛成
|
||
// 一条跳过/失败/已应用的结果。它从 tidy_apply.go 拆出来只为守 AGENTS §10 的
|
||
// 文件行数上限(≤500 行),判据与注释**逐字未动**。
|
||
|
||
// tallyTidyApply 把一条结果计入批量回执的计数器。
|
||
func tallyTidyApply(batch *TidyApplyBatch, res TidyApplyResult) {
|
||
switch res.Status {
|
||
case TidyApplyApplied:
|
||
batch.Applied++
|
||
switch res.Action {
|
||
case TidyMerge:
|
||
batch.Merged++
|
||
case TidyArchive:
|
||
batch.Archived++
|
||
case TidyReclassify:
|
||
batch.Reclassified++
|
||
case TidyDedupeGroup:
|
||
batch.Deduped++
|
||
}
|
||
case TidyApplyFailed:
|
||
batch.Failed++
|
||
default:
|
||
batch.Skipped++
|
||
}
|
||
}
|
||
|
||
// skipTidyApply 造一条 skipped 结果。
|
||
func skipTidyApply(base TidyApplyResult, reason string) TidyApplyResult {
|
||
base.Status = TidyApplySkipped
|
||
base.Reason = reason
|
||
if base.AffectedIDs == nil {
|
||
base.AffectedIDs = []string{}
|
||
}
|
||
return base
|
||
}
|
||
|
||
// tidyFailed 造一条 failed 结果(错误只进 Error 字段,调用方决定记不记日志)。
|
||
func tidyFailed(base TidyApplyResult, err error) TidyApplyResult {
|
||
base.Status = TidyApplyFailed
|
||
base.Reason = "apply_failed"
|
||
base.Error = err.Error()
|
||
if base.AffectedIDs == nil {
|
||
base.AffectedIDs = []string{}
|
||
}
|
||
return base
|
||
}
|
||
|
||
// finishTidyApply 把事务结果收敛成一条结果:nil ⇒ applied;幂等哨兵 ⇒ skipped;
|
||
// 目标行不在册 ⇒ skipped(原因由 notActiveReason 给);其余 ⇒ failed。
|
||
//
|
||
// affected 是**成功时才会挂上去**的受影响行 id:跳过的结果绝不能声称改过哪一行
|
||
// (那是把「没做」说成「做了」)。
|
||
func finishTidyApply(base TidyApplyResult, affected []string, err error, notActiveReason string) (TidyApplyResult, error) {
|
||
switch {
|
||
case err == nil:
|
||
base.Status = TidyApplyApplied
|
||
base.AffectedIDs = affected
|
||
return base, nil
|
||
case errors.Is(err, errTidyCandidateDecided):
|
||
return skipTidyApply(base, tidySkipAlreadyDecided), nil
|
||
case errors.Is(err, errTidyItemNotActive):
|
||
return skipTidyApply(base, notActiveReason), nil
|
||
default:
|
||
return tidyFailed(base, err), nil
|
||
}
|
||
}
|