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

+6
View File
@@ -96,6 +96,12 @@ func run() error {
if a.memoryTidy != nil {
a.memoryTidy.Start(ctx)
}
// 入库前审查(design/23 §15.1 D3):有界后台队列,在 store 的写入路径上
// 接住每条新记忆。启动即就绪(它不做「启动即跑」,只等写入侧投递);
// 配置关闭时组合根根本没建它(nil),写入路径逐字与改动前一致。
if a.memoryReview != nil {
a.memoryReview.Start(ctx)
}
// 文档回收(切片 S7):启动期清扫一次 + 此后按 DOCUMENT_GC_INTERVAL 定时清扫。
// 它不等这一次跑完就去拉网关(见 wire_document_gc.go 的取舍 1)。
a.startDocumentGC(ctx)
+7 -3
View File
@@ -19,8 +19,8 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/emotion"
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/exec"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/events"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/exec"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/flowgraph"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
@@ -74,8 +74,12 @@ type app struct {
retriever *memory.Retriever
// embedder 与 memoryTidy 属于记忆整理(design/23):整理只在 embedder
// 上算召回相似度,绝不接触会话/历史读数。
embedder embedding.Embedder
memoryTidy *memory.TidyService
embedder embedding.Embedder
memoryTidy *memory.TidyService
// memoryReview 是 D3「入库前审查」(design/23 §15.1 D3):新记忆落库后由
// 独立的记忆管理会话判定 merge/modify/create/skip。它同样只依赖记忆存储
// 与 embedder,拿不到任何会话/历史读数。未启用时为 nil(写入路径不变)。
memoryReview *memory.PreInsertReviewer
projStore *projectindex.Store
loop *loop.Loop
scheduler *orchestration.Scheduler
+69
View File
@@ -0,0 +1,69 @@
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 的写入路径因此
// 逐字回到本次改动之前(纯增量,无破坏性变更)。
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
}
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,
}, 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
}
// 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)
}
+203
View File
@@ -0,0 +1,203 @@
package main
import (
"context"
"os"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
)
// 本文件锁定 D3「入库前审查」(design/23 §15.1 D3)在**组合根**上的三件事:
// 1. 跑在与整理同一个专属隔离会话(sess-memorytidy),不是主人的对话车道;
// 2. 接线存在(服务被装配、被接到 store 的写入路径、被启动、被停止),
// 少一处就会**静默不工作**(不报错、不审查、日志里一句都没有);
// 3. 「独立会话、不碰主会话上下文」是**可验证**的:审查跑完后主会话的消息条数、
// 最后一条消息与已读游标逐字不变,且 chat_messages 里没有审查会话的行。
// reviewEmbedder 是组合根测试用的确定性 embedder:所有文本同向(余弦 1),
// 因此「同内容的两条记忆」一定被召回,审查的判定路径真的会跑到。
type reviewEmbedder struct{}
func (reviewEmbedder) Embed(_ context.Context, texts []string) ([]embedding.Vector, error) {
out := make([]embedding.Vector, 0, len(texts))
for range texts {
out = append(out, embedding.Vector{1, 0})
}
return out, nil
}
func (reviewEmbedder) Dim() int { return 2 }
// TestMemoryReviewIsWired 是接线存在性防回归:组合根少任何一处,入库前审查
// 都会静默不工作,而这不会让别的用例变红。
//
// 刻意**不**在这里断言 `.env.example`:该文件由并行切片(MT-A)持有,
// 新增的 MEMORY_REVIEW_* 键已登记在 MTB-report.md 里,由主会话统一补模板。
func TestMemoryReviewIsWired(t *testing.T) {
t.Parallel()
seams := map[string][]string{
"wire_runtime.go": {
// 记忆栈建好后就装配审查(它只需要 store + embedder)。
"a.wireMemoryReview()",
},
"wire_memory_review.go": {
"config.LoadMemoryReviewConfig(envPath)",
"memory.NewPreInsertReviewer(memory.ReviewConfig{",
// 依赖面里**没有**任何会话/历史读数 —— 这是「独立会话、零上下文」的
// 结构性保证,而不是靠自觉。
"Audit: a.auditMemoryReview",
// 接到 store 的唯一写入路径上。
"a.memStore.AttachIngestReviewer(reviewer)",
},
"main.go": {
"a.memoryReview.Start(ctx)",
},
"wire_services.go": {
// 关库之前先停审查(它要写 memory_items)。
"a.memoryReview.Stop(ctx)",
},
}
for file, wants := range seams {
src, err := os.ReadFile(file)
if err != nil {
t.Fatalf("read %s: %v", file, err)
}
for _, want := range wants {
if !strings.Contains(string(src), want) {
t.Fatalf("%s 里找不到 %q —— 入库前审查没有被接上组合根", file, want)
}
}
}
// store 侧的唯一写入路径必须投递审查(否则新记忆永远不进队列)。
storeSrc, err := os.ReadFile("../../internal/runtime/memory/store.go")
if err != nil {
t.Fatalf("read memory/store.go: %v", err)
}
if !strings.Contains(string(storeSrc), "s.enqueueReview(item)") {
t.Fatal("memory/store.go 的写入路径上没有投递审查(写路径唯一)")
}
}
// TestMemoryReviewSessionIsNotConversationLane 与整理同一条口径:审查会话不落在
// 主人的对话车道("web" / "web:<uuid>")上,因此不改她的心情、不进主会话泳道。
func TestMemoryReviewSessionIsNotConversationLane(t *testing.T) {
t.Parallel()
if isConversationLane(memory.TidySessionID) {
t.Fatalf("审查会话 %q 被判成主人对话回合", memory.TidySessionID)
}
if !isConversationLane(history.MainSessionID) {
t.Fatal("主人车道判定被改坏了")
}
}
// TestMemoryReviewLeavesChatHistoryUntouched 是 design/23 §15 D3-7 的可验证版本:
// 审查真的干活(把重复行折回既有行)之后,主会话的消息条数、最后一条消息与
// read_at 游标逐字不变,且 chat_messages 里没有任何属于审查会话的行。
func TestMemoryReviewLeavesChatHistoryUntouched(t *testing.T) {
ctx := context.Background()
conn, err := db.Open(ctx, ":memory:", tidyTestLog())
if err != nil {
t.Fatalf("open db: %v", err)
}
defer func() { _ = conn.Close() }()
if err := db.MigrateUp(ctx, conn, tidyTestLog()); err != nil {
t.Fatalf("migrate: %v", err)
}
hist, err := history.NewStore(ctx, conn, tidyTestLog())
if err != nil {
t.Fatalf("history store: %v", err)
}
if _, err := hist.AppendFull(ctx, history.Message{
SessionID: history.MainSessionID, Role: history.RoleUser,
Content: "今晚又要加班到很晚", CreatedAt: 1_700_000_000_000,
}); err != nil {
t.Fatalf("append user message: %v", err)
}
last, err := hist.AppendFull(ctx, history.Message{
SessionID: history.MainSessionID, Role: history.RoleAssistant,
Content: "别熬太晚,我陪你", CreatedAt: 1_700_000_001_000,
})
if err != nil {
t.Fatalf("append assistant message: %v", err)
}
if _, err := hist.MarkSessionRead(ctx, history.MainSessionID, time.UnixMilli(1_700_000_000_500)); err != nil {
t.Fatalf("mark read: %v", err)
}
before := readConversationFingerprint(t, ctx, conn, history.MainSessionID)
store, err := memory.NewStore(ctx, conn, tidyTestLog())
if err != nil {
t.Fatalf("memory store: %v", err)
}
content := "主人常在深夜与助手聊天,有熬夜习惯"
seed := func(at time.Time) memory.MemoryItem {
got, err := store.Add(ctx, memory.MemoryItem{
PersonaID: "persona.a", Scope: memory.ScopePersona, Kind: memory.KindRelationship,
Content: content, Confidence: 0.9, CreatedAt: at,
SourceConversation: memory.FormatSourceConversation(history.MainSessionID, "turn-1"),
})
if err != nil {
t.Fatalf("seed memory: %v", err)
}
return got
}
seed(time.UnixMilli(1_700_000_000_000))
reviewer, err := memory.NewPreInsertReviewer(memory.ReviewConfig{
Enabled: true, QueueSize: 8, ScanLimit: 20, Timeout: 5 * time.Second, Threshold: 0.8,
}, memory.ReviewDeps{Store: store, Embedder: reviewEmbedder{}, Log: tidyTestLog()})
if err != nil {
t.Fatalf("NewPreInsertReviewer: %v", err)
}
store.AttachIngestReviewer(reviewer)
reviewer.Start(ctx)
defer func() {
stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = reviewer.Stop(stopCtx)
}()
dup := seed(time.UnixMilli(1_700_000_060_000))
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
var status string
if err := conn.QueryRowContext(ctx,
"SELECT status FROM memory_items WHERE id = ?", dup.ID).Scan(&status); err != nil {
t.Fatalf("read dup status: %v", err)
}
if status == "merged" {
break
}
time.Sleep(5 * time.Millisecond)
}
after := readConversationFingerprint(t, ctx, conn, history.MainSessionID)
if before != after {
t.Fatalf("审查改动了主会话上下文:\n before=%+v\n after=%+v", before, after)
}
if after.lastContent != last.Content || after.lastID != last.ID {
t.Fatalf("主会话最后一条消息变了:%+v", after)
}
var reviewMsgs int
if err := conn.QueryRowContext(ctx,
"SELECT count(*) FROM chat_messages WHERE session_id = ?", memory.TidySessionID).Scan(&reviewMsgs); err != nil {
t.Fatalf("count review messages: %v", err)
}
if reviewMsgs != 0 {
t.Fatalf("审查向 chat_messages 写入了 %d 行(§15 D3-7 要求零写入)", reviewMsgs)
}
reviews, err := store.ListIngestReviews(ctx, 10)
if err != nil {
t.Fatalf("ListIngestReviews: %v", err)
}
if len(reviews) != 1 || reviews[0].Decision != memory.IngestSkip {
t.Fatalf("审查留痕 = %+v, want 恰为一条 skip", reviews)
}
}
+5
View File
@@ -118,6 +118,11 @@ func (a *app) wireMemory(ctx context.Context) error {
if err := a.wireMemoryTidy(); err != nil {
return err
}
// 入库前审查(design/23 §15.1 D3):记忆管理会话的后台队列,接在 store 的
// 唯一写入路径上。它与整理心跳并列启动(main.go / close)。
if err := a.wireMemoryReview(); err != nil {
return err
}
return nil
}
+5
View File
@@ -118,6 +118,11 @@ func (a *app) close(ctx context.Context) {
if a.memoryTidy != nil {
_ = a.memoryTidy.Stop(ctx)
}
// 入库前审查与整理同属记忆后台任务:先停它(Stop 会等正在审的那一条跑完),
// 再关库 —— 否则正在写 memory_items 的审查会撞上已关闭的连接池。
if a.memoryReview != nil {
_ = a.memoryReview.Stop(ctx)
}
if a.automation != nil {
_ = a.automation.Stop(ctx)
}