feat(memory): 护栏抬高+时间预算闸+试算加性异步 与 D3 入库前审查(耦合两片一起落)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

【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 无该文件权限,由我按其代码语义补写)。
This commit is contained in:
AskaEth committed 2026-10-06 08:04:12 +08:00
1 parent fe4c702f87
commit 6e28b38b08
37 files changed
+4011 -75

No files matched your search

+37 -10
View File
@@ -165,8 +165,9 @@ type tidyCandidateSink func(ctx context.Context, cands []TidyCandidate, stats *T
// tidyRecall 是**本轮的召回策略**:给定已截断的参与比对条目,产出候选对。
// 它把「相似度来源不可用时怎么办」的差异收在一处:生产轮降级到零依赖词项
// 余弦,试算轮如实上报错误。
type tidyRecall func(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats) ([]tidyPair, error)
// 余弦,试算轮如实上报错误。budget 是本轮的时间预算闸(见 tidy_budget.go),
// 由调用方一路传下来,使两条策略共享**同一个**预算(而不是各自从 ctx 再派生)。
type tidyRecall func(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget) ([]tidyPair, error)
// tidyRoundPolicy 是本轮整理的出口策略:候选去哪 + 召回来源不可用时怎么办。
// 「生产轮」与「试算轮」的**全部**差异都在这两个字段上,规则引擎其余部分
@@ -261,10 +262,14 @@ func (t *Tidier) beginRound(ctx context.Context) (TidyStats, []tidyPartition, er
// scanPartitions 逐个分区跑规则并把候选交给 policy.sink。它返回失败列表
// (单个分区失败不阻塞其它分区,design §9);成功时返回 nil。
//
// 时间预算(tidy_budget.go)在**轮**级派生一次:ctx 有墙钟上限(同步/异步试算)
// 时,预算用尽后的分区不再开跑,而是如实登记为 (budget) 截断。
func (t *Tidier) scanPartitions(ctx context.Context, partitions []tidyPartition, stats *TidyStats, policy tidyRoundPolicy) []error {
budget := t.newTidyBudget(ctx)
var failures []error
for _, p := range partitions {
if err := t.runPartition(ctx, p, stats, policy); err != nil {
if err := t.runPartition(ctx, p, stats, policy, budget); err != nil {
stats.Errors++
failures = append(failures, err)
t.log.Warn("memory tidy partition failed", "partition", p.label(), "error", err)
@@ -276,21 +281,31 @@ func (t *Tidier) scanPartitions(ctx context.Context, partitions []tidyPartition,
// 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)
func (t *Tidier) fallbackRecall(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 {
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
return t.collectPairs(p, items, t.cfg.RecallThreshold,
lexicalSimFunc(items), tidyRecallLexical, stats, budget), nil
}
// runPartition 整理一个分区(persona_id + scope + project_id)。
// 分区之间**永不比较**(design/23 §6 硬约束)。候选去处与召回降级策略都由
// policy 决定,因此生产轮与试算轮走的是**同一条**扫描→召回→判定→构造候选
// 的路径。
func (t *Tidier) runPartition(ctx context.Context, p tidyPartition, stats *TidyStats, policy tidyRoundPolicy) error {
func (t *Tidier) runPartition(ctx context.Context, p tidyPartition, stats *TidyStats, policy tidyRoundPolicy, budget tidyBudget) error {
// 预算用尽 ⇒ 这个分区本轮根本不看。**如实登记**(响应 truncated + WARN):
// 「没扫到」与「扫了没有候选」必须能区分,否则面板会把预算截断读成
// 「这个分区没有建议」。
if budget.exhausted() {
stats.Truncated = append(stats.Truncated, budgetTruncationLabel(p))
t.log.Warn("memory tidy partition skipped by time budget", "partition", p.label())
return nil
}
items, err := t.deps.Store.listTidyPartitionItems(ctx, p, t.cfg.ScanLimit)
if err != nil {
return fmt.Errorf("memory/tidy: list partition %s: %w", p.label(), err)
@@ -300,16 +315,24 @@ func (t *Tidier) runPartition(ctx context.Context, p tidyPartition, stats *TidyS
// 扫描上限命中:本分区更大的部分这一轮不看(design §9「超限分片,下一轮
// 继续」)。本实现只做「最新优先」的截断、不推进分片游标,因此这里必须
// 留下痕迹,否则「配置了 5000 却永远只看最新 5000」会无人知晓。
// 日志带**真总数**(LIMIT 之后的 len(items) 只是下限):另查一次 COUNT,
// 否则 WARN 会把「至少 5000」说成「正好 5000」。
total, totalErr := t.deps.Store.countTidyPartitionItems(ctx, p)
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)
if totalErr != nil {
t.log.Warn("memory tidy partition hit the scan limit (total unknown)",
"partition", p.label(), "limit", t.cfg.ScanLimit, "error", totalErr)
} else {
t.log.Warn("memory tidy partition hit the scan limit",
"partition", p.label(), "limit", t.cfg.ScanLimit, "total", total)
}
}
comparable, archives := t.classifyItems(items, stats)
// 截断与计数只做一次:召回策略拿到的是**已截断**的条目,回退重算不重复计数。
compared := t.limitCompareItems(p, comparable, stats)
pairs, err := policy.recall(ctx, p, compared, stats)
pairs, err := policy.recall(ctx, p, compared, stats, budget)
if err != nil {
return fmt.Errorf("memory/tidy: recall partition %s: %w", p.label(), err)
}
@@ -327,6 +350,10 @@ func (t *Tidier) runPartition(ctx context.Context, p tidyPartition, stats *TidyS
// 每分区候选上限(design §9)对**全部候选**生效(含归档建议),
// 超出的只登记不入队,并记进 stats.Truncated(绝不静默)。
if len(candidates) > t.cfg.CandidateLimit {
// WARN 带总数/上限/分区名:候选被上限截断意味着面板看到的**少于**规则
// 判出的,必须能从这里读出被丢掉了多少。
t.log.Warn("memory tidy candidates truncated by per-partition limit",
"partition", p.label(), "candidates", len(candidates), "limit", t.cfg.CandidateLimit)
stats.Skipped += len(candidates) - t.cfg.CandidateLimit
stats.Truncated = append(stats.Truncated, p.label()+"(candidates)")
candidates = candidates[:t.cfg.CandidateLimit]