Files
Cyrene_v2/cmd/cyrene/wire_memory_tidy_test.go
T
AskaEth 91a3209c9b
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(memory): 记忆整理 v1(design/23)——低峰定期扫描 + 事实包含判定 + 规则否决 + 只产建议队列(不自动应用)
2026-10-05 11:29:36 +08:00

209 lines
7.9 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 (
"context"
"database/sql"
"io"
"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/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
)
// 本文件锁定记忆整理(design/23 §4.1)在**组合根**上的三件事:
// 1. 跑在专属隔离会话,绝不是主人的对话车道(isConversationLane 对它恒 false);
// 2. 接线存在(服务被装配、被启动、被停止,env 键同步),少一处就会静默不工作;
// 3. 「绝不碰主会话上下文」是**可验证**的:跑完一轮整理后,主会话的消息条数、
// 最后一条消息、已读游标逐字不变,且对 chat_messages 零写入。
// tidyTestLog 返回不写任何地方的 logger,保持测试输出干净。
func tidyTestLog() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// TestMemoryTidySessionIsNotConversationLane 是 design/23 §4.1 B 的对外断言:
// 整理会话不落在主人的对话车道("web" / "web:<uuid>")上。
func TestMemoryTidySessionIsNotConversationLane(t *testing.T) {
t.Parallel()
if isConversationLane(memory.TidySessionID) {
t.Fatalf("整理会话 %q 被判成主人对话回合(会改写她的心情、进主会话泳道)", memory.TidySessionID)
}
// 对照组:主人车道本身必须仍然为 true(避免把断言写成「函数恒 false」)。
if !isConversationLane(history.MainSessionID) || !isConversationLane(history.MainSessionID+":sub-1") {
t.Fatal("主人车道判定被改坏了")
}
}
// TestMemoryTidyIsWired 是接线存在性防回归:组合根少任何一处,整理都会**静默
// 不工作**(不报错、不产候选、日志里一句都没有),而这不会让别的用例变红。
func TestMemoryTidyIsWired(t *testing.T) {
t.Parallel()
seams := map[string][]string{
"wire_runtime.go": {
// 记忆栈建好后就装配整理(它只需要 store + embedder)。
"a.wireMemoryTidy()",
"a.embedder = embedder",
},
"wire_memory_tidy.go": {
"memory.NewTidyService(memory.TidyConfigFromConfig(a.cfg.MemoryTidy)",
// 依赖面里**没有**任何会话/历史读数 —— 这是「绝不碰主会话上下文」
// 的结构性保证,而不是靠自觉。
"Audit: a.auditMemoryTidy",
},
"main.go": {
"a.memoryTidy.Start(ctx)",
},
"wire_services.go": {
// 关库之前先停整理(它每轮要用连接池)。
"a.memoryTidy.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)
}
}
}
raw, err := os.ReadFile(filepath.Join("..", "..", ".env.example"))
if err != nil {
t.Fatalf("read .env.example: %v", err)
}
for _, key := range []string{
"MEMORY_TIDY_ENABLED=", "MEMORY_TIDY_INTERVAL_SEC=", "MEMORY_TIDY_QUIET_START_HOUR=",
"MEMORY_TIDY_QUIET_END_HOUR=", "MEMORY_TIDY_RECALL_THRESHOLD=",
"MEMORY_TIDY_SCAN_LIMIT=", "MEMORY_TIDY_CANDIDATE_LIMIT=",
} {
if !strings.Contains(string(raw), key) {
t.Fatalf(".env.example 里找不到 %q(新 env 键必须同步模板,AGENTS §10 协同 E6)", key)
}
}
}
// TestMemoryTidyRunLeavesChatHistoryUntouched 是 design/23 §4.1 E 的可验证版本:
// 整理跑完一轮后,主会话的消息条数、最后一条消息、read_at 游标**逐字不变**,
// 且 chat_messages 里没有任何属于整理会话的行。
//
// 用例让整理真的干活(有可合并的一对),否则断言会是空的。
func TestMemoryTidyRunLeavesChatHistoryUntouched(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)
}
seed := []string{"主人常在深夜与助手聊天,有熬夜习惯", "主人常在深夜与助手聊天,有熬夜习惯,作息偏晚"}
for i, content := range seed {
if _, err := store.Add(ctx, memory.MemoryItem{
PersonaID: "persona.a", Scope: memory.ScopePersona, Kind: memory.KindRelationship,
Content: content, Confidence: 0.9,
CreatedAt: time.UnixMilli(1_700_000_000_000 + int64(i)*60_000),
}); err != nil {
t.Fatalf("seed memory %d: %v", i, err)
}
}
svc, err := memory.NewTidyService(memory.TidyConfigFromConfig(config.MemoryTidyConfig{
Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7,
RecallThreshold: 0.80, ScanLimit: 100, CandidateLimit: 20,
}), memory.TidyDeps{Store: store, Log: tidyTestLog()})
if err != nil {
t.Fatalf("tidy service: %v", err)
}
stats, err := svc.RunOnce(ctx)
if err != nil {
t.Fatalf("tidy RunOnce: %v", err)
}
if stats.Scanned != 2 || stats.Candidates == 0 {
t.Fatalf("整理没真干活,断言会落空:%+v", stats)
}
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 tidyMsgs int
if err := conn.QueryRowContext(ctx,
"SELECT count(*) FROM chat_messages WHERE session_id = ?", memory.TidySessionID).Scan(&tidyMsgs); err != nil {
t.Fatalf("count tidy messages: %v", err)
}
if tidyMsgs != 0 {
t.Fatalf("整理向 chat_messages 写入了 %d 行(design §4.1 D 要求零写入)", tidyMsgs)
}
}
// conversationFingerprint 是主会话的「逐字不变」指纹:消息条数、最后一条消息
// 的 id 与正文、以及会话已读游标。
type conversationFingerprint struct {
messages int
lastID string
lastContent string
readAt int64
}
// readConversationFingerprint 直接读 chat_messages / chat_sessions(主会话只由
// 消息写入路径触碰,因此这是「整理有没有碰会话」最直接的证据)。
func readConversationFingerprint(t *testing.T, ctx context.Context, conn *sql.DB, sessionID string) conversationFingerprint {
t.Helper()
var fp conversationFingerprint
if err := conn.QueryRowContext(ctx,
"SELECT count(*) FROM chat_messages WHERE session_id = ?", sessionID).Scan(&fp.messages); err != nil {
t.Fatalf("count messages: %v", err)
}
if err := conn.QueryRowContext(ctx,
"SELECT id, content FROM chat_messages WHERE session_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1",
sessionID).Scan(&fp.lastID, &fp.lastContent); err != nil {
t.Fatalf("read last message: %v", err)
}
if err := conn.QueryRowContext(ctx,
"SELECT read_at FROM chat_sessions WHERE id = ?", sessionID).Scan(&fp.readAt); err != nil {
t.Fatalf("read cursor: %v", err)
}
return fp
}