feat(memory): 记忆整理 v1(design/23)——低峰定期扫描 + 事实包含判定 + 规则否决 + 只产建议队列(不自动应用)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

This commit is contained in:
AskaEth committed 2026-10-05 11:29:36 +08:00
1 parent 012e9f3ad4
commit 91a3209c9b
25 files changed
+3418 -13

No files matched your search

+367
View File
@@ -0,0 +1,367 @@
package memory
import (
"context"
"errors"
"fmt"
"log/slog"
"sort"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
)
// 本文件是记忆整理(design/23-记忆整理与去重)的整理器与编排:逐分区扫描 →
// 候选召回 → 包含判定 → 规则否决 → 候选落库 + 运行留痕。
//
// 三条不可走样的口径(design/23 §0/§5.1):
// 1. **相似度只用来召回候选对**,永不直接当合并判据;
// 2. **合并的唯一条件是「事实集合的包含关系」**(A ⊆ B 才并入 B);
// 相似但并列的事实只建链接,绝不合并;
// 3. **规则否决一票否决**(跨作用域/跨角色/跨项目/群聊来源/时效污染/
// 数值不一致/否定冲突/低置信),命中即踢出候选。
//
// 第一版**只产建议**:RunOnce 唯一的写入是候选表与运行表,
// 绝不改写、绝不删除任何一条记忆(design/23 §3 非目标 + §7)。
// TidyAction 是整理建议的动作类别(design/23 §10 的 action 枚举)。
type TidyAction string
const (
// TidyMerge 表示 A 的事实集合是 B 的子集,建议把 A 并入 B(保留 B)。
// 它仍需人工确认(design/23 §7),第一版绝不自动应用。
TidyMerge TidyAction = "merge"
// TidyLink 表示两条相似但**互不包含**,只建链接/分组,绝不合并
// (design/23 §1.3 的 [1]/[3]「红牛 vs 冰美式」)。
TidyLink TidyAction = "link"
// TidyArchive 表示含绝对日期/时刻的一次性事件,建议归档而非合并
// (design/23 §5.1 T0-5)。
TidyArchive TidyAction = "archive"
// TidyReclassify 表示 kind 误分类(design/23 §5.1 T0-6),只产候选不自动改。
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")
// TidyRun 是一轮整理的留痕(design/23 §10 memory_tidy_runs)。
type TidyRun struct {
ID string
StartedAt time.Time
FinishedAt time.Time
Scanned int
Candidates int
Applied int
Skipped int
Errors int
Vetoed int
VetoReasons map[string]int
RuleVersion string
}
// TidyStats 是一轮整理的观测口径:**扫描 N / 候选 M / 建议 K / 否决原因分布**
// (design/23 §9)。它同时写进日志与运行表,绝不静默。
type TidyStats struct {
Scanned int
Partitions int
Compared int
Pairs int
Candidates int
Duplicates int
Skipped int
Vetoed int
VetoReasons map[string]int
// Errors 是本轮的失败计数(分区失败 + 候选写入失败),进运行表。
Errors int
// Truncated 是被上限截断的分区标签(扫描或比对上限),供运维放大配额。
Truncated []string
}
// TidyDeps 是注入给整理器的依赖。Store 必需;其余可空并优雅降级。
type TidyDeps struct {
// Store 是记忆存储。必需 —— 整理只读写它,绝不接触会话历史。
Store *Store
// Embedder 是候选召回的相似度来源。为 nil 时回退到零依赖的
// 字符二元组余弦(离线可用、测试确定性),见 tidy_recall.go。
Embedder embedding.Embedder
// Audit 是审计出口(design/23 §8 的 memory.tidy.* 动作)。可选:
// 组合根把它接到网关的 AuditEvent;为 nil 时只记日志。
Audit func(who, what, result string)
// Now 是运行时间戳的墙钟来源,便于测试注入。
Now func() time.Time
// Log 是基础日志器,来源标记为 "memory/tidy"。
Log *slog.Logger
}
// Tidier 是记忆整理器。它并发安全**仅限**「一轮之内不重入」:
// 服务层用一把互斥锁保证同一时刻只有一轮(design/23 §9 单写者)。
type Tidier struct {
cfg TidyConfig
deps TidyDeps
log *slog.Logger
now func() time.Time
}
// NewTidier 由 cfg 与 deps 构建整理器。Store 为 nil 时返回 ErrNilStore。
func NewTidier(cfg TidyConfig, deps TidyDeps) (*Tidier, error) {
if deps.Store == nil {
return nil, ErrNilStore
}
cfg = normalizeTidyConfig(cfg)
now := deps.Now
if now == nil {
now = time.Now
}
return &Tidier{
cfg: cfg,
deps: deps,
log: logger.WithSource(baseTidyLog(deps.Log), "memory/tidy"),
now: now,
}, nil
}
// Config 返回生效的整理配置(已套默认值)。
func (t *Tidier) Config() TidyConfig { return t.cfg }
// RunOnce 跑一轮整理,并写一条运行留痕。它返回本轮的观测计数。
//
// 失败隔离(design/23 §9):单个分区失败不影响其它分区,失败计数进运行表与
// 日志;只有「所有分区都失败」或运行留痕本身写不进去时才回错误。
func (t *Tidier) RunOnce(ctx context.Context) (TidyStats, error) {
started := t.now().UTC()
stats := TidyStats{VetoReasons: make(map[string]int)}
partitions, err := t.deps.Store.listTidyPartitions(ctx)
if err != nil {
return stats, fmt.Errorf("memory/tidy: list partitions: %w", err)
}
stats.Partitions = len(partitions)
var failures []error
for _, p := range partitions {
if err := t.runPartition(ctx, p, &stats); err != nil {
stats.Errors++
failures = append(failures, err)
t.log.Warn("memory tidy partition failed", "partition", p.label(), "error", err)
}
}
run := TidyRun{
// 运行 id 在这里就定下来:留痕与日志必须指向同一条运行记录。
ID: mustNewTidyID(),
StartedAt: started,
FinishedAt: t.now().UTC(),
Scanned: stats.Scanned,
Candidates: stats.Candidates,
Applied: 0, // 第一版绝不自动应用(design/23 §7)
Skipped: stats.Skipped + stats.Duplicates,
Errors: stats.Errors,
Vetoed: stats.Vetoed,
VetoReasons: stats.VetoReasons,
RuleVersion: TidyRuleVersion,
}
if err := t.deps.Store.persistTidyRun(ctx, run); err != nil {
failures = append(failures, err)
}
t.logRun(stats, run)
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),
"errors", run.Errors,
"duration_ms", run.FinishedAt.Sub(run.StartedAt).Milliseconds(),
"recall_mode", tidyRecallMode(t.deps.Embedder != nil),
}
if len(stats.Truncated) > 0 {
fields = append(fields, "truncated", strings.Join(stats.Truncated, ","))
}
t.log.Info("memory tidy run finished", fields...)
}
// runPartition 整理一个分区(persona_id + scope + project_id)。
// 分区之间**永不比较**(design/23 §6 硬约束)。
func (t *Tidier) runPartition(ctx context.Context, p tidyPartition, stats *TidyStats) error {
items, err := t.deps.Store.listTidyPartitionItems(ctx, p, t.cfg.ScanLimit)
if err != nil {
return err
}
stats.Scanned += len(items)
if len(items) == t.cfg.ScanLimit {
// 扫描上限命中:本分区更大的部分这一轮不看(design §9「超限分片,下一轮
// 继续」)。本实现只做「最新优先」的截断、不推进分片游标,因此这里必须
// 留下痕迹,否则「配置了 5000 却永远只看最新 5000」会无人知晓。
stats.Truncated = append(stats.Truncated, p.label()+"(scan)")
t.log.Warn("memory tidy partition hit the scan limit",
"partition", p.label(), "limit", t.cfg.ScanLimit)
}
comparable := make([]MemoryItem, 0, len(items))
archives := make([]TidyCandidate, 0)
for _, it := range items {
switch t.classifyForTidy(it, stats) {
case tidySkip:
continue
case tidyArchive:
// 一次性事件:产 archive 候选,且**不参与任何 merge**(design §5.1-5)。
archives = append(archives, archiveCandidate(it, t.now().UTC()))
continue
default:
comparable = append(comparable, it)
}
}
pairs := t.recallPairs(ctx, p, comparable, stats)
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
}
candidates = append(candidates, buildCandidates(pr, verdict, t.now().UTC())...)
}
// 每分区候选上限(design §9)对**全部候选**生效(含归档建议),
// 超出的只登记不入队,并记进 stats.Truncated(绝不静默)。
if len(candidates) > t.cfg.CandidateLimit {
stats.Skipped += len(candidates) - t.cfg.CandidateLimit
stats.Truncated = append(stats.Truncated, p.label()+"(candidates)")
candidates = candidates[:t.cfg.CandidateLimit]
}
t.persistCandidates(ctx, candidates, stats)
return nil
}
// 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「重复跑不重复产候选」)。
func (t *Tidier) persistCandidates(ctx context.Context, cands []TidyCandidate, stats *TidyStats) {
for _, c := range cands {
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++
}
}
// 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, ",")
}
// tidyRecallMode 报告本轮相似度的来源,便于运维判断阈值是否在预期口径上。
func tidyRecallMode(hasEmbedder bool) string {
if hasEmbedder {
return "embedding"
}
return "lexical"
}
// baseTidyLog 在 log 非 nil 时返回它,否则返回丢弃式日志器。
func baseTidyLog(log *slog.Logger) *slog.Logger {
return discardLogger(log)
}