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 ./... 全绿。
219 lines
8.9 KiB
Go
219 lines
8.9 KiB
Go
package memory
|
||
|
||
import (
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"fmt"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// 本文件是记忆整理的**候选**侧:候选与证据的落库形状、由判定结果构造候选、
|
||
// 以及候选的幂等键。它从 tidy.go 拆出来,让编排(扫描→召回→判定→落库)
|
||
// 保持在一屏内(AGENTS §10 文件 ≤500 行)。
|
||
|
||
// TidyEvidence 是一条候选的判定证据。design/23 §7 要求 diff 预览能展示
|
||
// 「A 原文 / B 原文 / 合并后内容 / A 被 B 包含的证据 / 命中或未命中的否决规则」,
|
||
// 因此这四项都在这里。它装的是主人自己的记忆正文,只落本机库、只走 owner-only
|
||
// 的读侧(design/23 §11)。
|
||
type TidyEvidence struct {
|
||
// Similarity 是召回用的余弦相似度(只用来召回,不是判定依据)。
|
||
Similarity float64 `json:"similarity"`
|
||
// RecallMode 记录本轮相似度的来源(embedding / lexical),便于运维判读。
|
||
RecallMode string `json:"recall_mode,omitempty"`
|
||
// Containment 是包含判定结论:a_subset_b / b_subset_a / both / none。
|
||
Containment string `json:"containment,omitempty"`
|
||
// ContainmentEvidence 是「A ⊆ B」的逐要素证据(缺哪一项就列哪一项)。
|
||
ContainmentEvidence []string `json:"containment_evidence,omitempty"`
|
||
// VetoReason 是命中的否决规则(未命中时为空)。
|
||
VetoReason string `json:"veto_reason,omitempty"`
|
||
// KindA/KindB/ProposedKind 供 reclassify 候选展示 kind 误分类建议。
|
||
KindA string `json:"kind_a,omitempty"`
|
||
KindB string `json:"kind_b,omitempty"`
|
||
ProposedKind string `json:"proposed_kind,omitempty"`
|
||
// ContentA/ContentB 是两条原文,供面板 diff 预览。
|
||
ContentA string `json:"content_a,omitempty"`
|
||
ContentB string `json:"content_b,omitempty"`
|
||
// 以下是 dedupe_group(2026-10-24)的**簇信息**:判据、簇键、簇大小、存活规则,
|
||
// 以及被吸收行的正文列表(面板要能一次看清「这一簇吞了哪几条」——merge 候选
|
||
// 只有一对,ContentA/ContentB 装得下;dedupe_group 一簇最多十来条,装不下)。
|
||
//
|
||
// 🔴 回滚**不需要**这些字段:回滚靠 SourceIDs + TargetID(被吸收行的
|
||
// merged_into 必须等于 target),与 merge 候选完全同一套。簇信息只服务审计。
|
||
DedupeRule string `json:"dedupe_rule,omitempty"`
|
||
DedupeKey string `json:"dedupe_key,omitempty"`
|
||
ClusterSize int `json:"cluster_size,omitempty"`
|
||
KeptReason string `json:"kept_reason,omitempty"`
|
||
DedupeMembers []string `json:"dedupe_members,omitempty"`
|
||
}
|
||
|
||
// TidyCandidate 是一条整理建议(落库形状,design/23 §10)。
|
||
type TidyCandidate struct {
|
||
ID string
|
||
PersonaID string
|
||
Scope Scope
|
||
ProjectID string
|
||
Kind Kind
|
||
Action TidyAction
|
||
SourceIDs []string
|
||
TargetID string
|
||
// RunID 是产出这条候选的那一轮整理(切片 TIDY-1 补的列:批量应用的抓手)。
|
||
// 幂等跳过的候选保留**首次**产它的那一轮,不被后来的轮次改写。
|
||
RunID string
|
||
ProposedContent string
|
||
MergedContent string
|
||
Reason string
|
||
Evidence TidyEvidence
|
||
RuleVersion string
|
||
ScoreMin float64
|
||
ScoreMax float64
|
||
Status string
|
||
IdempotencyKey string
|
||
CreatedAt time.Time
|
||
}
|
||
|
||
// tidyVerdict 是一条候选对的判定结果。
|
||
type tidyVerdict struct {
|
||
Action TidyAction
|
||
Veto TidyVetoReason
|
||
Target *MemoryItem
|
||
Containment containment
|
||
Reason string
|
||
}
|
||
|
||
// 候选与判定结论的理由标签(落 memory_tidy_candidates.reason;`tidyAutoApplyEligible`
|
||
// 会把其中两条读回来当自动落地的判据)。抽成常量是因为它们各有两处以上使用:
|
||
// 「三次复制必须抽取」(AGENTS §10)。
|
||
const (
|
||
// tidyReasonSimilarNotContained 是「相似但互不包含」的判定结论。2026-10-07 起
|
||
// 这种对**不再产候选**(判 keep,见 tidy_rules.go),它只留在判定结果与日志里。
|
||
tidyReasonSimilarNotContained = "similar_but_not_contained"
|
||
// tidyReasonFactSubset 是包含式合并(A ⊆ B,把 A 并入 B)的理由。
|
||
tidyReasonFactSubset = "fact_subset"
|
||
// tidyReasonOneOffEvent 是一次性事件的归档理由,**也是自动档唯一认的归档理由**:
|
||
// 理由不同(例如将来新增的归档类别)就不在新口径的授权范围内,交人工。
|
||
tidyReasonOneOffEvent = "one_off_event"
|
||
// tidyReasonKindMismatch 是 kind 误分类的理由(design/23 §5.1 T0-6)。2026-10-07
|
||
// 「全自动挡吧」起它**也是自动档认的 reclassify 理由**(判据还要形状完整 +
|
||
// 可回滚的改前 kind,见 tidy_auto.go 的 tidyAutoReclassifyKind)—— 只改分类列,
|
||
// 不碰正文。
|
||
tidyReasonKindMismatch = "kind_mismatch"
|
||
)
|
||
|
||
// archiveCandidate 为一次性事件产一条归档建议(design/23 §5.1 T0-5)。
|
||
func archiveCandidate(it MemoryItem, now time.Time) TidyCandidate {
|
||
return TidyCandidate{
|
||
ID: mustNewTidyID(),
|
||
PersonaID: it.PersonaID,
|
||
Scope: it.Scope,
|
||
ProjectID: it.ProjectID,
|
||
Kind: it.Kind,
|
||
Action: TidyArchive,
|
||
SourceIDs: []string{it.ID},
|
||
ProposedContent: it.Content,
|
||
Reason: tidyReasonOneOffEvent,
|
||
Evidence: TidyEvidence{
|
||
Containment: "none",
|
||
ContentA: it.Content,
|
||
KindA: string(it.Kind),
|
||
},
|
||
RuleVersion: TidyRuleVersion,
|
||
Status: tidyCandidatePending,
|
||
CreatedAt: now,
|
||
}
|
||
}
|
||
|
||
// buildCandidates 把一条判定结果转成候选:merge 需要一条存活条目(超集),
|
||
// kind 不一致时附带一条 reclassify 建议(design/23 §5.1 T0-6)。
|
||
func buildCandidates(pr tidyPair, v tidyVerdict, now time.Time) []TidyCandidate {
|
||
evidence := TidyEvidence{
|
||
Similarity: pr.score,
|
||
RecallMode: pr.mode,
|
||
Containment: string(v.Containment.Kind),
|
||
ContainmentEvidence: v.Containment.Missing,
|
||
KindA: string(pr.a.Kind),
|
||
KindB: string(pr.b.Kind),
|
||
ContentA: pr.a.Content,
|
||
ContentB: pr.b.Content,
|
||
}
|
||
base := TidyCandidate{
|
||
ID: mustNewTidyID(),
|
||
PersonaID: pr.a.PersonaID,
|
||
Scope: pr.a.Scope,
|
||
ProjectID: pr.a.ProjectID,
|
||
Evidence: evidence,
|
||
RuleVersion: TidyRuleVersion,
|
||
Status: tidyCandidatePending,
|
||
ScoreMin: pr.score,
|
||
ScoreMax: pr.score,
|
||
CreatedAt: now,
|
||
}
|
||
|
||
if v.Action != TidyMerge || v.Target == nil {
|
||
// 非 merge 的判定结论**不产候选**:link 没有执行语义(design/23 §14 的待
|
||
// 细化项,见 tidy_rules.go 的 containNone 分支),keep 按 §5.2-10 就是
|
||
// 「什么都不做」。返回空切片而不是一条应用不了的候选 —— 后者只会污染待办。
|
||
return nil
|
||
}
|
||
|
||
absorbed, target := pr.a, *v.Target
|
||
if v.Target.ID == pr.a.ID {
|
||
absorbed, target = pr.b, pr.a
|
||
}
|
||
base.Action = TidyMerge
|
||
base.SourceIDs = []string{absorbed.ID}
|
||
base.TargetID = target.ID
|
||
base.Kind = target.Kind
|
||
base.ProposedContent = absorbed.Content
|
||
base.MergedContent = target.Content
|
||
base.Reason = v.Reason
|
||
|
||
out := []TidyCandidate{base}
|
||
if absorbed.Kind != target.Kind {
|
||
// kind 误分类:以存活条目(超集)的 kind 为建议值(design/23 §5.1 T0-6)。
|
||
// 2026-10-07「全自动挡吧」起,形状完整的这类候选由**轮末自动档**直接落地
|
||
// (只改分类列;判据见 tidy_auto.go),不完整的仍交人工。
|
||
re := base
|
||
re.ID = mustNewTidyID()
|
||
re.Action = TidyReclassify
|
||
re.SourceIDs = []string{absorbed.ID}
|
||
re.TargetID = ""
|
||
re.ProposedContent = ""
|
||
re.MergedContent = ""
|
||
re.Reason = tidyReasonKindMismatch
|
||
re.Evidence.ProposedKind = string(target.Kind)
|
||
// KindA 在 reclassify 候选上**重新定义**为「被改那一行当前的 kind」:
|
||
// merge 候选的 KindA/KindB 是一对各自的 kind(供 diff 预览),而
|
||
// reclassify 候选只有一个主体(absorbed),回滚必须知道它原来的 kind,
|
||
// 否则 revert 会拿另一条行的 kind 去覆盖(design/23 §8 的可逆口径)。
|
||
re.Evidence.KindA = string(absorbed.Kind)
|
||
out = append(out, re)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// tidyIdempotencyKey 由 design/23 §9 的幂等键口径算出:
|
||
// hash(persona_id, scope, project_id, sorted(source_ids), action, rule_version)。
|
||
// 规则不变时同一批数据重复运行产出同一个键,因此候选绝不重复落库。
|
||
func tidyIdempotencyKey(c TidyCandidate) string {
|
||
ids := append([]string(nil), c.SourceIDs...)
|
||
sort.Strings(ids)
|
||
parts := []string{
|
||
c.PersonaID, string(c.Scope), c.ProjectID,
|
||
strings.Join(ids, ","), string(c.Action), c.RuleVersion,
|
||
}
|
||
sum := sha256.Sum256([]byte(strings.Join(parts, "\x00")))
|
||
return hex.EncodeToString(sum[:])
|
||
}
|
||
|
||
// mustNewTidyID 生成候选 id。它复用与记忆条目同一个随机源;熵源损坏时回退到
|
||
// 时间戳,而不是让整理整轮失败(候选 id 只需要在本库唯一)。
|
||
func mustNewTidyID() string {
|
||
id, err := newID()
|
||
if err != nil {
|
||
return fmt.Sprintf("tidy-%d", time.Now().UnixNano())
|
||
}
|
||
return id
|
||
}
|