Files
Cyrene_v2/internal/runtime/memory/tidy_auto.go
T
AskaEth f310173b4c
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(memory): reclassify 纳入自动档(主人 2026-10-07「全自动挡吧」)
自动落地现在三类:merge(字面重复 ∨ 事实等价 ∧ ≥0.95)· archive(one_off_event
且不像习惯)· **reclassify(新增)**。reclassify 只改 `kind` 一列
(`reclassifyTidyItemSQL` 只写 kind+updated_at),**不动正文、不动作用域、不碰第二条行**。

判据(纯函数 `tidyAutoReclassifyKind`,四个条件全中才自动):
① 恰 1 个 source 且无 target;② 理由 = `kind_mismatch`;③ 建议 kind 合法;
④ **改前 kind 合法且 ≠ 建议值** —— ③④ 是 **revert 抓手**:没有它自动落地就不可回滚。
**边界**:只读 ProposedKind / KindA,**一概不读** ProposedContent / MergedContent /
ContentA/B / 分区 / 时间字段。

六条护栏逐条覆盖它:软动作(同一条 apply 路径)/ 每轮上限(三类同占一个池子,
默认 50 硬顶 500,混合时截断如实)/ 审计 `memory.tidy.apply`(`auto=true`,
`reclassified=%d` + stats + 日志 + run 审计,**只带计数与理由、不带正文**)/
**可 revert**(`revertTidyReclassify` 按 KindA 写回,且只在当前 kind 仍等于建议值时生效)/
幂等(候选条件更新 + 行 `AND status='active'` 双闸)/ 开关关闭 ⇒ 连查询都不发。
另补一道本片新增的门:**受限来源**(群聊/陌生人)与跨分区的 reclassify **skipped**
(原先只有 merge/archive 有这道门)。

验收(主会话复跑 + 亲自变异):整树 `go build ./...` + vet 绿;memory/gateway 两包
测试绿;我用 -overlay 禁掉「必须真的变了」那一格 ⇒ `…EligibilityBoundary` **3 条子例变红**
(改前 kind 缺失/非法/与建议值相同 ⇒ 都"没法回滚")。子代理另做 4 个变异(连正文一起写 /
摘受限来源门 / 去掉 reclassified 计数 / 不要求可回滚抓手)全红。

🔴 **未动的红线(一行未改)**:`archive` 的「习惯类不归档」守卫(design/23 §5.2-1)
—— `looksLikeHabit` / `tidyHabitMarkers` / `tidyReasonOneOffEvent` 零改动,
`TestTidyAutoNeverArchivesHabitMisjudgedAsOneOff` 仍绿。要连它也自动请主人明说。

⚠️ 未部署(要一次 build + restart);⚠️ design/23 的 §7「reclassify 交人工」与
§13 开放点 5「全部人工应用」**现已过期**,主会话同步。
⚠️ 已知遗留(需新授权):历史积压里指向**已被吸收行**的 reclassify 候选会每轮被选中
又永远 `source_not_active` 跳过,白占一次每轮名额;彻底解法是「永久不可用的候选自动
标 rejected」(新行为)。
2026-10-07 11:26:34 +08:00

404 lines
19 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)」。主人本次的
// 「该删就删」(2026-10-07)与同日追加的「全自动挡吧」覆盖那两条;design 文档需同步
// (交付报告已列出建议补的几行)。
//
// 五条护栏,缺一不可:
//
// 1. **可自动的类别只有三类**,判据是纯函数 tidyAutoApplyEligible:
// - `merge` = 字面重复 ∨(**事实集合等价** ∧ 相似度 ≥ 0.95);
// - `archive` = 理由为 one_off_event **且不像习惯**(design/23 §5.2-1:一次性
// 事件 ≠ 长期习惯,习惯类绝不能被钟点误判归档 —— 这条守卫是红线,不许动);
// - `reclassify` = 理由为 kind_mismatch ∧ **只**要求改分类列(2026-10-07 追加
// 口径「全自动挡吧」)。它只改 memory_items.kind 一列:**不动正文、不删行、
// 不影响任何召回内容**(检索读的是 content/向量,kind 只是分类标记),
// 因此与 merge(软合并)/ archive(软删)同级安全。
// 2. **只走既有 apply 路径**(tidy_apply.go):软动作(只改 status/merged_*/kind,
// 绝不改正文、绝不删行)、候选状态条件更新(幂等)、失败隔离、可 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)。
//
// 🔴 仍然人工 / **绝不自动**的清单(写在这里以免后来者扩大范围):
// - 跨分区、跨角色:候选与目标行分区不一致 ⇒ tidySkipPartitionMismatch;
// - 受限来源(群聊 / 陌生人,design/23 §5.2-9 + §6)⇒ tidySkipSourceRestricted;
// - **任何改正文**:改内容的动作根本不在这套动作闭集里,三类自动动作的落库
// UPDATE 也各自只写白名单列(status / merged_* / kind),正文列永不出现;
// - **低置信 merge**:规则层 VetoLowConfidence 一票否决 ⇒ 根本不成候选;
// - **非 one_off_event 理由的归档**(理由变了说明规则变了 = 拿不准,交人工);
// - 🔴 **archive 的「习惯类不归档」守卫不动**(design/23 §5.2-1):习惯类会因正文
// 带钟点被误判成一次性事件,归档 = 从检索里消失 ⇒ 风险是「她忘掉主人的作息
// 习惯」。它不在任何自动档授权内,改它就是偏离。
// 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"
// tidyAutoReasonKindMismatch 表示 kind 误分类修正(design/23 §5.1 T0-6)。
// 它**刻意与候选理由同值**(tidyReasonKindMismatch):这条动作的自动档与人工档
// 是同一个口径(只改分类列),没有第二个理由标签可引入 —— 写成别名而不是复制
// 一个字面量,是为了让「两处必须一致」由编译器保证。
tidyAutoReasonKindMismatch = tidyReasonKindMismatch
)
// 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
// Reclassified 是成功落地的 reclassify 条数(2026-10-07「全自动挡吧」起纳入
// 自动档)。它与 Merged/Archived 同口径:只数**成功落地**的,跳过的计入 Skipped。
Reclassified 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
case TidyReclassify:
// 2026-10-07 追加口径「全自动挡吧」:reclassify 只改 kind(分类字段),
// **不动正文、不删行、不影响召回内容** ⇒ 与 merge/archive 同级安全。
//
// 边界写在**读字段的那一处**(tidyAutoReclassifyKind),以免后来者顺手扩大:
// 本档只采纳它的分类要求。候选上任何正文/合并后正文(ProposedContent /
// MergedContent / ContentA / ContentB)、任何指向第二条行的 target_id、
// 任何分区或时间字段,一概不读、不采纳 —— reclassify 的授权范围只有
// 「分类列」,它不能改正文、不能改作用域;落地路径的 UPDATE 也只写 kind。
if _, ok := tidyAutoReclassifyKind(c); ok {
return true, tidyAutoReasonKindMismatch
}
return false, ""
default:
// link / keep 都不自动(它们本来也没有执行语义)。
return false, ""
}
}
// tidyAutoReclassifyKind 报告一条候选是否**只**要求改分类,并给出它要求改成的分类。
// 四个条件缺一不可:
//
// 1. **形状**:恰 1 个 source、且 target_id 为空。reclassify 的主体只有一行;
// 指着第二条行的「改成什么」不是分类修正(那是 merge 的形状),拒。
// 2. **理由**必须是 kind_mismatch —— 与 archive 只认 one_off_event 同一手法:
// 理由变了说明规则变了,那属于「拿不准」,交人工。
// 3. ProposedKind 必须是**合法分类**(memory 只有 relationship / world_event)。
// 4. 候选自带的「改前 kind」(KindA)必须合法、且与建议值**不同**。这一条是
// **可 revert 护栏的抓手**:回滚靠 KindA 把 kind 写回去(tidy_apply_store.go 的
// undoTidyReclassifySQL),KindA 缺失/非法的候选一旦自动落地就**没法回滚**,
// 那违反「自动落地的每一条都能 revert」;同理 KindA == ProposedKind 的候选
// 无事可做,让它每轮都占一次上限(又永远跳过)是纯噪声。
//
// 除 ProposedKind / KindA 外,候选上的字段一概不参与判定(尤其 ProposedContent
// 之类的正文:reclassify 无权改正文 ⇒ 一律不采纳)。
func tidyAutoReclassifyKind(c TidyCandidate) (Kind, bool) {
if len(c.SourceIDs) != 1 || strings.TrimSpace(c.SourceIDs[0]) == "" ||
strings.TrimSpace(c.TargetID) != "" {
return "", false
}
if c.Reason != tidyReasonKindMismatch {
return "", false
}
proposed := Kind(c.Evidence.ProposedKind)
previous := Kind(c.Evidence.KindA)
if !proposed.valid() || !previous.valid() || previous == proposed {
return "", false
}
return proposed, true
}
// 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 TidyReclassify:
// 2026-10-07 起 reclassify 也在自动档里,因此它必须有独立的计数位:
// 少了这一格,自动改了 kind 的那些会**静默消失**在 Applied 总数里,
// 审计与日志就再也分不清「自动档做了什么」。
batch.Reclassified++
}
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.AutoReclassified = batch.Reclassified
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 reclassified=%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.Reclassified, 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, ",")
}