feat(memory): 记忆整理「试算(dry-run)」入口 + 面板卡(契约 v0.75)
主人要「现在就想看到在 736 条上它会建议合并哪些」,而不是等 23:00 的低峰首跑。
- 后端:Tidier.DryRun + TidyService.DryRun,**复用同一条扫描→召回→判定→构造候选路径**(抽出 tidyRoundPolicy,生产轮与试算轮只差「候选出口」与「召回降级策略」两处;禁止第二套实现)
- 🔴 零副作用:不写 memory_tidy_candidates / memory_tidy_runs,绝不改 memory_items 任何列;机械证明 = 跑前跑后 cyrene.db 与 -wal 的 SHA256 一致(测试断言「表行数不变」)
- 失败码诚实:500 tidy_unavailable / 409 tidy_disabled / 500 tidy_recall_unavailable(**embedding 不可用不降级成 0 条**)/ 503 tidy_timeout / 500 tidy_dry_run_failed;**空数组唯一含义 = 算过了没建议**
- 端点 POST /api/v1/admin/memories/tidy/dry-run(writeAuth);面板「记忆」页加试算卡(点了才跑、无轮询、三态分开、明写「仅试算,不会改动任何记忆」)
- 真机 e2e(741 条活库副本):76 ms / 15 分区 / 53 候选 / 41 skipped / 1 vetoed(low_confidence);样例 merge reason=fact_subset score=1.0000、link reason=similar_but_not_contained score=0.9248
- 变异:dry-run 顺手写库 ⇒ 两条测试红(行数 0→2);自造阈值 ⇒ 红;失败回 200 空数组 ⇒ 红
- 如实登记:Compared=688 被 400/分区护栏截断、候选上限 20/分区被打满 ⇒ 护栏值需在 741 条量级重定(非 bug)
This commit is contained in:
1 parent
3d8c30dc99
commit
d6ed9cfa2b
18 files changed
+1631
-79
No files matched your search
@@ -158,28 +158,39 @@ func NewTidier(cfg TidyConfig, deps TidyDeps) (*Tidier, error) {
|
||||
// Config 返回生效的整理配置(已套默认值)。
|
||||
func (t *Tidier) Config() TidyConfig { return t.cfg }
|
||||
|
||||
// tidyCandidateSink 是**本轮的候选出口**:一个分区比完就把候选交给它。
|
||||
// 生产轮是落库(persistCandidates),试算轮只收集(tidy_dryrun.go)——
|
||||
// 规则引擎本身与出口无关,这正是「禁止写第二套实现」的落点。
|
||||
type tidyCandidateSink func(ctx context.Context, cands []TidyCandidate, stats *TidyStats)
|
||||
|
||||
// tidyRecall 是**本轮的召回策略**:给定已截断的参与比对条目,产出候选对。
|
||||
// 它把「相似度来源不可用时怎么办」的差异收在一处:生产轮降级到零依赖词项
|
||||
// 余弦,试算轮如实上报错误。
|
||||
type tidyRecall func(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats) ([]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()
|
||||
stats := TidyStats{VetoReasons: make(map[string]int)}
|
||||
|
||||
partitions, err := t.deps.Store.listTidyPartitions(ctx)
|
||||
stats, partitions, err := t.beginRound(ctx)
|
||||
if err != nil {
|
||||
return stats, fmt.Errorf("memory/tidy: list partitions: %w", err)
|
||||
return stats, 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)
|
||||
}
|
||||
}
|
||||
failures := t.scanPartitions(ctx, partitions, &stats, tidyRoundPolicy{
|
||||
sink: t.persistCandidates,
|
||||
recall: t.fallbackRecall,
|
||||
})
|
||||
|
||||
run := TidyRun{
|
||||
// 运行 id 在这里就定下来:留痕与日志必须指向同一条运行记录。
|
||||
@@ -236,12 +247,53 @@ func (t *Tidier) logRun(stats TidyStats, run TidyRun) {
|
||||
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)}
|
||||
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。
|
||||
func (t *Tidier) scanPartitions(ctx context.Context, partitions []tidyPartition, stats *TidyStats, policy tidyRoundPolicy) []error {
|
||||
var failures []error
|
||||
for _, p := range partitions {
|
||||
if err := t.runPartition(ctx, p, stats, policy); 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) ([]tidyPair, error) {
|
||||
pairs, err := t.recallPairs(ctx, p, items, stats)
|
||||
if err == nil {
|
||||
return pairs, nil
|
||||
}
|
||||
t.log.Warn("memory tidy embedding recall unavailable, falling back to lexical",
|
||||
"partition", p.label(), "error", err)
|
||||
return collectTidyPairs(items, t.cfg.RecallThreshold, lexicalSimFunc(items), tidyRecallLexical, stats), nil
|
||||
}
|
||||
|
||||
// runPartition 整理一个分区(persona_id + scope + project_id)。
|
||||
// 分区之间**永不比较**(design/23 §6 硬约束)。
|
||||
func (t *Tidier) runPartition(ctx context.Context, p tidyPartition, stats *TidyStats) error {
|
||||
// 分区之间**永不比较**(design/23 §6 硬约束)。候选去处与召回降级策略都由
|
||||
// policy 决定,因此生产轮与试算轮走的是**同一条**扫描→召回→判定→构造候选
|
||||
// 的路径。
|
||||
func (t *Tidier) runPartition(ctx context.Context, p tidyPartition, stats *TidyStats, policy tidyRoundPolicy) error {
|
||||
items, err := t.deps.Store.listTidyPartitionItems(ctx, p, t.cfg.ScanLimit)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("memory/tidy: list partition %s: %w", p.label(), err)
|
||||
}
|
||||
stats.Scanned += len(items)
|
||||
if len(items) == t.cfg.ScanLimit {
|
||||
@@ -253,22 +305,15 @@ func (t *Tidier) runPartition(ctx context.Context, p tidyPartition, stats *TidyS
|
||||
"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)
|
||||
}
|
||||
comparable, archives := t.classifyItems(items, stats)
|
||||
|
||||
// 截断与计数只做一次:召回策略拿到的是**已截断**的条目,回退重算不重复计数。
|
||||
compared := t.limitCompareItems(p, comparable, stats)
|
||||
pairs, err := policy.recall(ctx, p, compared, stats)
|
||||
if err != nil {
|
||||
return fmt.Errorf("memory/tidy: recall partition %s: %w", p.label(), err)
|
||||
}
|
||||
|
||||
pairs := t.recallPairs(ctx, p, comparable, stats)
|
||||
candidates := archives
|
||||
for _, pr := range pairs {
|
||||
verdict := judgePair(pr.a, pr.b, pr.score)
|
||||
@@ -286,10 +331,31 @@ func (t *Tidier) runPartition(ctx context.Context, p tidyPartition, stats *TidyS
|
||||
stats.Truncated = append(stats.Truncated, p.label()+"(candidates)")
|
||||
candidates = candidates[:t.cfg.CandidateLimit]
|
||||
}
|
||||
t.persistCandidates(ctx, candidates, stats)
|
||||
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
|
||||
|
||||
@@ -353,14 +419,6 @@ func tidyReasonsLabel(reasons map[string]int) string {
|
||||
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