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

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

No files matched your search

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