主人原话:「那么多 link 是干什么的?该删就删,怎么这个记忆整理机制这么保守呢」。
① **link 停产**(依据 design/23 §14 把它列为「未细化」⇒ 既无落点也无数据模型;
`applyable()` 也只认 merge/archive/reclassify ⇒ 应用 link 恒 `skipped`,是纯噪音):
- 规则层 `containNone` 判定改为 `keep`(不再产出 501 条不可应用的待办);
- `TidyLink` 常量保留,只为**读历史行**与「不可应用」判定;
- 存量出路:新端点 `POST /api/v1/admin/memories/candidates/reject?action=&limit=`
(**幂等、只改候选 status、不碰记忆**),主会话用它清掉存量 501 条。
② **轮末自动落地**(默认开,仅两类,判据是纯函数):
- `merge`:**字面重复** ∨「事实集合等价 ∧ 相似度 ≥ 0.95」;
- `archive`:理由 = `one_off_event` **且不像习惯**(`design/23 §5.2-1` 红线:
习惯类会因正文带钟点被误判成一次性事件,归档它是错的)。
- **不自动**:`reclassify`、低置信 merge、跨分区/跨角色、任何改正文、受限来源
(群聊/陌生人)—— 新增执行面守卫 `tidySkipSourceRestricted`。
③ **六条护栏**(缺一不可):软动作(复用既有 apply 路径)/ 每轮上限 50(硬顶 500,
超限如实 `Truncated` + 真总数)/ 审计 `memory.tidy.apply`(`auto=true`,不带正文)/
**可 revert**(复用既有回滚)/ 幂等(复用候选状态条件更新)/ **一键关回只产建议**
(`$DATA_DIR/config/memory-tidy.json` 的 `autoApply:false`,**零新 env 键**)。
验收(主会话复跑 + 亲自变异):memory 与 gateway 两包 build/vet/test 全绿;我用
-overlay 短路「习惯不归档」守卫 ⇒ TestTidyAutoNeverArchivesHabitMisjudgedAsOneOff
变红(逐字报「作息习惯被自动归档了 …design §5.2-1 红线」)。
⚠️ 未部署(要 build+restart 才生效);⚠️ 契约欠登记(新端点 B25 + §7.2),主会话补;
⚠️ 真机端到端未跑(不能重启后端)。
250 lines
8.8 KiB
Go
250 lines
8.8 KiB
Go
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 / 候选 200 / 比对 2000
|
||
// (design §9/§13;后两个是切片 MT-A 抬高的护栏,见 tidy_config.go 的注释)。
|
||
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 != 200 || cfg.CompareLimit != 2000 {
|
||
t.Fatalf("配额默认 = %d/%d/%d, want 5000/200/2000(scan/candidate/compare)",
|
||
cfg.ScanLimit, cfg.CandidateLimit, cfg.CompareLimit)
|
||
}
|
||
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。
|