Files
Cyrene_v2/cmd/cyrene/wire_memory_review.go
T
AskaEth 90f430216a
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(memory): D3 从「异步后置」改成「同步入库闸」—— 由整理层决定入库形态
主人 2026-10-07 裁定:「把记忆整理功能接到昔涟和记忆库中间做审查,由整理功能
进行入库」。现状是**异步 + 入库后软合并** ⇒ 行已经落库才审,面板/召回先脏后清,
而写入速率实测 ~25 条/小时(≈600/天)⇒ 清理永远追不上。

落法(三条硬规格,全部落地):
① **同步闸但有界**:闸在 `store.go:247` 的 `planIngest`(INSERT **之前**),
   本体 `preinsert_gate.go`。墙钟预算 **150ms**(实测最坏 p95 仅 4~7ms ⇒ 20~50 倍
   余量)、候选扫描上限 **100**(比异步侧 200 更紧,因为它在写路径上)。
   🔴 **零远端 embedding**:闸恒用词项余弦(`deps.Embedder` 根本不参与)——
   远端 embedding 一次 2.3~3.0s 且对同文本返回不同向量(不可复现),不许摆在同步
   路径上拍板。
② **fail-open,绝不丢记忆**:候选源报错 ⇒ `gate:candidates_unavailable`;超预算 ⇒
   `gate:budget_exceeded` ⇒ **照常 insert(active) + 投既有队列 + 留痕 + WARN**。
③ **三态落库(落 merged,而不是"不插入")**:确定性命中(字面重复 / 归一化等价 /
   真包含)⇒ INSERT **直接带 `status='merged'` + `merged_into=<存活行>`**,存活行
   `merged_from` 追加、`merge_count`+1 ⇒ **新行从不进 active、从不建向量索引**,
   而**行与原文都在表里 ⇒ revert 无损**。若"干脆不插",那条文本从未存在过行,
   revert 无从下手,违反 design/23 §8 的红线。
- **不与异步 D3 重复审**:闸做决定的那条 `Async=false`(留痕由闸自己落,
  审计动作名 `memory.ingest.gate`);真新/拿不准的走原有队列(留痕仍归异步层)。
- **开关**:`$DATA_DIR/config/memory-tidy.json` 的 `{"ingestGate": false}` ⇒ 一键回到
  「只插不审」(= 改动前的异步行为)。**零新 env 键**(`.env` 未碰)。
- 顺手把 `store.go` 的行映射拆到 `store_rows.go`(守 §10 的 ≤500 行)。

验收(主会话复跑 + 亲自变异):`go build ./...` + `go vet` 绿;memory 包测试绿;
我用 -overlay 摘掉闸的预算 ⇒ `TestIngestGate_Add墙钟有界` 与
`…failOpen_超预算绝不丢记忆` 变红("Add 等了 2.0s,越过闸预算 150ms")。
子代理另做 2 个变异(去掉预算 / 把 fail-open 改成 fail-closed)全红,并实测
**闸开 vs 关的 `Add` 额外墙钟:p50 ≈2.5~3.1ms、p95 ≈4~7ms**(基线批均值 35.8µs)。

⚠️ 未部署(要一次 build + restart);⚠️ 已知边界(如实):闸只用词项余弦 ⇒
「同义但字面差得远」拦不住(有意为之,由异步层/夜间整理兜);闸的候选是**最新
100 条**(更老的重复闸看不到);**并发同内容双写**仍可能两条都落 active(既有竞态,
SELECT 与 INSERT 不在同一事务,非本次引入,第二层会清)。
2026-10-07 11:41:44 +08:00

93 lines
4.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"fmt"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
)
// 本文件把 D3「入库前审查」(DevDocs/design/23-记忆整理与去重 §15.1 D3)接进
// 组合根。三件事只在这里定:
//
// - **形态**:记忆管理会话(复用既有的 sess-memorytidy 装配,见
// memory.TidySessionID)的有界后台队列,不是会话内派 subagent。它与整理
// 心跳并列启动、在 close 里先于关库停止;
// - **依赖面**:只给它记忆存储与 embedder —— 服务侧拿不到任何会话/历史读数,
// 因此「独立会话、零上下文、不碰主会话上下文」(§15 D3)是结构性成立的,
// 而不是靠自觉;
// - **审计**:接到网关的 AuditEvent(口径 memory.review.ingest)。出口在调用
// 时才解析 a.gw —— 网关比本服务晚组装(wireFrontend),与 memoryTidy 同
// 一手法。未就绪时只记日志,绝不假装写过审计。
//
// 关闭(MEMORY_REVIEW_ENABLED=false)时**不建、不接**:store 的写入路径因此
// 逐字回到本次改动之前(纯增量,无破坏性变更)。
//
// 🔴 同步入库闸的开关(主人 2026-10-07 裁定)不在 env 里 —— `.env` 已冻结
// (design/13 §2.1):它读 `$DATA_DIR/config/memory-tidy.json` 的
// `{"ingestGate": false}`,与整理自动档同一个文件、**零新 env 键**。
func (a *app) wireMemoryReview() error {
cfg, err := config.LoadMemoryReviewConfig(envPath)
if err != nil {
return fmt.Errorf("memory review config: %w", err)
}
if !cfg.Enabled {
logger.WithSource(a.log, "memory/review").Info(
"memory ingest review disabled by configuration", "env", "MEMORY_REVIEW_ENABLED")
return nil
}
gateOff := a.memoryIngestGateOff()
reviewer, err := memory.NewPreInsertReviewer(memory.ReviewConfig{
Enabled: cfg.Enabled,
QueueSize: cfg.QueueSize,
ScanLimit: cfg.ScanLimit,
Timeout: time.Duration(cfg.TimeoutSec) * time.Second,
// 召回门复用记忆整理的既有阈值(design/23 §13 开放点 2 = 0.80):
// 入库前审查与夜间整理必须用同一把尺子,否则同一对条目会有两个结论。
Threshold: a.cfg.MemoryTidy.RecallThreshold,
// 同步入库闸:`$DATA_DIR/config/memory-tidy.json` 的 `ingestGate: false`
// 一键回到「只插不审」(= 本次改动之前的异步行为)。预算与扫描上限走
// 代码默认值(闸的总墙钟预算必须有界,但它是**实现参数**不是运维旋钮)。
DisableIngestGate: gateOff,
}, memory.ReviewDeps{
Store: a.memStore,
Embedder: a.embedder,
Audit: a.auditMemoryReview,
Log: a.log,
})
if err != nil {
return fmt.Errorf("memory ingest review: %w", err)
}
a.memStore.AttachIngestReviewer(reviewer)
a.memoryReview = reviewer
return nil
}
// memoryIngestGateOff 报告同步入库闸是否被配置显式关掉。读不到/解不开
// `memory-tidy.json` 时返回 false(= 保持代码默认的「开」)并记 WARN ——
// 坏配置绝不能让一处防护静默失效,也绝不能让主人以为它关了却其实开着。
func (a *app) memoryIngestGateOff() bool {
tuning, path, err := a.readMemoryTidyTuning()
if err != nil {
logger.WithSource(a.log, "memory/review").Warn(
"memory ingest gate tuning unreadable, falling back to code default (enabled)",
"path", path, "error", err)
return false
}
return tuning.IngestGateOff()
}
// auditMemoryReview 把一条审查结论写进网关审计链。它**只带决定、依据与 id**,
// 绝不带任何记忆正文(design §15 D3-8「记忆正文不进日志」)。
func (a *app) auditMemoryReview(who, what, result string) {
if a.gw != nil {
a.gw.AuditEvent(who, what, result)
return
}
logger.WithSource(a.log, "memory/review").Info(
"memory ingest review audit dropped: gateway not ready",
"who", who, "what", what, "result", result)
}