Files
Cyrene_v2/internal/runtime/memory/tidy_auto.go
T
AskaEth 6d92536276
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(memory): 整理不再只产建议 —— 轮末自动落地高置信 + 停产 link 候选
主人原话:「那么多 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),主会话补;
⚠️ 真机端到端未跑(不能重启后端)。
2026-10-07 11:07:00 +08:00

332 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, ",")
}