Files
Cyrene_v2/cmd/cyrene/wire_memory_review_test.go
T
AskaEth a684328b76
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(memory): T1 加保守全局速率限流(防每天数百到上千次调用失控)
主人 2026-10-07 裁定:按最优解加全局限流。

- 新增 t1RateGate(滑动窗口、可注入时钟,~70 行);默认
  120 次 / 60 分钟(现网 700-1000 条新记忆/天 ≈ 25-42/小时,
  取 120 留约 3-5 倍余量)
- 过闸摆在「t1Enabled + 有灰区候选」两个守卫之后、发请求之前
  ⇒ 不调模型就不占额度;放行才记账
- 超限语义与既有失败路径同源:跳过 T1、决定仍是 create、零改写,
  只多一个 reason=t1_throttled 进留痕 + ReviewStats.T1Throttled 计数
- 配置键 t1PerWindow / t1WindowSec 放 $DATA_DIR/config/memory-tidy.json
  (零新 env 键,启动期读一次);坏配置回落默认 + WARN,不启动失败
- 每窗口只记一条 WARN(超限是预期行为,一天几百条会淹掉真故障)
- IngestRuleVersion 仍为 d3.2:判定规则集与解析契约没变,变的只是
  额度状态,reason 列已可分辨(升版会有 §17.6 记过的幂等键重复行副作用)

自研而非复用 session.NewRateLimiter 的理由(design/12 §N C.5 自己登记过
这条取舍):既有件 Allow() 内部写死 time.Now(),没有时钟注入缝,
而本切片必须能注入可控时钟;且复用会把 channel 层概念拖进 memory 包。
2026-10-07 16:25:54 +08:00

403 lines
16 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 (
"bytes"
"context"
"errors"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"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"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件锁定 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)",
// 同步入库闸(2026-10-07)的开关来自 `$DATA_DIR/config/memory-tidy.json`
// 的 `{"ingestGate": false}`(**零新 env 键**,`.env` 已冻结)。
"a.memoryIngestGateOff()",
"DisableIngestGate: gateOff",
// T1 灰区模型判定(design/23 §15.1 D3-2):开关同样来自那个文件
// (`{"t1Judge": false}`),模型出口**在调用时才解析**(本服务早于
// 模型栈装配,与 a.gw 的晚组装同一手法)。
"a.memoryT1JudgeOff()",
"DisableT1: t1Off",
"Judge: a.memoryT1Judge()",
// T1 的**全局限速**额度(design/23 §15.4)同样来自那个文件,
// 缺位/坏文件一律回落代码默认(120 次/60 分钟)。
"a.memoryT1RateLimit()",
"T1PerWindow: t1PerWindow",
"T1Window: time.Duration(t1WindowSec) * time.Second",
},
"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 侧的唯一写入路径必须投递审查(否则新记忆永远不进队列),
// 且必须**先过同步入库闸**(2026-10-07:闸就摆在写路径上,重复当场落 merged)。
storeSrc, err := os.ReadFile("../../internal/runtime/memory/store.go")
if err != nil {
t.Fatalf("read memory/store.go: %v", err)
}
for _, want := range []string{"s.enqueueReview(item)", "s.planIngest(ctx, item)", "s.finishIngest(ctx, item, plan)"} {
if !strings.Contains(string(storeSrc), want) {
t.Fatalf("memory/store.go 的写入路径上找不到 %q(唯一写路径上的闸/投递断了)", want)
}
}
}
// TestMemoryIngestGate_ReadsDataDirConfig 钉住**同步入库闸**的开关确实来自
// `$DATA_DIR/config/memory-tidy.json`(它没有 env 键:`.env` 已冻结)——
// 若组合根没读那个文件,「一键回到只插不审」就是一句空话。
//
// 口径与自动档同一条:没配 = 开;显式 false = 关;坏文件 = 回落代码默认(开)
// 并记 WARN,**绝不静默把一处防护关掉**。
func TestMemoryIngestGate_ReadsDataDirConfig(t *testing.T) {
t.Parallel()
empty := &app{cfg: &config.Config{DataDir: t.TempDir()}, log: tidyTestLog()}
if empty.memoryIngestGateOff() {
t.Fatal("没配过时入库闸应当保持代码默认(开)")
}
dataDir := t.TempDir()
dir := filepath.Join(dataDir, "config")
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatalf("mkdir: %v", err)
}
path := filepath.Join(dir, "memory-tidy.json")
if err := os.WriteFile(path, []byte(`{"ingestGate": false}`), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
off := &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()}
if !off.memoryIngestGateOff() {
t.Fatal("ingestGate: false 没被读到(闸没有一键关掉的路径)")
}
if err := os.WriteFile(path, []byte(`{"ingestGate": "nope"}`), 0o600); err != nil {
t.Fatalf("write broken: %v", err)
}
broken := &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()}
if broken.memoryIngestGateOff() {
t.Fatal("坏配置把入库闸静默关掉了:必须回落代码默认(开)")
}
}
// TestMemoryT1Judge_ReadsDataDirConfig 钉住 **T1 灰区模型判定**的开关确实来自
// `$DATA_DIR/config/memory-tidy.json` 的 `t1Judge`(它没有 env 键:`.env` 已冻结)
// —— T1 是一处**会花掉模型额度**的动作,「一行配置关掉」必须真的成立。
//
// 口径与入库闸同一条:没配 = 开(主人 2026-10-07 要的就是「真的走记忆管理会话」);
// 显式 false = 关(回落纯规则、零模型调用);坏文件 = 回落代码默认(开)并记
// WARN,**绝不静默把一处防护关掉**。
func TestMemoryT1Judge_ReadsDataDirConfig(t *testing.T) {
t.Parallel()
empty := &app{cfg: &config.Config{DataDir: t.TempDir()}, log: tidyTestLog()}
if empty.memoryT1JudgeOff() {
t.Fatal("没配过时 T1 应当保持代码默认(开)")
}
dataDir := t.TempDir()
dir := filepath.Join(dataDir, "config")
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatalf("mkdir: %v", err)
}
path := filepath.Join(dir, "memory-tidy.json")
// 只写一个上限键:它不许把 T1 静默关掉(指针字段的存在理由)。
if err := os.WriteFile(path, []byte(`{"autoApplyPerRound": 5}`), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
partial := &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()}
if partial.memoryT1JudgeOff() {
t.Fatal("只写了别的键时 T1 被静默关掉了(必须保持代码默认的「开」)")
}
if err := os.WriteFile(path, []byte(`{"t1Judge": false}`), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
off := &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()}
if !off.memoryT1JudgeOff() {
t.Fatal("t1Judge: false 没被读到(T1 没有一键关掉的路径)")
}
if err := os.WriteFile(path, []byte(`{"t1Judge": "nope"}`), 0o600); err != nil {
t.Fatalf("write broken: %v", err)
}
broken := &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()}
if broken.memoryT1JudgeOff() {
t.Fatal("坏配置把 T1 静默关掉了:必须回落代码默认(开)")
}
}
// TestMemoryT1RateLimit_ReadsDataDirConfig 钉住 **T1 全局限速**的额度确实来自
// `$DATA_DIR/config/memory-tidy.json` 的 `t1PerWindow` / `t1WindowSec`
// (design/23 §15.4):T1 默认开,每次有灰区候选的审查就发一次调用 ⇒ 额度必须能
// 按现网节奏调,而 `.env` 已冻结、不许为它开 env 键。
//
// 口径与入库闸/T1 开关同一条:没配 = 返回零值(交给 normalizeReviewConfig 回落
// 代码默认 120 次/60 分钟);坏文件 = 同样回落默认额 **并记 WARN** ——
// 坏配置绝不能让限速静默变成「无上限」。
func TestMemoryT1RateLimit_ReadsDataDirConfig(t *testing.T) {
t.Parallel()
empty := &app{cfg: &config.Config{DataDir: t.TempDir()}, log: tidyTestLog()}
if perWindow, windowSec := empty.memoryT1RateLimit(); perWindow != 0 || windowSec != 0 {
t.Fatalf("没配过时应当返回零值(= 回落代码默认):%d/%d", perWindow, windowSec)
}
dataDir := t.TempDir()
dir := filepath.Join(dataDir, "config")
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatalf("mkdir: %v", err)
}
path := filepath.Join(dir, "memory-tidy.json")
if err := os.WriteFile(path, []byte(`{"t1PerWindow": 240, "t1WindowSec": 1800}`), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
set := &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()}
if perWindow, windowSec := set.memoryT1RateLimit(); perWindow != 240 || windowSec != 1800 {
t.Fatalf("额度没被读到:%d/%d, want 240/1800", perWindow, windowSec)
}
// 坏配置:回落默认额(零值)+ 一条 WARN,绝不静默不限流。
if err := os.WriteFile(path, []byte(`{"t1PerWindow": "many"}`), 0o600); err != nil {
t.Fatalf("write broken: %v", err)
}
var buf bytes.Buffer
broken := &app{cfg: &config.Config{DataDir: dataDir},
log: slog.New(slog.NewTextHandler(&buf, nil))}
if perWindow, windowSec := broken.memoryT1RateLimit(); perWindow != 0 || windowSec != 0 {
t.Fatalf("坏配置下应当回落零值:%d/%d", perWindow, windowSec)
}
if logged := buf.String(); !strings.Contains(logged, "t1 rate limit tuning unreadable") ||
!strings.Contains(logged, "level=WARN") {
t.Fatalf("坏配置没有留下 WARN:%q", logged)
}
}
// TestMemoryT1Judge_UsesTheWiredModelStack 钉住 T1 的模型出口**复用既有模型栈**
// (不新造 LLM 客户端),且返回一个闭包而不是当场取值 —— 本服务在 wireMemory
// 阶段装配,而 a.modelAdapter 要等更晚的 wireRuntime 才建好。
func TestMemoryT1Judge_UsesTheWiredModelStack(t *testing.T) {
t.Parallel()
a := &app{log: tidyTestLog()}
judge := a.memoryT1Judge()
if judge == nil {
t.Fatal("memoryT1Judge 返回了 nil")
}
// 模型栈还没建好:必须 fail-safe(返回错误),绝不 panic、绝不假装成功。
if _, err := judge(context.Background(), model.Request{}, model.Options{}); !errors.Is(err, errT1ModelNotReady) {
t.Fatalf("模型栈未就绪时 err = %v, want %v", err, errT1ModelNotReady)
}
// 接上模型栈后,同一个闭包就能发请求(晚解析的证据)。
stub := &reviewAdapterStub{reply: "{}"}
a.modelAdapter = stub
if _, err := judge(context.Background(), model.Request{Purpose: "memory_t1_judge"}, model.Options{}); err != nil {
t.Fatalf("接上模型栈后调用失败:%v", err)
}
if stub.calls != 1 {
t.Fatalf("模型栈被调用 %d 次, want 1", stub.calls)
}
}
// reviewAdapterStub 是最小的 model.Adapter 桩(只为验「闭包解析到既有的那个
// 模型栈」这件事,不参与判定正确性)。
type reviewAdapterStub struct {
calls int
reply string
}
func (s *reviewAdapterStub) Name() string { return "stub" }
func (s *reviewAdapterStub) Chat(context.Context, model.Request, model.Options) (model.Response, error) {
s.calls++
return model.Response{Content: s.reply}, nil
}
func (s *reviewAdapterStub) ChatStream(
ctx context.Context, req model.Request, onEvent func(model.StreamEvent),
) (model.Response, error) {
return s.Chat(ctx, req, model.Options{})
}
// 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)
}
}