主人原话:「那么多 link 是干什么的?该删就删,怎么这个记忆整理机制这么保守呢」。
① **link 停产**(依据 design/23 §14 把它列为「未细化」⇒ 既无落点也无数据模型;
`applyable()` 也只认 merge/archive/reclassify ⇒ 应用 link 恒 `skipped`,是纯噪音):
- 规则层 `containNone` 判定改为 `keep`(不再产出 501 条不可应用的待办);
- `TidyLink` 常量保留,只为**读历史行**与「不可应用」判定;
- 存量出路:新端点 `POST /api/v1/admin/memories/candidates/reject?action=&limit=`
(**幂等、只改候选 status、不碰记忆**),主会话用它清掉存量 501 条。
② **轮末自动落地**(默认开,仅两类,判据是纯函数):
- `merge`:**字面重复** ∨「事实集合等价 ∧ 相似度 ≥ 0.95」;
- `archive`:理由 = `one_off_event` **且不像习惯**(`design/23 §5.2-1` 红线:
习惯类会因正文带钟点被误判成一次性事件,归档它是错的)。
- **不自动**:`reclassify`、低置信 merge、跨分区/跨角色、任何改正文、受限来源
(群聊/陌生人)—— 新增执行面守卫 `tidySkipSourceRestricted`。
③ **六条护栏**(缺一不可):软动作(复用既有 apply 路径)/ 每轮上限 50(硬顶 500,
超限如实 `Truncated` + 真总数)/ 审计 `memory.tidy.apply`(`auto=true`,不带正文)/
**可 revert**(复用既有回滚)/ 幂等(复用候选状态条件更新)/ **一键关回只产建议**
(`$DATA_DIR/config/memory-tidy.json` 的 `autoApply:false`,**零新 env 键**)。
验收(主会话复跑 + 亲自变异):memory 与 gateway 两包 build/vet/test 全绿;我用
-overlay 短路「习惯不归档」守卫 ⇒ TestTidyAutoNeverArchivesHabitMisjudgedAsOneOff
变红(逐字报「作息习惯被自动归档了 …design §5.2-1 红线」)。
⚠️ 未部署(要 build+restart 才生效);⚠️ 契约欠登记(新端点 B25 + §7.2),主会话补;
⚠️ 真机端到端未跑(不能重启后端)。
332 lines
15 KiB
Go
332 lines
15 KiB
Go
package memory
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"sort"
|
||
"strings"
|
||
)
|
||
|
||
// 本文件是记忆整理**轮末自动落地**的策略与执行面。动因是主人 2026-10-07 的口径:
|
||
// 「那么多 link 是干什么的?该删就删,怎么这个记忆整理机制这么保守呢」——
|
||
// 整理不能永远只产建议,规则层已经判死的高置信动作就该自己落地。
|
||
//
|
||
// ⚠️ 与 design/23 的既有口径冲突,且**是有意为之**:§7 原写「自动应用默认关闭;
|
||
// 即便将来开启,也只允许 apply 低风险动作(如 reclassify),merge 与 archive 永远
|
||
// 人工确认」,§13 开放点 5 的处置是「全部人工应用(无自动 apply)」。主人本次的
|
||
// 「该删就删」覆盖那两条;design 文档需同步(交付报告已列出建议补的几行)。
|
||
//
|
||
// 五条护栏,缺一不可:
|
||
//
|
||
// 1. **可自动的类别只有两类**,判据是纯函数 tidyAutoApplyEligible:
|
||
// merge = 字面重复 ∨(**事实集合等价** ∧ 相似度 ≥ 0.95);archive = 理由为
|
||
// one_off_event **且不像习惯**(design/23 §5.2-1:一次性事件 ≠ 长期习惯,
|
||
// 习惯类绝不能被钟点误判归档)。reclassify 虽小也不在本次授权内,交人工。
|
||
// 2. **只走既有 apply 路径**(tidy_apply.go):软动作(只改 status/merged_*,
|
||
// 绝不改正文、绝不删行)、候选状态条件更新(幂等)、失败隔离、可 revert。
|
||
// 3. **每轮上限**(cfg.AutoApplyPerRound):超限如实报 Truncated + 真总数,
|
||
// 绝不静默少做;下一轮继续。
|
||
// 4. **审计**:走 design/23 §8 的 `memory.tidy.apply` 族,result 带 `auto=true`
|
||
// 以区分「人点的」与「整理器自己落的」;只带计数,不带任何记忆正文。
|
||
// 5. **一键回到只产建议**:`$DATA_DIR/config/memory-tidy.json` 的
|
||
// `{"autoApply": false}`(不加 env 键 —— `.env` 已冻结,design/13 §2.1)。
|
||
//
|
||
// 绝不自动做的事(同样重要,写在这里以免后来者扩大范围):跨分区、跨角色、
|
||
// 任何改正文(modify)、任何低置信 merge、任何非 one_off_event 的归档。
|
||
|
||
// tidyAutoApplyVerb 是自动落地的审计动作名。它与网关侧 HTTP 触发那条同族
|
||
// (gateway.tidyApplyAction 逐字同值):动作名是 design/23 §8 的契约,不是实现细节,
|
||
// 因此两处必须是同一个字符串(跨包各写一份,改则两处一起改)。
|
||
const tidyAutoApplyVerb = "memory.tidy.apply"
|
||
|
||
// 自动档的**理由标签**:进日志/审计,机器可读(面板与排障按它聚合)。
|
||
const (
|
||
// tidyAutoReasonLiteralDuplicate 表示两侧正文逐字相同(归一化后)。
|
||
tidyAutoReasonLiteralDuplicate = "literal_duplicate"
|
||
// tidyAutoReasonEquivalentFacts 表示事实集合等价(双向包含)且相似度 ≥ 0.95。
|
||
tidyAutoReasonEquivalentFacts = "fact_equivalent_high_similarity"
|
||
// tidyAutoReasonOneOffEvent 表示一次性事件(含绝对日期/时刻且不像习惯)。
|
||
tidyAutoReasonOneOffEvent = "one_off_event"
|
||
)
|
||
|
||
// TidyAutoApplyBatch 是轮末自动档的一趟回执(进日志、审计与统计)。
|
||
//
|
||
// EligibleTotal 是命中自动判据的**真总数**(含因每轮上限没处理的)—— 与扫描/候选
|
||
// 上限同一口径:Truncated 为真时,面板看到的落地数**少于**判据命中的数。
|
||
type TidyAutoApplyBatch struct {
|
||
EligibleTotal int
|
||
Limit int
|
||
Truncated bool
|
||
// ScanTruncated 报告**读取**待定候选时撞上了 maxTidyAutoApplyScan:此时判据
|
||
// 只看过了最新的一批,可能还有命中判据的候选没被看到。它与 Truncated(每轮
|
||
// 上限截断)是两件事,所以分开报 —— 原因不同,运维动作也不同。
|
||
ScanTruncated bool
|
||
Applied int
|
||
Skipped int
|
||
Failed int
|
||
Merged int
|
||
Archived int
|
||
// ByReason 是**成功落地**的条数按自动理由分解(跳过的计不计:它们没落地)。
|
||
ByReason map[string]int
|
||
}
|
||
|
||
// normalizedAutoLimit 归一轮末自动档的每轮上限:非正取 defaultTidyAutoApplyPerRound,
|
||
// 超过 maxTidyApplyBatchLimit 时钳到它 —— 与批量应用共用同一个硬顶,因为两者都是
|
||
// 「一次批量改记忆」的动作,不该有两套上限。
|
||
func (o TidyApplyOptions) normalizedAutoLimit() int {
|
||
if o.Limit <= 0 {
|
||
return defaultTidyAutoApplyPerRound
|
||
}
|
||
if o.Limit > maxTidyApplyBatchLimit {
|
||
return maxTidyApplyBatchLimit
|
||
}
|
||
return o.Limit
|
||
}
|
||
|
||
// AutoApplyTidyCandidates 跑一趟轮末自动落地:从**全部待定候选**里挑出命中自动
|
||
// 判据的那些,按每轮上限逐条落地。
|
||
//
|
||
// 为什么是「全部待定」而不是「本轮产出的」:整理是周期任务,积压的候选不会自己消失
|
||
// (活库曾积压 501 条 link + 56 条 merge 全 pending)。每轮都把判据命中的捞一遍,
|
||
// 幂等由候选状态的条件更新保证,于是积压会被逐轮排干,而不会因为「不是这一轮产的」
|
||
// 就永远躺着。上限仍然是每轮一次,因此不会出现「一次点击改几百条」。
|
||
func (s *Store) AutoApplyTidyCandidates(ctx context.Context, opts TidyApplyOptions) (TidyAutoApplyBatch, error) {
|
||
limit := opts.normalizedAutoLimit()
|
||
pending, err := s.listTidyCandidates(ctx, tidyCandidatePending, "", maxTidyAutoApplyScan)
|
||
if err != nil {
|
||
return TidyAutoApplyBatch{}, fmt.Errorf("memory/tidy: auto apply list pending: %w", err)
|
||
}
|
||
selected, reasons, total := selectAutoEligible(pending, limit)
|
||
batch := TidyAutoApplyBatch{
|
||
EligibleTotal: total,
|
||
Limit: limit,
|
||
Truncated: total > len(selected),
|
||
ScanTruncated: len(pending) >= maxTidyAutoApplyScan,
|
||
ByReason: make(map[string]int),
|
||
}
|
||
for i, cand := range selected {
|
||
if err := ctx.Err(); err != nil {
|
||
// 上下文取消时**已落地的保持已落地**,未做的留给下一轮(与批量应用同口径)。
|
||
break
|
||
}
|
||
res, err := s.applyCandidate(ctx, cand, opts)
|
||
if err != nil {
|
||
res = tidyFailed(TidyApplyResult{
|
||
CandidateID: cand.ID, Action: cand.Action, TargetID: cand.TargetID,
|
||
AffectedIDs: []string{},
|
||
}, err)
|
||
}
|
||
tallyTidyAutoApply(&batch, res, reasons[i])
|
||
}
|
||
return batch, nil
|
||
}
|
||
|
||
// selectAutoEligible 挑出自动档要处理的候选(输入顺序即优先序 = 最新优先),最多
|
||
// limit 条;返回的 total 是命中判据的**真总数**(不受 limit 影响),reasons 与
|
||
// selected 一一对应。total > len(selected) 即「被每轮上限截断」。
|
||
func selectAutoEligible(cands []TidyCandidate, limit int) (selected []TidyCandidate, reasons []string, total int) {
|
||
selected = make([]TidyCandidate, 0, limit)
|
||
reasons = make([]string, 0, limit)
|
||
for _, c := range cands {
|
||
ok, reason := tidyAutoApplyEligible(c)
|
||
if !ok {
|
||
continue
|
||
}
|
||
total++
|
||
if len(selected) >= limit {
|
||
continue
|
||
}
|
||
selected = append(selected, c)
|
||
reasons = append(reasons, reason)
|
||
}
|
||
return selected, reasons, total
|
||
}
|
||
|
||
// tidyAutoApplyEligible 是**自动落地的唯一判据**:纯函数、零 IO、确定性,因此可以
|
||
// 逐条断言(含反例),也不依赖候选此刻是否还在库。
|
||
//
|
||
// 读的是候选自带的证据(ContentA/ContentB/Containment/Similarity/Reason),不是
|
||
// 此刻的记忆行 —— 因此「判据」与「行是否还在册」是两件事:后者由 apply 路径的
|
||
// 分区校验与 `AND status='active'` 条件更新兜住(过时的候选如实 skip)。
|
||
//
|
||
// 低置信刻意**不在这里判**:候选的**存在本身**就证明它过了 §5.2-6 的低置信否决门
|
||
// (tidy_rules.go 的 judgePair 是产候选的唯一路径),因此库里不可能存在低置信的
|
||
// merge 候选 —— 在这里再查一遍会是一条永远不生效的死代码。
|
||
func tidyAutoApplyEligible(c TidyCandidate) (bool, string) {
|
||
switch c.Action {
|
||
case TidyMerge:
|
||
if len(c.SourceIDs) != 1 || strings.TrimSpace(c.TargetID) == "" {
|
||
// 形状不完整(老候选可能有):不自动,交给 apply 路径如实 skip。
|
||
return false, ""
|
||
}
|
||
if literalDuplicate(c.Evidence.ContentA, c.Evidence.ContentB) {
|
||
return true, tidyAutoReasonLiteralDuplicate
|
||
}
|
||
if containmentKind(c.Evidence.Containment) == containBoth &&
|
||
c.Evidence.Similarity >= tidyAutoMergeMinSimilarity {
|
||
return true, tidyAutoReasonEquivalentFacts
|
||
}
|
||
return false, ""
|
||
case TidyArchive:
|
||
if len(c.SourceIDs) != 1 {
|
||
return false, ""
|
||
}
|
||
if c.Reason != tidyReasonOneOffEvent {
|
||
// 只认这一个归档理由:理由变了说明规则变了,那属于「拿不准」。
|
||
return false, ""
|
||
}
|
||
if looksLikeHabit(candidateSubjectContent(c)) {
|
||
// design/23 §5.2-1:一次性事件 ≠ 长期习惯。习惯类(「常在深夜 23:30
|
||
// 后还醒着」)会因为正文里带钟点而被判成一次性事件 —— 归档它是错的,
|
||
// 因此这一条守卫是自动档的**红线**,不是优化。
|
||
return false, ""
|
||
}
|
||
return true, tidyAutoReasonOneOffEvent
|
||
default:
|
||
// reclassify / link / keep 都不自动(link/keep 本来也没有执行语义)。
|
||
return false, ""
|
||
}
|
||
}
|
||
|
||
// literalDuplicate 报告两条正文是否逐字相同(只去掉首尾空白)。
|
||
// 大小写**不**折叠、内部空白**不**压缩:「逐字相同」就该逐字比。
|
||
func literalDuplicate(a, b string) bool {
|
||
ta, tb := strings.TrimSpace(a), strings.TrimSpace(b)
|
||
return ta != "" && ta == tb
|
||
}
|
||
|
||
// candidateSubjectContent 取一条候选「讲的是哪条记忆」的正文:归档候选记在
|
||
// ProposedContent(= 原文),其余记在证据的 ContentA(对/条各自的原文)。
|
||
func candidateSubjectContent(c TidyCandidate) string {
|
||
if strings.TrimSpace(c.ProposedContent) != "" {
|
||
return c.ProposedContent
|
||
}
|
||
return c.Evidence.ContentA
|
||
}
|
||
|
||
// looksLikeHabit 报告一段记忆正文看起来是不是**反复发生的习惯/规律**(而不是
|
||
// 一次性事件)。刻意**偏宽**:多一条进人工队列,远小于把主人的作息习惯当成一次性
|
||
// 事件归档掉的代价(归档虽可 revert,但主人不会知道要 revert 什么)。
|
||
func looksLikeHabit(content string) bool {
|
||
normalized := strings.ToLower(strings.TrimSpace(content))
|
||
for _, marker := range tidyHabitMarkers() {
|
||
if strings.Contains(normalized, marker) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// tidyHabitMarkers 是「反复发生」的信号词。它是函数而不是包级变量
|
||
// (AGENTS §10 禁包级全局变量;同 tidySystemSessions 的手法)。
|
||
//
|
||
// 刻意**不含光杆「常」**:它在中文里到处都是(「非常」「不正常」),且「常在深夜」
|
||
// 这类真信号已由「常在」覆盖 —— 判据宁可用长词命中,也不要把绝大多数一次性事件
|
||
// 都挡在自动档之外(那会让自动档等于没开)。
|
||
func tidyHabitMarkers() []string {
|
||
return []string{
|
||
"习惯", "经常", "常常", "常年", "常态", "常在", "总是", "反复", "长期",
|
||
"一直", "持续", "例行", "惯例", "每逢", "每天", "每日", "每晚", "每周",
|
||
"每月", "每次", "作息", "规律", "平时", "平常", "一向", "向来", "通常",
|
||
"大多", "有时", "偶尔", "偏向", "偏好", "倾向于", "喜欢",
|
||
}
|
||
}
|
||
|
||
// tallyTidyAutoApply 把一条落地结果计入自动档回执(跳过的与失败的都不算「落地」,
|
||
// 因此不进 ByReason —— 理由分布要回答的是「自动做成的那些是什么理由」)。
|
||
func tallyTidyAutoApply(batch *TidyAutoApplyBatch, res TidyApplyResult, reason string) {
|
||
switch res.Status {
|
||
case TidyApplyApplied:
|
||
batch.Applied++
|
||
batch.ByReason[reason]++
|
||
switch res.Action {
|
||
case TidyMerge:
|
||
batch.Merged++
|
||
case TidyArchive:
|
||
batch.Archived++
|
||
}
|
||
case TidyApplyFailed:
|
||
batch.Failed++
|
||
default:
|
||
batch.Skipped++
|
||
}
|
||
}
|
||
|
||
// autoApply 是 RunOnce 的轮末自动档入口。它在开关关闭时**什么都不做**(连查询都不
|
||
// 发),因此「回到只产建议」是一次配置改动而不是一次代码回退。
|
||
//
|
||
// 失败不让整轮失败(与候选写入失败同口径):记 stats.Errors + WARN —— 这一轮的
|
||
// 价值(候选与留痕)已经拿到了,不该因为自动档而回一份错误。
|
||
func (t *Tidier) autoApply(ctx context.Context, stats *TidyStats) TidyAutoApplyBatch {
|
||
if !t.cfg.autoApplyEnabled() {
|
||
return TidyAutoApplyBatch{}
|
||
}
|
||
batch, err := t.deps.Store.AutoApplyTidyCandidates(ctx, TidyApplyOptions{
|
||
Actor: tidyAuditWho,
|
||
Limit: t.cfg.AutoApplyPerRound,
|
||
})
|
||
if err != nil {
|
||
stats.Errors++
|
||
t.log.Warn("memory tidy auto apply failed", "error", err)
|
||
return TidyAutoApplyBatch{}
|
||
}
|
||
stats.AutoEligible = batch.EligibleTotal
|
||
stats.AutoApplied = batch.Applied
|
||
stats.AutoMerged = batch.Merged
|
||
stats.AutoArchived = batch.Archived
|
||
stats.AutoTruncated = batch.Truncated
|
||
if batch.Failed > 0 {
|
||
stats.Errors += batch.Failed
|
||
t.log.Warn("memory tidy auto apply had failures",
|
||
"failed", batch.Failed, "applied", batch.Applied, "skipped", batch.Skipped)
|
||
}
|
||
if batch.Truncated {
|
||
t.log.Warn("memory tidy auto apply truncated by per-round limit",
|
||
"eligible_total", batch.EligibleTotal, "limit", batch.Limit)
|
||
}
|
||
if batch.ScanTruncated {
|
||
// 待定候选多到读不完:判据只看过了最新的一批。**绝不静默**(与扫描上限同口径)。
|
||
t.log.Warn("memory tidy auto apply hit the pending-candidate scan limit",
|
||
"scan_limit", maxTidyAutoApplyScan, "pending", maxTidyAutoApplyScan)
|
||
}
|
||
return batch
|
||
}
|
||
|
||
// auditAutoApply 写一条自动落地的审计。审计只带**计数与理由分布**,不带任何记忆
|
||
// 正文(design/23 §8);没接审计出口时退到一条带 source 的日志,绝不静默。
|
||
func (t *Tidier) auditAutoApply(batch TidyAutoApplyBatch) {
|
||
if !t.cfg.autoApplyEnabled() {
|
||
return
|
||
}
|
||
result := fmt.Sprintf(
|
||
"ok auto=true applied=%d merged=%d archived=%d skipped=%d failed=%d eligible_total=%d limit=%d truncated=%t scan_truncated=%t by_reason=%s",
|
||
batch.Applied, batch.Merged, batch.Archived, batch.Skipped, batch.Failed,
|
||
batch.EligibleTotal, batch.Limit, batch.Truncated, batch.ScanTruncated,
|
||
tidyAutoReasonLabel(batch.ByReason))
|
||
hook := t.deps.Audit
|
||
if hook == nil {
|
||
t.log.Info("memory tidy auto apply audit (no sink configured)",
|
||
"what", tidyAutoApplyVerb, "result", result)
|
||
return
|
||
}
|
||
hook(tidyAuditWho, tidyAutoApplyVerb, result)
|
||
}
|
||
|
||
// tidyAutoReasonLabel 把自动理由分布渲染成稳定字符串(按理由排序),
|
||
// 与 tidyReasonsLabel 同一手法。
|
||
func tidyAutoReasonLabel(reasons map[string]int) string {
|
||
if len(reasons) == 0 {
|
||
return "none"
|
||
}
|
||
keys := make([]string, 0, len(reasons))
|
||
for k := range reasons {
|
||
keys = append(keys, k)
|
||
}
|
||
sort.Strings(keys)
|
||
parts := make([]string, 0, len(keys))
|
||
for _, k := range keys {
|
||
parts = append(parts, fmt.Sprintf("%s=%d", k, reasons[k]))
|
||
}
|
||
return strings.Join(parts, ",")
|
||
}
|