feat(memory): 记忆整理 v1(design/23)——低峰定期扫描 + 事实包含判定 + 规则否决 + 只产建议队列(不自动应用)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

This commit is contained in:
AskaEth committed 2026-10-05 11:29:36 +08:00
1 parent 012e9f3ad4
commit 91a3209c9b
25 files changed
+3418 -13

No files matched your search

+23
View File
@@ -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)。
+6
View File
@@ -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)
+17 -12
View File
@@ -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
+49
View File
@@ -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)
}
+208
View File
@@ -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:<uuid>")上。
func TestMemoryTidySessionIsNotConversationLane(t *testing.T) {
t.Parallel()
if isConversationLane(memory.TidySessionID) {
t.Fatalf("整理会话 %q 被判成主人对话回合(会改写她的心情、进主会话泳道)", memory.TidySessionID)
}
// 对照组:主人车道本身必须仍然为 true(避免把断言写成「函数恒 false」)。
if !isConversationLane(history.MainSessionID) || !isConversationLane(history.MainSessionID+":sub-1") {
t.Fatal("主人车道判定被改坏了")
}
}
// TestMemoryTidyIsWired 是接线存在性防回归:组合根少任何一处,整理都会**静默
// 不工作**(不报错、不产候选、日志里一句都没有),而这不会让别的用例变红。
func TestMemoryTidyIsWired(t *testing.T) {
t.Parallel()
seams := map[string][]string{
"wire_runtime.go": {
// 记忆栈建好后就装配整理(它只需要 store + embedder)。
"a.wireMemoryTidy()",
"a.embedder = embedder",
},
"wire_memory_tidy.go": {
"memory.NewTidyService(memory.TidyConfigFromConfig(a.cfg.MemoryTidy)",
// 依赖面里**没有**任何会话/历史读数 —— 这是「绝不碰主会话上下文」
// 的结构性保证,而不是靠自觉。
"Audit: a.auditMemoryTidy",
},
"main.go": {
"a.memoryTidy.Start(ctx)",
},
"wire_services.go": {
// 关库之前先停整理(它每轮要用连接池)。
"a.memoryTidy.Stop(ctx)",
},
}
for file, wants := range seams {
src, err := os.ReadFile(file)
if err != nil {
t.Fatalf("read %s: %v", file, err)
}
for _, want := range wants {
if !strings.Contains(string(src), want) {
t.Fatalf("%s 里找不到 %q —— 记忆整理没有被接上组合根", file, want)
}
}
}
raw, err := os.ReadFile(filepath.Join("..", "..", ".env.example"))
if err != nil {
t.Fatalf("read .env.example: %v", err)
}
for _, key := range []string{
"MEMORY_TIDY_ENABLED=", "MEMORY_TIDY_INTERVAL_SEC=", "MEMORY_TIDY_QUIET_START_HOUR=",
"MEMORY_TIDY_QUIET_END_HOUR=", "MEMORY_TIDY_RECALL_THRESHOLD=",
"MEMORY_TIDY_SCAN_LIMIT=", "MEMORY_TIDY_CANDIDATE_LIMIT=",
} {
if !strings.Contains(string(raw), key) {
t.Fatalf(".env.example 里找不到 %q(新 env 键必须同步模板,AGENTS §10 协同 E6)", key)
}
}
}
// TestMemoryTidyRunLeavesChatHistoryUntouched 是 design/23 §4.1 E 的可验证版本:
// 整理跑完一轮后,主会话的消息条数、最后一条消息、read_at 游标**逐字不变**,
// 且 chat_messages 里没有任何属于整理会话的行。
//
// 用例让整理真的干活(有可合并的一对),否则断言会是空的。
func TestMemoryTidyRunLeavesChatHistoryUntouched(t *testing.T) {
ctx := context.Background()
conn, err := db.Open(ctx, ":memory:", tidyTestLog())
if err != nil {
t.Fatalf("open db: %v", err)
}
defer func() { _ = conn.Close() }()
if err := db.MigrateUp(ctx, conn, tidyTestLog()); err != nil {
t.Fatalf("migrate: %v", err)
}
hist, err := history.NewStore(ctx, conn, tidyTestLog())
if err != nil {
t.Fatalf("history store: %v", err)
}
if _, err := hist.AppendFull(ctx, history.Message{
SessionID: history.MainSessionID, Role: history.RoleUser, Content: "今晚又要加班到很晚", CreatedAt: 1_700_000_000_000,
}); err != nil {
t.Fatalf("append user message: %v", err)
}
last, err := hist.AppendFull(ctx, history.Message{
SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: "别熬太晚,我陪你", CreatedAt: 1_700_000_001_000,
})
if err != nil {
t.Fatalf("append assistant message: %v", err)
}
if _, err := hist.MarkSessionRead(ctx, history.MainSessionID, time.UnixMilli(1_700_000_000_500)); err != nil {
t.Fatalf("mark read: %v", err)
}
before := readConversationFingerprint(t, ctx, conn, history.MainSessionID)
store, err := memory.NewStore(ctx, conn, tidyTestLog())
if err != nil {
t.Fatalf("memory store: %v", err)
}
seed := []string{"主人常在深夜与助手聊天,有熬夜习惯", "主人常在深夜与助手聊天,有熬夜习惯,作息偏晚"}
for i, content := range seed {
if _, err := store.Add(ctx, memory.MemoryItem{
PersonaID: "persona.a", Scope: memory.ScopePersona, Kind: memory.KindRelationship,
Content: content, Confidence: 0.9,
CreatedAt: time.UnixMilli(1_700_000_000_000 + int64(i)*60_000),
}); err != nil {
t.Fatalf("seed memory %d: %v", i, err)
}
}
svc, err := memory.NewTidyService(memory.TidyConfigFromConfig(config.MemoryTidyConfig{
Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7,
RecallThreshold: 0.80, ScanLimit: 100, CandidateLimit: 20,
}), memory.TidyDeps{Store: store, Log: tidyTestLog()})
if err != nil {
t.Fatalf("tidy service: %v", err)
}
stats, err := svc.RunOnce(ctx)
if err != nil {
t.Fatalf("tidy RunOnce: %v", err)
}
if stats.Scanned != 2 || stats.Candidates == 0 {
t.Fatalf("整理没真干活,断言会落空:%+v", stats)
}
after := readConversationFingerprint(t, ctx, conn, history.MainSessionID)
if before != after {
t.Fatalf("整理改动了主会话上下文:\n before=%+v\n after=%+v", before, after)
}
if after.lastContent != last.Content || after.lastID != last.ID {
t.Fatalf("主会话最后一条消息变了:%+v", after)
}
var tidyMsgs int
if err := conn.QueryRowContext(ctx,
"SELECT count(*) FROM chat_messages WHERE session_id = ?", memory.TidySessionID).Scan(&tidyMsgs); err != nil {
t.Fatalf("count tidy messages: %v", err)
}
if tidyMsgs != 0 {
t.Fatalf("整理向 chat_messages 写入了 %d 行(design §4.1 D 要求零写入)", tidyMsgs)
}
}
// conversationFingerprint 是主会话的「逐字不变」指纹:消息条数、最后一条消息
// 的 id 与正文、以及会话已读游标。
type conversationFingerprint struct {
messages int
lastID string
lastContent string
readAt int64
}
// readConversationFingerprint 直接读 chat_messages / chat_sessions(主会话只由
// 消息写入路径触碰,因此这是「整理有没有碰会话」最直接的证据)。
func readConversationFingerprint(t *testing.T, ctx context.Context, conn *sql.DB, sessionID string) conversationFingerprint {
t.Helper()
var fp conversationFingerprint
if err := conn.QueryRowContext(ctx,
"SELECT count(*) FROM chat_messages WHERE session_id = ?", sessionID).Scan(&fp.messages); err != nil {
t.Fatalf("count messages: %v", err)
}
if err := conn.QueryRowContext(ctx,
"SELECT id, content FROM chat_messages WHERE session_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1",
sessionID).Scan(&fp.lastID, &fp.lastContent); err != nil {
t.Fatalf("read last message: %v", err)
}
if err := conn.QueryRowContext(ctx,
"SELECT read_at FROM chat_sessions WHERE id = ?", sessionID).Scan(&fp.readAt); err != nil {
t.Fatalf("read cursor: %v", err)
}
return fp
}
+7
View File
@@ -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
}
+5
View File
@@ -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)
}
+28
View File
@@ -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
+57
View File
@@ -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)
}
}
@@ -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;
@@ -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);
+13 -1
View File
@@ -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 = `
+367
View File
@@ -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)
}
+181
View File
@@ -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
}
+163
View File
@@ -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
}
+296
View File
@@ -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
}
@@ -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)
}
}
+144
View File
@@ -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
}
+152
View File
@@ -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
}
+122
View File
@@ -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: <col>",这是预期路径。
if strings.Contains(err.Error(), "duplicate column name") {
continue
}
return fmt.Errorf("memory: roll forward tidied column %s: %w", col.name, err)
}
}
return nil
}
+227
View File
@@ -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"
@@ -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)
}
}
+264
View File
@@ -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
}
+390
View File
@@ -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
}
+247
View File
@@ -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, &notNull, &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。