diff --git a/.env.example b/.env.example index 75a5537..1e71fd5 100644 --- a/.env.example +++ b/.env.example @@ -210,6 +210,29 @@ THINK_KIND_WINDOW_SEC=7200 QUIET_START_HOUR=23 QUIET_END_HOUR=7 +# ---- 记忆整理与去重(design/23;主题 N · 长期记忆维护) ---- +# **低峰定期后台任务**(不是会话内派 subagent):逐分区扫描 → 相似度**召回**候选对 → +# 「事实集合包含关系」判定 → 规则否决 → 落 `memory_tidy_candidates` 建议队列。 +# 第一版**只产建议**:绝不自动合并/删除/改写任何一条记忆,也**不推送**、不进 chat、 +# 不碰主会话上下文(跑在专属隔离会话 sess-memorytidy,与 backthink 同一范式)。 +# 每轮汇总量(扫描/候选/否决原因分布)进日志与 `memory_tidy_runs` 表,绝不静默。 +# 关闭开关:写 false 即完全不跑(日志会写明)。 +MEMORY_TIDY_ENABLED=true +# 整理周期(秒),默认 86400 = 每天 1 次。它是**下限**:实际在低峰窗口起点跑, +# 因此真实间隔取「低峰窗口每天一次」与本值中较长者。 +MEMORY_TIDY_INTERVAL_SEC=86400 +# 低峰窗口(本地钟点),默认与 backthink 一致的 23:00-07:00(窗口可跨午夜; +# 两端相等且非 0 = 关闭窗口,改为按上面周期跑)。整理只在窗口内开跑。 +MEMORY_TIDY_QUIET_START_HOUR=23 +MEMORY_TIDY_QUIET_END_HOUR=7 +# 候选召回的余弦**召回门**,默认 0.80。⚠️ 它只决定「哪些对值得看一眼」, +# **不是**合并判据 —— 能否合并由「事实集合包含关系(A ⊆ B)+ 规则否决」决定 +# (实据:必合对 0.8330 与必不合对 0.7100 只差 0.12,单阈值双向失效)。 +MEMORY_TIDY_RECALL_THRESHOLD=0.80 +# 每分区每轮的扫描上限(默认 5000)与候选上限(默认 20)。 +MEMORY_TIDY_SCAN_LIMIT=5000 +MEMORY_TIDY_CANDIDATE_LIMIT=20 + # ---- 唤醒式能力渐进(capacity 意图判定,design/14 §2.2) ---- # 总开关(默认 false):关闭时 loop 向模型暴露【全部】已注册工具,行为与接线前逐字节一致; # 置 true 才按判定出的状态裁剪可用工具集(判定只在 loop 外围,不阻塞 Invoke)。 diff --git a/cmd/cyrene/main.go b/cmd/cyrene/main.go index 24cf247..e3f2a7d 100644 --- a/cmd/cyrene/main.go +++ b/cmd/cyrene/main.go @@ -90,6 +90,12 @@ func run() error { return fmt.Errorf("start automations: %w", err) } a.backthink.Start(ctx) + // 记忆整理(design/23 §4):低峰定期后台任务。它不做启动即跑 —— 只在 + // 安静时段起点开跑,因此进程重启不会触发一次全库扫描;配置关闭时只记 + // 一条日志并立即返回。 + if a.memoryTidy != nil { + a.memoryTidy.Start(ctx) + } // 文档回收(切片 S7):启动期清扫一次 + 此后按 DOCUMENT_GC_INTERVAL 定时清扫。 // 它不等这一次跑完就去拉网关(见 wire_document_gc.go 的取舍 1)。 a.startDocumentGC(ctx) diff --git a/cmd/cyrene/wire.go b/cmd/cyrene/wire.go index f0484db..29d5a17 100644 --- a/cmd/cyrene/wire.go +++ b/cmd/cyrene/wire.go @@ -17,6 +17,7 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/iot" "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/history" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" @@ -52,18 +53,22 @@ const ( // app 持有单二进制 Cyrene 组装的全部子系统:由 newApp 构建,关闭时由 close 拆除。 type app struct { - cfg *config.Config - log *slog.Logger - db *sql.DB - dbPath string - bus bus.Bus - modelAdapter model.Adapter - llm *model.RuntimeLLM - personas *personareg.Registry - caps *registry.Registry - dispatcher *tool.Dispatcher - memStore *memory.Store - retriever *memory.Retriever + cfg *config.Config + log *slog.Logger + db *sql.DB + dbPath string + bus bus.Bus + modelAdapter model.Adapter + llm *model.RuntimeLLM + personas *personareg.Registry + caps *registry.Registry + dispatcher *tool.Dispatcher + memStore *memory.Store + retriever *memory.Retriever + // embedder 与 memoryTidy 属于记忆整理(design/23):整理只在 embedder + // 上算召回相似度,绝不接触会话/历史读数。 + embedder embedding.Embedder + memoryTidy *memory.TidyService projStore *projectindex.Store loop *loop.Loop scheduler *orchestration.Scheduler diff --git a/cmd/cyrene/wire_memory_tidy.go b/cmd/cyrene/wire_memory_tidy.go new file mode 100644 index 0000000..9af32d6 --- /dev/null +++ b/cmd/cyrene/wire_memory_tidy.go @@ -0,0 +1,49 @@ +package main + +import ( + "fmt" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory" +) + +// 本文件把记忆整理(DevDocs/design/23-记忆整理与去重 §4)接进组合根。 +// +// 三件事只在这里定: +// - **形态**:低峰定期后台任务(memory.TidyService 的心跳),不是会话内派 +// subagent。它在 main.go 与 backthink 并列启动、在 close 里先于关库停止; +// - **依赖面**:只给它记忆存储与 embedder —— 服务侧拿不到任何会话/历史读数, +// 因此 design §4.1「绝不碰主会话上下文」是结构性成立的,而不是靠自觉; +// - **审计**:接到网关的 AuditEvent(design §8 的 memory.tidy.*),出口在调用 +// 时才解析 a.gw —— 网关比本服务晚组装(wireFrontend),与 onToolAudit +// 同一手法。未就绪时只记日志,绝不假装写过审计。 +// +// 刻意**不做**的两件事(design §4.1 F、§3 非目标): +// - 不把 sess-memorytidy 加进 flowPub 的图源白名单(默认关:整理是系统任务, +// 不是主人的会话); +// - 不给它任何写记忆的路径(第一版只产候选,"立即整理"入口是 +// TidyService.RunOnce)。 +func (a *app) wireMemoryTidy() error { + svc, err := memory.NewTidyService(memory.TidyConfigFromConfig(a.cfg.MemoryTidy), memory.TidyDeps{ + Store: a.memStore, + Embedder: a.embedder, + Audit: a.auditMemoryTidy, + Log: logger.WithSource(a.log, "memory/tidy"), + }) + if err != nil { + return fmt.Errorf("memory tidy: %w", err) + } + a.memoryTidy = svc + return nil +} + +// auditMemoryTidy 把一轮整理的计数写进网关审计链。它**只带计数**,绝不带 +// 任何记忆正文(design §8 的 who/what/when/result 口径)。 +func (a *app) auditMemoryTidy(who, what, result string) { + if a.gw != nil { + a.gw.AuditEvent(who, what, result) + return + } + logger.WithSource(a.log, "memory/tidy").Info( + "memory tidy audit dropped: gateway not ready", "who", who, "what", what, "result", result) +} diff --git a/cmd/cyrene/wire_memory_tidy_test.go b/cmd/cyrene/wire_memory_tidy_test.go new file mode 100644 index 0000000..da1c94d --- /dev/null +++ b/cmd/cyrene/wire_memory_tidy_test.go @@ -0,0 +1,208 @@ +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:")上。 +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 +} diff --git a/cmd/cyrene/wire_runtime.go b/cmd/cyrene/wire_runtime.go index 5cace27..3b37980 100644 --- a/cmd/cyrene/wire_runtime.go +++ b/cmd/cyrene/wire_runtime.go @@ -89,6 +89,7 @@ func (a *app) wireMemory(ctx context.Context) error { embedManager := embedding.New(a.cfg.Embedding) embedder := embedManager.Embedder() + a.embedder = embedder index, err := vector.New(a.dbCfgForEmbed(), embedder, a.log) if err != nil { @@ -112,6 +113,12 @@ func (a *app) wireMemory(ctx context.Context) error { opts = append(opts, memory.WithProjectIndex(projStore)) } a.retriever = memory.NewRetriever(store, opts...) + // 记忆整理(design/23 §4):低峰定期后台任务,只产候选不写记忆。 + // 它只需 memStore 与 embedder,因此在这里(记忆栈刚建好时)装配; + // 启动/停止与 backthink 并列(main.go / close)。 + if err := a.wireMemoryTidy(); err != nil { + return err + } return nil } diff --git a/cmd/cyrene/wire_services.go b/cmd/cyrene/wire_services.go index 6d099aa..2c29310 100644 --- a/cmd/cyrene/wire_services.go +++ b/cmd/cyrene/wire_services.go @@ -109,6 +109,11 @@ func (a *app) close(ctx context.Context) { if a.backthink != nil { _ = a.backthink.Stop(ctx) } + // 记忆整理与 backthink 同属后台定时任务:先停它,再关库(它每轮只有 + // 查询与候选写入,且 Stop 会等本轮跑完)。 + if a.memoryTidy != nil { + _ = a.memoryTidy.Stop(ctx) + } if a.automation != nil { _ = a.automation.Stop(ctx) } diff --git a/internal/config/config.go b/internal/config/config.go index a2c3d8f..bb700de 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -53,6 +53,7 @@ type Config struct { Channels ChannelsConfig Context ContextConfig Guardrail GuardrailConfig + MemoryTidy MemoryTidyConfig } // ServerConfig holds the single gateway listener settings. @@ -228,6 +229,33 @@ type BackgroundConfig struct { KindWindowSec int `env:"THINK_KIND_WINDOW_SEC" default:"7200"` } +// MemoryTidyConfig 是记忆整理(design/23-记忆整理与去重)的开关与配额旋钮。 +// 它映射进 memory.TidyConfig;未暴露为 env 的项(更低频的调参)由该包取 +// 设计稿默认值。所有键的默认值都取设计稿的默认/建议值(design/23 §13)。 +type MemoryTidyConfig struct { + // Enabled 是总开关,默认 true(design/23 §4:可用 MEMORY_TIDY_ENABLED=false + // 关闭)。整理**只产建议、绝不自动改记忆**,因此默认开启是安全的; + // 它带来的是每轮一次的记忆向量化(召回用)与一批候选行。 + Enabled bool `env:"MEMORY_TIDY_ENABLED" default:"true"` + // IntervalSec 是整理周期(秒),默认 86400 = 每天 1 次(design/23 §13 + // 开放点 8 的建议值)。它是下限:实际在安静时段起点跑,因此间隔取 + // 「安静时段每天一次」与它之中较长者。 + IntervalSec int `env:"MEMORY_TIDY_INTERVAL_SEC" default:"86400"` + // QuietStartHour / QuietEndHour 是低峰窗口(本地钟点),默认复用 + // backthink 的 23:00-07:00 语义(窗口可跨午夜;两端相等且非 0 则关闭窗口, + // 改为纯周期)。整理只在低峰跑,且**从不推送**(design/23 §4)。 + QuietStartHour int `env:"MEMORY_TIDY_QUIET_START_HOUR" default:"23"` + QuietEndHour int `env:"MEMORY_TIDY_QUIET_END_HOUR" default:"7"` + // RecallThreshold 是候选召回的余弦**召回门**,默认 0.80(design/23 §5.1 + // T0-2 与 §13 开放点 2)。它只决定「哪些对值得看一眼」,能否合并由包含 + // 判定 + 规则否决决定 —— 绝不是「超过阈值就合」。 + RecallThreshold float64 `env:"MEMORY_TIDY_RECALL_THRESHOLD" default:"0.80"` + // ScanLimit 是每分区每轮的扫描条数上限,默认 5000(design/23 §9)。 + ScanLimit int `env:"MEMORY_TIDY_SCAN_LIMIT" default:"5000"` + // CandidateLimit 是每分区每轮的候选条数上限,默认 20(design/23 §9)。 + CandidateLimit int `env:"MEMORY_TIDY_CANDIDATE_LIMIT" default:"20"` +} + // SubagentConfig holds the sub-agent orchestration settings (design/05 §7). type SubagentConfig struct { // TimeoutSec bounds one sub-agent's whole run. It feeds the dispatch tool's diff --git a/internal/config/memory_tidy_test.go b/internal/config/memory_tidy_test.go new file mode 100644 index 0000000..c9251a9 --- /dev/null +++ b/internal/config/memory_tidy_test.go @@ -0,0 +1,57 @@ +package config + +import ( + "strings" + "testing" +) + +// TestLoad_MemoryTidyDefaults 锁定记忆整理(design/23 §4/§9/§13)的 env 契约默认值。 +// 它们必须与 design/23 的默认/建议值一致,也必须与 `memory` 包的兜底默认值一致 +// (两处不一致会让「没配这个键」与「配了但解析失败」走向不同的行为)。 +func TestLoad_MemoryTidyDefaults(t *testing.T) { + t.Setenv("JWT_SECRET", strings.Repeat("s", 40)) + + cfg, err := Load("") + if err != nil { + t.Fatalf("Load() error: %v", err) + } + got := cfg.MemoryTidy + if !got.Enabled { + t.Error("MEMORY_TIDY_ENABLED 默认应为 true(design §4:可用 false 关闭)") + } + if got.IntervalSec != 86400 { + t.Errorf("MEMORY_TIDY_INTERVAL_SEC = %d, want 86400(每天 1 次)", got.IntervalSec) + } + if got.QuietStartHour != 23 || got.QuietEndHour != 7 { + t.Errorf("低峰窗口 = %d-%d, want 23-7(复用 backthink 语义)", got.QuietStartHour, got.QuietEndHour) + } + if got.RecallThreshold != 0.80 { + t.Errorf("MEMORY_TIDY_RECALL_THRESHOLD = %v, want 0.80(design §13 开放点 2 的建议值)", got.RecallThreshold) + } + if got.ScanLimit != 5000 || got.CandidateLimit != 20 { + t.Errorf("配额 = %d/%d, want 5000/20(design §9)", got.ScanLimit, got.CandidateLimit) + } +} + +// TestLoad_MemoryTidyEnvOverrides 锁定键名与类型:写进 .env 的旋钮真的生效 +// (键名写错会静默沿用默认值,这类错误只能靠这条用例抓住)。 +func TestLoad_MemoryTidyEnvOverrides(t *testing.T) { + t.Setenv("JWT_SECRET", strings.Repeat("s", 40)) + t.Setenv("MEMORY_TIDY_ENABLED", "false") + t.Setenv("MEMORY_TIDY_INTERVAL_SEC", "3600") + t.Setenv("MEMORY_TIDY_QUIET_START_HOUR", "1") + t.Setenv("MEMORY_TIDY_QUIET_END_HOUR", "5") + t.Setenv("MEMORY_TIDY_RECALL_THRESHOLD", "0.7") + t.Setenv("MEMORY_TIDY_SCAN_LIMIT", "10") + t.Setenv("MEMORY_TIDY_CANDIDATE_LIMIT", "3") + + cfg, err := Load("") + if err != nil { + t.Fatalf("Load() error: %v", err) + } + got := cfg.MemoryTidy + if got.Enabled || got.IntervalSec != 3600 || got.QuietStartHour != 1 || got.QuietEndHour != 5 || + got.RecallThreshold != 0.7 || got.ScanLimit != 10 || got.CandidateLimit != 3 { + t.Fatalf("MEMORY_TIDY_* 未生效:%+v", got) + } +} diff --git a/internal/db/migrations/0019_memory_tidy.down.sql b/internal/db/migrations/0019_memory_tidy.down.sql new file mode 100644 index 0000000..dc31cc3 --- /dev/null +++ b/internal/db/migrations/0019_memory_tidy.down.sql @@ -0,0 +1,14 @@ +-- 0019 的回滚:先删索引(否则 SQLite 拒绝 DROP COLUMN),再删两张新表, +-- 最后按逆序删掉 memory_items 的整理列。 +DROP INDEX IF EXISTS idx_memory_tidy_runs_started_at; +DROP TABLE IF EXISTS memory_tidy_runs; +DROP INDEX IF EXISTS idx_memory_tidy_candidates_status; +DROP TABLE IF EXISTS memory_tidy_candidates; +DROP INDEX IF EXISTS idx_memory_items_status; +ALTER TABLE memory_items DROP COLUMN expires_at; +ALTER TABLE memory_items DROP COLUMN valid_to; +ALTER TABLE memory_items DROP COLUMN occurred_at; +ALTER TABLE memory_items DROP COLUMN merge_count; +ALTER TABLE memory_items DROP COLUMN merged_from; +ALTER TABLE memory_items DROP COLUMN merged_into; +ALTER TABLE memory_items DROP COLUMN status; diff --git a/internal/db/migrations/0019_memory_tidy.up.sql b/internal/db/migrations/0019_memory_tidy.up.sql new file mode 100644 index 0000000..6c7e65e --- /dev/null +++ b/internal/db/migrations/0019_memory_tidy.up.sql @@ -0,0 +1,58 @@ +-- 记忆整理与去重(design/23)。本迁移只落「建议侧」的结构: +-- ① memory_items 的整理列(全部带默认值 ⇒ 既有行行为逐字不变); +-- ② 整理候选表 memory_tidy_candidates(人工确认队列); +-- ③ 整理运行表 memory_tidy_runs(留痕,不靠会话)。 +-- 第一版**不自动应用任何动作**(design/23 §7),因此 ① 的新列在此版本里 +-- 只被读取(扫描过滤 status='active'),不被本迁移之后的代码改写。 +ALTER TABLE memory_items ADD COLUMN status TEXT NOT NULL DEFAULT 'active'; +ALTER TABLE memory_items ADD COLUMN merged_into TEXT NOT NULL DEFAULT ''; +ALTER TABLE memory_items ADD COLUMN merged_from TEXT NOT NULL DEFAULT '[]'; +ALTER TABLE memory_items ADD COLUMN merge_count INTEGER NOT NULL DEFAULT 1; +ALTER TABLE memory_items ADD COLUMN occurred_at TEXT NOT NULL DEFAULT ''; +ALTER TABLE memory_items ADD COLUMN valid_to TEXT NOT NULL DEFAULT ''; +ALTER TABLE memory_items ADD COLUMN expires_at TEXT NOT NULL DEFAULT ''; +CREATE INDEX IF NOT EXISTS idx_memory_items_status + ON memory_items (persona_id, status); + +CREATE TABLE IF NOT EXISTS memory_tidy_candidates ( + id TEXT PRIMARY KEY, + persona_id TEXT NOT NULL, + scope TEXT NOT NULL, + project_id TEXT NOT NULL DEFAULT '', + kind TEXT NOT NULL DEFAULT '', + action TEXT NOT NULL, + source_ids TEXT NOT NULL DEFAULT '[]', + target_id TEXT NOT NULL DEFAULT '', + proposed_content TEXT NOT NULL DEFAULT '', + merged_content TEXT NOT NULL DEFAULT '', + reason TEXT NOT NULL DEFAULT '', + evidence TEXT NOT NULL DEFAULT '{}', + rule_version TEXT NOT NULL DEFAULT '', + score_min REAL NOT NULL DEFAULT 0, + score_max REAL NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'pending', + idempotency_key TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL, + decided_at TEXT NOT NULL DEFAULT '', + decided_by TEXT NOT NULL DEFAULT '' +); + +CREATE INDEX IF NOT EXISTS idx_memory_tidy_candidates_status + ON memory_tidy_candidates (status, scope); + +CREATE TABLE IF NOT EXISTS memory_tidy_runs ( + id TEXT PRIMARY KEY, + started_at TEXT NOT NULL, + finished_at TEXT NOT NULL DEFAULT '', + scanned INTEGER NOT NULL DEFAULT 0, + candidates INTEGER NOT NULL DEFAULT 0, + applied INTEGER NOT NULL DEFAULT 0, + skipped INTEGER NOT NULL DEFAULT 0, + errors INTEGER NOT NULL DEFAULT 0, + vetoed INTEGER NOT NULL DEFAULT 0, + veto_reasons TEXT NOT NULL DEFAULT '{}', + rule_version TEXT NOT NULL DEFAULT '' +); + +CREATE INDEX IF NOT EXISTS idx_memory_tidy_runs_started_at + ON memory_tidy_runs (started_at); diff --git a/internal/runtime/memory/store.go b/internal/runtime/memory/store.go index 5ace4cd..fa62156 100644 --- a/internal/runtime/memory/store.go +++ b/internal/runtime/memory/store.go @@ -185,6 +185,11 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error) if err := ensureProjectIDColumn(ctx, db); err != nil { return nil, err } + // 记忆整理(design/23 §10)的列与两张新表:同样在这一处幂等前滚, + // 使「在任意 *sql.DB 上都能整理」与 memory_items 自身口径一致。 + if err := ensureTidySchema(ctx, db); err != nil { + return nil, err + } if err := normalizeMemoryTimestamps(ctx, db); err != nil { return nil, err } @@ -473,7 +478,14 @@ CREATE TABLE IF NOT EXISTS memory_items ( source_conversation TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, - decay_weight REAL NOT NULL DEFAULT 1.0 + decay_weight REAL NOT NULL DEFAULT 1.0, + status TEXT NOT NULL DEFAULT 'active', + merged_into TEXT NOT NULL DEFAULT '', + merged_from TEXT NOT NULL DEFAULT '[]', + merge_count INTEGER NOT NULL DEFAULT 1, + occurred_at TEXT NOT NULL DEFAULT '', + valid_to TEXT NOT NULL DEFAULT '', + expires_at TEXT NOT NULL DEFAULT '' )` const createMemoryItemsPersonaIndex = ` diff --git a/internal/runtime/memory/tidy.go b/internal/runtime/memory/tidy.go new file mode 100644 index 0000000..ad15ced --- /dev/null +++ b/internal/runtime/memory/tidy.go @@ -0,0 +1,367 @@ +package memory + +import ( + "context" + "errors" + "fmt" + "log/slog" + "sort" + "strings" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding" +) + +// 本文件是记忆整理(design/23-记忆整理与去重)的整理器与编排:逐分区扫描 → +// 候选召回 → 包含判定 → 规则否决 → 候选落库 + 运行留痕。 +// +// 三条不可走样的口径(design/23 §0/§5.1): +// 1. **相似度只用来召回候选对**,永不直接当合并判据; +// 2. **合并的唯一条件是「事实集合的包含关系」**(A ⊆ B 才并入 B); +// 相似但并列的事实只建链接,绝不合并; +// 3. **规则否决一票否决**(跨作用域/跨角色/跨项目/群聊来源/时效污染/ +// 数值不一致/否定冲突/低置信),命中即踢出候选。 +// +// 第一版**只产建议**:RunOnce 唯一的写入是候选表与运行表, +// 绝不改写、绝不删除任何一条记忆(design/23 §3 非目标 + §7)。 + +// TidyAction 是整理建议的动作类别(design/23 §10 的 action 枚举)。 +type TidyAction string + +const ( + // TidyMerge 表示 A 的事实集合是 B 的子集,建议把 A 并入 B(保留 B)。 + // 它仍需人工确认(design/23 §7),第一版绝不自动应用。 + TidyMerge TidyAction = "merge" + // TidyLink 表示两条相似但**互不包含**,只建链接/分组,绝不合并 + // (design/23 §1.3 的 [1]/[3]「红牛 vs 冰美式」)。 + TidyLink TidyAction = "link" + // TidyArchive 表示含绝对日期/时刻的一次性事件,建议归档而非合并 + // (design/23 §5.1 T0-5)。 + TidyArchive TidyAction = "archive" + // TidyReclassify 表示 kind 误分类(design/23 §5.1 T0-6),只产候选不自动改。 + TidyReclassify TidyAction = "reclassify" + // TidyKeep 是「拿不准 = 什么都不做」(design/23 §5.2-10)。它是合法动作, + // 但第一版不为它落库(只进否决计数与日志),以免候选队列被噪声填满。 + TidyKeep TidyAction = "keep" +) + +// TidyVetoReason 是规则否决的原因标签:进日志、进运行表的原因分布 +// (design/23 §9「每轮记否决原因分布」),也是测试断言的抓手。 +type TidyVetoReason string + +const ( + // VetoNone 表示未命中任何否决规则。 + VetoNone TidyVetoReason = "" + // VetoScope 是跨作用域硬门(design/23 §5.2-7):user/persona/project 之间永不合。 + VetoScope TidyVetoReason = "scope" + // VetoPersona 是跨角色硬门(design/23 §5.2-8)。 + VetoPersona TidyVetoReason = "persona" + // VetoProject 是跨项目硬门(design/23 §5.2-8)。 + VetoProject TidyVetoReason = "project" + // VetoSource 是群聊/陌生人来源(design/23 §5.2-9、§6):只读不写。 + VetoSource TidyVetoReason = "source" + // VetoTemporal 是时效污染(design/23 §5.2-1):一次性事件/有期限状态 + // 不等于长期事实([8]「9月8日晚」≠ 熬夜习惯)。 + VetoTemporal TidyVetoReason = "temporal" + // VetoNumeric 是数值/配置不一致(design/23 §5.2-2):数值不同即不同事实。 + VetoNumeric TidyVetoReason = "numeric" + // VetoConflict 是否定/冲突(design/23 §5.2-3):X vs 非X 是覆盖不是重复。 + VetoConflict TidyVetoReason = "conflict" + // VetoLowConfidence 是低置信否决门(design/23 §5.2-6)。 + VetoLowConfidence TidyVetoReason = "low_confidence" +) + +// ErrNilStore 在整理器没有收到记忆存储时返回。 +var ErrNilStore = errors.New("memory: tidy needs a store") + +// ErrTidyEmbedCount 在 embedder 返回的向量条数与请求条数不一致时返回: +// 缺失向量会让相似度与条目错位,宁可整体回退到词项余弦。 +var ErrTidyEmbedCount = errors.New("memory/tidy: embedder returned a mismatched vector count") + +// TidyRun 是一轮整理的留痕(design/23 §10 memory_tidy_runs)。 +type TidyRun struct { + ID string + StartedAt time.Time + FinishedAt time.Time + Scanned int + Candidates int + Applied int + Skipped int + Errors int + Vetoed int + VetoReasons map[string]int + RuleVersion string +} + +// TidyStats 是一轮整理的观测口径:**扫描 N / 候选 M / 建议 K / 否决原因分布** +// (design/23 §9)。它同时写进日志与运行表,绝不静默。 +type TidyStats struct { + Scanned int + Partitions int + Compared int + Pairs int + Candidates int + Duplicates int + Skipped int + Vetoed int + VetoReasons map[string]int + // Errors 是本轮的失败计数(分区失败 + 候选写入失败),进运行表。 + Errors int + // Truncated 是被上限截断的分区标签(扫描或比对上限),供运维放大配额。 + Truncated []string +} + +// TidyDeps 是注入给整理器的依赖。Store 必需;其余可空并优雅降级。 +type TidyDeps struct { + // Store 是记忆存储。必需 —— 整理只读写它,绝不接触会话历史。 + Store *Store + // Embedder 是候选召回的相似度来源。为 nil 时回退到零依赖的 + // 字符二元组余弦(离线可用、测试确定性),见 tidy_recall.go。 + Embedder embedding.Embedder + // Audit 是审计出口(design/23 §8 的 memory.tidy.* 动作)。可选: + // 组合根把它接到网关的 AuditEvent;为 nil 时只记日志。 + Audit func(who, what, result string) + // Now 是运行时间戳的墙钟来源,便于测试注入。 + Now func() time.Time + // Log 是基础日志器,来源标记为 "memory/tidy"。 + Log *slog.Logger +} + +// Tidier 是记忆整理器。它并发安全**仅限**「一轮之内不重入」: +// 服务层用一把互斥锁保证同一时刻只有一轮(design/23 §9 单写者)。 +type Tidier struct { + cfg TidyConfig + deps TidyDeps + log *slog.Logger + now func() time.Time +} + +// NewTidier 由 cfg 与 deps 构建整理器。Store 为 nil 时返回 ErrNilStore。 +func NewTidier(cfg TidyConfig, deps TidyDeps) (*Tidier, error) { + if deps.Store == nil { + return nil, ErrNilStore + } + cfg = normalizeTidyConfig(cfg) + now := deps.Now + if now == nil { + now = time.Now + } + return &Tidier{ + cfg: cfg, + deps: deps, + log: logger.WithSource(baseTidyLog(deps.Log), "memory/tidy"), + now: now, + }, nil +} + +// Config 返回生效的整理配置(已套默认值)。 +func (t *Tidier) Config() TidyConfig { return t.cfg } + +// RunOnce 跑一轮整理,并写一条运行留痕。它返回本轮的观测计数。 +// +// 失败隔离(design/23 §9):单个分区失败不影响其它分区,失败计数进运行表与 +// 日志;只有「所有分区都失败」或运行留痕本身写不进去时才回错误。 +func (t *Tidier) RunOnce(ctx context.Context) (TidyStats, error) { + started := t.now().UTC() + stats := TidyStats{VetoReasons: make(map[string]int)} + + partitions, err := t.deps.Store.listTidyPartitions(ctx) + if err != nil { + return stats, fmt.Errorf("memory/tidy: list partitions: %w", err) + } + stats.Partitions = len(partitions) + + var failures []error + for _, p := range partitions { + if err := t.runPartition(ctx, p, &stats); err != nil { + stats.Errors++ + failures = append(failures, err) + t.log.Warn("memory tidy partition failed", "partition", p.label(), "error", err) + } + } + + run := TidyRun{ + // 运行 id 在这里就定下来:留痕与日志必须指向同一条运行记录。 + ID: mustNewTidyID(), + StartedAt: started, + FinishedAt: t.now().UTC(), + Scanned: stats.Scanned, + Candidates: stats.Candidates, + Applied: 0, // 第一版绝不自动应用(design/23 §7) + Skipped: stats.Skipped + stats.Duplicates, + Errors: stats.Errors, + Vetoed: stats.Vetoed, + VetoReasons: stats.VetoReasons, + RuleVersion: TidyRuleVersion, + } + if err := t.deps.Store.persistTidyRun(ctx, run); err != nil { + failures = append(failures, err) + } + + t.logRun(stats, run) + + switch { + case len(failures) == 0: + return stats, nil + case len(failures) == len(partitions) && len(partitions) > 0: + return stats, fmt.Errorf("memory/tidy: every partition failed: %w", errors.Join(failures...)) + default: + return stats, fmt.Errorf("memory/tidy: run finished with errors: %w", errors.Join(failures...)) + } +} + +// logRun 输出本轮的可观测口径:扫描 N / 候选 M / 建议 K / 否决原因分布。 +// 它带 source(memory/tidy),因此这条汇总永远可回溯到整理子系统。 +func (t *Tidier) logRun(stats TidyStats, run TidyRun) { + fields := []any{ + "run_id", run.ID, + "rule_version", run.RuleVersion, + "partitions", stats.Partitions, + "scanned", run.Scanned, + "compared", stats.Compared, + "pairs", stats.Pairs, + "candidates", run.Candidates, + "duplicates", stats.Duplicates, + "skipped", stats.Skipped, + "vetoed", run.Vetoed, + "veto_reasons", tidyReasonsLabel(run.VetoReasons), + "errors", run.Errors, + "duration_ms", run.FinishedAt.Sub(run.StartedAt).Milliseconds(), + "recall_mode", tidyRecallMode(t.deps.Embedder != nil), + } + if len(stats.Truncated) > 0 { + fields = append(fields, "truncated", strings.Join(stats.Truncated, ",")) + } + t.log.Info("memory tidy run finished", fields...) +} + +// runPartition 整理一个分区(persona_id + scope + project_id)。 +// 分区之间**永不比较**(design/23 §6 硬约束)。 +func (t *Tidier) runPartition(ctx context.Context, p tidyPartition, stats *TidyStats) error { + items, err := t.deps.Store.listTidyPartitionItems(ctx, p, t.cfg.ScanLimit) + if err != nil { + return err + } + stats.Scanned += len(items) + if len(items) == t.cfg.ScanLimit { + // 扫描上限命中:本分区更大的部分这一轮不看(design §9「超限分片,下一轮 + // 继续」)。本实现只做「最新优先」的截断、不推进分片游标,因此这里必须 + // 留下痕迹,否则「配置了 5000 却永远只看最新 5000」会无人知晓。 + stats.Truncated = append(stats.Truncated, p.label()+"(scan)") + t.log.Warn("memory tidy partition hit the scan limit", + "partition", p.label(), "limit", t.cfg.ScanLimit) + } + + comparable := make([]MemoryItem, 0, len(items)) + archives := make([]TidyCandidate, 0) + for _, it := range items { + switch t.classifyForTidy(it, stats) { + case tidySkip: + continue + case tidyArchive: + // 一次性事件:产 archive 候选,且**不参与任何 merge**(design §5.1-5)。 + archives = append(archives, archiveCandidate(it, t.now().UTC())) + continue + default: + comparable = append(comparable, it) + } + } + + pairs := t.recallPairs(ctx, p, comparable, stats) + candidates := archives + for _, pr := range pairs { + verdict := judgePair(pr.a, pr.b, pr.score) + if verdict.Veto != VetoNone { + stats.Vetoed++ + stats.VetoReasons[string(verdict.Veto)]++ + continue + } + candidates = append(candidates, buildCandidates(pr, verdict, t.now().UTC())...) + } + // 每分区候选上限(design §9)对**全部候选**生效(含归档建议), + // 超出的只登记不入队,并记进 stats.Truncated(绝不静默)。 + if len(candidates) > t.cfg.CandidateLimit { + stats.Skipped += len(candidates) - t.cfg.CandidateLimit + stats.Truncated = append(stats.Truncated, p.label()+"(candidates)") + candidates = candidates[:t.cfg.CandidateLimit] + } + t.persistCandidates(ctx, candidates, stats) + return nil +} + +// tidyDisposition 是扫描时一条记忆的处置分流。 +type tidyDisposition int + +const ( + tidyCompare tidyDisposition = iota + tidyArchive + tidySkip +) + +// classifyForTidy 决定一条记忆进入比对、进归档候选,还是被排除: +// - 群聊/陌生人来源 → 排除在扫描范围外(design/23 §6,只读不写); +// - 含绝对日期/时刻 → 一次性事件,走归档而非合并([8]「9月8日晚22:38」); +// - 过短内容 → 没有可判定的事实要素,跳过(不产噪声候选)。 +func (t *Tidier) classifyForTidy(it MemoryItem, stats *TidyStats) tidyDisposition { + if nonOwnerSource(it.SourceConversation) { + stats.Vetoed++ + stats.VetoReasons[string(VetoSource)]++ + return tidySkip + } + if hasAbsoluteTimestamp(it.Content) { + return tidyArchive + } + if len([]rune(it.Content)) < minTidyContentRunes { + return tidySkip + } + return tidyCompare +} + +// persistCandidates 落库一批候选:幂等键已存在时只计入 duplicates +// (design/23 §9「重复跑不重复产候选」)。 +func (t *Tidier) persistCandidates(ctx context.Context, cands []TidyCandidate, stats *TidyStats) { + for _, c := range cands { + created, err := t.deps.Store.insertTidyCandidate(ctx, c) + if err != nil { + stats.Errors++ + t.log.Warn("memory tidy candidate insert failed", "action", string(c.Action), "error", err) + continue + } + if !created { + stats.Duplicates++ + continue + } + stats.Candidates++ + } +} + +// tidyReasonsLabel 把否决原因分布渲染成稳定的日志字符串(按原因排序)。 +func tidyReasonsLabel(reasons map[string]int) string { + if len(reasons) == 0 { + return "none" + } + keys := make([]string, 0, len(reasons)) + for k := range reasons { + keys = append(keys, k) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append(parts, fmt.Sprintf("%s=%d", k, reasons[k])) + } + return strings.Join(parts, ",") +} + +// tidyRecallMode 报告本轮相似度的来源,便于运维判断阈值是否在预期口径上。 +func tidyRecallMode(hasEmbedder bool) string { + if hasEmbedder { + return "embedding" + } + return "lexical" +} + +// baseTidyLog 在 log 非 nil 时返回它,否则返回丢弃式日志器。 +func baseTidyLog(log *slog.Logger) *slog.Logger { + return discardLogger(log) +} diff --git a/internal/runtime/memory/tidy_candidate.go b/internal/runtime/memory/tidy_candidate.go new file mode 100644 index 0000000..db3bc60 --- /dev/null +++ b/internal/runtime/memory/tidy_candidate.go @@ -0,0 +1,181 @@ +package memory + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "sort" + "strings" + "time" +) + +// 本文件是记忆整理的**候选**侧:候选与证据的落库形状、由判定结果构造候选、 +// 以及候选的幂等键。它从 tidy.go 拆出来,让编排(扫描→召回→判定→落库) +// 保持在一屏内(AGENTS §10 文件 ≤500 行)。 + +// TidyEvidence 是一条候选的判定证据。design/23 §7 要求 diff 预览能展示 +// 「A 原文 / B 原文 / 合并后内容 / A 被 B 包含的证据 / 命中或未命中的否决规则」, +// 因此这四项都在这里。它装的是主人自己的记忆正文,只落本机库、只走 owner-only +// 的读侧(design/23 §11)。 +type TidyEvidence struct { + // Similarity 是召回用的余弦相似度(只用来召回,不是判定依据)。 + Similarity float64 `json:"similarity"` + // RecallMode 记录本轮相似度的来源(embedding / lexical),便于运维判读。 + RecallMode string `json:"recall_mode,omitempty"` + // Containment 是包含判定结论:a_subset_b / b_subset_a / both / none。 + Containment string `json:"containment,omitempty"` + // ContainmentEvidence 是「A ⊆ B」的逐要素证据(缺哪一项就列哪一项)。 + ContainmentEvidence []string `json:"containment_evidence,omitempty"` + // VetoReason 是命中的否决规则(未命中时为空)。 + VetoReason string `json:"veto_reason,omitempty"` + // KindA/KindB/ProposedKind 供 reclassify 候选展示 kind 误分类建议。 + KindA string `json:"kind_a,omitempty"` + KindB string `json:"kind_b,omitempty"` + ProposedKind string `json:"proposed_kind,omitempty"` + // ContentA/ContentB 是两条原文,供面板 diff 预览。 + ContentA string `json:"content_a,omitempty"` + ContentB string `json:"content_b,omitempty"` +} + +// TidyCandidate 是一条整理建议(落库形状,design/23 §10)。 +type TidyCandidate struct { + ID string + PersonaID string + Scope Scope + ProjectID string + Kind Kind + Action TidyAction + SourceIDs []string + TargetID string + ProposedContent string + MergedContent string + Reason string + Evidence TidyEvidence + RuleVersion string + ScoreMin float64 + ScoreMax float64 + Status string + IdempotencyKey string + CreatedAt time.Time +} + +// tidyVerdict 是一条候选对的判定结果。 +type tidyVerdict struct { + Action TidyAction + Veto TidyVetoReason + Target *MemoryItem + Containment containment + Reason string +} + +// archiveCandidate 为一次性事件产一条归档建议(design/23 §5.1 T0-5)。 +func archiveCandidate(it MemoryItem, now time.Time) TidyCandidate { + return TidyCandidate{ + ID: mustNewTidyID(), + PersonaID: it.PersonaID, + Scope: it.Scope, + ProjectID: it.ProjectID, + Kind: it.Kind, + Action: TidyArchive, + SourceIDs: []string{it.ID}, + ProposedContent: it.Content, + Reason: "one_off_event", + Evidence: TidyEvidence{ + Containment: "none", + ContentA: it.Content, + KindA: string(it.Kind), + }, + RuleVersion: TidyRuleVersion, + Status: tidyCandidatePending, + CreatedAt: now, + } +} + +// buildCandidates 把一条判定结果转成候选:merge 需要一条存活条目(超集), +// kind 不一致时附带一条 reclassify 建议(design/23 §5.1 T0-6)。 +func buildCandidates(pr tidyPair, v tidyVerdict, now time.Time) []TidyCandidate { + evidence := TidyEvidence{ + Similarity: pr.score, + RecallMode: pr.mode, + Containment: string(v.Containment.Kind), + ContainmentEvidence: v.Containment.Missing, + KindA: string(pr.a.Kind), + KindB: string(pr.b.Kind), + ContentA: pr.a.Content, + ContentB: pr.b.Content, + } + base := TidyCandidate{ + ID: mustNewTidyID(), + PersonaID: pr.a.PersonaID, + Scope: pr.a.Scope, + ProjectID: pr.a.ProjectID, + Evidence: evidence, + RuleVersion: TidyRuleVersion, + Status: tidyCandidatePending, + ScoreMin: pr.score, + ScoreMax: pr.score, + CreatedAt: now, + } + + if v.Action != TidyMerge || v.Target == nil { + // 相似但互不包含:只建链接/分组,绝不合并(design §1.3 的 A-MEM 口径)。 + base.Action = TidyLink + base.SourceIDs = []string{pr.a.ID, pr.b.ID} + base.Kind = pr.a.Kind + base.Reason = "similar_but_not_contained" + return []TidyCandidate{base} + } + + absorbed, target := pr.a, *v.Target + if v.Target.ID == pr.a.ID { + absorbed, target = pr.b, pr.a + } + base.Action = TidyMerge + base.SourceIDs = []string{absorbed.ID} + base.TargetID = target.ID + base.Kind = target.Kind + base.ProposedContent = absorbed.Content + base.MergedContent = target.Content + base.Reason = v.Reason + + out := []TidyCandidate{base} + if absorbed.Kind != target.Kind { + // kind 误分类:以存活条目(超集)的 kind 为建议值,交人工确认 + // (design/23 §5.1 T0-6「取多数票或交人工,不自动改」)。 + re := base + re.ID = mustNewTidyID() + re.Action = TidyReclassify + re.SourceIDs = []string{absorbed.ID} + re.TargetID = "" + re.ProposedContent = "" + re.MergedContent = "" + re.Reason = "kind_mismatch" + re.Evidence.ProposedKind = string(target.Kind) + out = append(out, re) + } + return out +} + +// tidyIdempotencyKey 由 design/23 §9 的幂等键口径算出: +// hash(persona_id, scope, project_id, sorted(source_ids), action, rule_version)。 +// 规则不变时同一批数据重复运行产出同一个键,因此候选绝不重复落库。 +func tidyIdempotencyKey(c TidyCandidate) string { + ids := append([]string(nil), c.SourceIDs...) + sort.Strings(ids) + parts := []string{ + c.PersonaID, string(c.Scope), c.ProjectID, + strings.Join(ids, ","), string(c.Action), c.RuleVersion, + } + sum := sha256.Sum256([]byte(strings.Join(parts, "\x00"))) + return hex.EncodeToString(sum[:]) +} + +// mustNewTidyID 生成候选 id。它复用与记忆条目同一个随机源;熵源损坏时回退到 +// 时间戳,而不是让整理整轮失败(候选 id 只需要在本库唯一)。 +func mustNewTidyID() string { + id, err := newID() + if err != nil { + return fmt.Sprintf("tidy-%d", time.Now().UnixNano()) + } + return id +} diff --git a/internal/runtime/memory/tidy_config.go b/internal/runtime/memory/tidy_config.go new file mode 100644 index 0000000..47dd90e --- /dev/null +++ b/internal/runtime/memory/tidy_config.go @@ -0,0 +1,163 @@ +package memory + +import ( + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" +) + +// 本文件收拢记忆整理(design/23-记忆整理与去重)全部的标识、默认值与旋钮, +// 使运行路径上不出现裸魔法数字(AGENTS §10),并让「设计稿默认值」 +// 只有一处定义、可被复核。 +// +// 形态(design/23 §4 方案 A/C):整理是一件**低峰定期后台任务** —— 跑在 +// 专属隔离会话下、零上下文启动、只产候选**不写记忆**,绝不是会话内派 +// subagent。 + +const ( + // TidySessionID 是记忆整理运行的专属隔离会话标识(design/23 §4.1 B)。 + // 它与主会话 history.MainSessionID("web")不同,因此整理不改主人的心情、 + // 不进主会话泳道(cmd/cyrene 的 isConversationLane 对它恒为 false), + // 默认也不进 flowPub 的图源白名单(design/23 §4.1 F 的可选小开关默认关)。 + TidySessionID = "sess-memorytidy" + + // TidyRuleVersion 是 T0 规则集的版本号,进候选的幂等键(design/23 §9): + // 规则不变时重复运行绝不重复产候选;升版后同一数据重新产候选, + // 因此被 reject 的对可以重评。**改判定规则必须同时升它**。 + TidyRuleVersion = "t0.1" + + // defaultTidyIntervalSec 是整理周期秒数,默认 86400 = 每天 1 次 + // (design/23 §13 开放点 8 的建议值)。 + defaultTidyIntervalSec = 86400 + + // defaultTidyQuietStartHour / defaultTidyQuietEndHour 复用 backthink 的 + // 安静时段语义(默认 23:00-07:00,窗口跨午夜;两端相等且非 0 则关闭窗口) + // —— 整理只在低峰跑(design/23 §4)。 + defaultTidyQuietStartHour = 23 + defaultTidyQuietEndHour = 7 + + // defaultTidyRecallThreshold 是候选召回的余弦**召回门**,0.80 + // (design/23 §5.1 T0-2 与 §13 开放点 2 的建议值)。它只决定「哪些对值得 + // 看一眼」:能否合并由包含判定 + 规则否决决定,绝不退化成阈值判断 + // (design/23 §1.2:「必合」0.8330 与「必不合」0.7100 只差 0.12)。 + defaultTidyRecallThreshold = 0.80 + + // defaultTidyScanLimit 是每分区每轮的扫描上限(design/23 §9)。 + defaultTidyScanLimit = 5000 + + // maxTidyCompareItems 是每分区每轮**真正参与两两比对**的条目上限。它是 + // 本实现的性能护栏:两两相似度是 O(n²·dim),5000 条会到十亿级乘加, + // 低峰任务也不该跑成那样。超限时按「最新优先」截断并记一条 WARN + // (绝不静默),见 tidy_recall.go。 + maxTidyCompareItems = 400 + + // defaultTidyCandidateLimit 是每分区每轮的候选上限(design/23 §9)。 + defaultTidyCandidateLimit = 20 + + // tidyLowConfidenceThreshold 是置信度否决门(design/23 §5.2-6): + // confidence 不能当合并门([2]0.7 与 [5]0.8 是同一事实),但必须当否决门。 + tidyLowConfidenceThreshold = 0.7 + + // tidyEmbedBatch 是单次 Embed 调用的文本条数上限,避免把整个分区的文本 + // 一次性交给远端 embedding API。 + tidyEmbedBatch = 64 + + // minTidyContentRunes 是参与比对的最小内容长度(按 rune)。过短的内容 + // (如单字)没有可判定的事实要素,进比对只会产出噪声,直接跳过。 + minTidyContentRunes = 3 +) + +// TidyConfig 携带记忆整理的调优旋钮。零值经 normalize 选取安全默认值, +// 因此半填的 Config 仍可用。 +type TidyConfig struct { + // Enabled 是总开关。关闭时组合根不建服务、不跑任何整理 + // (design/23 §4 的 MEMORY_TIDY_ENABLED)。 + Enabled bool + // Interval 是整理周期。为零时由 IntervalSec(或其默认值)推导。 + Interval time.Duration + // IntervalSec 是整理周期秒数,映射自 config.MemoryTidyConfig.IntervalSec。 + // 设置了 Interval 时忽略。它是**下限**:安静时段每天只有一次, + // 因此实际间隔取两者中较长者。 + IntervalSec int + // QuietStartHour / QuietEndHour 以本地钟点约束低峰窗口 + // (复用 backthink 语义:窗口可跨午夜,两端相等且非 0 则关闭窗口)。 + QuietStartHour int + QuietEndHour int + // RecallThreshold 是候选召回的余弦门(见 defaultTidyRecallThreshold)。 + // 非正用默认值。 + RecallThreshold float64 + // ScanLimit 是每分区每轮扫描条数上限。非正用默认值。 + ScanLimit int + // CandidateLimit 是每分区每轮候选条数上限。非正用默认值。 + CandidateLimit int +} + +// TidyConfigFromConfig 把 config.MemoryTidyConfig(env 侧旋钮)映射成 +// TidyConfig,env 未暴露的项保持零值,交给 normalize 填默认值。 +func TidyConfigFromConfig(cfg config.MemoryTidyConfig) TidyConfig { + return TidyConfig{ + Enabled: cfg.Enabled, + IntervalSec: cfg.IntervalSec, + QuietStartHour: cfg.QuietStartHour, + QuietEndHour: cfg.QuietEndHour, + RecallThreshold: cfg.RecallThreshold, + ScanLimit: cfg.ScanLimit, + CandidateLimit: cfg.CandidateLimit, + } +} + +// normalize 用设计稿默认值填充 TidyConfig 的零值字段。 +func normalizeTidyConfig(cfg TidyConfig) TidyConfig { + if cfg.IntervalSec <= 0 { + cfg.IntervalSec = defaultTidyIntervalSec + } + if cfg.Interval <= 0 { + cfg.Interval = time.Duration(cfg.IntervalSec) * time.Second + } + if cfg.QuietStartHour == 0 && cfg.QuietEndHour == 0 { + cfg.QuietStartHour = defaultTidyQuietStartHour + cfg.QuietEndHour = defaultTidyQuietEndHour + } + if cfg.RecallThreshold <= 0 { + cfg.RecallThreshold = defaultTidyRecallThreshold + } + if cfg.ScanLimit <= 0 { + cfg.ScanLimit = defaultTidyScanLimit + } + if cfg.CandidateLimit <= 0 { + cfg.CandidateLimit = defaultTidyCandidateLimit + } + return cfg +} + +// quietWindowDisabled 报告低峰窗口是否被显式关闭(两端相等且非 0)。 +func (c TidyConfig) quietWindowDisabled() bool { + return c.QuietStartHour != 0 && c.QuietStartHour == c.QuietEndHour +} + +// inQuietHours 报告 at 是否落在低峰窗口内。窗口可跨午夜(23:00-07:00); +// 与 backthink.inQuietHours 同语义。 +func (c TidyConfig) inQuietHours(at time.Time) bool { + if c.quietWindowDisabled() { + return true + } + h := at.Hour() + if c.QuietStartHour <= c.QuietEndHour { + return h >= c.QuietStartHour && h < c.QuietEndHour + } + return h >= c.QuietStartHour || h < c.QuietEndHour +} + +// nextQuietStart 返回 at 之后(严格晚于 at)的下一个低峰窗口起点: +// 今天/明天的 QuietStartHour 整点。窗口关闭时返回零值时间,调用方据此 +// 改走纯周期路径。 +func (c TidyConfig) nextQuietStart(at time.Time) time.Time { + if c.quietWindowDisabled() { + return time.Time{} + } + start := time.Date(at.Year(), at.Month(), at.Day(), c.QuietStartHour, 0, 0, 0, at.Location()) + if !start.After(at) { + start = start.AddDate(0, 0, 1) + } + return start +} diff --git a/internal/runtime/memory/tidy_containment.go b/internal/runtime/memory/tidy_containment.go new file mode 100644 index 0000000..9a87c78 --- /dev/null +++ b/internal/runtime/memory/tidy_containment.go @@ -0,0 +1,296 @@ +package memory + +import ( + "regexp" + "strings" + "unicode" +) + +// 本文件是记忆整理(design/23 §5.1 T0-3/5、§5.2-1/2/3)的**内容判据**: +// 把一条记忆正文拆成可判定的「事实要素」集合、判定包含关系、识别一次性事件 +// 与时效性状态、抽取数值与否定标记。 +// +// 它是纯函数、零 LLM、确定性:同样的输入永远给同样的结论。这样整理才可 +// 幂等(design/23 §4.1 A:两次相同输入的运行必须产出相同候选),测试才 +// 能逐条断言而不是「看起来对」。design/23 §14 把它列为待细化的空白, +// 这里是本切片按「保守近似」落下的具体算法(见各函数的说明)。 + +// 事实要素抽取用的字符分类与固定词表。 +const ( + // tidyMissingEvidenceCap 是 diff 预览里逐要素证据的条数上限:缺失要素 + // 太多时说明两条内容差得远,列全没有意义,也会把 evidence JSON 撑大。 + tidyMissingEvidenceCap = 8 + // tidySingleRuneTerm 表示单字 CJK 串本身作为一个词项(成词才有意义)。 + tidySingleRuneTerm = 1 +) + +// 绝对日期/时刻的判定:它们把一条记忆钉在**具体某一天/某一刻**,因此是 +// 一次性事件的信号([8]「2026年9月8日周二晚22:38」)。 +// 刻意**不含**「22点后 / 十点多」这类周期性表述 —— 那是习惯([5]/[13]/[17])。 +var tidyAbsoluteTimeRe = regexp.MustCompile( + `(?:\d{4}\s*年\s*\d{1,2}\s*月(?:\s*\d{1,2}\s*日)?)` + + `|(?:\d{1,2}\s*月\s*\d{1,2}\s*日)` + + `|(?:\d{4}\s*[-/]\s*\d{1,2}\s*[-/]\s*\d{1,2})` + + `|(?:\d{1,2}\s*/\s*\d{1,2})` + + `|(?:\d{1,2}\s*[::]\s*\d{1,2})`) + +// 星期几:只有**不是**「每周X」这类周期表述时才算绝对时刻。 +var tidyWeekdayRe = regexp.MustCompile(`(?:周|星期)[一二三四五六日天]`) + +// tidyTemporalStateWords 是有期限状态的信号词(design/23 §1.1 的 +// 「时效性污染:一次性事件当长期记忆」里的状态类,如 [12]/[15] 的假期状态)。 +// 命中即否决合并 —— 时效性状态不是长期事实。 +var tidyTemporalStateWords = []string{ + "假期", "放假", "休假", "期间", "暂时", "临时", + "本周", "本月", "这周", "这个月", "上周", "下周", + "今天", "明天", "昨天", "前几天", "即将", +} + +// tidyNegationMarkers 是否定/负向标记(design/23 §5.2-3:「X vs 非X、戒了X、 +// 改成了Y」是**新事实覆盖旧事实**,不是重复,绝不自动合)。 +// +// ⚠️ 刻意**不含**光杆「不」:它在中文里无处不在(「不错」「不一定」), +// 拿它当否定标记会把正常语句误判成冲突。宁可漏判冲突(保守), +// 也不让「不」把绝大多数合并建议否决掉。 +var tidyNegationMarkers = []string{ + "不再", "没有", "没再", "未", "别", "勿", "戒", "停止", "取消", + "拒绝", "否认", "无", "非", "不喜欢", "不爱", "不想", +} + +// tidyFacts 是一条记忆内容的事实要素集合(「原子断言」的可判定近似)。 +type tidyFacts struct { + // nums 是数值/时刻/日期 token:数值不同即不同事实(design/23 §5.2-2)。 + nums map[string]struct{} + // negs 是否定标记(design/23 §5.2-3)。 + negs map[string]struct{} + // terms 是词项:CJK 二元组、拉丁词、emoji/符号。它保留相邻关系 + // (「熬夜」与「夜熬」不该等价),供**召回相似度**使用。 + terms map[string]struct{} + // runes / words 是**包含判据**用的关键要素:CJK 内容单字(已去掉功能字) + // 与拉丁词/emoji 符号。用「关键要素逐一出现」而不是二元组,是因为二元组 + // 在插入词时会整片断裂(「主人喜欢喝红牛提神」与「主人不喜欢喝红牛提神」 + // 会因多了一个「不」而失配),那样会把大量真包含对误判成互不包含。 + r runes + w words +} + +// runes 是 CJK 内容单字集合(键是 rune,避免每次比较做字符串转换)。 +type runes map[rune]struct{} + +// words 是拉丁词与 emoji/符号集合。 +type words map[string]struct{} + +// isFunctionRune 报告 r 是否是不承载事实的功能字(的/了/也…)。它是 +// switch 而不是查表:既避免包级全局变量(AGENTS §10),也让判定无分配。 +// +// 刻意**只排除纯结构字**:像「常」「深」「夜」「习」「惯」这类带意义的字 +// 必须留在关键要素里,否则「常在深夜」与「深夜」会被当成同一事实。 +func isFunctionRune(r rune) bool { + switch r { + case '的', '了', '地', '得', '着', '过', '是', '在', '和', '与', '及', '或', + '也', '都', '很', '就', '还', '而', '但', '吧', '吗', '呢', '啊', '呀', + '之', '其', '并', '把', '被', '给', '让', '向', '从', '对', '个', '们', + '这', '那', '我', '你', '他', '她', '它', '有', '会', '能', '要', '又', + '再', '才', '太', '更', '最', '请', '不', '没', '无', '未', '别', '勿', '非': + return true + default: + return false + } +} + +// extractTidyFacts 把正文拆成事实要素集合。它对内容做最小规范化 +// (大小写折叠、压缩空白),不改变任何字符类别。 +func extractTidyFacts(content string) tidyFacts { + f := tidyFacts{ + nums: make(map[string]struct{}), + negs: make(map[string]struct{}), + terms: make(map[string]struct{}), + r: make(runes), + w: make(words), + } + normalized := strings.ToLower(strings.TrimSpace(content)) + for _, marker := range tidyNegationMarkers { + if strings.Contains(normalized, marker) { + f.negs[marker] = struct{}{} + } + } + + var cjk, latin, num []rune + flush := func() { + switch { + case len(cjk) > 0: + if len(cjk) == tidySingleRuneTerm { + f.terms[string(cjk)] = struct{}{} + } + for i := 0; i+1 < len(cjk); i++ { + f.terms[string(cjk[i:i+2])] = struct{}{} + } + for _, r := range cjk { + if !isFunctionRune(r) { + f.r[r] = struct{}{} + } + } + case len(latin) > 0: + word := string(latin) + f.terms[word] = struct{}{} + f.w[word] = struct{}{} + case len(num) > 0: + if tok := strings.Trim(string(num), "::/.-"); tok != "" { + f.nums[tok] = struct{}{} + } + } + cjk, latin, num = nil, nil, nil + } + + for _, r := range normalized { + switch { + case unicode.Is(unicode.Han, r): + if len(latin) > 0 || len(num) > 0 { + flush() + } + cjk = append(cjk, r) + case unicode.IsLetter(r): + if len(cjk) > 0 || len(num) > 0 { + flush() + } + latin = append(latin, r) + case unicode.IsDigit(r): + if len(cjk) > 0 || len(latin) > 0 { + flush() + } + num = append(num, r) + case (r == ':' || r == ':' || r == '/' || r == '.' || r == '-') && len(num) > 0: + // 22:38 / 10/3 / 3.5 这类时刻与日期:数字之间的分隔符并入数值 + // token,使「22:38」是一个数值要素,而不是 22 与 38 两个。 + num = append(num, r) + case unicode.IsSymbol(r): + // emoji 与符号本身是事实要素:[4] 有 🎁、[20] 有 👀 正是 + // design/23 §1.3「相似度极高但不是包含关系 ⇒ 只应链接」的判据。 + flush() + sym := string(r) + f.terms[sym] = struct{}{} + f.w[sym] = struct{}{} + default: + flush() + } + } + flush() + return f +} + +// containmentKind 是包含判定的结论。 +type containmentKind string + +const ( + containNone containmentKind = "none" + containASubsetB containmentKind = "a_subset_b" + containBSubsetA containmentKind = "b_subset_a" + containBoth containmentKind = "both" +) + +// containment 是包含判定的结果与证据。 +type containment struct { + Kind containmentKind + // Missing 是「A ⊆ B 缺了哪些要素」的逐条证据(最多 tidyMissingEvidenceCap 条)。 + Missing []string +} + +// containsFacts 报告 a 的关键要素是否**逐一**出现在 b 中(a ⊆ b):数值、 +// 否定标记、CJK 内容字、拉丁词/emoji 符号四类都要满足。这就是 design/23 +// §5.1 T0-3 的「保守近似」:A ⊆ B 仅当 A 的关键要素(专有名词、数值、否定词) +// 逐一出现在 B 中,不满足即视为互不包含 → 只建链接。 +func containsFacts(a, b tidyFacts) (bool, []string) { + var missing []string + for n := range a.nums { + if _, ok := b.nums[n]; !ok { + missing = append(missing, "数值:"+n) + } + } + for n := range a.negs { + if _, ok := b.negs[n]; !ok { + missing = append(missing, "否定:"+n) + } + } + for r := range a.r { + if _, ok := b.r[r]; !ok { + missing = append(missing, "字:"+string(r)) + } + } + for w := range a.w { + if _, ok := b.w[w]; !ok { + missing = append(missing, "词:"+w) + } + } + if len(missing) == 0 { + return true, nil + } + if len(missing) > tidyMissingEvidenceCap { + missing = missing[:tidyMissingEvidenceCap] + } + return false, missing +} + +// judgeContainment 判定两条内容的事实集合包含关系。互不包含 +// (含「相似但并列」)返回 containNone —— 调用方据此只建链接,绝不合并。 +func judgeContainment(aContent, bContent string) containment { + a := extractTidyFacts(aContent) + b := extractTidyFacts(bContent) + aSubsetB, missingA := containsFacts(a, b) + bSubsetA, _ := containsFacts(b, a) + switch { + case aSubsetB && bSubsetA: + return containment{Kind: containBoth} + case aSubsetB: + return containment{Kind: containASubsetB, Missing: missingA} + case bSubsetA: + return containment{Kind: containBSubsetA} + default: + return containment{Kind: containNone, Missing: missingA} + } +} + +// hasAbsoluteTimestamp 报告正文里有没有**绝对日期/时刻**(一次性事件的信号, +// design/23 §5.1 T0-5)。「每周二」这类周期表述不算。 +func hasAbsoluteTimestamp(content string) bool { + normalized := strings.ToLower(strings.TrimSpace(content)) + if tidyAbsoluteTimeRe.MatchString(normalized) { + return true + } + for _, loc := range tidyWeekdayRe.FindAllStringIndex(normalized, -1) { + if loc[0] == 0 { + return true + } + // 「每」紧邻在星期词之前时是周期表述(每周二),不算绝对时刻。 + if !strings.HasSuffix(normalized[:loc[0]], "每") { + return true + } + } + return false +} + +// hasTemporalState 报告正文里有没有有期限状态的信号词(假期/期间/本周…)。 +// 命中即否决合并:有期限的状态不是长期事实(design/23 §5.2-1)。 +func hasTemporalState(content string) bool { + normalized := strings.ToLower(strings.TrimSpace(content)) + for _, w := range tidyTemporalStateWords { + if strings.Contains(normalized, w) { + return true + } + } + return false +} + +// sameNumbers 报告两条内容的数值 token 集合是否一致:数值不同即不同事实 +// (design/23 §5.2-2);两边都没有数值时返回 true(无数值可比)。 +func sameNumbers(a, b string) bool { + fa, fb := extractTidyFacts(a), extractTidyFacts(b) + if len(fa.nums) != len(fb.nums) { + return false + } + for n := range fa.nums { + if _, ok := fb.nums[n]; !ok { + return false + } + } + return true +} diff --git a/internal/runtime/memory/tidy_containment_test.go b/internal/runtime/memory/tidy_containment_test.go new file mode 100644 index 0000000..4e15bf4 --- /dev/null +++ b/internal/runtime/memory/tidy_containment_test.go @@ -0,0 +1,169 @@ +package memory + +import ( + "testing" +) + +// 本文件逐条锁定 design/23 §5.1/§5.2 的内容判据(包含、时效、数值、来源), +// 因为它们是「该合 / 不该合」的全部依据 —— 判据错了,整理就会丢信息。 + +// TestJudgeContainment子集与并列 覆盖 design/23 §1.3 的核心区分: +// 子集(同一事实的碎片)判为包含;并列事实(红牛 vs 冰美式)判为互不包含。 +func TestJudgeContainment子集与并列(t *testing.T) { + t.Parallel() + if c := judgeContainment(fixtureHabitShort, fixtureHabitLong); c.Kind != containASubsetB { + t.Fatalf("碎片 ⊂ 完整 = %q, want %q", c.Kind, containASubsetB) + } + if c := judgeContainment(fixtureHabitLong, fixtureHabitShort); c.Kind != containBSubsetA { + t.Fatalf("反向包含 = %q, want %q", c.Kind, containBSubsetA) + } + if c := judgeContainment(fixtureHabitShort, fixtureHabitShort); c.Kind != containBoth { + t.Fatalf("同一内容 = %q, want %q", c.Kind, containBoth) + } + if c := judgeContainment(fixtureEnergyRedBull, fixtureEnergyIced); c.Kind != containNone { + t.Fatalf("并列偏好 = %q, want %q(相似不等于包含)", c.Kind, containNone) + } + if len(judgeContainment(fixtureEnergyRedBull, fixtureEnergyIced).Missing) == 0 { + t.Fatal("互不包含时必须给出缺失要素证据(diff 预览要用)") + } +} + +// TestJudgeContainmentEmoji要素 覆盖 design/23 §1.3 的 emoji 判据:emoji 是 +// 关键要素,因此「多一个 emoji」的一方不可能是子集。 +// +// ⚠️ 与 design/23 §12.1 P4 的差异如实记录在这里:P4 把「[9] 有 🎁、[24] 无」 +// 期望为 `link`(非包含)。但按设计稿 §1.3 的核心规则(A ⊆ B 才并入), +// **少 🎁 的那条确实是子集** —— 它的每一个关键要素都在对方里,合并不会丢信息。 +// 本实现跟随核心规则(产 merge 建议,且第一版全部交人工确认、不自动应用), +// 并把「各有一个对方没有的 emoji」([4] 的 🎁 vs [20] 的 👀)判为互不包含 ⇒ link。 +// 该不一致已在交付报告里列为「要登记的契约/设计变更点」。 +func TestJudgeContainmentEmoji要素(t *testing.T) { + t.Parallel() + withGift := "主人送过助手礼物🎁,助手很开心" + withoutGift := "主人送过助手礼物,助手很开心" + if c := judgeContainment(withGift, withoutGift); c.Kind != containBSubsetA { + t.Fatalf("含 🎁 的一方不是子集,无 🎁 的一方是:got %q, want %q", c.Kind, containBSubsetA) + } + if c := judgeContainment(withoutGift, withGift); c.Kind != containASubsetB { + t.Fatalf("少 🎁 的一方是子集:got %q, want %q", c.Kind, containASubsetB) + } + if c := judgeContainment("主人送过助手礼物🎁", "主人送过助手礼物👀"); c.Kind != containNone { + t.Fatalf("各有一个对方没有的 emoji ⇒ 互不包含,got %q", c.Kind) + } +} + +// TestHasAbsoluteTimestamp 覆盖 design/23 §5.1 T0-5:绝对日期/时刻是一次性 +// 事件的信号,而「22点后 / 十点多」这类周期性表述不是。 +func TestHasAbsoluteTimestamp(t *testing.T) { + t.Parallel() + cases := []struct { + content string + want bool + }{ + {fixtureOneOffEvent, true}, + {"2026-09-08 的晚上", true}, + {"国庆黄金周 10/3 假期第三天", true}, + {"9月8日那晚聊到很晚", true}, + {"周二晚上他还在加班", true}, + {"主人常在晚上22点后保持活跃", false}, + {"主人常熬夜到十点多", false}, + {"每周二晚上一起看剧", false}, + {fixtureHabitLong, false}, + } + for _, tc := range cases { + if got := hasAbsoluteTimestamp(tc.content); got != tc.want { + t.Errorf("hasAbsoluteTimestamp(%q) = %v, want %v", tc.content, got, tc.want) + } + } +} + +// TestHasTemporalState 覆盖 design/23 §5.2-1:有期限的状态词(假期/本周…) +// 命中即否决合并。 +func TestHasTemporalState(t *testing.T) { + t.Parallel() + for _, content := range []string{ + fixtureHabitVacation, "国庆假期期间处于休假状态", "本周在赶项目", "这几天暂时没空", + } { + if !hasTemporalState(content) { + t.Errorf("hasTemporalState(%q) = false, want true", content) + } + } + for _, content := range []string{ + fixtureHabitLong, fixtureEnergyRedBull, "主人喜欢喝冰美式", + } { + if hasTemporalState(content) { + t.Errorf("hasTemporalState(%q) = true, want false", content) + } + } +} + +// TestSameNumbers 覆盖 design/23 §5.2-2:数值不同即不同事实。 +func TestSameNumbers(t *testing.T) { + t.Parallel() + if !sameNumbers("服务跑在 8080 端口", "服务跑在 8080 端口上") { + t.Fatal("同一数值应视为一致") + } + if sameNumbers("服务跑在 8080 端口", "服务跑在 9090 端口") { + t.Fatal("数值不同必须判定不一致") + } + if sameNumbers("主人常在深夜加班", fixtureHabitLong) == false { + t.Fatal("两边都无数值时视为一致(无数值可比)") + } +} + +// TestNonOwnerSource 覆盖 design/23 §5.2-9/§6:群聊/陌生人来源只读不写, +// 而本进程的系统会话(backthink / 整理自身)与主人链路必须放行。 +func TestNonOwnerSource(t *testing.T) { + t.Parallel() + cases := []struct { + source string + want bool + }{ + {FormatSourceConversation("sess-telegram:group1", "m1"), true}, + {FormatSourceConversation("sess-kook:3bef", ""), true}, + {FormatSourceConversation(backthinkSessionID, "m2"), false}, + {FormatSourceConversation(TidySessionID, ""), false}, + {FormatSourceConversation("web", "m3"), false}, + {FormatSourceConversation("web:sub-1", ""), false}, + {"主会话 2026-09-08 深夜", false}, // 裁决前的 LLM 自由文本:无法机器判定,放行 + {"", false}, + } + for _, tc := range cases { + if got := nonOwnerSource(tc.source); got != tc.want { + t.Errorf("nonOwnerSource(%q) = %v, want %v", tc.source, got, tc.want) + } + } +} + +// TestExtractTidyFacts 锁定事实要素抽取的两个关键形状:时刻是**一个**数值 +// token(22:38),emoji 是词项。 +func TestExtractTidyFacts(t *testing.T) { + t.Parallel() + facts := extractTidyFacts("2026年9月8日周二晚22:38,主人送了我🎁") + for _, want := range []string{"22:38", "2026", "9"} { + if _, ok := facts.nums[want]; !ok { + t.Errorf("数值要素缺 %q:%v", want, facts.nums) + } + } + if _, ok := facts.terms["🎁"]; !ok { + t.Errorf("emoji 应是词项:%v", facts.terms) + } + if _, ok := facts.terms["熬夜"]; ok { + t.Errorf("不存在的词项不该出现:%v", facts.terms) + } +} + +// TestTidyNegationConflictVetoed 覆盖 design/23 §5.2-3:X 与非X 是覆盖关系, +// 不是重复,即使事实集合看起来包含也必须否决合并。 +func TestTidyNegationConflictVetoed(t *testing.T) { + t.Parallel() + before := habitItem("主人喜欢喝红牛提神", tidyTestTime(t), 0.9) + after := habitItem("主人不喜欢喝红牛提神", tidyTestTime(t), 0.9) + verdict := judgePair(before, after, 0.98) + if verdict.Veto != VetoConflict { + t.Fatalf("冲突对 veto = %q, want %q", verdict.Veto, VetoConflict) + } + if verdict.Action != "" { + t.Fatalf("冲突对不得带动作,got %q(冲突是覆盖,要走人工)", verdict.Action) + } +} diff --git a/internal/runtime/memory/tidy_recall.go b/internal/runtime/memory/tidy_recall.go new file mode 100644 index 0000000..d102117 --- /dev/null +++ b/internal/runtime/memory/tidy_recall.go @@ -0,0 +1,144 @@ +package memory + +import ( + "context" + "math" + "sort" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding" +) + +// 本文件是记忆整理的**候选召回**(design/23 §5.1 T0-2):分区内两两相似度 +// ≥ recallThreshold 的对进入候选。 +// +// ⚠️ 口径(不可走样):相似度**只用来召回候选对**,永不直接当合并判据。 +// design/23 §1.2 的实据是「必合」的 [12]/[15]=0.8330 与「必不合」的 +// [3]/[6]=0.7100 只差 0.12,且全库最高相似度的一对(0.9319)恰是跨作用域 +// ⇒ 单阈值双向失效,判定必须靠包含判定 + 规则否决(tidy_rules.go)。 + +// tidyPair 是一条被召回的候选对。 +type tidyPair struct { + a, b MemoryItem + score float64 + // mode 记录本轮相似度来源(embedding / lexical),进证据与日志。 + mode string +} + +// recallPairs 在分区内召回候选对。条目少于 2 条时为空操作(无候选、不报错)。 +// +// 两两比对是 O(n²·dim):设计稿每分区扫描上限 5000 条(design §9),但真正 +// 参与比对的最多 maxTidyCompareItems 条(最新优先),超限记 WARN 并登记到 +// stats.Truncated —— 截断绝不静默。 +// +// 返回的对按「分数降序 + id」确定性排序,使每分区候选上限的截断可复现 +// (同一批数据两次运行产出同一批候选 ⇒ 幂等,design §4.1 A)。 +func (t *Tidier) recallPairs(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats) []tidyPair { + if len(items) < 2 { + return nil + } + if len(items) > maxTidyCompareItems { + stats.Truncated = append(stats.Truncated, p.label()+"(compare)") + t.log.Warn("memory tidy compare truncated by per-iteration guard", + "partition", p.label(), "items", len(items), "limit", maxTidyCompareItems) + items = items[:maxTidyCompareItems] + } + stats.Compared += len(items) + + sims, mode := t.similarityFunc(ctx, p, items) + pairs := make([]tidyPair, 0) + for i := 0; i < len(items); i++ { + for j := i + 1; j < len(items); j++ { + score := sims(i, j) + if score < t.cfg.RecallThreshold { + continue + } + pairs = append(pairs, tidyPair{a: items[i], b: items[j], score: score, mode: mode}) + } + } + stats.Pairs += len(pairs) + sort.Slice(pairs, func(i, j int) bool { + if pairs[i].score != pairs[j].score { + return pairs[i].score > pairs[j].score + } + if pairs[i].a.ID != pairs[j].a.ID { + return pairs[i].a.ID < pairs[j].a.ID + } + return pairs[i].b.ID < pairs[j].b.ID + }) + return pairs +} + +// similarityFunc 返回分区内的两两相似度函数与来源标签。 +// +// 接入 embedder 时用它批量向量化后取余弦(与检索侧同一口径:余弦夹到 +// [0,1])。embedder 缺席或整批 Embed 失败时回退到零依赖的词项余弦 +// —— 该回退保证整理在离线/无 embedding 配置时仍可跑(宁可比语义路径更保守), +// 且让测试无需网络即可确定性运行。两个来源的相似度**不可混用阈值**: +// 阈值只有一个(design §13 开放点 2 的 0.80),回退路径命中更少 ⇒ 更保守。 +func (t *Tidier) similarityFunc(ctx context.Context, p tidyPartition, items []MemoryItem) (func(i, j int) float64, string) { + if t.deps.Embedder != nil { + vecs, err := t.embedItems(ctx, items) + if err == nil && len(vecs) == len(items) { + return func(i, j int) float64 { + return clampUnit(cosineSim(vecs[i], vecs[j])) + }, "embedding" + } + t.log.Warn("memory tidy embedding recall unavailable, falling back to lexical", + "partition", p.label(), "error", err) + } + return func(i, j int) float64 { + return lexicalSimilarity(items[i].Content, items[j].Content) + }, "lexical" +} + +// embedItems 分批把分区内条目向量化。单批失败即整体失败(调用方回退到 +// 词项余弦),不半途改用混合表示 —— 混用会让召回范围不可预测。 +func (t *Tidier) embedItems(ctx context.Context, items []MemoryItem) ([]embedding.Vector, error) { + out := make([]embedding.Vector, 0, len(items)) + for start := 0; start < len(items); start += tidyEmbedBatch { + end := min(start+tidyEmbedBatch, len(items)) + texts := make([]string, 0, end-start) + for _, it := range items[start:end] { + texts = append(texts, it.Content) + } + vecs, err := t.deps.Embedder.Embed(ctx, texts) + if err != nil { + return nil, err + } + if len(vecs) != len(texts) { + return nil, ErrTidyEmbedCount + } + out = append(out, vecs...) + } + return out, nil +} + +// lexicalSimilarity 是零依赖的相似度回退:两条内容词项集合的余弦。 +// 词项来自 extractTidyFacts(CJK 二元组 + 拉丁词 + 数值 + emoji 符号), +// 因此「同义不同措辞」的中文近似表述仍能召回,而「红牛 vs 冰美式」 +// 这类并列事实只共享极少词项(相似度低或中等),靠包含判定兜住。 +func lexicalSimilarity(a, b string) float64 { + fa, fb := extractTidyFacts(a), extractTidyFacts(b) + if len(fa.terms) == 0 || len(fb.terms) == 0 { + return 0 + } + shared := 0 + for term := range fa.terms { + if _, ok := fb.terms[term]; ok { + shared++ + } + } + return float64(shared) / math.Sqrt(float64(len(fa.terms))*float64(len(fb.terms))) +} + +// clampUnit 把余弦夹到 [0,1]:相似度是「召回门」的输入,负相关对 +// (余弦 < 0)与零相关对在召回上等价。 +func clampUnit(v float64) float64 { + if v < 0 { + return 0 + } + if v > 1 { + return 1 + } + return v +} diff --git a/internal/runtime/memory/tidy_rules.go b/internal/runtime/memory/tidy_rules.go new file mode 100644 index 0000000..ae064e7 --- /dev/null +++ b/internal/runtime/memory/tidy_rules.go @@ -0,0 +1,152 @@ +package memory + +import "strings" + +// 本文件是记忆整理的 T0 判定(design/23 §5.1/§5.2):对一条被召回的候选对 +// 施加**规则否决**与**包含判定**,给出建议动作或否决原因。 +// +// 判定顺序刻意是「先否决、后包含」: +// 1. 硬门(跨作用域/跨角色/跨项目/群聊来源)—— design §5.2-7/8/9; +// 2. 时效污染(一次性事件、有期限状态)—— §5.2-1; +// 3. 数值不一致 —— §5.2-2; +// 4. 低置信 —— §5.2-6; +// 5. 包含判定:互不包含 → 只建链接(§1.3); +// 6. 包含成立但否定不一致 → 冲突,走覆盖而非重复(§5.2-3)。 +// +// 命中任一条否决规则即踢出候选(design §5.1 T0-4)。 + +// isolatedSessionPrefix 是渠道隔离会话的 id 前缀(internal/channel/session +// 的 sessionIDPrefix)。主人的私聊与 Web 主会话落 "web",群聊/陌生人落 +// "sess-…",因此前缀是「这条记忆来自只读上下文」的可判定信号。 +const isolatedSessionPrefix = "sess-" + +// backthinkSessionID 与 backthink.DefaultSessionID 逐字一致:自主后台思考是 +// 本进程的系统会话,不是主人的隔离渠道会话,必须放行。 +const backthinkSessionID = "sess-backthink" + +// judgePair 对一条候选对做 T0 判定。score 是召回相似度(只用于证据, +// **绝不参与判定**:design/23 §1.2「单阈值双向失效」)。 +func judgePair(a, b MemoryItem, score float64) tidyVerdict { + if veto, hit := tidyHardGate(a, b); hit { + return tidyVerdict{Veto: veto} + } + if hasAbsoluteTimestamp(a.Content) || hasAbsoluteTimestamp(b.Content) || + hasTemporalState(a.Content) || hasTemporalState(b.Content) { + return tidyVerdict{Veto: VetoTemporal} + } + if !sameNumbers(a.Content, b.Content) { + return tidyVerdict{Veto: VetoNumeric} + } + if a.Confidence < tidyLowConfidenceThreshold || b.Confidence < tidyLowConfidenceThreshold { + return tidyVerdict{Veto: VetoLowConfidence} + } + + c := judgeContainment(a.Content, b.Content) + if c.Kind == containNone { + // 相似但互不包含(design §1.3 的 [1]/[3]「红牛 vs 冰美式」): + // 只建链接/分组,绝不合并。 + return tidyVerdict{Action: TidyLink, Containment: c, Reason: "similar_but_not_contained"} + } + if !sameNegations(a.Content, b.Content) { + // 事实集合看似包含,但一方带否定而另一方没有(「X」vs「戒了X」): + // 那是新事实覆盖旧事实,不是重复(design §5.2-3)。 + return tidyVerdict{Veto: VetoConflict, Containment: c} + } + target := pickMergeTarget(a, b, c.Kind) + return tidyVerdict{ + Action: TidyMerge, + Target: &target, + Containment: c, + Reason: "fact_subset", + } +} + +// tidyHardGate 施加作用域与来源硬门(design/23 §5.2-7/8/9)。 +// 整理器本身**逐分区扫描、分区之间永不比较**(§6);这里再判一次是纵深 +// 防御:任何经其它路径送进来的跨分区对都必须被否决,而不是侥幸漏过。 +func tidyHardGate(a, b MemoryItem) (TidyVetoReason, bool) { + if a.Scope != b.Scope { + return VetoScope, true + } + if a.PersonaID != b.PersonaID { + return VetoPersona, true + } + if a.ProjectID != b.ProjectID { + return VetoProject, true + } + if nonOwnerSource(a.SourceConversation) || nonOwnerSource(b.SourceConversation) { + return VetoSource, true + } + return VetoNone, false +} + +// pickMergeTarget 选存活条目:包含关系里**超集**那一方存活,子集并入它 +// (design §1.3「把 A 并入 B,content 取 B(超集),保留 B 的 id」)。 +// 双向包含(事实集合等价)时取**更早创建**的一方存活 —— 它是 first_seen, +// 时间跨度因此落在存活行上(design §8);仍并列时按 id 字典序,保证确定性。 +func pickMergeTarget(a, b MemoryItem, kind containmentKind) MemoryItem { + switch kind { + case containASubsetB: + return b + case containBSubsetA: + return a + default: + if a.CreatedAt.Equal(b.CreatedAt) { + if a.ID <= b.ID { + return a + } + return b + } + if a.CreatedAt.Before(b.CreatedAt) { + return a + } + return b + } +} + +// nonOwnerSource 报告一条记忆的溯源是不是群聊/陌生人这类**只读**来源 +// (design/23 §5.2-9、§6)。判据是结构化溯源(design/03 §2 裁决 04.4.3) +// 里的会话 id:渠道隔离会话由 channel/session 以 "sess-" 前缀命名, +// 而主人私聊与 Web 主会话落 "web"。 +// +// 这是纵深防御:上游(loop.extractMemory)在受限上下文本就绝不提取, +// 所以这类行只可能来自历史遗留。规则在此兜住它,使整理既不合并、 +// 也不归档群聊/陌生人来源的条目。非结构化溯源(裁决前的 LLM 自由文本) +// 无法机器判定,按「不是受限来源」处理。 +func nonOwnerSource(sourceConversation string) bool { + ref, ok := ParseSourceConversation(sourceConversation) + if !ok { + return false + } + if !strings.HasPrefix(ref.SessionID, isolatedSessionPrefix) { + return false + } + for _, system := range tidySystemSessions() { + if ref.SessionID == system { + return false + } + } + return true +} + +// tidySystemSessions 返回本进程自己的系统会话(它们不是主人的隔离渠道会话, +// 因此不作为「群聊/陌生人来源」)。它是函数而不是包级变量,符合 +// 「禁包级全局变量」的项目规范(同 backthink.triggers 的手法)。 +func tidySystemSessions() []string { + return []string{backthinkSessionID, TidySessionID} +} + +// sameNegations 报告两条内容的否定标记集合是否一致。不一致意味着 +// 「X」与「非X」并存 —— 那是覆盖关系,合并会丢掉一次事实变更。 +func sameNegations(a, b string) bool { + fa, fb := extractTidyFacts(a), extractTidyFacts(b) + if len(fa.negs) != len(fb.negs) { + return false + } + for n := range fa.negs { + if _, ok := fb.negs[n]; !ok { + return false + } + } + return true +} diff --git a/internal/runtime/memory/tidy_schema.go b/internal/runtime/memory/tidy_schema.go new file mode 100644 index 0000000..abfa6b7 --- /dev/null +++ b/internal/runtime/memory/tidy_schema.go @@ -0,0 +1,122 @@ +package memory + +import ( + "context" + "database/sql" + "fmt" + "strings" +) + +// 本文件是记忆整理(design/23 §10)的 schema 侧:新表(候选 / 运行)的 DDL、 +// memory_items 整理列的前滚,以及两者的一处装配入口。 +// +// 口径与 memory_items 一致(见 store.go 末尾的说明):领域表的权威机制是 +// internal/db 的迁移(0019_memory_tidy),但记忆存储必须自包含、可在任意 +// *sql.DB 上使用,因此 NewStore 也幂等地创建这些表、前滚这些列。两处定义 +// 必须保持一致(tidy_schema_test.go 锁定)。 + +// createMemoryTidyCandidatesTable 是人工确认队列(design/23 §10)。 +// idempotency_key 唯一:同一批数据重复跑不重复产候选(design/23 §9)。 +const createMemoryTidyCandidatesTable = ` +CREATE TABLE IF NOT EXISTS memory_tidy_candidates ( + id TEXT PRIMARY KEY, + persona_id TEXT NOT NULL, + scope TEXT NOT NULL, + project_id TEXT NOT NULL DEFAULT '', + kind TEXT NOT NULL DEFAULT '', + action TEXT NOT NULL, + source_ids TEXT NOT NULL DEFAULT '[]', + target_id TEXT NOT NULL DEFAULT '', + proposed_content TEXT NOT NULL DEFAULT '', + merged_content TEXT NOT NULL DEFAULT '', + reason TEXT NOT NULL DEFAULT '', + evidence TEXT NOT NULL DEFAULT '{}', + rule_version TEXT NOT NULL DEFAULT '', + score_min REAL NOT NULL DEFAULT 0, + score_max REAL NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'pending', + idempotency_key TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL, + decided_at TEXT NOT NULL DEFAULT '', + decided_by TEXT NOT NULL DEFAULT '' +)` + +const createMemoryTidyCandidatesStatusIndex = ` +CREATE INDEX IF NOT EXISTS idx_memory_tidy_candidates_status + ON memory_tidy_candidates (status, scope)` + +// createMemoryTidyRunsTable 是整理运行的留痕(design/23 §4.1 F:留痕不靠 +// 会话)。vetoed / veto_reasons 是本实现在设计稿列集之上补的两列:design/23 +// §9 要求「每轮记否决原因分布」,只落日志就无法查询,故一并入库。 +const createMemoryTidyRunsTable = ` +CREATE TABLE IF NOT EXISTS memory_tidy_runs ( + id TEXT PRIMARY KEY, + started_at TEXT NOT NULL, + finished_at TEXT NOT NULL DEFAULT '', + scanned INTEGER NOT NULL DEFAULT 0, + candidates INTEGER NOT NULL DEFAULT 0, + applied INTEGER NOT NULL DEFAULT 0, + skipped INTEGER NOT NULL DEFAULT 0, + errors INTEGER NOT NULL DEFAULT 0, + vetoed INTEGER NOT NULL DEFAULT 0, + veto_reasons TEXT NOT NULL DEFAULT '{}', + rule_version TEXT NOT NULL DEFAULT '' +)` + +const createMemoryTidyRunsStartedIndex = ` +CREATE INDEX IF NOT EXISTS idx_memory_tidy_runs_started_at + ON memory_tidy_runs (started_at)` + +// tidyMemoryColumns 是 memory_items 上记忆整理所需的列与前滚 DDL。 +// 全部带默认值 ⇒ 既有行行为逐字不变(design/23 §10)。 +var tidyMemoryColumns = []struct { + name string + ddl string +}{ + {"status", `ALTER TABLE memory_items ADD COLUMN status TEXT NOT NULL DEFAULT 'active'`}, + {"merged_into", `ALTER TABLE memory_items ADD COLUMN merged_into TEXT NOT NULL DEFAULT ''`}, + {"merged_from", `ALTER TABLE memory_items ADD COLUMN merged_from TEXT NOT NULL DEFAULT '[]'`}, + {"merge_count", `ALTER TABLE memory_items ADD COLUMN merge_count INTEGER NOT NULL DEFAULT 1`}, + {"occurred_at", `ALTER TABLE memory_items ADD COLUMN occurred_at TEXT NOT NULL DEFAULT ''`}, + {"valid_to", `ALTER TABLE memory_items ADD COLUMN valid_to TEXT NOT NULL DEFAULT ''`}, + {"expires_at", `ALTER TABLE memory_items ADD COLUMN expires_at TEXT NOT NULL DEFAULT ''`}, +} + +const createMemoryItemsStatusIndex = ` +CREATE INDEX IF NOT EXISTS idx_memory_items_status + ON memory_items (persona_id, status)` + +// ensureTidySchema 幂等地创建整理所需的两张新表、状态索引,并前滚 +// memory_items 的整理列。它由 NewStore 调用,因此整理能力在任何 +// *sql.DB 上都可用(与 memory_items 自身同一口径)。 +func ensureTidySchema(ctx context.Context, db *sql.DB) error { + for _, ddl := range []string{ + createMemoryTidyCandidatesTable, + createMemoryTidyCandidatesStatusIndex, + createMemoryTidyRunsTable, + createMemoryTidyRunsStartedIndex, + createMemoryItemsStatusIndex, + } { + if _, err := db.ExecContext(ctx, ddl); err != nil { + return fmt.Errorf("memory: tidy schema: %w", err) + } + } + return ensureTidyMemoryColumns(ctx, db) +} + +// ensureTidyMemoryColumns 把 memory_items 的整理列前滚到既有表上:列缺失时 +// 补加,已存在时忽略重复列错误。它是 SQLite 特有的(记忆存储基于 SQLite) +// 且幂等,与 ensureProjectIDColumn 同一手法。 +func ensureTidyMemoryColumns(ctx context.Context, db *sql.DB) error { + for _, col := range tidyMemoryColumns { + if _, err := db.ExecContext(ctx, col.ddl); err != nil { + // 该列已存在(由 CREATE TABLE 或上一次运行创建)时报 + // "duplicate column name: ",这是预期路径。 + if strings.Contains(err.Error(), "duplicate column name") { + continue + } + return fmt.Errorf("memory: roll forward tidied column %s: %w", col.name, err) + } + } + return nil +} diff --git a/internal/runtime/memory/tidy_service.go b/internal/runtime/memory/tidy_service.go new file mode 100644 index 0000000..251ec54 --- /dev/null +++ b/internal/runtime/memory/tidy_service.go @@ -0,0 +1,227 @@ +package memory + +import ( + "context" + "fmt" + "log/slog" + "sync" + "time" +) + +// 本文件是记忆整理的**后台任务形态**(design/23 §4 方案 A/C):低峰定期跑、 +// 独立运行、零上下文启动、只产建议、绝不推送。 +// +// 与主人的硬约束对齐(design/23 §4.1): +// - 零上下文:本服务的依赖里**没有任何会话/历史读数**,输入只有记忆条目 +// 与本次候选数据,因此「不读会话」是结构性成立的,不靠自觉; +// - 独立运行:跑在专属隔离会话 TidySessionID(不是 history.MainSessionID), +// 因此不改主人的心情、不进主会话泳道(cmd/cyrene 的 isConversationLane +// 对它恒为 false); +// - 它不是任何角色:系统任务,不带人格提示词,也不把写入限到某个角色的域; +// - 不写会话:唯一的写入是候选表与运行表;agent 侧对 chat_messages 零写入。 +// +// 心跳范式参照 internal/services/backthink(同一低峰/安静时段语义),但有一处 +// **有意的不同**:它**不做启动即跑**。backthink 的启动轮是产品行为(一句 +// 拟人化问候),而整理启动即跑会在每次重启时触发一次全库向量化扫描 —— 那是 +// 主人没要求的开销。整理因此只在安静时段起点开跑。 + +// minTidyTickDelay 是心跳循环的最小等待,防止配置异常(周期/时钟跳变)导致 +// 空转忙轮询。 +const minTidyTickDelay = time.Minute + +// TidyService 是记忆整理服务。用 NewTidyService 构建,用 Start 启动心跳、 +// Stop 停止它;RunOnce 是「立即整理」入口(design/23 §4 的手动可触发)。 +type TidyService struct { + tidier *Tidier + cfg TidyConfig + log *slog.Logger + + mu sync.Mutex + running bool + cancel context.CancelFunc + lastRun time.Time + wg sync.WaitGroup + + // runMu 保证同一时刻只有一轮整理(design/23 §9 单写者):心跳与手动触发 + // 若撞在一起,后者排队而不是并发改写候选表。 + runMu sync.Mutex +} + +// NewTidyService 由 cfg 与 deps 构建整理服务。Store 为 nil 时返回 ErrNilStore。 +func NewTidyService(cfg TidyConfig, deps TidyDeps) (*TidyService, error) { + tidier, err := NewTidier(cfg, deps) + if err != nil { + return nil, err + } + return &TidyService{ + tidier: tidier, + cfg: tidier.Config(), + log: tidier.log, + }, nil +} + +// SessionID 返回整理运行的专属隔离会话标识(design/23 §4.1 B)。 +func (s *TidyService) SessionID() string { return TidySessionID } + +// Enabled 报告整理是否被配置开启(design/23 §4 的 MEMORY_TIDY_ENABLED)。 +func (s *TidyService) Enabled() bool { return s.cfg.Enabled } + +// Start 启动心跳。它是幂等的:第二次调用是空操作。配置关闭时只记一条日志 +// 并立即返回(关闭是显式选择,绝不静默)。ctx(可为 nil)是所有内部工作的 +// 父级:取消它会像 Stop 一样停止服务。 +func (s *TidyService) Start(ctx context.Context) { + if !s.cfg.Enabled { + s.log.Info("memory tidy disabled by configuration", "env", "MEMORY_TIDY_ENABLED") + return + } + if ctx == nil { + ctx = context.Background() + } + s.mu.Lock() + if s.running { + s.mu.Unlock() + return + } + s.running = true + cctx, cancel := context.WithCancel(ctx) + s.cancel = cancel + s.wg.Add(1) + s.mu.Unlock() + + go s.loop(cctx) + + s.log.Info("memory tidy started", + "interval", s.cfg.Interval.String(), + "quiet_hours", fmt.Sprintf("%02d:00-%02d:00", s.cfg.QuietStartHour, s.cfg.QuietEndHour), + "recall_threshold", s.cfg.RecallThreshold, + "scan_limit", s.cfg.ScanLimit, + "candidate_limit", s.cfg.CandidateLimit, + "rule_version", TidyRuleVersion, + "session_id", TidySessionID) +} + +// Stop 停止心跳并等待进行中的一轮跑完。它是幂等的;若 ctx 在排空完成前被 +// 取消则返回包装后的 context 错误,而不是泄漏 goroutine。 +func (s *TidyService) Stop(ctx context.Context) error { + if ctx == nil { + ctx = context.Background() + } + s.mu.Lock() + if !s.running { + s.mu.Unlock() + return nil + } + s.running = false + cancel := s.cancel + s.mu.Unlock() + + cancel() + done := make(chan struct{}) + go func() { + s.wg.Wait() + close(done) + }() + select { + case <-done: + s.log.Info("memory tidy stopped") + return nil + case <-ctx.Done(): + return fmt.Errorf("memory/tidy: stop: %w", ctx.Err()) + } +} + +// RunOnce 立即跑一轮整理(「立即整理」入口,design/23 §4 的手动可触发)。 +// 它不改变心跳节奏,也不依赖上一次的结果 —— 幂等由 rule_version + 数据指纹 +// 保证(design/23 §4.1 A)。 +func (s *TidyService) RunOnce(ctx context.Context) (TidyStats, error) { + return s.runRound(ctx, "manual") +} + +// loop 是心跳 goroutine:算出下一次低峰时刻 → 等到 →(在低峰窗口内时)跑一轮 +// → 重算。ctx 被取消时退出。 +func (s *TidyService) loop(ctx context.Context) { + defer s.wg.Done() + for { + delay := s.nextDelay() + timer := time.NewTimer(delay) + select { + case <-ctx.Done(): + timer.Stop() + return + case <-timer.C: + } + if ctx.Err() != nil { + return + } + if !s.cfg.inQuietHours(s.tidier.now()) { + // 低峰窗口外不跑:整理不是随时可做的动作(design/23 §4/§9)。 + s.log.Debug("memory tidy skipped: outside quiet hours", + "now_hour", s.tidier.now().Hour()) + continue + } + if _, err := s.runRound(ctx, "heartbeat"); err != nil { + s.log.Warn("memory tidy heartbeat round failed", "error", err) + } + } +} + +// nextDelay 返回距下一次整理开跑的等待时长。 +// +// 口径:下一次 = 下一个低峰窗口起点(窗口关闭时 = 现在 + 周期),并且不早于 +// 「上一次运行 + 周期」—— 让配置的周期成为真实下限,而不是让安静时段每天 +// 一次把「每周一次」冲掉。等待恒 ≥ minTidyTickDelay,避免配置异常时空转。 +func (s *TidyService) nextDelay() time.Duration { + now := s.tidier.now() + next := s.cfg.nextQuietStart(now) + if s.cfg.quietWindowDisabled() { + next = now.Add(s.cfg.Interval) + } + s.mu.Lock() + last := s.lastRun + s.mu.Unlock() + if !last.IsZero() { + if floor := last.Add(s.cfg.Interval); next.Before(floor) { + next = floor + } + } + delay := next.Sub(now) + if delay < minTidyTickDelay { + return minTidyTickDelay + } + return delay +} + +// runRound 跑一轮并把结果与审计留痕。单写者:runMu 串行化心跳与手动触发。 +func (s *TidyService) runRound(ctx context.Context, trigger string) (TidyStats, error) { + s.runMu.Lock() + defer s.runMu.Unlock() + + s.log.Info("memory tidy round starting", "trigger", trigger, "session_id", TidySessionID) + stats, err := s.tidier.RunOnce(ctx) + s.mu.Lock() + s.lastRun = s.tidier.now() + s.mu.Unlock() + s.audit(stats, trigger) + if err != nil { + s.log.Warn("memory tidy round failed", "trigger", trigger, "error", err) + } + return stats, err +} + +// audit 写一条审计(design/23 §8 的 memory.tidy.* 口径)。审计出口由组合根 +// 接到网关的 AuditEvent;未接线时只记日志(绝不静默)。审计只带计数, +// **不带任何记忆正文**。 +func (s *TidyService) audit(stats TidyStats, trigger string) { + hook := s.tidier.deps.Audit + result := fmt.Sprintf("trigger=%s scanned=%d candidates=%d vetoed=%d rejected_reasons=%s", + trigger, stats.Scanned, stats.Candidates, stats.Vetoed, tidyReasonsLabel(stats.VetoReasons)) + if hook == nil { + s.log.Info("memory tidy audit (no sink configured)", "what", "memory.tidy.run", "result", result) + return + } + hook(tidyAuditWho, "memory.tidy.run", result) +} + +// tidyAuditWho 是整理审计的调用方标签:它是**系统任务**,不是任何角色 +// (design/23 §4.1 C)。 +const tidyAuditWho = "system/memorytidy" diff --git a/internal/runtime/memory/tidy_service_test.go b/internal/runtime/memory/tidy_service_test.go new file mode 100644 index 0000000..10a9b14 --- /dev/null +++ b/internal/runtime/memory/tidy_service_test.go @@ -0,0 +1,211 @@ +package memory + +import ( + "context" + "strings" + "testing" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding" +) + +// 本文件锁定记忆整理的**运行隔离与心跳口径**(design/23 §4/§4.1/§9): +// 独立会话标识、低峰调度、可开关、手动可触发、审计留痕。 + +// fixedTidyClock 返回固定时刻的时钟,好让调度断言不依赖真实时间。 +func fixedTidyClock(at time.Time) func() time.Time { + return func() time.Time { return at } +} + +// newTidyServiceForTest 构建带固定时钟与可选审计出口的整理服务。 +func newTidyServiceForTest(t *testing.T, store *Store, cfg TidyConfig, emb embedding.Embedder, + clock func() time.Time, audit func(who, what, result string)) *TidyService { + t.Helper() + svc, err := NewTidyService(cfg, TidyDeps{ + Store: store, Embedder: emb, Audit: audit, Log: testLogger(), Now: clock, + }) + if err != nil { + t.Fatalf("NewTidyService: %v", err) + } + return svc +} + +// TestTidyServiceNilStoreRejected:没有记忆存储就没有整理(依赖面收窄到最小)。 +func TestTidyServiceNilStoreRejected(t *testing.T) { + t.Parallel() + if _, err := NewTidyService(TidyConfig{Enabled: true}, TidyDeps{Log: testLogger()}); err == nil { + t.Fatal("缺 Store 应当报错") + } +} + +// TestTidySessionIDIsNotAConversationLane 锁定 design/23 §4.1 B:整理跑在 +// 专属隔离会话,绝不是主人的对话车道(cmd/cyrene 的 isConversationLane +// 对 "web"/"web:*" 才为 true)。 +func TestTidySessionIDIsNotAConversationLane(t *testing.T) { + t.Parallel() + if TidySessionID == "web" || strings.HasPrefix(TidySessionID, "web:") { + t.Fatalf("整理会话 %q 落在主人对话车道上", TidySessionID) + } + svc := newTidyServiceForTest(t, newTestStore(t), tidyTestCfg(0.80), nil, time.Now, nil) + if svc.SessionID() != TidySessionID { + t.Fatalf("SessionID() = %q, want %q", svc.SessionID(), TidySessionID) + } +} + +// TestTidyServiceNextDelay 锁定低峰调度:下一个低峰窗口起点、周期下限、 +// 窗口关闭时退回纯周期,且等待恒有下界(不空转)。 +func TestTidyServiceNextDelay(t *testing.T) { + t.Parallel() + store := newTestStore(t) + + // 15:00 起跑,低峰 23:00-07:00 ⇒ 等 8 小时。 + at := time.Date(2026, 10, 5, 15, 0, 0, 0, time.Local) + svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), nil, fixedTidyClock(at), nil) + if got, want := svc.nextDelay(), 8*time.Hour; got != want { + t.Fatalf("nextDelay = %s, want %s", got, want) + } + + // 23:30 起跑 ⇒ 下一个窗口起点是明天 23:00(约 23.5 小时),且不早于 + // 「上次运行 + 周期(24h)」。 + late := time.Date(2026, 10, 5, 23, 30, 0, 0, time.Local) + svc = newTidyServiceForTest(t, store, tidyTestCfg(0.80), nil, fixedTidyClock(late), nil) + svc.lastRun = time.Date(2026, 10, 5, 23, 0, 0, 0, time.Local) + if got := svc.nextDelay(); got < 23*time.Hour || got > 24*time.Hour { + t.Fatalf("周期下限未生效:nextDelay = %s, want ≈ 23.5h 且 ≤ 24h", got) + } + + // 窗口关闭(两端相等且非 0)⇒ 纯周期路径。 + cfg := tidyTestCfg(0.80) + cfg.QuietStartHour, cfg.QuietEndHour = 9, 9 + svc = newTidyServiceForTest(t, store, cfg, nil, fixedTidyClock(at), nil) + if got, want := svc.nextDelay(), 24*time.Hour; got != want { + t.Fatalf("窗口关闭时 nextDelay = %s, want %s", got, want) + } +} + +// TestTidyServiceStartDoesNotRunImmediately 锁定与 backthink 的**有意差异**: +// 整理不做启动即跑,进程重启不会触发一次全库扫描。 +func TestTidyServiceStartDoesNotRunImmediately(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := newTestStore(t) + seedTidyItem(t, store, habitItem(fixtureHabitShort, tidyTestTime(t), 0.9)) + + svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), nil, time.Now, nil) + svc.Start(ctx) + if err := svc.Stop(ctx); err != nil { + t.Fatalf("Stop: %v", err) + } + runs, err := store.ListTidyRuns(ctx, 10) + if err != nil { + t.Fatalf("ListTidyRuns: %v", err) + } + if len(runs) != 0 { + t.Fatalf("启动即跑了整理:%+v", runs) + } +} + +// TestTidyServiceDisabledStartIsNoop:关闭是显式选择 —— 不跑、不报错。 +func TestTidyServiceDisabledStartIsNoop(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := newTestStore(t) + seedTidyItem(t, store, habitItem(fixtureHabitShort, tidyTestTime(t), 0.9)) + + cfg := tidyTestCfg(0.80) + cfg.Enabled = false + svc := newTidyServiceForTest(t, store, cfg, nil, time.Now, nil) + if svc.Enabled() { + t.Fatal("Enabled() 应为 false") + } + svc.Start(ctx) + if err := svc.Stop(ctx); err != nil { + t.Fatalf("Stop: %v", err) + } + runs, err := store.ListTidyRuns(ctx, 10) + if err != nil { + t.Fatalf("ListTidyRuns: %v", err) + } + if len(runs) != 0 { + t.Fatalf("关闭时仍跑了整理:%+v", runs) + } +} + +// TestTidyServiceManualRunAndAudit:「立即整理」入口可手动触发一轮, +// 并写一条 memory.tidy.run 审计(design/23 §8;审计只带计数、不带正文)。 +func TestTidyServiceManualRunAndAudit(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := newTestStore(t) + base := tidyTestTime(t) + seedTidyItem(t, store, habitItem(fixtureHabitShort, base, 0.9)) + seedTidyItem(t, store, habitItem(fixtureHabitLong, base.Add(time.Hour), 0.9)) + + type auditRow struct{ who, what, result string } + var rows []auditRow + svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), + stubEmbedder{vecs: map[string]embedding.Vector{ + fixtureHabitShort: {1, 0}, + fixtureHabitLong: simVector(0.95), + }}, time.Now, func(who, what, result string) { + rows = append(rows, auditRow{who, what, result}) + }) + + stats, err := svc.RunOnce(ctx) + if err != nil { + t.Fatalf("RunOnce: %v", err) + } + if stats.Candidates != 1 { + t.Fatalf("手动一轮候选 = %d, want 1", stats.Candidates) + } + if len(rows) != 1 { + t.Fatalf("审计行数 = %d, want 1", len(rows)) + } + if rows[0].what != "memory.tidy.run" || rows[0].who != tidyAuditWho { + t.Fatalf("审计 who/what = %q/%q", rows[0].who, rows[0].what) + } + if strings.Contains(rows[0].result, fixtureHabitShort) { + t.Fatal("审计里不得出现记忆正文") + } + runs, err := store.ListTidyRuns(ctx, 10) + if err != nil { + t.Fatalf("ListTidyRuns: %v", err) + } + if len(runs) != 1 || runs[0].RuleVersion != TidyRuleVersion || runs[0].Applied != 0 { + t.Fatalf("运行留痕 = %+v, want 1 条、规则版本 %s、applied=0(不自动应用)", runs, TidyRuleVersion) + } +} + +// TestTidyRunRecordedVetoDistribution:否决原因分布进运行表(可查询,不只在日志里)。 +func TestTidyRunRecordedVetoDistribution(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := newTestStore(t) + base := tidyTestTime(t) + seedTidyItem(t, store, habitItem(fixtureHabitPlain, base, 0.9)) + seedTidyItem(t, store, habitItem(fixtureHabitVacation, base.Add(time.Minute), 0.9)) + + svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{ + fixtureHabitPlain: {1, 0}, + fixtureHabitVacation: simVector(0.97), + }}, time.Now, nil) + if _, err := svc.RunOnce(ctx); err != nil { + t.Fatalf("RunOnce: %v", err) + } + runs, err := store.ListTidyRuns(ctx, 1) + if err != nil { + t.Fatalf("ListTidyRuns: %v", err) + } + if len(runs) != 1 { + t.Fatalf("运行留痕 = %d 条, want 1", len(runs)) + } + if runs[0].VetoReasons[string(VetoTemporal)] != 1 { + t.Fatalf("运行表的否决分布 = %+v, want temporal=1", runs[0].VetoReasons) + } + if runs[0].ID == "" { + t.Fatal("运行留痕缺 id(日志里的 run_id 也必须指向同一条记录)") + } + if runs[0].Scanned != 2 { + t.Fatalf("运行表 scanned = %d, want 2", runs[0].Scanned) + } +} diff --git a/internal/runtime/memory/tidy_store.go b/internal/runtime/memory/tidy_store.go new file mode 100644 index 0000000..8bfa871 --- /dev/null +++ b/internal/runtime/memory/tidy_store.go @@ -0,0 +1,264 @@ +package memory + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" +) + +// 本文件是记忆整理的存储层:分区扫描、候选落库与查询、运行留痕。它复用 +// memory 包既有的 *sql.DB 与格式化辅助(formatTime/parseTime/scanItem), +// 不新建存储机制。 +// +// 第一版唯一的写入就是候选表与运行表:**没有任何一条 memory_items 会被改写 +// 或删除**(design/23 §3 非目标 + §7「apply 才写记忆」)。 + +// tidyCandidatePending 是候选的初始状态(design/23 §10)。 +const tidyCandidatePending = "pending" + +// tidyPartition 是整理的隔离单元:design/23 §6 的分区键 +// (persona_id, scope, project_id)。分区之间**永不比较**。 +type tidyPartition struct { + personaID string + scope Scope + projectID string +} + +// label 渲染分区标签,供日志与截断登记使用(不含任何记忆正文)。 +func (p tidyPartition) label() string { + return fmt.Sprintf("%s/%s/%s", p.personaID, p.scope, p.projectID) +} + +// tidyStatusActive 是「在册」状态。整理只扫描在册条目:已合并/已归档的行 +// 不再参与比对(design §10 status 列)。 +const tidyStatusActive = "active" + +// listTidyPartitionItemsSQL 按分区读取**在册**(status='active')条目, +// 最新在前;rowid 打破 created_at 并列,使同一刻写入的条目顺序稳定 +// (与 ListByPersona 同一口径)。分区过滤写在 SQL 里,而不是读全表再在内存 +// 里筛 —— design/23 §6 要求在分区切分层就隔断。 +const listTidyPartitionItemsSQL = `SELECT id, persona_id, role_id, project_id, scope, kind, + content, tags, confidence, source_conversation, created_at, updated_at, + decay_weight FROM memory_items + WHERE persona_id = ? AND scope = ? AND project_id = ? AND status = ? + ORDER BY created_at DESC, rowid DESC LIMIT ?` + +const listTidyPartitionsSQL = `SELECT DISTINCT persona_id, scope, project_id + FROM memory_items WHERE status = ? + ORDER BY persona_id, scope, project_id` + +const insertTidyCandidateSQL = `INSERT OR IGNORE INTO memory_tidy_candidates + (id, persona_id, scope, project_id, kind, action, source_ids, target_id, + proposed_content, merged_content, reason, evidence, rule_version, + score_min, score_max, status, idempotency_key, created_at, decided_at, decided_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', '')` + +const insertTidyRunSQL = `INSERT INTO memory_tidy_runs + (id, started_at, finished_at, scanned, candidates, applied, skipped, errors, + vetoed, veto_reasons, rule_version) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + +const listTidyCandidatesSQL = `SELECT id, persona_id, scope, project_id, kind, action, + source_ids, target_id, proposed_content, merged_content, reason, evidence, + rule_version, score_min, score_max, status, idempotency_key, created_at + FROM memory_tidy_candidates + WHERE status = ? + ORDER BY created_at DESC, rowid DESC LIMIT ?` + +const listTidyRunsSQL = `SELECT id, started_at, finished_at, scanned, candidates, + applied, skipped, errors, vetoed, veto_reasons, rule_version + FROM memory_tidy_runs ORDER BY started_at DESC, rowid DESC LIMIT ?` + +// listTidyPartitions 返回所有含在册条目的分区。DISTINCT 让每个分区的 +// 扫描上限(design §9 每分区 5000 条)各自独立。 +func (s *Store) listTidyPartitions(ctx context.Context) ([]tidyPartition, error) { + rows, err := s.db.QueryContext(ctx, listTidyPartitionsSQL, string(tidyStatusActive)) + if err != nil { + return nil, fmt.Errorf("memory: list tidy partitions: %w", err) + } + defer func() { _ = rows.Close() }() + + parts := make([]tidyPartition, 0) + for rows.Next() { + var p tidyPartition + var scope string + if err := rows.Scan(&p.personaID, &scope, &p.projectID); err != nil { + return nil, fmt.Errorf("memory: scan tidy partition: %w", err) + } + p.scope = Scope(scope) + parts = append(parts, p) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("memory: iterate tidy partitions: %w", err) + } + return parts, nil +} + +// listTidyPartitionItems 读取一个分区内的在册条目(最新在前,最多 limit 条)。 +func (s *Store) listTidyPartitionItems(ctx context.Context, p tidyPartition, limit int) ([]MemoryItem, error) { + rows, err := s.db.QueryContext(ctx, listTidyPartitionItemsSQL, + p.personaID, string(p.scope), p.projectID, string(tidyStatusActive), limit) + if err != nil { + return nil, fmt.Errorf("memory: list tidy partition items: %w", err) + } + return scanItems(rows, "tidy partition item") +} + +// insertTidyCandidate 落库一条候选,返回它是否是**新建**的。幂等键 +// (design/23 §9)唯一:同一批数据重复运行命中已存在行时返回 (false, nil), +// 绝不重复产候选。 +func (s *Store) insertTidyCandidate(ctx context.Context, c TidyCandidate) (bool, error) { + c.IdempotencyKey = tidyIdempotencyKey(c) + sourceJSON, err := json.Marshal(c.SourceIDs) + if err != nil { + return false, fmt.Errorf("memory: encode tidy source ids: %w", err) + } + evidenceJSON, err := json.Marshal(c.Evidence) + if err != nil { + return false, fmt.Errorf("memory: encode tidy evidence: %w", err) + } + if c.CreatedAt.IsZero() { + c.CreatedAt = time.Now().UTC() + } + res, err := s.db.ExecContext(ctx, insertTidyCandidateSQL, + c.ID, c.PersonaID, string(c.Scope), c.ProjectID, string(c.Kind), string(c.Action), + string(sourceJSON), c.TargetID, c.ProposedContent, c.MergedContent, c.Reason, + string(evidenceJSON), c.RuleVersion, c.ScoreMin, c.ScoreMax, c.Status, + c.IdempotencyKey, formatTime(c.CreatedAt)) + if err != nil { + return false, fmt.Errorf("memory: insert tidy candidate: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return false, fmt.Errorf("memory: tidy candidate rows affected: %w", err) + } + return n > 0, nil +} + +// persistTidyRun 写一条运行留痕(design/23 §10 memory_tidy_runs)。 +func (s *Store) persistTidyRun(ctx context.Context, run TidyRun) error { + if run.ID == "" { + run.ID = mustNewTidyID() + } + reasons, err := json.Marshal(run.VetoReasons) + if err != nil { + return fmt.Errorf("memory: encode tidy veto reasons: %w", err) + } + if _, err := s.db.ExecContext(ctx, insertTidyRunSQL, + run.ID, formatTime(run.StartedAt), formatTime(run.FinishedAt), + run.Scanned, run.Candidates, run.Applied, run.Skipped, run.Errors, + run.Vetoed, string(reasons), run.RuleVersion); err != nil { + return fmt.Errorf("memory: insert tidy run: %w", err) + } + return nil +} + +// ListTidyCandidates 返回指定状态的整理候选(最新在前,最多 limit 条, +// 非正 limit 取 defaultTidyCandidateLimit)。它是读侧入口:候选正文含隐私, +// 只应由 owner-only 的读侧使用(design/23 §11)。 +func (s *Store) ListTidyCandidates(ctx context.Context, status string, limit int) ([]TidyCandidate, error) { + if strings.TrimSpace(status) == "" { + status = tidyCandidatePending + } + if limit <= 0 { + limit = defaultTidyCandidateLimit + } + rows, err := s.db.QueryContext(ctx, listTidyCandidatesSQL, status, limit) + if err != nil { + return nil, fmt.Errorf("memory: list tidy candidates: %w", err) + } + defer func() { _ = rows.Close() }() + + out := make([]TidyCandidate, 0) + for rows.Next() { + c, err := scanTidyCandidate(rows) + if err != nil { + return nil, fmt.Errorf("memory: scan tidy candidate: %w", err) + } + out = append(out, c) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("memory: iterate tidy candidates: %w", err) + } + return out, nil +} + +// ListTidyRuns 返回整理运行留痕(最新在前,最多 limit 条)。非正 limit 取 +// 一个保守的默认条数。 +func (s *Store) ListTidyRuns(ctx context.Context, limit int) ([]TidyRun, error) { + if limit <= 0 { + limit = defaultTidyCandidateLimit + } + rows, err := s.db.QueryContext(ctx, listTidyRunsSQL, limit) + if err != nil { + return nil, fmt.Errorf("memory: list tidy runs: %w", err) + } + defer func() { _ = rows.Close() }() + + out := make([]TidyRun, 0) + for rows.Next() { + var ( + r TidyRun + startedAt string + finishedAt string + reasonsJSON string + ) + if err := rows.Scan(&r.ID, &startedAt, &finishedAt, &r.Scanned, &r.Candidates, + &r.Applied, &r.Skipped, &r.Errors, &r.Vetoed, &reasonsJSON, &r.RuleVersion); err != nil { + return nil, fmt.Errorf("memory: scan tidy run: %w", err) + } + var err error + if r.StartedAt, err = parseTime(startedAt); err != nil { + return nil, err + } + if r.FinishedAt, err = parseTime(finishedAt); err != nil { + return nil, err + } + if err := json.Unmarshal([]byte(reasonsJSON), &r.VetoReasons); err != nil { + return nil, fmt.Errorf("memory: decode tidy veto reasons: %w", err) + } + out = append(out, r) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("memory: iterate tidy runs: %w", err) + } + return out, nil +} + +// scanTidyCandidate 读取一行候选:source_ids 与 evidence 以 JSON 存储。 +func scanTidyCandidate(sc rowScanner) (TidyCandidate, error) { + var ( + c TidyCandidate + scope, kind string + action string + sourceJSON string + evidenceRaw string + createdAt string + ) + if err := sc.Scan(&c.ID, &c.PersonaID, &scope, &c.ProjectID, &kind, &action, + &sourceJSON, &c.TargetID, &c.ProposedContent, &c.MergedContent, &c.Reason, + &evidenceRaw, &c.RuleVersion, &c.ScoreMin, &c.ScoreMax, &c.Status, + &c.IdempotencyKey, &createdAt); err != nil { + return TidyCandidate{}, err + } + c.Scope = Scope(scope) + c.Kind = Kind(kind) + c.Action = TidyAction(action) + if err := json.Unmarshal([]byte(sourceJSON), &c.SourceIDs); err != nil { + return TidyCandidate{}, fmt.Errorf("memory: decode tidy source ids: %w", err) + } + if c.SourceIDs == nil { + c.SourceIDs = []string{} + } + if err := json.Unmarshal([]byte(evidenceRaw), &c.Evidence); err != nil { + return TidyCandidate{}, fmt.Errorf("memory: decode tidy evidence: %w", err) + } + t, err := parseTime(createdAt) + if err != nil { + return TidyCandidate{}, err + } + c.CreatedAt = t + return c, nil +} diff --git a/internal/runtime/memory/tidy_test.go b/internal/runtime/memory/tidy_test.go new file mode 100644 index 0000000..c3d34b8 --- /dev/null +++ b/internal/runtime/memory/tidy_test.go @@ -0,0 +1,390 @@ +package memory + +import ( + "context" + "math" + "testing" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding" +) + +// 本文件是记忆整理(design/23 §12)的正/反例测试集:用设计稿引文脱敏构造的 +// 夹具,逐条断言「该合的合、不该合的不合」。夹具取自 design/23 §1 的引文 +// ([8] 一次性事件、[12]/[15] 假期状态、[1]/[3] 红牛 vs 冰美式、 +// [21]/[25] 熬夜习惯的真子集),确保测的是设计稿点名的情形。 +const ( + fixtureHabitShort = "主人常在深夜与助手聊天,有熬夜习惯" + fixtureHabitLong = "主人常在深夜与助手聊天,有熬夜习惯,作息偏晚" + fixtureHabitPlain = "主人常在深夜加班" + fixtureHabitVacation = "主人国庆假期期间常在深夜加班,处于休假状态" + fixtureEnergyRedBull = "主人喜欢喝红牛来提神,红牛是提神饮料" + fixtureEnergyIced = "主人喜欢喝冰美式来提神,冰美式是提神饮料" + fixtureOneOffEvent = "2026年9月8日周二晚22:38,主人仍在加班中度过的夜晚。" + fixtureWorkShort = "主人用喵打招呼" + fixtureWorkLong = "主人用喵打招呼,还会配合可爱的表情" + baseTidyTime = "2026-10-01T10:00:00.000000000Z" +) + +// stubEmbedder 是按文本查表的确定性 embedder:测试用它精确控制「哪几对被 +// 召回」,把**召回**与**判定**分开验(判定绝不看相似度)。未登记文本得到 +// 一个与相似族正交的向量,因此不会意外参与召回。 +type stubEmbedder struct { + vecs map[string]embedding.Vector +} + +func (s stubEmbedder) Embed(_ context.Context, texts []string) ([]embedding.Vector, error) { + out := make([]embedding.Vector, 0, len(texts)) + for _, text := range texts { + v, ok := s.vecs[text] + if !ok { + v = embedding.Vector{0, 1} + } + out = append(out, v) + } + return out, nil +} + +func (s stubEmbedder) Dim() int { return 2 } + +// simVector 返回与 {1,0} 余弦恰为 want 的向量(want ∈ (0,1])。 +func simVector(want float64) embedding.Vector { + return embedding.Vector{float32(want), float32(math.Sqrt(1 - want*want))} +} + +// tidyTestTime 把固定字符串解析成基准时刻。 +func tidyTestTime(t *testing.T) time.Time { + t.Helper() + parsed, err := parseTime(baseTidyTime) + if err != nil { + t.Fatalf("parse base time: %v", err) + } + return parsed +} + +// tidyTestCfg 是测试用的整理配置:只把召回门显式写死,其余走设计稿默认值。 +func tidyTestCfg(threshold float64) TidyConfig { + return TidyConfig{ + Enabled: true, + IntervalSec: defaultTidyIntervalSec, + QuietStartHour: defaultTidyQuietStartHour, + QuietEndHour: defaultTidyQuietEndHour, + RecallThreshold: threshold, + ScanLimit: 200, + CandidateLimit: defaultTidyCandidateLimit, + } +} + +// newTestTidier 用给定存储、配置与可选 embedder 构建整理器。 +func newTestTidier(t *testing.T, store *Store, cfg TidyConfig, emb embedding.Embedder) *Tidier { + t.Helper() + tr, err := NewTidier(cfg, TidyDeps{Store: store, Embedder: emb, Log: testLogger()}) + if err != nil { + t.Fatalf("NewTidier: %v", err) + } + return tr +} + +// seedTidyItem 落库一条测试记忆。 +func seedTidyItem(t *testing.T, store *Store, item MemoryItem) MemoryItem { + t.Helper() + got, err := store.Add(context.Background(), item) + if err != nil { + t.Fatalf("seed item %q: %v", item.Content, err) + } + return got +} + +// habitItem 是熬夜类记忆的构造器(同一分区:persona.a / persona / 无项目)。 +func habitItem(content string, at time.Time, confidence float64) MemoryItem { + return MemoryItem{ + PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship, + Content: content, Confidence: confidence, CreatedAt: at, SourceConversation: FormatSourceConversation("web", "msg-1"), + } +} + +// candidatesByAction 从候选列表里按动作筛选。 +func candidatesByAction(cands []TidyCandidate, action TidyAction) []TidyCandidate { + out := make([]TidyCandidate, 0) + for _, c := range cands { + if c.Action == action { + out = append(out, c) + } + } + return out +} + +// TestTidyContainedFactProducesMergeCandidate 是 design/23 §12.1 P1 的口径: +// A 的事实集合是 B 的子集 ⇒ 产 merge 候选,存活条目是超集 B,被并入的是 A。 +func TestTidyContainedFactProducesMergeCandidate(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := newTestStore(t) + base := tidyTestTime(t) + short := seedTidyItem(t, store, habitItem(fixtureHabitShort, base, 0.9)) + long := seedTidyItem(t, store, habitItem(fixtureHabitLong, base.Add(time.Hour), 0.9)) + + tr := newTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{ + fixtureHabitShort: {1, 0}, + fixtureHabitLong: simVector(0.95), + }}) + stats, err := tr.RunOnce(ctx) + if err != nil { + t.Fatalf("RunOnce: %v", err) + } + if stats.Scanned != 2 || stats.Pairs != 1 { + t.Fatalf("stats scanned/pairs = %d/%d, want 2/1 (%+v)", stats.Scanned, stats.Pairs, stats) + } + + cands, err := store.ListTidyCandidates(ctx, "", 50) + if err != nil { + t.Fatalf("ListTidyCandidates: %v", err) + } + merges := candidatesByAction(cands, TidyMerge) + if len(merges) != 1 { + t.Fatalf("merge candidates = %d, want 1 (%+v)", len(merges), cands) + } + got := merges[0] + if got.TargetID != long.ID { + t.Fatalf("merge target = %q, want 超集 B = %q", got.TargetID, long.ID) + } + if len(got.SourceIDs) != 1 || got.SourceIDs[0] != short.ID { + t.Fatalf("merge source ids = %v, want [%s]", got.SourceIDs, short.ID) + } + if got.MergedContent != fixtureHabitLong { + t.Fatalf("merged content = %q, want 超集原文", got.MergedContent) + } + if got.Evidence.Containment == string(containNone) || got.Evidence.Similarity < 0.80 { + t.Fatalf("evidence = %+v, want 包含判定为子集关系且带召回分数", got.Evidence) + } + if got.MergedContent != fixtureHabitLong || got.ProposedContent != fixtureHabitShort { + t.Fatalf("diff 预览的原文/合并结果不对:proposed=%q merged=%q", got.ProposedContent, got.MergedContent) + } + if got.Status != tidyCandidatePending { + t.Fatalf("status = %q, want %q(人工确认队列)", got.Status, tidyCandidatePending) + } +} + +// TestTidyParallelFactsOnlyLink 是主人红线「不要什么内容都合并」与 +// design/23 §12.1 P8 的口径:相似但并列的事实(红牛 vs 冰美式)只建链接, +// 绝不合并 —— 即使召回分数很高。 +func TestTidyParallelFactsOnlyLink(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := newTestStore(t) + base := tidyTestTime(t) + redBull := seedTidyItem(t, store, habitItem(fixtureEnergyRedBull, base, 0.95)) + iced := seedTidyItem(t, store, habitItem(fixtureEnergyIced, base.Add(time.Minute), 0.95)) + + tr := newTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{ + fixtureEnergyRedBull: {1, 0}, + fixtureEnergyIced: simVector(0.99), + }}) + if _, err := tr.RunOnce(ctx); err != nil { + t.Fatalf("RunOnce: %v", err) + } + + cands, err := store.ListTidyCandidates(ctx, "", 50) + if err != nil { + t.Fatalf("ListTidyCandidates: %v", err) + } + if merges := candidatesByAction(cands, TidyMerge); len(merges) != 0 { + t.Fatalf("并列事实被合并了:%+v(主人红线:不要什么内容都合并)", merges) + } + links := candidatesByAction(cands, TidyLink) + if len(links) != 1 { + t.Fatalf("link candidates = %d, want 1 (%+v)", len(links), cands) + } + ids := links[0].SourceIDs + if len(ids) != 2 || !containsID(ids, redBull.ID) || !containsID(ids, iced.ID) { + t.Fatalf("link source ids = %v, want 两条并列事实", ids) + } + if links[0].MergedContent != "" || links[0].TargetID != "" { + t.Fatalf("link 候选不得带合并结果:%+v", links[0]) + } +} + +// TestTidyCrossScopeNeverComparedOrMerged 是 design/23 §12.2 N1 与 §6 硬约束: +// 跨作用域([10] user vs [19] persona,全库相似度最高的一对 0.9319)永不合 —— +// 运行期连比较都不发生(分区隔离),判定层再兜一次否决。 +func TestTidyCrossScopeNeverComparedOrMerged(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := newTestStore(t) + base := tidyTestTime(t) + content := "主人喜欢被叫小笨蛋" + userItem := seedTidyItem(t, store, MemoryItem{ + PersonaID: "persona.a", Scope: ScopeUser, Kind: KindRelationship, + Content: content, Confidence: 0.95, CreatedAt: base, + }) + personaItem := habitItem(content, base.Add(time.Minute), 0.95) + seedTidyItem(t, store, personaItem) + + emb := stubEmbedder{vecs: map[string]embedding.Vector{content: {1, 0}}} + + // 判定层:跨作用域对被硬门否决(纵深防御,design §5.2-7)。 + verdict := judgePair(userItem, personaItem, 0.9319) + if verdict.Veto != VetoScope { + t.Fatalf("judgePair 跨作用域 veto = %q, want %q", verdict.Veto, VetoScope) + } + if verdict.Action != "" { + t.Fatalf("被判否决的对不得带动作,got %q", verdict.Action) + } + + // 运行期:分区之间永不比较 ⇒ 零候选(design §6「跨分区永不产生候选」)。 + tr := newTestTidier(t, store, tidyTestCfg(0.80), emb) + stats, err := tr.RunOnce(ctx) + if err != nil { + t.Fatalf("RunOnce: %v", err) + } + if stats.Pairs != 0 || stats.Candidates != 0 { + t.Fatalf("跨作用域产出了候选:pairs=%d candidates=%d", stats.Pairs, stats.Candidates) + } + cands, err := store.ListTidyCandidates(ctx, "", 50) + if err != nil { + t.Fatalf("ListTidyCandidates: %v", err) + } + if len(cands) != 0 { + t.Fatalf("跨作用域候选 = %+v, want 空", cands) + } +} + +// TestTidyTemporalPollutionVetoed 是 design/23 §12.2 N2 与 §5.2-1:一次性事件 +// 与有期限状态不得被当长期事实合并;含绝对时刻的条目单独走 archive。 +func TestTidyTemporalPollutionVetoed(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := newTestStore(t) + base := tidyTestTime(t) + plain := seedTidyItem(t, store, habitItem(fixtureHabitPlain, base, 0.9)) + vacation := seedTidyItem(t, store, habitItem(fixtureHabitVacation, base.Add(time.Minute), 0.9)) + oneOff := seedTidyItem(t, store, habitItem(fixtureOneOffEvent, base.Add(2*time.Minute), 0.9)) + + // 判定层:[8] 那类一次性事件与长期习惯硬不合。 + if v := judgePair(plain, vacation, 0.95); v.Veto != VetoTemporal { + t.Fatalf("时效状态 veto = %q, want %q", v.Veto, VetoTemporal) + } + if v := judgePair(oneOff, plain, 0.95); v.Veto != VetoTemporal { + t.Fatalf("一次性事件 veto = %q, want %q", v.Veto, VetoTemporal) + } + + tr := newTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{ + fixtureHabitPlain: {1, 0}, + fixtureHabitVacation: simVector(0.97), + fixtureOneOffEvent: simVector(0.96), + }}) + stats, err := tr.RunOnce(ctx) + if err != nil { + t.Fatalf("RunOnce: %v", err) + } + if stats.VetoReasons[string(VetoTemporal)] < 1 { + t.Fatalf("否决原因分布里没有 temporal:%+v", stats.VetoReasons) + } + + cands, err := store.ListTidyCandidates(ctx, "", 50) + if err != nil { + t.Fatalf("ListTidyCandidates: %v", err) + } + if merges := candidatesByAction(cands, TidyMerge); len(merges) != 0 { + t.Fatalf("时效污染被合并:%+v", merges) + } + archives := candidatesByAction(cands, TidyArchive) + if len(archives) != 1 || archives[0].SourceIDs[0] != oneOff.ID { + t.Fatalf("archive 候选 = %+v, want 恰为一次性事件 %s", archives, oneOff.ID) + } + for _, c := range cands { + if containsID(c.SourceIDs, oneOff.ID) && c.Action != TidyArchive { + t.Fatalf("一次性事件参与了 %s:%+v", c.Action, c) + } + } +} + +// TestTidyEmptyAndSingleItemAreNoops 是 design/23 §12 的边界口径:空库与单条 +// 记忆都不产候选、不报错,但仍留一条运行痕迹(不静默)。 +func TestTidyEmptyAndSingleItemAreNoops(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := newTestStore(t) + tr := newTestTidier(t, store, tidyTestCfg(0.80), nil) + + stats, err := tr.RunOnce(ctx) + if err != nil { + t.Fatalf("空库 RunOnce: %v", err) + } + if stats.Scanned != 0 || stats.Candidates != 0 || stats.Pairs != 0 { + t.Fatalf("空库 stats = %+v, want 全 0", stats) + } + + seedTidyItem(t, store, habitItem(fixtureHabitShort, tidyTestTime(t), 0.9)) + stats, err = tr.RunOnce(ctx) + if err != nil { + t.Fatalf("单条 RunOnce: %v", err) + } + if stats.Scanned != 1 || stats.Candidates != 0 || stats.Pairs != 0 { + t.Fatalf("单条 stats = %+v, want scanned=1/candidates=0/pairs=0", stats) + } + + runs, err := store.ListTidyRuns(ctx, 10) + if err != nil { + t.Fatalf("ListTidyRuns: %v", err) + } + if len(runs) != 2 { + t.Fatalf("运行留痕 = %d 条, want 2", len(runs)) + } +} + +// TestTidyRerunIsIdempotent 是 design/23 §12.2 N6 与 §9:同一批数据连跑两轮 +// 不重复产候选(幂等键 = 规则版本 + 数据指纹,不靠「记住上次」)。 +func TestTidyRerunIsIdempotent(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := newTestStore(t) + base := tidyTestTime(t) + seedTidyItem(t, store, habitItem(fixtureHabitShort, base, 0.9)) + seedTidyItem(t, store, habitItem(fixtureHabitLong, base.Add(time.Hour), 0.9)) + seedTidyItem(t, store, habitItem(fixtureOneOffEvent, base.Add(2*time.Hour), 0.9)) + + tr := newTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{ + fixtureHabitShort: {1, 0}, + fixtureHabitLong: simVector(0.95), + fixtureOneOffEvent: simVector(0.9), + }}) + first, err := tr.RunOnce(ctx) + if err != nil { + t.Fatalf("第一轮 RunOnce: %v", err) + } + if first.Candidates == 0 { + t.Fatalf("第一轮没有产出候选,用例无效:%+v", first) + } + before, err := store.ListTidyCandidates(ctx, "", 100) + if err != nil { + t.Fatalf("ListTidyCandidates: %v", err) + } + + second, err := tr.RunOnce(ctx) + if err != nil { + t.Fatalf("第二轮 RunOnce: %v", err) + } + if second.Candidates != 0 { + t.Fatalf("第二轮新产候选 = %d, want 0(幂等)", second.Candidates) + } + if second.Duplicates != first.Candidates { + t.Fatalf("第二轮 duplicates = %d, want %d", second.Duplicates, first.Candidates) + } + after, err := store.ListTidyCandidates(ctx, "", 100) + if err != nil { + t.Fatalf("ListTidyCandidates: %v", err) + } + if len(after) != len(before) { + t.Fatalf("候选行数 %d → %d, want 不变", len(before), len(after)) + } +} + +func containsID(ids []string, id string) bool { + for _, v := range ids { + if v == id { + return true + } + } + return false +} diff --git a/internal/runtime/memory/tidy_veto_test.go b/internal/runtime/memory/tidy_veto_test.go new file mode 100644 index 0000000..f46425b --- /dev/null +++ b/internal/runtime/memory/tidy_veto_test.go @@ -0,0 +1,247 @@ +package memory + +import ( + "context" + "math" + "testing" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding" +) + +// 本文件收拢记忆整理的**否决与边界/配置**用例:来源硬门、低置信、kind 误分类、 +// 每分区配额、离线回退、设计稿默认值与 schema。主流程(包含→合并 / 并列→链接 / +// 跨作用域 / 时效污染 / 空库 / 幂等)在 tidy_test.go。 +// TestTidyGroupSourceExcluded 是 design/23 §12.2 N7 与 §5.2-9/§6:群聊/陌生人 +// 来源的条目既不合并也不归档,只读不写。 +func TestTidyGroupSourceExcluded(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := newTestStore(t) + base := tidyTestTime(t) + groupItem := seedTidyItem(t, store, MemoryItem{ + PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship, + Content: fixtureHabitShort, Confidence: 0.9, CreatedAt: base, + SourceConversation: FormatSourceConversation("sess-telegram:group1", "msg-9"), + }) + ownerItem := seedTidyItem(t, store, habitItem(fixtureHabitShort, base.Add(time.Minute), 0.9)) + + tr := newTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{ + fixtureHabitShort: {1, 0}, + }}) + stats, err := tr.RunOnce(ctx) + if err != nil { + t.Fatalf("RunOnce: %v", err) + } + if stats.VetoReasons[string(VetoSource)] < 1 { + t.Fatalf("否决原因分布里没有 source:%+v", stats.VetoReasons) + } + cands, err := store.ListTidyCandidates(ctx, "", 50) + if err != nil { + t.Fatalf("ListTidyCandidates: %v", err) + } + for _, c := range cands { + if containsID(c.SourceIDs, groupItem.ID) { + t.Fatalf("群聊来源条目进了候选:%+v", c) + } + } + if ownerItem.ID == "" { + t.Fatal("owner 条目应当落库成功") + } +} + +// TestTidyLowConfidenceVetoed 是 design/23 §12.2 N4 与 §5.2-6:confidence 不是 +// 合并门,但必须是否决门。 +func TestTidyLowConfidenceVetoed(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := newTestStore(t) + base := tidyTestTime(t) + low := seedTidyItem(t, store, habitItem(fixtureHabitShort, base, 0.6)) + high := seedTidyItem(t, store, habitItem(fixtureHabitLong, base.Add(time.Hour), 0.9)) + + if v := judgePair(low, high, 0.99); v.Veto != VetoLowConfidence { + t.Fatalf("低置信 veto = %q, want %q", v.Veto, VetoLowConfidence) + } + tr := newTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{ + fixtureHabitShort: {1, 0}, + fixtureHabitLong: simVector(0.97), + }}) + if _, err := tr.RunOnce(ctx); err != nil { + t.Fatalf("RunOnce: %v", err) + } + cands, err := store.ListTidyCandidates(ctx, "", 50) + if err != nil { + t.Fatalf("ListTidyCandidates: %v", err) + } + if len(cands) != 0 { + t.Fatalf("低置信对产出了候选:%+v", cands) + } +} + +// TestTidyKindMismatchAlsoSuggestsReclassify 是 design/23 §12.1 P7 的口径: +// 包含关系成立但 kind 不同 ⇒ 既产 merge 候选,也产 reclassify 候选(都交人工)。 +func TestTidyKindMismatchAlsoSuggestsReclassify(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := newTestStore(t) + base := tidyTestTime(t) + short := MemoryItem{ + PersonaID: "persona.a", Scope: ScopePersona, Kind: KindWorldEvent, + Content: fixtureHabitShort, Confidence: 0.9, CreatedAt: base, + } + long := MemoryItem{ + PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship, + Content: fixtureHabitLong, Confidence: 0.9, CreatedAt: base.Add(time.Hour), + } + seedTidyItem(t, store, short) + seedTidyItem(t, store, long) + + tr := newTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{ + fixtureHabitShort: {1, 0}, + fixtureHabitLong: simVector(0.95), + }}) + if _, err := tr.RunOnce(ctx); err != nil { + t.Fatalf("RunOnce: %v", err) + } + cands, err := store.ListTidyCandidates(ctx, "", 50) + if err != nil { + t.Fatalf("ListTidyCandidates: %v", err) + } + if len(candidatesByAction(cands, TidyMerge)) != 1 || len(candidatesByAction(cands, TidyReclassify)) != 1 { + t.Fatalf("期望 1 条 merge + 1 条 reclassify,实际 %+v", cands) + } +} + +// TestTidyCandidateLimitPerPartition 锁定 design/23 §9 的每分区候选上限: +// 超出的只登记不入队,且绝不静默。 +func TestTidyCandidateLimitPerPartition(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := newTestStore(t) + base := tidyTestTime(t) + shortA, longA := fixtureWorkShort, fixtureWorkLong + shortB, longB := "主人喜欢被叫小笨蛋", "主人喜欢被叫小笨蛋,还喜欢被摸摸头" + seedTidyItem(t, store, habitItem(shortA, base, 0.9)) + seedTidyItem(t, store, habitItem(longA, base.Add(time.Minute), 0.9)) + seedTidyItem(t, store, habitItem(shortB, base.Add(2*time.Minute), 0.9)) + seedTidyItem(t, store, habitItem(longB, base.Add(3*time.Minute), 0.9)) + + cfg := tidyTestCfg(0.80) + cfg.CandidateLimit = 1 + // 两族向量彼此近似正交(跨族 cosine ≈ 0.31 < 0.80),因此恰好召回 2 对。 + tr := newTestTidier(t, store, cfg, stubEmbedder{vecs: map[string]embedding.Vector{ + shortA: {1, 0}, + longA: simVector(0.95), + shortB: {0, 1}, + longB: {float32(math.Sqrt(1 - 0.95*0.95)), 0.95}, + }}) + + stats, err := tr.RunOnce(ctx) + if err != nil { + t.Fatalf("RunOnce: %v", err) + } + if stats.Candidates != 1 { + t.Fatalf("候选 = %d, want 1(每分区上限)", stats.Candidates) + } + if stats.Skipped < 1 { + t.Fatalf("超限未登记:skipped=%d", stats.Skipped) + } +} + +// TestTidyOfflineLexicalRecall 锁定「无 embedder 时的零依赖回退」:整理在没有 +// embedding 配置时仍能召回并判定(阈值仍是可配的召回门)。 +func TestTidyOfflineLexicalRecall(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := newTestStore(t) + base := tidyTestTime(t) + seedTidyItem(t, store, habitItem(fixtureHabitShort, base, 0.9)) + seedTidyItem(t, store, habitItem(fixtureHabitLong, base.Add(time.Hour), 0.9)) + + cfg := tidyTestCfg(0.5) + tr := newTestTidier(t, store, cfg, nil) + stats, err := tr.RunOnce(ctx) + if err != nil { + t.Fatalf("RunOnce: %v", err) + } + if stats.Pairs != 1 { + t.Fatalf("词项余弦召回 pairs = %d, want 1", stats.Pairs) + } + cands, err := store.ListTidyCandidates(ctx, "", 50) + if err != nil { + t.Fatalf("ListTidyCandidates: %v", err) + } + if len(candidatesByAction(cands, TidyMerge)) != 1 { + t.Fatalf("离线回退未产出 merge 候选:%+v", cands) + } +} + +// TestTidyConfigDefaults 锁定「设计稿默认值只有一处定义」:召回门 0.80、 +// 每天 1 次、安静时段 23:00-07:00、每分区扫描 5000 / 候选 20(design §9/§13)。 +func TestTidyConfigDefaults(t *testing.T) { + t.Parallel() + cfg := normalizeTidyConfig(TidyConfig{Enabled: true}) + if cfg.IntervalSec != 86400 || cfg.Interval != 24*time.Hour { + t.Fatalf("周期默认 = %ds / %s, want 86400s / 24h", cfg.IntervalSec, cfg.Interval) + } + if cfg.RecallThreshold != 0.80 { + t.Fatalf("召回门默认 = %v, want 0.80", cfg.RecallThreshold) + } + if cfg.ScanLimit != 5000 || cfg.CandidateLimit != 20 { + t.Fatalf("配额默认 = %d/%d, want 5000/20", cfg.ScanLimit, cfg.CandidateLimit) + } + if cfg.QuietStartHour != 23 || cfg.QuietEndHour != 7 { + t.Fatalf("安静时段默认 = %d-%d, want 23-7", cfg.QuietStartHour, cfg.QuietEndHour) + } + if TidySessionID != "sess-memorytidy" { + t.Fatalf("隔离会话 = %q, want sess-memorytidy", TidySessionID) + } +} + +// TestTidySchemaCreatedByNewStore 锁定「整理在任意 *sql.DB 上都可用」: +// NewStore 幂等创建候选表/运行表并前滚 memory_items 的整理列。 +func TestTidySchemaCreatedByNewStore(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := newTestStore(t) + if _, err := NewStore(ctx, store.db, testLogger()); err != nil { + t.Fatalf("第二次 NewStore(幂等): %v", err) + } + for _, table := range []string{"memory_tidy_candidates", "memory_tidy_runs"} { + var n int + if err := store.db.QueryRowContext(ctx, + "SELECT count(*) FROM sqlite_master WHERE type='table' AND name = ?", table).Scan(&n); err != nil { + t.Fatalf("查询表 %s: %v", table, err) + } + if n != 1 { + t.Fatalf("表 %s 不存在", table) + } + } + rows, err := store.db.QueryContext(ctx, "PRAGMA table_info(memory_items)") + if err != nil { + t.Fatalf("pragma table_info: %v", err) + } + defer rows.Close() + cols := map[string]bool{} + for rows.Next() { + var ( + cid int + name, typ string + notNull int + dflt any + pk int + ) + if err := rows.Scan(&cid, &name, &typ, ¬Null, &dflt, &pk); err != nil { + t.Fatalf("scan pragma: %v", err) + } + cols[name] = true + } + for _, want := range []string{"status", "merged_into", "merged_from", "merge_count", "occurred_at", "valid_to", "expires_at"} { + if !cols[want] { + t.Fatalf("memory_items 缺列 %s(现有列:%v)", want, cols) + } + } +} + +// containsID 报告 ids 里是否含 id。