368 lines
13 KiB
Go
368 lines
13 KiB
Go
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)
|
||
}
|