feat(memory): 记忆整理 v1(design/23)——低峰定期扫描 + 事实包含判定 + 规则否决 + 只产建议队列(不自动应用)
This commit is contained in:
1 parent
012e9f3ad4
commit
91a3209c9b
25 files changed
+3418
-13
No files matched your search
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user