【MT-A:护栏 + 诚实截断 + 试算异步】
- maxTidyCompareItems 400(写死)→2000 并开放为 MEMORY_TIDY_COMPARE_LIMIT;
MEMORY_TIDY_CANDIDATE_LIMIT 默认 20→200(语义不变,仍每分区)
- 依据是首跑实测:459 条的分区被 400 截断 ⇒ compared(705) < scanned(784),
候选数(77)由上限而不是规则决定;三处上限的 WARN 都补齐了总数/上限/分区名
- 新增「上限 + 时间预算」双闸(实测 2000 条/分区 ≈234s > 3 分钟墙钟):
整分区跳过 / 两两比对中途收手,一律如实记 (budget) + WARN
- ?async=1 ⇒ 202 {runId,status};GET …/{runId} 轮询(writeAuth 同档);
done 的 result 与同步 data 逐字节同形;并发=复用既有 runId、TTL 30min 自完成起算、
重启即如实 404 tidy_run_not_found;不带 async 时同步路径逐字不变
【MT-B:D3 入库前审查(design/23 §15.1)】
- 新记忆经 Store.Add(仓库里唯一 INSERT INTO memory_items)落库后,由独立记忆管理
会话(sess-memorytidy)在有界后台队列异步判定 skip/merge/modify/create
- 软合并回写(status + merged_into/merged_from/merge_count,可 revert、绝不改 content)
- 隔离来源(群聊/陌生人)**概不审查**、召回/超时失败**零改写**(fail-safe 结构性成立)
- 写入侧仅 +62.7µs/条(非阻塞入队);默认 ENABLED=true(主人裁定:入库前先审查)
【耦合说明(重要)】两片同包且互相依赖:MT-B 的 preinsert_recall.go 用 MT-A 的
collectTidyPairs 新签名 + tidyBudget ⇒ **各自单独都 build 不过**,故一并落地。
MT-A 为让整包编译,最小幅度适配了 MT-B 的 preinsert_recall.go / preinsert_test.go
(零值预算=行为不变;符号重名改名)。
【我的验收】隔离 worktree(37 文件)build/vet/linux/darwin 全过,切片包全绿;
我亲手打红两个变异:① 去掉 (compare) 截断标记 ⇒ TestTidyCompareLimit_HitIsLoud 红;
② 中和隔离来源守卫 ⇒ TestReviewRestrictedSourceIsNeverTouched 红。
唯一红是 internal/runtime/tool 的两条超时用例(该包无人改)⇒ 主仓库 -count=8 全绿,
判定为负载敏感 flake(当时两个整树验收 + 子代理并发),已记入开发习惯与约定 §7。
.env.example 补 MEMORY_REVIEW_* 四键(MT-B 无该文件权限,由我按其代码语义补写)。
127 lines
5.6 KiB
Go
127 lines
5.6 KiB
Go
package memory
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
)
|
|
|
|
// 本文件是记忆整理的**试算(dry-run)**入口(契约 v0.75;切片 MT1)。
|
|
//
|
|
// 它与低峰心跳跑的是**同一条**规则路径(扫描 → 召回 → 包含判定 → 规则否决 →
|
|
// 构造候选),只有两点刻意不同:
|
|
//
|
|
// 1. **零副作用**:候选不落库 —— 不写 memory_tidy_candidates、不写
|
|
// memory_tidy_runs、**绝不改 memory_items 的任何列**。这是 dry-run 的命门,
|
|
// 因此它连运行留痕都不写(留痕本身也是一次写入)。
|
|
// 2. **召回不降级**:语义召回(embedding)不可用时**如实报错**,绝不静默回退到
|
|
// 词项余弦 —— 否则面板上的「没有候选」会是一句谎话(那是「没算出来」,不是
|
|
// 「算过了,没有」)。生产轮的降级口径见 tidy.go 的 fallbackRecall。
|
|
//
|
|
// 它**不**受低峰窗口约束:窗口是心跳的调度口径(design/23 §4),不是规则的
|
|
// 一部分 —— 「现在就想看看它会建议什么」正是这个入口存在的理由。
|
|
|
|
// ErrTidyDisabled 在整理被配置显式关闭(MEMORY_TIDY_ENABLED=false)却又被要求
|
|
// 试算时返回。它与「未接线」不同:这是配置选择,必须如实说出来,而不是回一份
|
|
// 空候选假装「没有建议」。
|
|
var ErrTidyDisabled = errors.New("memory/tidy: disabled by configuration")
|
|
|
|
// ErrTidyRecallUnavailable 在**试算**要求语义召回、而 embedding 不可用时返回。
|
|
// 生产轮此时会降级到词项余弦;试算轮刻意不降级(见本文件头注释)。
|
|
var ErrTidyRecallUnavailable = errors.New("memory/tidy: recall embedder unavailable")
|
|
|
|
// TidyDryRun 是一次试算的结果。Candidates 与落库候选**同构**(同一套
|
|
// buildCandidates 产物),但没有 status/decided 字段 —— 试算不落库,因此不存在
|
|
// 「已决定」这回事。
|
|
type TidyDryRun struct {
|
|
// Stats 是本轮的观测口径(扫描/比对/召回/否决分布),与真跑同一套计数。
|
|
Stats TidyStats
|
|
// Candidates 是本轮产出的候选(每个分区已套用 CandidateLimit)。
|
|
Candidates []TidyCandidate
|
|
// RecallMode 记录相似度来源(embedding / lexical),供面板与运维判读。
|
|
// 严格模式下一次试算只可能有一种来源:要么语义召回成功,要么如实报错。
|
|
RecallMode string
|
|
// DurationMS 是本轮试算的墙钟耗时(毫秒),供面板显示「跑一次要多久」。
|
|
DurationMS int64
|
|
}
|
|
|
|
// DryRun 跑一轮整理的**规则**并返回候选,但零副作用(见本文件头注释)。
|
|
//
|
|
// 与 RunOnce 的失败口径一致:单个分区失败不阻塞其它分区;只要有分区失败就回
|
|
// 错误(此时 Candidates 可能只含已完成分区的部分结果,调用方**不得**把它当
|
|
// 「完整的建议」展示)。
|
|
func (t *Tidier) DryRun(ctx context.Context) (TidyDryRun, error) {
|
|
started := t.now()
|
|
out := TidyDryRun{RecallMode: tidyRecallMode(t.deps.Embedder != nil)}
|
|
stats, partitions, err := t.beginRound(ctx)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
|
|
// 收集出口:闭包只在 scanPartitions 的**串行**循环里被调用,因此无需加锁。
|
|
collected := make([]TidyCandidate, 0)
|
|
policy := tidyRoundPolicy{
|
|
sink: func(_ context.Context, cands []TidyCandidate, _ *TidyStats) {
|
|
collected = append(collected, cands...)
|
|
},
|
|
recall: t.strictRecall,
|
|
}
|
|
failures := t.scanPartitions(ctx, partitions, &stats, policy)
|
|
|
|
out.Stats = stats
|
|
out.Candidates = collected
|
|
out.DurationMS = t.now().Sub(started).Milliseconds()
|
|
if len(failures) > 0 {
|
|
return out, fmt.Errorf("memory/tidy: dry-run finished with errors: %w", errors.Join(failures...))
|
|
}
|
|
t.logDryRun(out)
|
|
return out, nil
|
|
}
|
|
|
|
// strictRecall 是**试算轮**的召回:语义召回不可用时**不**降级,如实上报。
|
|
// 日志只记分区与错误,绝不带记忆正文(候选预览里已含正文)。
|
|
func (t *Tidier) strictRecall(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 {
|
|
t.log.Warn("memory tidy dry-run recall unavailable",
|
|
"partition", p.label(), "items", len(items), "error", err)
|
|
return nil, fmt.Errorf("%w: %w", ErrTidyRecallUnavailable, err)
|
|
}
|
|
return pairs, nil
|
|
}
|
|
|
|
// logDryRun 输出试算的观测口径。它带 source(memory/tidy),**不带任何记忆
|
|
// 正文** —— 只记计数、分区数与截断标签。
|
|
func (t *Tidier) logDryRun(out TidyDryRun) {
|
|
t.log.Info("memory tidy dry-run finished",
|
|
"rule_version", TidyRuleVersion,
|
|
"partitions", out.Stats.Partitions,
|
|
"scanned", out.Stats.Scanned,
|
|
"compared", out.Stats.Compared,
|
|
"pairs", out.Stats.Pairs,
|
|
"candidates", len(out.Candidates),
|
|
"vetoed", out.Stats.Vetoed,
|
|
"veto_reasons", tidyReasonsLabel(out.Stats.VetoReasons),
|
|
"errors", out.Stats.Errors,
|
|
"recall_mode", out.RecallMode,
|
|
"duration_ms", out.DurationMS)
|
|
}
|
|
|
|
// DryRun 立即跑一次试算(契约 v0.75;切片 MT1)。它复用与心跳、手动轮**同一把**
|
|
// runMu:试算与真跑串行,因此「一边试算一边落候选」不可能发生(design/23 §9
|
|
// 单写者)。试算自身零写入。
|
|
//
|
|
// 配置关闭(MEMORY_TIDY_ENABLED=false)时如实返回 ErrTidyDisabled:关闭是显式
|
|
// 选择,试算不绕过它,也不回一份空候选假装「没有建议」。
|
|
func (s *TidyService) DryRun(ctx context.Context) (TidyDryRun, error) {
|
|
if !s.cfg.Enabled {
|
|
return TidyDryRun{}, ErrTidyDisabled
|
|
}
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
s.runMu.Lock()
|
|
defer s.runMu.Unlock()
|
|
return s.tidier.DryRun(ctx)
|
|
}
|