Files
Cyrene_v2/internal/runtime/memory/preinsert_async_test.go
T
AskaEth 1b4d0a552f
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(memory): T1 灰区模型判定 —— D3 入库前审查补上 LLM 那一半
主人 2026-10-07 裁定「可以做 T1」。此前 D3 只有纯规则 T0,凡灰区(相似但
互不包含)一律保守 create,真机上「同一事实换措辞」类重复结构性治不住
(清完 92 条字面级重复后,active 里含「喵喵七号」的仍有 101 条)。

- 插入点:T0 判定内核只多吐一份结构化 Gray(惰性收集,恒零模型调用);
  T1 挂在异步审查层 Review,同步入库闸不碰它 ⇒ 写入路径零模型调用是
  **数据流结构**而不是承诺
- 只许回 merge / modify / keep + 一句理由;坏 JSON、非法枚举、编号越界
  一律回落 create(绝不猜、绝不挑最近的一条)
- 禁产新内容:模型只给「动作 + 候选编号 + 一句理由」,正文一律取自库里
  既有的两行 ⇒ T1 结构性地不可能把幻觉写进记忆库
- 不越硬门与红线:灰区已滤掉跨角色/作用域/项目/只读来源与时效/数值/冲突/
  低置信;「规则否决一票否决」不可走样,T1 不提供翻案入口
- 有界:每次审查至多一次调用(灰区打包,上限 8 条),子超时 8s 且不超过
  审查总预算;失败只改 reason、决定恒 create、零 UPDATE
- 开关 t1Judge(放 $DATA_DIR/config/memory-tidy.json,零新 env 键),默认开;
  模型出口用闭包晚绑定既有模型栈(零新客户端、零新依赖)
- 留痕 reason 结构化(t1_merge / t1_modify / t1_keep / t1_reply_invalid /
  t1_unavailable),rule_version d3.1 → d3.2;模型的自由文本理由一律丢弃
- preinsert_config.go 为纯搬移(为守 500 行上限,无新逻辑)
2026-10-07 14:10:33 +08:00

318 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package memory
import (
"context"
"errors"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
)
// 本文件是 D3「入库前审查」的**隔离与异步**测试(design/23 §15 D3-3/4/5/7/8):
// 群聊/陌生人来源绝不触碰、跨分区绝不合并、写入方绝不等待审查、队列有界诚实、
// 开关关闭时行为不变、留痕如实登记降级。
// TestReviewRestrictedSourceIsNeverTouched 是 design/23 §6/§9 红线的钉死:
// 群聊/陌生人来源的条目**既不被审查、也不被合并**(只读不写)。
func TestReviewRestrictedSourceIsNeverTouched(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
groupItem := reviewAt(fixtureHabitShort, base, 0.9)
groupItem.SourceConversation = FormatSourceConversation("sess-qq-group-1", "m-1")
group := seedTidyItem(t, store, groupItem)
owner := 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)
// ① 隔离来源的**新条目**:概不审查。
if _, err := r.Review(ctx, group); !errors.Is(err, ErrReviewRestrictedSource) {
t.Fatalf("隔离来源审查 err = %v, want %v", err, ErrReviewRestrictedSource)
}
if reviews, err := store.ListIngestReviews(ctx, 10); err != nil || len(reviews) != 0 {
t.Fatalf("隔离来源不该留任何留痕:reviews=%+v err=%v", reviews, err)
}
if got := readReviewRow(t, store, group.ID); got.status != tidyStatusActive {
t.Fatalf("隔离来源被改写了:%+v", got)
}
// ② 隔离来源作为**候选**:不得与主人条目合并(群体来源硬门)。
rev, err := r.Review(ctx, owner)
if err != nil {
t.Fatalf("Review: %v", err)
}
if rev.Decision != IngestCreate || rev.Veto != VetoSource {
t.Fatalf("verdict = %+v, want create + source 否决", rev)
}
if got := readReviewRow(t, store, owner.ID); got.status != tidyStatusActive {
t.Fatalf("主人条目被跨来源合并了:%+v", got)
}
if n := countActiveRows(t, store, "persona.a"); n != 2 {
t.Fatalf("活跃行数 = %d, want 2", n)
}
}
// TestReviewNeverCrossesPersonaOrScope 是 design/23 §15 D3-9⑤ 与 §6 硬约束:
// 跨角色 / 跨作用域**绝不合并** —— 运行期连比较都不发生,判定层再兜一次否决。
func TestReviewNeverCrossesPersonaOrScope(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
other := habitItem(fixtureHabitShort, base, 0.9)
other.PersonaID = "persona.b"
otherItem := seedTidyItem(t, store, other)
otherScope := MemoryItem{
PersonaID: "persona.a", Scope: ScopeUser, Kind: KindRelationship,
Content: fixtureHabitShort, Confidence: 0.9, CreatedAt: base,
SourceConversation: FormatSourceConversation("web", "msg-1"),
}
seedTidyItem(t, store, otherScope)
mine := 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, mine)
if err != nil {
t.Fatalf("Review: %v", err)
}
if rev.Decision != IngestCreate {
t.Fatalf("跨分区被判成 %q,分区之间永不比较(§6)", rev.Decision)
}
if got := readReviewRow(t, store, otherItem.ID); got.status != tidyStatusActive {
t.Fatalf("别的角色的记忆被改写了:%+v", got)
}
// 判定层的纵深防御:直接喂一条跨角色候选,必须被硬门否决。
verdict := judgeIngest(mine, []ingestCandidate{{item: otherItem, score: 0.99, mode: tidyRecallEmbedding}})
if verdict.Decision != IngestCreate || verdict.Veto != VetoPersona {
t.Fatalf("跨角色判定 = %+v, want create + persona 否决", verdict)
}
}
// TestReviewIsNonBlockingAndAsyncPathWorks 是 design/23 §15 D3-3/D3-4:
// 写入方**绝不等待**异步审查;审查在后台(工作协程)按队列跑完。
//
// ⚠️ 2026-10-07 起 D3 是**两层**:写路径上有同步入库闸(preinsert_gate.go,
// 重复当场落 merged),闸没拦下的才进这条异步队列。因此本用例**显式关闸**
// (DisableIngestGate)来单独验异步那一层 —— 否则它测的会是闸而不是队列。
func TestReviewIsNonBlockingAndAsyncPathWorks(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
existing := seedTidyItem(t, store, reviewAt(fixtureHabitShort, base, 0.9))
asyncOnly, err := NewPreInsertReviewer(ReviewConfig{
Enabled: true, QueueSize: 8, ScanLimit: 50, Timeout: 5 * time.Second,
Threshold: 0.80, DisableIngestGate: true,
}, ReviewDeps{
Store: store,
Embedder: stubEmbedder{vecs: map[string]embedding.Vector{
fixtureHabitShort: {1, 0},
}},
Now: func() time.Time { return tidyTestTime(t) },
Log: testLogger(),
})
if err != nil {
t.Fatalf("NewPreInsertReviewer: %v", err)
}
r := asyncOnly
store.AttachIngestReviewer(r)
// 未 Start:写入方投递后**立即返回**,审查尚未发生(异步性证据)。
start := time.Now()
dup := seedTidyItem(t, store, reviewAt(fixtureHabitShort, base.Add(time.Minute), 0.9))
if elapsed := time.Since(start); elapsed > 2*time.Second {
t.Fatalf("写入等了 %s,审查绝不能阻塞写入方", elapsed)
}
if got := readReviewRow(t, store, dup.ID); got.status != tidyStatusActive {
t.Fatalf("关闸后审查仍在写入方返回前生效了:%+v", got)
}
r.Start(ctx)
t.Cleanup(func() {
stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = r.Stop(stopCtx)
})
waitFor(t, func() bool {
return readReviewRow(t, store, dup.ID).status == ingestStatusMerged
}, "后台审查把重复行折回既有行")
if got := readReviewRow(t, store, existing.ID); got.mergeCount != 2 {
t.Fatalf("survivor = %+v, want merge_count=2", got)
}
}
// waitFor 轮询等待条件成立(后台工作协程的完成信号),超时即失败。
func waitFor(t *testing.T, cond func() bool, what string) {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("等待超时:%s", what)
}
// TestReviewQueueIsBoundedAndDropsHonestly 是 design/23 §15 D3-4:审查有界,
// 队列满时**有界丢弃**(绝不阻塞写入方),且次数被如实计数。
func TestReviewQueueIsBoundedAndDropsHonestly(t *testing.T) {
t.Parallel()
store := newTestStore(t)
r, err := NewPreInsertReviewer(ReviewConfig{
Enabled: true, QueueSize: 2, ScanLimit: 10, Timeout: time.Second, Threshold: 0.8,
}, ReviewDeps{Store: store, Log: testLogger()})
if err != nil {
t.Fatalf("NewPreInsertReviewer: %v", err)
}
item := reviewAt(fixtureHabitShort, tidyTestTime(t), 0.9)
item.ID = "item-1"
accepted := 0
for i := 0; i < 5; i++ {
if r.Enqueue(item) {
accepted++
}
}
if accepted != 2 {
t.Fatalf("入队 = %d, want 2(队列上限)", accepted)
}
if got := r.Stats().Dropped; got != 3 {
t.Fatalf("丢弃计数 = %d, want 3(绝不静默)", got)
}
}
// TestReviewIsIdempotentPerItem 是幂等口径:同一条记忆被审第二次时**不再判定**
// (它已不在册),因此存活行的 merge_count 不会被同一条事实数两次。
func TestReviewIsIdempotentPerItem(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)
if _, err := r.Review(ctx, dup); err != nil {
t.Fatalf("第一次 Review: %v", err)
}
if _, err := r.Review(ctx, dup); !errors.Is(err, ErrReviewItemNotActive) {
t.Fatalf("第二次 Review err = %v, want %v", err, ErrReviewItemNotActive)
}
if got := readReviewRow(t, store, existing.ID); got.mergeCount != 2 {
t.Fatalf("存活行 merge_count = %d, want 2(同一条事实只该数一次)", got.mergeCount)
}
if reviews, err := store.ListIngestReviews(ctx, 10); err != nil || len(reviews) != 1 {
t.Fatalf("审查留痕 = %+v(err=%v), want 恰为一条", reviews, err)
}
}
// TestReviewDisabledChangesNothing 是 design/23 §15 D3-7 的开关口径:
// 关闭时审查一次性拒绝,且**不接线**的 store 写入路径逐字回到今天。
func TestReviewDisabledChangesNothing(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
seedTidyItem(t, store, reviewAt(fixtureHabitShort, base, 0.9))
off, err := NewPreInsertReviewer(ReviewConfig{Enabled: false}, ReviewDeps{Store: store, Log: testLogger()})
if err != nil {
t.Fatalf("NewPreInsertReviewer: %v", err)
}
off.Start(ctx) // 关闭时只记一条日志并立即返回,不起任何协程
store.AttachIngestReviewer(off)
if off.Enqueue(reviewAt(fixtureHabitShort, base, 0.9)) {
t.Fatal("关闭时不该接受审查任务")
}
dup := seedTidyItem(t, store, reviewAt(fixtureHabitShort, base.Add(time.Minute), 0.9))
if _, err := off.Review(ctx, dup); !errors.Is(err, ErrReviewDisabled) {
t.Fatalf("关闭时 Review err = %v, want %v", err, ErrReviewDisabled)
}
if got := off.Stats(); got.Dropped != 0 {
t.Fatalf("关闭时不该把条目记成「被丢弃」:%+v", got)
}
// 未接线的 store:连审查留痕都不该有,两条记忆都照旧在册。
if reviews, err := store.ListIngestReviews(ctx, 10); err != nil || len(reviews) != 0 {
t.Fatalf("关闭时不该有留痕:%+v", reviews)
}
if n := countActiveRows(t, store, "persona.a"); n != 2 {
t.Fatalf("关闭时行为被改动了:活跃行数 = %d, want 2", n)
}
}
// TestReviewReusesTidySessionAndStaysZeroLLM 是 design/23 §15 D3 的装配口径:
// 复用既有 sess-memorytidy 装配(不造第二形态会话),且**没接模型出口**时
// ⇒ 灰区(相似但不包含)**不调任何模型**,只按 T0 回落 create。
//
// ⚠️ 2026-10-07 起 T1(灰区模型判定,preinsert_t1.go)默认**开**:本条用例的
// 「零模型调用」来自**结构性缺席**(ReviewDeps.Judge 为 nil),不是来自默认关。
// 「T1 开启时真的会调一次模型」由 preinsert_t1_test.go 覆盖。
func TestReviewReusesTidySessionAndStaysZeroLLM(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
seedTidyItem(t, store, reviewAt(fixtureEnergyRedBull, base, 0.95))
gray := 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.88),
}}, 0.80)
if r.SessionID() != TidySessionID || !strings.HasPrefix(r.SessionID(), "sess-") {
t.Fatalf("审查会话 = %q, want 复用 %q", r.SessionID(), TidySessionID)
}
rev, err := r.Review(ctx, gray)
if err != nil {
t.Fatalf("Review: %v", err)
}
if rev.Decision != IngestCreate {
t.Fatalf("灰区被判成 %q,T1 关闭时必须保守回落 create", rev.Decision)
}
if rev.RecallMode != tidyRecallEmbedding {
t.Fatalf("召回来源 = %q, want %q", rev.RecallMode, tidyRecallEmbedding)
}
}
// TestReviewDegradedRecallIsRecordedHonestly 是 design/23 §15 D3-8 的「是否降级」:
// embedder 报错时降级到词项余弦,判定照旧,但留痕必须如实写 degraded。
func TestReviewDegradedRecallIsRecordedHonestly(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
seedTidyItem(t, store, reviewAt(fixtureHabitShort, base, 0.9))
fresh := seedTidyItem(t, store, reviewAt(fixtureHabitLong, base.Add(time.Minute), 0.9))
r := newTestReviewer(t, store, failingReviewEmbedder{}, 0.80)
rev, err := r.Review(ctx, fresh)
if err != nil {
t.Fatalf("Review: %v", err)
}
if !rev.Degraded || rev.RecallMode != tidyRecallLexical {
t.Fatalf("降级未被如实登记:%+v", rev)
}
if got := readReviewRow(t, store, fresh.ID); got.status != tidyStatusActive {
t.Fatalf("降级不该丢掉新条目:%+v", got)
}
}
// failingReviewEmbedder 模拟「embedder 挂」:Embed 恒报错(design §15 D3-5 的情形之一)。
type failingReviewEmbedder struct{}
func (failingReviewEmbedder) Embed(context.Context, []string) ([]embedding.Vector, error) {
return nil, errors.New("embedder down")
}
func (failingReviewEmbedder) Dim() int { return 2 }