主人原话:「那么多 link 是干什么的?该删就删,怎么这个记忆整理机制这么保守呢」。
① **link 停产**(依据 design/23 §14 把它列为「未细化」⇒ 既无落点也无数据模型;
`applyable()` 也只认 merge/archive/reclassify ⇒ 应用 link 恒 `skipped`,是纯噪音):
- 规则层 `containNone` 判定改为 `keep`(不再产出 501 条不可应用的待办);
- `TidyLink` 常量保留,只为**读历史行**与「不可应用」判定;
- 存量出路:新端点 `POST /api/v1/admin/memories/candidates/reject?action=&limit=`
(**幂等、只改候选 status、不碰记忆**),主会话用它清掉存量 501 条。
② **轮末自动落地**(默认开,仅两类,判据是纯函数):
- `merge`:**字面重复** ∨「事实集合等价 ∧ 相似度 ≥ 0.95」;
- `archive`:理由 = `one_off_event` **且不像习惯**(`design/23 §5.2-1` 红线:
习惯类会因正文带钟点被误判成一次性事件,归档它是错的)。
- **不自动**:`reclassify`、低置信 merge、跨分区/跨角色、任何改正文、受限来源
(群聊/陌生人)—— 新增执行面守卫 `tidySkipSourceRestricted`。
③ **六条护栏**(缺一不可):软动作(复用既有 apply 路径)/ 每轮上限 50(硬顶 500,
超限如实 `Truncated` + 真总数)/ 审计 `memory.tidy.apply`(`auto=true`,不带正文)/
**可 revert**(复用既有回滚)/ 幂等(复用候选状态条件更新)/ **一键关回只产建议**
(`$DATA_DIR/config/memory-tidy.json` 的 `autoApply:false`,**零新 env 键**)。
验收(主会话复跑 + 亲自变异):memory 与 gateway 两包 build/vet/test 全绿;我用
-overlay 短路「习惯不归档」守卫 ⇒ TestTidyAutoNeverArchivesHabitMisjudgedAsOneOff
变红(逐字报「作息习惯被自动归档了 …design §5.2-1 红线」)。
⚠️ 未部署(要 build+restart 才生效);⚠️ 契约欠登记(新端点 B25 + §7.2),主会话补;
⚠️ 真机端到端未跑(不能重启后端)。
256 lines
10 KiB
Go
256 lines
10 KiB
Go
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(cfg, memory.TidyDeps{",
|
||
// 调优(自动落地开关 + 每轮上限)从 $DATA_DIR/config/memory-tidy.json
|
||
// 来,**零新 env 键**(`.env` 已冻结,design/13 §2.1)。少了这一句,
|
||
// 那个文件就成了「写了没人读」的摆设,而自动档仍然开着 —— 静默。
|
||
"runtimeconfig.LoadMemoryTidyTuning",
|
||
// 依赖面里**没有**任何会话/历史读数 —— 这是「绝不碰主会话上下文」
|
||
// 的结构性保证,而不是靠自觉。
|
||
"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
|
||
}
|
||
|
||
// TestApplyMemoryTidyTuning_ReadsDataDirConfig 钉住「自动档开关确实来自
|
||
// $DATA_DIR/config/memory-tidy.json」这条接线:它没有 env 键(`.env` 已冻结),
|
||
// 因此若组合根没读那个文件,「一键回到只产建议」就是一句空话 —— 文件写了没人看。
|
||
func TestApplyMemoryTidyTuning_ReadsDataDirConfig(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
// ① 文件不存在 ⇒ 保持代码默认(自动档开着)。
|
||
empty := &app{cfg: &config.Config{DataDir: t.TempDir()}, log: tidyTestLog()}
|
||
cfg := memory.TidyConfigFromConfig(config.MemoryTidyConfig{Enabled: true})
|
||
empty.applyMemoryTidyTuning(&cfg)
|
||
if cfg.DisableAutoApply || cfg.AutoApplyPerRound != 0 {
|
||
t.Fatalf("没配过时 cfg = %+v, want 保持零值(由 normalize 填代码默认)", cfg)
|
||
}
|
||
|
||
// ② 写 {"autoApply": false, "autoApplyPerRound": 5} ⇒ 关掉 + 抬高上限。
|
||
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(`{"autoApply": false, "autoApplyPerRound": 5}`), 0o600); err != nil {
|
||
t.Fatalf("write: %v", err)
|
||
}
|
||
off := &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()}
|
||
cfg = memory.TidyConfigFromConfig(config.MemoryTidyConfig{Enabled: true})
|
||
off.applyMemoryTidyTuning(&cfg)
|
||
if !cfg.DisableAutoApply || cfg.AutoApplyPerRound != 5 {
|
||
t.Fatalf("cfg = %+v, want DisableAutoApply=true 且 AutoApplyPerRound=5", cfg)
|
||
}
|
||
|
||
// ③ 坏文件 ⇒ 记 WARN 并回落代码默认(绝不把自动档读成「无限制」或「关掉」)。
|
||
if err := os.WriteFile(path, []byte(`{"autoApply": "nope"}`), 0o600); err != nil {
|
||
t.Fatalf("write broken: %v", err)
|
||
}
|
||
broken := &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()}
|
||
cfg = memory.TidyConfigFromConfig(config.MemoryTidyConfig{Enabled: true})
|
||
broken.applyMemoryTidyTuning(&cfg)
|
||
if cfg.DisableAutoApply || cfg.AutoApplyPerRound != 0 {
|
||
t.Fatalf("坏配置时 cfg = %+v, want 回落零值(默认值由 normalize 填)", cfg)
|
||
}
|
||
}
|