Files
Cyrene_v2/internal/runtime/memory/tidy.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

411 lines
18 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"
"errors"
"fmt"
"sort"
"strings"
)
// 本文件是记忆整理(design/23-记忆整理与去重)的整理器与编排:逐分区扫描 →
// 候选召回 → 包含判定 → 规则否决 → 候选落库 + 运行留痕。
//
// 三条不可走样的口径(design/23 §0/§5.1):
// 1. **相似度只用来召回候选对**,永不直接当合并判据;
// 2. **合并的唯一条件是「事实集合的包含关系」**(A ⊆ B 才并入 B);
// 相似但并列的事实**什么都不做**(判 keep)—— link 只有提示、没有执行语义,
// 因此 2026-10-07 起不再产出它(详见 tidy_rules.go 的 containNone 分支);
// 3. **规则否决一票否决**(跨作用域/跨角色/跨项目/群聊来源/时效污染/
// 数值不一致/否定冲突/低置信),命中即踢出候选。
//
// 写入面是候选表 + 运行表(§7 的人工在环),外加 2026-10-07 起的**轮末自动落地**:
// 规则层已判为高置信的那一小撮(字面重复 / 事实等价 ∧ ≥0.95 / 一次性事件且不像
// 习惯)由 tidy_auto.go 直接落地,其余仍等人点。**任何路径都不改正文、不删行**。
// TidyAction 是整理建议的动作类别(design/23 §10 的 action 枚举)。
type TidyAction string
const (
// TidyMerge 表示 A 的事实集合是 B 的子集,建议把 A 并入 B(保留 B)。
// **高置信的一部分会由轮末自动档直接落地**(字面重复 / 事实等价 ∧ ≥0.95,
// 见 tidy_auto.go);其余仍等人点(design/23 §7 的人工在环)。
TidyMerge TidyAction = "merge"
// TidyLink 是 design/23 §1.3 给「两条相似但**互不包含**」这一判定结论起的动作名
// ([1]/[3]「红牛 vs 冰美式」),语义上代表「只建链接/分组,绝不合并」。
//
// 🔴 2026-10-07 起**规则层不再产出它**:design/23 §14 把「link 关系如何存储与
// 呈现」列为待细化 ⇒ 它没有落点、没有数据模型,产出来只会得到一条永远应用不了
// 的待办(活库曾积压 501 条)。常量保留是**为了读历史行**:库里既有的 link 候选
// 仍要能在面板展示、能被判定为「不可应用」(tidy_apply.go 的 applyable),
// 也能被按动作批量拒绝(RejectTidyCandidatesByAction)。它不参与任何新判定。
TidyLink TidyAction = "link"
// TidyArchive 表示含绝对日期/时刻的一次性事件,建议归档而非合并
// (design/23 §5.1 T0-5)。
TidyArchive TidyAction = "archive"
// TidyReclassify 表示 kind 误分类(design/23 §5.1 T0-6):只改分类列,**不碰正文**。
// 2026-10-07「全自动挡吧」起,理由为 kind_mismatch 且形状完整(含可回滚的
// 改前 kind)的这类候选由轮末自动档直接落地(判据见 tidy_auto.go 的
// tidyAutoReclassifyKind);不满足判据的仍交人工。
TidyReclassify TidyAction = "reclassify"
// TidyKeep 是「拿不准 = 什么都不做」(design/23 §5.2-10)。它是合法动作,
// 但第一版不为它落库(只进否决计数与日志),以免候选队列被噪声填满。
TidyKeep TidyAction = "keep"
)
// TidyVetoReason 是规则否决的原因标签:进日志、进运行表的原因分布
// (design/23 §9「每轮记否决原因分布」),也是测试断言的抓手。
type TidyVetoReason string
const (
// VetoNone 表示未命中任何否决规则。
VetoNone TidyVetoReason = ""
// VetoScope 是跨作用域硬门(design/23 §5.2-7):user/persona/project 之间永不合。
VetoScope TidyVetoReason = "scope"
// VetoPersona 是跨角色硬门(design/23 §5.2-8)。
VetoPersona TidyVetoReason = "persona"
// VetoProject 是跨项目硬门(design/23 §5.2-8)。
VetoProject TidyVetoReason = "project"
// VetoSource 是群聊/陌生人来源(design/23 §5.2-9、§6):只读不写。
VetoSource TidyVetoReason = "source"
// VetoTemporal 是时效污染(design/23 §5.2-1):一次性事件/有期限状态
// 不等于长期事实([8]「9月8日晚」≠ 熬夜习惯)。
VetoTemporal TidyVetoReason = "temporal"
// VetoNumeric 是数值/配置不一致(design/23 §5.2-2):数值不同即不同事实。
VetoNumeric TidyVetoReason = "numeric"
// VetoConflict 是否定/冲突(design/23 §5.2-3):X vs 非X 是覆盖不是重复。
VetoConflict TidyVetoReason = "conflict"
// VetoLowConfidence 是低置信否决门(design/23 §5.2-6)。
VetoLowConfidence TidyVetoReason = "low_confidence"
)
// ErrNilStore 在整理器没有收到记忆存储时返回。
var ErrNilStore = errors.New("memory: tidy needs a store")
// ErrTidyEmbedCount 在 embedder 返回的向量条数与请求条数不一致时返回:
// 缺失向量会让相似度与条目错位,宁可整体回退到词项余弦。
var ErrTidyEmbedCount = errors.New("memory/tidy: embedder returned a mismatched vector count")
// tidyCandidateSink 是**本轮的候选出口**:一个分区比完就把候选交给它。
// 生产轮是落库(persistCandidates),试算轮只收集(tidy_dryrun.go)——
// 规则引擎本身与出口无关,这正是「禁止写第二套实现」的落点。
type tidyCandidateSink func(ctx context.Context, cands []TidyCandidate, stats *TidyStats)
// tidyRecall 是**本轮的召回策略**:给定已截断的参与比对条目,产出候选对。
// 它把「相似度来源不可用时怎么办」的差异收在一处:生产轮降级到零依赖词项
// 余弦,试算轮如实上报错误。budget 是本轮的时间预算闸(见 tidy_budget.go),
// 由调用方一路传下来,使两条策略共享**同一个**预算(而不是各自从 ctx 再派生)。
type tidyRecall func(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget) ([]tidyPair, error)
// tidyRoundPolicy 是本轮整理的出口策略:候选去哪 + 召回来源不可用时怎么办。
// 「生产轮」与「试算轮」的**全部**差异都在这两个字段上,规则引擎其余部分
// 逐字共用。
type tidyRoundPolicy struct {
sink tidyCandidateSink
recall tidyRecall
}
// RunOnce 跑一轮整理,并写一条运行留痕。它返回本轮的观测计数。
//
// 失败隔离(design/23 §9):单个分区失败不影响其它分区,失败计数进运行表与
// 日志;只有「所有分区都失败」或运行留痕本身写不进去时才回错误。
func (t *Tidier) RunOnce(ctx context.Context) (TidyStats, error) {
started := t.now().UTC()
// 运行 id 与轮次开始**同时**定下来:候选要按它归属(切片 TIDY-1 的按轮
// 批量应用),运行留痕也要用它,因此不能等到扫完再生成。
runID := mustNewTidyID()
stats, partitions, err := t.beginRound(ctx)
if err != nil {
return stats, err
}
stats.RunID = runID
failures := t.scanPartitions(ctx, partitions, &stats, tidyRoundPolicy{
sink: func(ctx context.Context, cands []TidyCandidate, stats *TidyStats) {
t.persistCandidates(ctx, runID, cands, stats)
},
recall: t.fallbackRecall,
})
// 轮末自动落地(2026-10-07 主人口径):规则层判为高置信的待定候选直接落地,
// 不再等人点。它排在留痕之前,因此 memory_tidy_runs.applied 这一轮就是真数
// (而不是先写 0、事后靠按 run 批量应用补记)。
auto := t.autoApply(ctx, &stats)
run := TidyRun{
ID: runID,
StartedAt: started,
FinishedAt: t.now().UTC(),
Scanned: stats.Scanned,
Candidates: stats.Candidates,
Applied: auto.Applied,
Skipped: stats.Skipped + stats.Duplicates,
Errors: stats.Errors,
Vetoed: stats.Vetoed,
VetoReasons: stats.VetoReasons,
RuleVersion: TidyRuleVersion,
}
stats.DurationMS = run.FinishedAt.Sub(run.StartedAt).Milliseconds()
if err := t.deps.Store.persistTidyRun(ctx, run); err != nil {
failures = append(failures, err)
}
t.logRun(stats, run)
t.auditAutoApply(auto)
switch {
case len(failures) == 0:
return stats, nil
case len(failures) == len(partitions) && len(partitions) > 0:
return stats, fmt.Errorf("memory/tidy: every partition failed: %w", errors.Join(failures...))
default:
return stats, fmt.Errorf("memory/tidy: run finished with errors: %w", errors.Join(failures...))
}
}
// logRun 输出本轮的可观测口径:扫描 N / 候选 M / 建议 K / 否决原因分布。
// 它带 source(memory/tidy),因此这条汇总永远可回溯到整理子系统。
func (t *Tidier) logRun(stats TidyStats, run TidyRun) {
fields := []any{
"run_id", run.ID,
"rule_version", run.RuleVersion,
"partitions", stats.Partitions,
"scanned", run.Scanned,
"compared", stats.Compared,
"pairs", stats.Pairs,
"candidates", run.Candidates,
"duplicates", stats.Duplicates,
"skipped", stats.Skipped,
"vetoed", run.Vetoed,
"veto_reasons", tidyReasonsLabel(run.VetoReasons),
"kept", stats.Kept,
"errors", run.Errors,
"duration_ms", run.FinishedAt.Sub(run.StartedAt).Milliseconds(),
"recall_mode", tidyRecallMode(t.deps.Embedder != nil),
"auto_apply", t.cfg.autoApplyEnabled(),
"auto_applied", stats.AutoApplied,
"auto_merge", stats.AutoMerged,
"auto_archive", stats.AutoArchived,
"auto_reclassify", stats.AutoReclassified,
"auto_eligible", stats.AutoEligible,
"auto_truncated", stats.AutoTruncated,
}
if len(stats.Truncated) > 0 {
fields = append(fields, "truncated", strings.Join(stats.Truncated, ","))
}
t.log.Info("memory tidy run finished", fields...)
}
// beginRound 开一轮:准备口径容器并列出待整理分区。分区列表拿不到时整轮失败,
// 且**不写运行留痕** —— 那一轮根本没开始(口径与 design §9 一致)。
func (t *Tidier) beginRound(ctx context.Context) (TidyStats, []tidyPartition, error) {
stats := TidyStats{
VetoReasons: make(map[string]int),
ByAction: make(map[string]int),
}
partitions, err := t.deps.Store.listTidyPartitions(ctx)
if err != nil {
return stats, nil, fmt.Errorf("memory/tidy: list partitions: %w", err)
}
stats.Partitions = len(partitions)
return stats, partitions, nil
}
// scanPartitions 逐个分区跑规则并把候选交给 policy.sink。它返回失败列表
// (单个分区失败不阻塞其它分区,design §9);成功时返回 nil。
//
// 时间预算(tidy_budget.go)在**轮**级派生一次:ctx 有墙钟上限(同步/异步试算)
// 时,预算用尽后的分区不再开跑,而是如实登记为 (budget) 截断。
func (t *Tidier) scanPartitions(ctx context.Context, partitions []tidyPartition, stats *TidyStats, policy tidyRoundPolicy) []error {
budget := t.newTidyBudget(ctx)
var failures []error
for _, p := range partitions {
if err := t.runPartition(ctx, p, stats, policy, budget); err != nil {
stats.Errors++
failures = append(failures, err)
t.log.Warn("memory tidy partition failed", "partition", p.label(), "error", err)
}
}
return failures
}
// fallbackRecall 是**生产轮**的召回:相似度来源不可用时降级到零依赖的词项
// 余弦(离线/无 embedding 配置时可跑,宁可比语义路径更保守),并记一条 WARN。
// 两个来源**不可混用阈值**:阈值只有一个(design §13 开放点 2 的 0.80)。
func (t *Tidier) fallbackRecall(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget) ([]tidyPair, error) {
pairs, err := t.recallPairs(ctx, p, items, stats, budget)
if err == nil {
return pairs, nil
}
t.log.Warn("memory tidy embedding recall unavailable, falling back to lexical",
"partition", p.label(), "error", err)
return t.collectPairs(p, items, t.cfg.RecallThreshold,
lexicalSimFunc(items), tidyRecallLexical, stats, budget), nil
}
// runPartition 整理一个分区(persona_id + scope + project_id)。
// 分区之间**永不比较**(design/23 §6 硬约束)。候选去处与召回降级策略都由
// policy 决定,因此生产轮与试算轮走的是**同一条**扫描→召回→判定→构造候选
// 的路径。
func (t *Tidier) runPartition(ctx context.Context, p tidyPartition, stats *TidyStats, policy tidyRoundPolicy, budget tidyBudget) error {
// 预算用尽 ⇒ 这个分区本轮根本不看。**如实登记**(响应 truncated + WARN):
// 「没扫到」与「扫了没有候选」必须能区分,否则面板会把预算截断读成
// 「这个分区没有建议」。
if budget.exhausted() {
stats.Truncated = append(stats.Truncated, budgetTruncationLabel(p))
t.log.Warn("memory tidy partition skipped by time budget", "partition", p.label())
return nil
}
items, err := t.deps.Store.listTidyPartitionItems(ctx, p, t.cfg.ScanLimit)
if err != nil {
return fmt.Errorf("memory/tidy: list partition %s: %w", p.label(), err)
}
stats.Scanned += len(items)
if len(items) == t.cfg.ScanLimit {
// 扫描上限命中:本分区更大的部分这一轮不看(design §9「超限分片,下一轮
// 继续」)。本实现只做「最新优先」的截断、不推进分片游标,因此这里必须
// 留下痕迹,否则「配置了 5000 却永远只看最新 5000」会无人知晓。
// 日志带**真总数**(LIMIT 之后的 len(items) 只是下限):另查一次 COUNT,
// 否则 WARN 会把「至少 5000」说成「正好 5000」。
total, totalErr := t.deps.Store.countTidyPartitionItems(ctx, p)
stats.Truncated = append(stats.Truncated, p.label()+"(scan)")
if totalErr != nil {
t.log.Warn("memory tidy partition hit the scan limit (total unknown)",
"partition", p.label(), "limit", t.cfg.ScanLimit, "error", totalErr)
} else {
t.log.Warn("memory tidy partition hit the scan limit",
"partition", p.label(), "limit", t.cfg.ScanLimit, "total", total)
}
}
comparable, archives := t.classifyItems(items, stats)
// 截断与计数只做一次:召回策略拿到的是**已截断**的条目,回退重算不重复计数。
compared := t.limitCompareItems(p, comparable, stats)
pairs, err := policy.recall(ctx, p, compared, stats, budget)
if err != nil {
return fmt.Errorf("memory/tidy: recall partition %s: %w", p.label(), err)
}
candidates := archives
for _, pr := range pairs {
verdict := judgePair(pr.a, pr.b, pr.score)
if verdict.Veto != VetoNone {
stats.Vetoed++
stats.VetoReasons[string(verdict.Veto)]++
continue
}
if verdict.Action != TidyMerge {
// 相似但互不包含 ⇒ keep:**不产候选**(2026-10-07,见 tidy_rules.go 的
// containNone 分支)。它必须单独计数,否则「抛掉多少对」会消失。
stats.Kept++
continue
}
candidates = append(candidates, buildCandidates(pr, verdict, t.now().UTC())...)
}
// 每分区候选上限(design §9)对**全部候选**生效(含归档建议),
// 超出的只登记不入队,并记进 stats.Truncated(绝不静默)。
if len(candidates) > t.cfg.CandidateLimit {
// WARN 带总数/上限/分区名:候选被上限截断意味着面板看到的**少于**规则
// 判出的,必须能从这里读出被丢掉了多少。
t.log.Warn("memory tidy candidates truncated by per-partition limit",
"partition", p.label(), "candidates", len(candidates), "limit", t.cfg.CandidateLimit)
stats.Skipped += len(candidates) - t.cfg.CandidateLimit
stats.Truncated = append(stats.Truncated, p.label()+"(candidates)")
candidates = candidates[:t.cfg.CandidateLimit]
}
policy.sink(ctx, candidates, stats)
return nil
}
// classifyItems 把分区内条目分流成**可比对**与**归档候选**(跳过的不产出):
// - 群聊/陌生人来源 → 排除在扫描范围外(design/23 §6,只读不写);
// - 含绝对日期/时刻 → 一次性事件,走归档而非合并([8]「9月8日晚22:38」),
// 且**不参与任何 merge**(design §5.1-5);
// - 过短内容 → 没有可判定的事实要素,跳过(不产噪声候选)。
func (t *Tidier) classifyItems(items []MemoryItem, stats *TidyStats) (comparable []MemoryItem, archives []TidyCandidate) {
comparable = make([]MemoryItem, 0, len(items))
for _, it := range items {
switch t.classifyForTidy(it, stats) {
case tidySkip:
continue
case tidyArchive:
archives = append(archives, archiveCandidate(it, t.now().UTC()))
continue
default:
comparable = append(comparable, it)
}
}
return comparable, archives
}
// tidyDisposition 是扫描时一条记忆的处置分流。
type tidyDisposition int
const (
tidyCompare tidyDisposition = iota
tidyArchive
tidySkip
)
// classifyForTidy 决定一条记忆进入比对、进归档候选,还是被排除:
// - 群聊/陌生人来源 → 排除在扫描范围外(design/23 §6,只读不写);
// - 含绝对日期/时刻 → 一次性事件,走归档而非合并([8]「9月8日晚22:38」);
// - 过短内容 → 没有可判定的事实要素,跳过(不产噪声候选)。
func (t *Tidier) classifyForTidy(it MemoryItem, stats *TidyStats) tidyDisposition {
if nonOwnerSource(it.SourceConversation) {
stats.Vetoed++
stats.VetoReasons[string(VetoSource)]++
return tidySkip
}
if hasAbsoluteTimestamp(it.Content) {
return tidyArchive
}
if len([]rune(it.Content)) < minTidyContentRunes {
return tidySkip
}
return tidyCompare
}
// persistCandidates 落库一批候选:幂等键已存在时只计入 duplicates
// (design/23 §9「重复跑不重复产候选」),新建的计入 Candidates 并按动作进
// ByAction(回执的领域来源)。runID 是本轮的留痕 id:「按 run 批量应用」据此
// 找到这一轮产出的候选;幂等跳过的行保留它**首次**被产出的轮次,不被改写。
func (t *Tidier) persistCandidates(ctx context.Context, runID string, cands []TidyCandidate, stats *TidyStats) {
for _, c := range cands {
c.RunID = runID
created, err := t.deps.Store.insertTidyCandidate(ctx, c)
if err != nil {
stats.Errors++
t.log.Warn("memory tidy candidate insert failed", "action", string(c.Action), "error", err)
continue
}
if !created {
stats.Duplicates++
continue
}
stats.Candidates++
if stats.ByAction != nil {
stats.ByAction[string(c.Action)]++
}
}
}
// tidyReasonsLabel 把否决原因分布渲染成稳定的日志字符串(按原因排序)。
func tidyReasonsLabel(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, ",")
}