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:")上,因此不改她的心情、不进主会话泳道。 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) } }