feat(memory): 记忆整理 v1(design/23)——低峰定期扫描 + 事实包含判定 + 规则否决 + 只产建议队列(不自动应用)
This commit is contained in:
1 parent
012e9f3ad4
commit
91a3209c9b
25 files changed
+3418
-13
No files matched your search
@@ -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)。
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -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 = `
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
|
||||
)
|
||||
|
||||
// 本文件收拢记忆整理的**否决与边界/配置**用例:来源硬门、低置信、kind 误分类、
|
||||
// 每分区配额、离线回退、设计稿默认值与 schema。主流程(包含→合并 / 并列→链接 /
|
||||
// 跨作用域 / 时效污染 / 空库 / 幂等)在 tidy_test.go。
|
||||
// TestTidyGroupSourceExcluded 是 design/23 §12.2 N7 与 §5.2-9/§6:群聊/陌生人
|
||||
// 来源的条目既不合并也不归档,只读不写。
|
||||
func TestTidyGroupSourceExcluded(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
base := tidyTestTime(t)
|
||||
groupItem := seedTidyItem(t, store, MemoryItem{
|
||||
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
|
||||
Content: fixtureHabitShort, Confidence: 0.9, CreatedAt: base,
|
||||
SourceConversation: FormatSourceConversation("sess-telegram:group1", "msg-9"),
|
||||
})
|
||||
ownerItem := seedTidyItem(t, store, habitItem(fixtureHabitShort, base.Add(time.Minute), 0.9))
|
||||
|
||||
tr := newTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{
|
||||
fixtureHabitShort: {1, 0},
|
||||
}})
|
||||
stats, err := tr.RunOnce(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("RunOnce: %v", err)
|
||||
}
|
||||
if stats.VetoReasons[string(VetoSource)] < 1 {
|
||||
t.Fatalf("否决原因分布里没有 source:%+v", stats.VetoReasons)
|
||||
}
|
||||
cands, err := store.ListTidyCandidates(ctx, "", 50)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTidyCandidates: %v", err)
|
||||
}
|
||||
for _, c := range cands {
|
||||
if containsID(c.SourceIDs, groupItem.ID) {
|
||||
t.Fatalf("群聊来源条目进了候选:%+v", c)
|
||||
}
|
||||
}
|
||||
if ownerItem.ID == "" {
|
||||
t.Fatal("owner 条目应当落库成功")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyLowConfidenceVetoed 是 design/23 §12.2 N4 与 §5.2-6:confidence 不是
|
||||
// 合并门,但必须是否决门。
|
||||
func TestTidyLowConfidenceVetoed(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
base := tidyTestTime(t)
|
||||
low := seedTidyItem(t, store, habitItem(fixtureHabitShort, base, 0.6))
|
||||
high := seedTidyItem(t, store, habitItem(fixtureHabitLong, base.Add(time.Hour), 0.9))
|
||||
|
||||
if v := judgePair(low, high, 0.99); v.Veto != VetoLowConfidence {
|
||||
t.Fatalf("低置信 veto = %q, want %q", v.Veto, VetoLowConfidence)
|
||||
}
|
||||
tr := newTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{
|
||||
fixtureHabitShort: {1, 0},
|
||||
fixtureHabitLong: simVector(0.97),
|
||||
}})
|
||||
if _, err := tr.RunOnce(ctx); err != nil {
|
||||
t.Fatalf("RunOnce: %v", err)
|
||||
}
|
||||
cands, err := store.ListTidyCandidates(ctx, "", 50)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTidyCandidates: %v", err)
|
||||
}
|
||||
if len(cands) != 0 {
|
||||
t.Fatalf("低置信对产出了候选:%+v", cands)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyKindMismatchAlsoSuggestsReclassify 是 design/23 §12.1 P7 的口径:
|
||||
// 包含关系成立但 kind 不同 ⇒ 既产 merge 候选,也产 reclassify 候选(都交人工)。
|
||||
func TestTidyKindMismatchAlsoSuggestsReclassify(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
base := tidyTestTime(t)
|
||||
short := MemoryItem{
|
||||
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindWorldEvent,
|
||||
Content: fixtureHabitShort, Confidence: 0.9, CreatedAt: base,
|
||||
}
|
||||
long := MemoryItem{
|
||||
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
|
||||
Content: fixtureHabitLong, Confidence: 0.9, CreatedAt: base.Add(time.Hour),
|
||||
}
|
||||
seedTidyItem(t, store, short)
|
||||
seedTidyItem(t, store, long)
|
||||
|
||||
tr := newTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{
|
||||
fixtureHabitShort: {1, 0},
|
||||
fixtureHabitLong: simVector(0.95),
|
||||
}})
|
||||
if _, err := tr.RunOnce(ctx); err != nil {
|
||||
t.Fatalf("RunOnce: %v", err)
|
||||
}
|
||||
cands, err := store.ListTidyCandidates(ctx, "", 50)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTidyCandidates: %v", err)
|
||||
}
|
||||
if len(candidatesByAction(cands, TidyMerge)) != 1 || len(candidatesByAction(cands, TidyReclassify)) != 1 {
|
||||
t.Fatalf("期望 1 条 merge + 1 条 reclassify,实际 %+v", cands)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyCandidateLimitPerPartition 锁定 design/23 §9 的每分区候选上限:
|
||||
// 超出的只登记不入队,且绝不静默。
|
||||
func TestTidyCandidateLimitPerPartition(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
base := tidyTestTime(t)
|
||||
shortA, longA := fixtureWorkShort, fixtureWorkLong
|
||||
shortB, longB := "主人喜欢被叫小笨蛋", "主人喜欢被叫小笨蛋,还喜欢被摸摸头"
|
||||
seedTidyItem(t, store, habitItem(shortA, base, 0.9))
|
||||
seedTidyItem(t, store, habitItem(longA, base.Add(time.Minute), 0.9))
|
||||
seedTidyItem(t, store, habitItem(shortB, base.Add(2*time.Minute), 0.9))
|
||||
seedTidyItem(t, store, habitItem(longB, base.Add(3*time.Minute), 0.9))
|
||||
|
||||
cfg := tidyTestCfg(0.80)
|
||||
cfg.CandidateLimit = 1
|
||||
// 两族向量彼此近似正交(跨族 cosine ≈ 0.31 < 0.80),因此恰好召回 2 对。
|
||||
tr := newTestTidier(t, store, cfg, stubEmbedder{vecs: map[string]embedding.Vector{
|
||||
shortA: {1, 0},
|
||||
longA: simVector(0.95),
|
||||
shortB: {0, 1},
|
||||
longB: {float32(math.Sqrt(1 - 0.95*0.95)), 0.95},
|
||||
}})
|
||||
|
||||
stats, err := tr.RunOnce(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("RunOnce: %v", err)
|
||||
}
|
||||
if stats.Candidates != 1 {
|
||||
t.Fatalf("候选 = %d, want 1(每分区上限)", stats.Candidates)
|
||||
}
|
||||
if stats.Skipped < 1 {
|
||||
t.Fatalf("超限未登记:skipped=%d", stats.Skipped)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyOfflineLexicalRecall 锁定「无 embedder 时的零依赖回退」:整理在没有
|
||||
// embedding 配置时仍能召回并判定(阈值仍是可配的召回门)。
|
||||
func TestTidyOfflineLexicalRecall(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
base := tidyTestTime(t)
|
||||
seedTidyItem(t, store, habitItem(fixtureHabitShort, base, 0.9))
|
||||
seedTidyItem(t, store, habitItem(fixtureHabitLong, base.Add(time.Hour), 0.9))
|
||||
|
||||
cfg := tidyTestCfg(0.5)
|
||||
tr := newTestTidier(t, store, cfg, nil)
|
||||
stats, err := tr.RunOnce(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("RunOnce: %v", err)
|
||||
}
|
||||
if stats.Pairs != 1 {
|
||||
t.Fatalf("词项余弦召回 pairs = %d, want 1", stats.Pairs)
|
||||
}
|
||||
cands, err := store.ListTidyCandidates(ctx, "", 50)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTidyCandidates: %v", err)
|
||||
}
|
||||
if len(candidatesByAction(cands, TidyMerge)) != 1 {
|
||||
t.Fatalf("离线回退未产出 merge 候选:%+v", cands)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyConfigDefaults 锁定「设计稿默认值只有一处定义」:召回门 0.80、
|
||||
// 每天 1 次、安静时段 23:00-07:00、每分区扫描 5000 / 候选 20(design §9/§13)。
|
||||
func TestTidyConfigDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := normalizeTidyConfig(TidyConfig{Enabled: true})
|
||||
if cfg.IntervalSec != 86400 || cfg.Interval != 24*time.Hour {
|
||||
t.Fatalf("周期默认 = %ds / %s, want 86400s / 24h", cfg.IntervalSec, cfg.Interval)
|
||||
}
|
||||
if cfg.RecallThreshold != 0.80 {
|
||||
t.Fatalf("召回门默认 = %v, want 0.80", cfg.RecallThreshold)
|
||||
}
|
||||
if cfg.ScanLimit != 5000 || cfg.CandidateLimit != 20 {
|
||||
t.Fatalf("配额默认 = %d/%d, want 5000/20", cfg.ScanLimit, cfg.CandidateLimit)
|
||||
}
|
||||
if cfg.QuietStartHour != 23 || cfg.QuietEndHour != 7 {
|
||||
t.Fatalf("安静时段默认 = %d-%d, want 23-7", cfg.QuietStartHour, cfg.QuietEndHour)
|
||||
}
|
||||
if TidySessionID != "sess-memorytidy" {
|
||||
t.Fatalf("隔离会话 = %q, want sess-memorytidy", TidySessionID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidySchemaCreatedByNewStore 锁定「整理在任意 *sql.DB 上都可用」:
|
||||
// NewStore 幂等创建候选表/运行表并前滚 memory_items 的整理列。
|
||||
func TestTidySchemaCreatedByNewStore(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
if _, err := NewStore(ctx, store.db, testLogger()); err != nil {
|
||||
t.Fatalf("第二次 NewStore(幂等): %v", err)
|
||||
}
|
||||
for _, table := range []string{"memory_tidy_candidates", "memory_tidy_runs"} {
|
||||
var n int
|
||||
if err := store.db.QueryRowContext(ctx,
|
||||
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name = ?", table).Scan(&n); err != nil {
|
||||
t.Fatalf("查询表 %s: %v", table, err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("表 %s 不存在", table)
|
||||
}
|
||||
}
|
||||
rows, err := store.db.QueryContext(ctx, "PRAGMA table_info(memory_items)")
|
||||
if err != nil {
|
||||
t.Fatalf("pragma table_info: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
cols := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var (
|
||||
cid int
|
||||
name, typ string
|
||||
notNull int
|
||||
dflt any
|
||||
pk int
|
||||
)
|
||||
if err := rows.Scan(&cid, &name, &typ, ¬Null, &dflt, &pk); err != nil {
|
||||
t.Fatalf("scan pragma: %v", err)
|
||||
}
|
||||
cols[name] = true
|
||||
}
|
||||
for _, want := range []string{"status", "merged_into", "merged_from", "merge_count", "occurred_at", "valid_to", "expires_at"} {
|
||||
if !cols[want] {
|
||||
t.Fatalf("memory_items 缺列 %s(现有列:%v)", want, cols)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// containsID 报告 ids 里是否含 id。
|
||||
Reference in new issue
Block a user