package memory import ( "context" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding" ) // 本文件是 D3「入库前审查」(design/23 §15.1 D3)的**四态判定与 fail-safe** 测试: // 完全等 ⇒ skip、真包含 ⇒ merge/modify、不相似/并列 ⇒ create、审查不可用 ⇒ // 一行都不改写。隔离、异步与开关口径在 preinsert_async_test.go。 // reviewRowState 是测试直接读回的一行记忆的整理列。 type reviewRowState struct { status string mergedInto string mergeCount int confidence float64 content string } // readReviewRow 直读一行记忆的整理列(status 等列不在 Store.Get 的读侧面上)。 func readReviewRow(t *testing.T, store *Store, id string) reviewRowState { t.Helper() var st reviewRowState if err := store.db.QueryRowContext(context.Background(), `SELECT status, merged_into, merge_count, confidence, content FROM memory_items WHERE id = ?`, id).Scan(&st.status, &st.mergedInto, &st.mergeCount, &st.confidence, &st.content); err != nil { t.Fatalf("read row %s: %v", id, err) } return st } // countActiveRows 数一个分区里还在册(status='active')的条目数。 func countActiveRows(t *testing.T, store *Store, personaID string) int { t.Helper() var n int if err := store.db.QueryRowContext(context.Background(), `SELECT COUNT(*) FROM memory_items WHERE persona_id = ? AND status = ?`, personaID, string(tidyStatusActive)).Scan(&n); err != nil { t.Fatalf("count active rows: %v", err) } return n } // newTestReviewer 构建固定时钟的审查服务。阈值显式写死,其余走设计稿默认值。 func newTestReviewer(t *testing.T, store *Store, emb embedding.Embedder, threshold float64) *PreInsertReviewer { t.Helper() r, err := NewPreInsertReviewer(ReviewConfig{ Enabled: true, QueueSize: 8, ScanLimit: 50, Timeout: 5 * time.Second, Threshold: threshold, }, ReviewDeps{ Store: store, Embedder: emb, Now: func() time.Time { return tidyTestTime(t) }, Log: testLogger(), }) if err != nil { t.Fatalf("NewPreInsertReviewer: %v", err) } return r } // reviewAt 构造与既有条目同分区、可指定内容与溯源的新条目。 func reviewAt(content string, at time.Time, confidence float64) MemoryItem { item := habitItem(content, at, confidence) item.SourceConversation = FormatSourceConversation("web", "msg-review") return item } // TestReviewExactDuplicateDoesNotCreateASecondActiveRow 是 design/23 §15 D3-9①: // **完全等价 ⇒ 不新建**(这正是主人报的「反复新建」)。活跃行数必须仍是 1。 func TestReviewExactDuplicateDoesNotCreateASecondActiveRow(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) base := tidyTestTime(t) existing := seedTidyItem(t, store, reviewAt(fixtureHabitShort, base, 0.9)) dup := seedTidyItem(t, store, reviewAt(fixtureHabitShort, base.Add(time.Minute), 0.9)) r := newTestReviewer(t, store, stubEmbedder{vecs: map[string]embedding.Vector{ fixtureHabitShort: {1, 0}, }}, 0.80) rev, err := r.Review(ctx, dup) if err != nil { t.Fatalf("Review: %v", err) } if rev.Decision != IngestSkip { t.Fatalf("decision = %q, want %q", rev.Decision, IngestSkip) } if rev.TargetID != existing.ID { t.Fatalf("命中的既有 id = %q, want %q", rev.TargetID, existing.ID) } got := readReviewRow(t, store, dup.ID) if got.status != ingestStatusMerged || got.mergedInto != existing.ID { t.Fatalf("重复行未折回既有行:status=%q merged_into=%q", got.status, got.mergedInto) } survivor := readReviewRow(t, store, existing.ID) if survivor.status != tidyStatusActive || survivor.mergeCount != 2 { t.Fatalf("既有行 survivor = %+v, want active 且 merge_count=2", survivor) } if n := countActiveRows(t, store, "persona.a"); n != 1 { t.Fatalf("活跃行数 = %d, want 1(反复新建没有被治住)", n) } reviews, err := store.ListIngestReviews(ctx, 10) if err != nil { t.Fatalf("ListIngestReviews: %v", err) } if len(reviews) != 1 || reviews[0].Decision != IngestSkip || reviews[0].RuleVer != IngestRuleVersion { t.Fatalf("审查留痕 = %+v, want 恰为一条 skip 且带规则版本", reviews) } if reviews[0].SessionID != TidySessionID { t.Fatalf("留痕会话 = %q, want 复用 %q(记忆管理会话)", reviews[0].SessionID, TidySessionID) } } // TestReviewSubsetMergesAndIsRevertable 是 design/23 §15 D3-9②:真包含 ⇒ 合并, // 且**旧行可 revert**(软删除绝不物理删除,§8)。 func TestReviewSubsetMergesAndIsRevertable(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) base := tidyTestTime(t) existing := seedTidyItem(t, store, reviewAt(fixtureHabitLong, base, 0.9)) short := seedTidyItem(t, store, reviewAt(fixtureHabitShort, base.Add(time.Minute), 0.9)) r := newTestReviewer(t, store, stubEmbedder{vecs: map[string]embedding.Vector{ fixtureHabitLong: {1, 0}, fixtureHabitShort: simVector(0.95), }}, 0.80) rev, err := r.Review(ctx, short) if err != nil { t.Fatalf("Review: %v", err) } if rev.Decision != IngestMerge || rev.TargetID != existing.ID { t.Fatalf("verdict = %+v, want merge 并入超集 %s", rev, existing.ID) } absorbed := readReviewRow(t, store, short.ID) if absorbed.status != ingestStatusMerged || absorbed.content != fixtureHabitShort { t.Fatalf("被并入行 = %+v, want merged 且原文逐字保留", absorbed) } survivor := readReviewRow(t, store, existing.ID) if survivor.mergeCount != 2 || survivor.content != fixtureHabitLong { t.Fatalf("存活行 = %+v, want merge_count=2 且 content 不改", survivor) } if err := store.RevertMerged(ctx, short.ID); err != nil { t.Fatalf("RevertMerged: %v", err) } restored := readReviewRow(t, store, short.ID) if restored.status != tidyStatusActive || restored.mergedInto != "" || restored.content != fixtureHabitShort { t.Fatalf("revert 后 = %+v, want active 且原文无损", restored) } afterRevert := readReviewRow(t, store, existing.ID) if afterRevert.mergeCount != 1 { t.Fatalf("revert 后存活行 merge_count = %d, want 1(登记也一并摘掉)", afterRevert.mergeCount) } } // TestReviewSupersetModifiesExisting 是 design/23 §15 D3-6 的「改写」路径: // 既有条目是新条目的子集 ⇒ **新行承载更全的内容**,旧行置为被合并。 func TestReviewSupersetModifiesExisting(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) base := tidyTestTime(t) existing := seedTidyItem(t, store, reviewAt(fixtureHabitShort, base, 0.9)) longer := seedTidyItem(t, store, reviewAt(fixtureHabitLong, base.Add(time.Minute), 0.9)) r := newTestReviewer(t, store, stubEmbedder{vecs: map[string]embedding.Vector{ fixtureHabitShort: {1, 0}, fixtureHabitLong: simVector(0.95), }}, 0.80) rev, err := r.Review(ctx, longer) if err != nil { t.Fatalf("Review: %v", err) } if rev.Decision != IngestModify || rev.TargetID != existing.ID { t.Fatalf("verdict = %+v, want modify(既有行被改写)", rev) } if got := readReviewRow(t, store, longer.ID); got.status != tidyStatusActive || got.content != fixtureHabitLong { t.Fatalf("新行 = %+v, want 存活且承载更全内容", got) } old := readReviewRow(t, store, existing.ID) if old.status != ingestStatusMerged || old.mergedInto != longer.ID { t.Fatalf("旧行 = %+v, want merged 进新行", old) } if n := countActiveRows(t, store, "persona.a"); n != 1 { t.Fatalf("活跃行数 = %d, want 1", n) } } // TestReviewDissimilarAndParallelFactsCreate 是 design/23 §15 D3-9③ 与主人红线 // 「不要什么内容都合并」:不相似、以及**相似但并列**的事实都正常新建。 func TestReviewDissimilarAndParallelFactsCreate(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) base := tidyTestTime(t) seedTidyItem(t, store, reviewAt(fixtureEnergyRedBull, base, 0.95)) parallel := seedTidyItem(t, store, reviewAt(fixtureEnergyIced, base.Add(time.Minute), 0.95)) r := newTestReviewer(t, store, stubEmbedder{vecs: map[string]embedding.Vector{ fixtureEnergyRedBull: {1, 0}, fixtureEnergyIced: simVector(0.99), }}, 0.80) rev, err := r.Review(ctx, parallel) if err != nil { t.Fatalf("Review: %v", err) } if rev.Decision != IngestCreate { t.Fatalf("并列事实被判成 %q,主人红线是「不要什么内容都合并」", rev.Decision) } if n := countActiveRows(t, store, "persona.a"); n != 2 { t.Fatalf("活跃行数 = %d, want 2", n) } } // TestReviewTemporalPollutionIsNeverMerged 是 design/23 §5.2-1:含时效词 // (假期/期间…)的记忆不参与合并 —— 有期限的状态不是长期事实。 func TestReviewTemporalPollutionIsNeverMerged(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) base := tidyTestTime(t) seedTidyItem(t, store, reviewAt(fixtureHabitPlain, base, 0.9)) vacation := seedTidyItem(t, store, reviewAt(fixtureHabitVacation, base.Add(time.Minute), 0.9)) r := newTestReviewer(t, store, stubEmbedder{vecs: map[string]embedding.Vector{ fixtureHabitPlain: {1, 0}, fixtureHabitVacation: simVector(0.97), }}, 0.80) rev, err := r.Review(ctx, vacation) if err != nil { t.Fatalf("Review: %v", err) } if rev.Decision != IngestCreate || rev.Veto != VetoTemporal { t.Fatalf("verdict = %+v, want create + temporal 否决", rev) } if q := readReviewRow(t, store, vacation.ID); q.status != tidyStatusActive { t.Fatalf("时效条目被改写了:%+v", q) } } // TestReviewFailureNeverTouchesMemory 是 design/23 §15 D3-9④ 的 fail-safe: // 审查不可用(超时/取消)⇒ **照旧保留**(一行都不改写)+ 交给调用方记 WARN。 func TestReviewFailureNeverTouchesMemory(t *testing.T) { t.Parallel() store := newTestStore(t) base := tidyTestTime(t) existing := seedTidyItem(t, store, reviewAt(fixtureHabitLong, base, 0.9)) short := seedTidyItem(t, store, reviewAt(fixtureHabitShort, base.Add(time.Minute), 0.9)) r := newTestReviewer(t, store, stubEmbedder{vecs: map[string]embedding.Vector{ fixtureHabitLong: {1, 0}, fixtureHabitShort: simVector(0.95), }}, 0.80) // 已经取消的 context:审查必须如实失败,而不是「猜一个结论」。 dead, cancel := context.WithCancel(context.Background()) cancel() if _, err := r.Review(dead, short); err == nil { t.Fatal("审查在不可用时返回了成功,必须如实报错") } if got := readReviewRow(t, store, short.ID); got.status != tidyStatusActive { t.Fatalf("审查失败却改写了记忆:%+v", got) } if got := readReviewRow(t, store, existing.ID); got.mergeCount != 1 { t.Fatalf("审查失败却改了 survivor:%+v", got) } if reviews, err := store.ListIngestReviews(context.Background(), 10); err != nil || len(reviews) != 0 { t.Fatalf("审查失败不该留决定留痕:reviews=%+v err=%v", reviews, err) } }