package memory import ( "bytes" "context" "errors" "log/slog" "strings" "sync" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // 本文件是 **T1 灰区模型判定**(design/23 §15.1 D3-2,preinsert_t1.go)的测试: // 关掉即零调用、merge/modify/keep 三态各自落地正确、失败回落且零改写。 // 硬门隔离与调用次数有界在 preinsert_t1_bound_test.go。 // // 夹具命名一律带 t1 前缀/后缀,避免与同包既有夹具(stubEmbedder、 // failingReviewEmbedder 等)撞车。 // 灰区夹具:**同一事实换措辞、互不包含** —— 两条各自带着对方没有的词 // (「摄取通道的」/「主人与助手约定」),因此 T0 的包含判定给 containNone, // 规则层判不出结论。这正是真机「喵喵七号」那一类(101 条 active)的脱敏等价物。 // 两侧都不含时效词与数值差异,所以规则否决也都不命中 ⇒ 真的落进灰区。 const ( fixtureT1CodeViaChannel = "摄取通道的验收暗号是喵喵七号" fixtureT1CodeAgreed = "主人与助手约定验收暗号喵喵七号" ) // t1JudgeStub 是 T1 模型出口的桩:记下**被调用了多少次**、每次都收到什么请求, // 并可按预设回复/错误/延迟作答。 // // 它是「T1 关 ⇒ 零模型调用」那把尺子:`panics` 置真时它一被调用就 panic, // 于是任何「以为关了其实还在调」的回归都会立刻现形。 type t1JudgeStub struct { mu sync.Mutex calls int reqs []model.Request opts []model.Options reply string fail error delay time.Duration panics bool panicked bool } // judge 是 T1Judge 的实现(签名与 model.Adapter.Chat 一致)。 func (s *t1JudgeStub) judge(ctx context.Context, req model.Request, opts model.Options) (model.Response, error) { s.mu.Lock() s.calls++ s.reqs = append(s.reqs, req) s.opts = append(s.opts, opts) panics, reply, fail, delay := s.panics, s.reply, s.fail, s.delay if panics { s.panicked = true } s.mu.Unlock() if panics { panic("t1 judge must not be called") } if delay > 0 { select { case <-time.After(delay): case <-ctx.Done(): return model.Response{}, ctx.Err() } } if fail != nil { return model.Response{}, fail } return model.Response{Content: reply}, nil } func (s *t1JudgeStub) count() int { s.mu.Lock() defer s.mu.Unlock() return s.calls } func (s *t1JudgeStub) lastReq(t *testing.T) model.Request { t.Helper() s.mu.Lock() defer s.mu.Unlock() if len(s.reqs) == 0 { t.Fatal("T1 桩一次都没被调用") } return s.reqs[len(s.reqs)-1] } // newT1Reviewer 构建「异步审查层 + 已接线 T1」的服务:同步入库闸显式关掉 // (这批用例只验异步那一层,否则测到的是闸),其余走设计稿默认值。 func newT1Reviewer( t *testing.T, store *Store, emb embedding.Embedder, cfg ReviewConfig, judge T1Judge, log *slog.Logger, ) *PreInsertReviewer { t.Helper() cfg.Enabled = true if cfg.QueueSize == 0 { cfg.QueueSize = 8 } if cfg.ScanLimit == 0 { cfg.ScanLimit = 50 } if cfg.Timeout == 0 { cfg.Timeout = 5 * time.Second } if cfg.Threshold == 0 { cfg.Threshold = 0.80 } cfg.DisableIngestGate = true if log == nil { log = testLogger() } r, err := NewPreInsertReviewer(cfg, ReviewDeps{ Store: store, Embedder: emb, Judge: judge, Now: func() time.Time { return tidyTestTime(t) }, Log: log, }) if err != nil { t.Fatalf("NewPreInsertReviewer: %v", err) } return r } // t1GrayEmbedder 让「新条目 ↔ 既有条目」余弦恰为 0.88(≥ 阈值 0.80 ⇒ 被召回), // 其余文本落到正交向量上(不会被意外召回)。 func t1GrayEmbedder() stubEmbedder { return stubEmbedder{vecs: map[string]embedding.Vector{ fixtureT1CodeViaChannel: {1, 0}, fixtureT1CodeAgreed: simVector(0.88), }} } // seedT1GrayPair 落两条**灰区**记忆并返回 (既有行, 新行)。 func seedT1GrayPair(t *testing.T, store *Store) (MemoryItem, MemoryItem) { t.Helper() base := tidyTestTime(t) existing := seedTidyItem(t, store, habitItem(fixtureT1CodeAgreed, base, 0.9)) fresh := seedTidyItem(t, store, habitItem(fixtureT1CodeViaChannel, base.Add(time.Minute), 0.9)) return existing, fresh } // TestT1DisabledMakesZeroModelCalls 是任务书 §5①(design/23 §15 D3-9⑥): // **T1 关 ⇒ 零模型调用**。桩「被调用即 panic」,因此任何绕过开关的调用都会 // 立刻现形,而不是悄悄多花一次额度。 func TestT1DisabledMakesZeroModelCalls(t *testing.T) { t.Parallel() store := newTestStore(t) existing, fresh := seedT1GrayPair(t, store) judge := &t1JudgeStub{panics: true, reply: `{"action":"merge","target":1}`} r := newT1Reviewer(t, store, t1GrayEmbedder(), ReviewConfig{DisableT1: true}, judge.judge, nil) rev := t1ReviewGuarded(t, r, fresh) if judge.count() != 0 { t.Fatalf("T1 被关掉仍被调用了 %d 次", judge.count()) } if rev.Decision != IngestCreate { t.Fatalf("关掉 T1 后灰区应保守回落 create,得到 %q", rev.Decision) } if got := readReviewRow(t, store, fresh.ID); got.status != tidyStatusActive { t.Fatalf("关掉 T1 却改写了新行:%+v", got) } if got := readReviewRow(t, store, existing.ID); got.mergeCount != 1 { t.Fatalf("关掉 T1 却改了既有行:%+v", got) } if n := countActiveRows(t, store, "persona.a"); n != 2 { t.Fatalf("关掉 T1 后活跃行数 = %d, want 2(行为必须与引入 T1 之前逐字相同)", n) } } // TestT1NoJudgeIsStructurallyZeroLLM 钉住「没有模型出口 ⇒ 结构性零模型调用」: // 连开关都不用配,依赖面里没有出口就发不出请求。 func TestT1NoJudgeIsStructurallyZeroLLM(t *testing.T) { t.Parallel() store := newTestStore(t) _, fresh := seedT1GrayPair(t, store) r := newT1Reviewer(t, store, t1GrayEmbedder(), ReviewConfig{}, nil, nil) if r.t1Enabled() { t.Fatal("没有模型出口时 t1Enabled 必须为 false") } rev, err := r.Review(context.Background(), fresh) if err != nil { t.Fatalf("Review: %v", err) } if rev.Decision != IngestCreate || rev.Reason != "no_contained_match" { t.Fatalf("verdict = %+v, want create + T0 原始原因码", rev) } } // TestT1MergeAbsorbsNewRowAndKeepsExistingContent 是任务书 §5②(design/23 // §15 D3-2 T0-2 的软合并口径):模型回 `merge` ⇒ 新行软合并进既有行, // **既有行正文一字未改**,且整件事可 revert。 func TestT1MergeAbsorbsNewRowAndKeepsExistingContent(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) existing, fresh := seedT1GrayPair(t, store) // 顺带钉住围栏容忍:模型用 ```json 包住也是合法回复。 judge := &t1JudgeStub{reply: "```json\n{\"action\":\"merge\",\"target\":1,\"reason\":\"同一验收暗号的两种说法\"}\n```"} r := newT1Reviewer(t, store, t1GrayEmbedder(), ReviewConfig{}, judge.judge, nil) rev, err := r.Review(ctx, fresh) 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) } if rev.Reason != t1ReasonMerge { t.Fatalf("留痕 reason = %q, want %q(T1 参与必须可辨)", rev.Reason, t1ReasonMerge) } if judge.count() != 1 { t.Fatalf("模型调用 = %d 次, want 恰为 1", judge.count()) } if got := t1JudgeRequest(t, judge); !strings.Contains(t1UserText(got), fixtureT1CodeAgreed) { t.Fatalf("prompt 没有带上候选原文:%q", t1UserText(got)) } absorbed := readReviewRow(t, store, fresh.ID) if absorbed.status != ingestStatusMerged || absorbed.mergedInto != existing.ID { t.Fatalf("新行 = %+v, want merged 进既有行", absorbed) } survivor := readReviewRow(t, store, existing.ID) if survivor.status != tidyStatusActive || survivor.mergeCount != 2 { t.Fatalf("既有行 = %+v, want active 且 merge_count=2", survivor) } if survivor.content != fixtureT1CodeAgreed { t.Fatalf("既有行正文被改动了:%q(T1 绝不许产新内容)", survivor.content) } if n := countActiveRows(t, store, "persona.a"); n != 1 { t.Fatalf("活跃行数 = %d, want 1", n) } // 可逆:软删除 + merged_into,行永不物理删除(design/23 §8)。 if err := store.RevertMerged(ctx, fresh.ID); err != nil { t.Fatalf("RevertMerged: %v", err) } restored := readReviewRow(t, store, fresh.ID) if restored.status != tidyStatusActive || restored.mergedInto != "" || restored.content != fixtureT1CodeViaChannel { t.Fatalf("revert 后 = %+v, want active 且原文无损", restored) } if after := readReviewRow(t, store, existing.ID); after.mergeCount != 1 { t.Fatalf("revert 后既有行 merge_count = %d, want 1", after.mergeCount) } } // TestT1ModifyRetiresExistingAndKeepsNewRowContent 钉住 `modify` 的落地口径 // (design/23 §15 D3-6):**新行承载更全的内容**、旧行置为被合并,两行正文都 // 取自各自的既有原文 —— 模型给不出任何正文。 func TestT1ModifyRetiresExistingAndKeepsNewRowContent(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) existing, fresh := seedT1GrayPair(t, store) judge := &t1JudgeStub{reply: `{"action":"modify","target":1,"reason":"新记忆覆盖了旧记忆"}`} r := newT1Reviewer(t, store, t1GrayEmbedder(), ReviewConfig{}, judge.judge, nil) rev, err := r.Review(ctx, fresh) if err != nil { t.Fatalf("Review: %v", err) } if rev.Decision != IngestModify || rev.TargetID != existing.ID || rev.Reason != t1ReasonModify { t.Fatalf("verdict = %+v, want modify 覆盖 %s", rev, existing.ID) } if got := readReviewRow(t, store, fresh.ID); got.status != tidyStatusActive || got.content != fixtureT1CodeViaChannel { t.Fatalf("新行 = %+v, want 存活且承载自己的原文", got) } old := readReviewRow(t, store, existing.ID) if old.status != ingestStatusMerged || old.mergedInto != fresh.ID || old.content != fixtureT1CodeAgreed { t.Fatalf("旧行 = %+v, want merged 进新行且原文无损", old) } if n := countActiveRows(t, store, "persona.a"); n != 1 { t.Fatalf("活跃行数 = %d, want 1", n) } } // TestT1KeepCreatesNormally 是任务书 §5③:模型回 `keep` ⇒ 正常新建 // (与没有 T1 时的行为逐字一致),但留痕要能看出「T1 看过并判 keep」。 func TestT1KeepCreatesNormally(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) existing, fresh := seedT1GrayPair(t, store) judge := &t1JudgeStub{reply: `{"action":"keep","target":0,"reason":"两件不同的事"}`} r := newT1Reviewer(t, store, t1GrayEmbedder(), ReviewConfig{}, judge.judge, nil) rev, err := r.Review(ctx, fresh) if err != nil { t.Fatalf("Review: %v", err) } if rev.Decision != IngestCreate || rev.Reason != t1ReasonKeep { t.Fatalf("verdict = %+v, want create + %q", rev, t1ReasonKeep) } if judge.count() != 1 { t.Fatalf("模型调用 = %d 次, want 恰为 1", judge.count()) } if got := readReviewRow(t, store, fresh.ID); got.status != tidyStatusActive { t.Fatalf("keep 却改写了新行:%+v", got) } if got := readReviewRow(t, store, existing.ID); got.mergeCount != 1 { t.Fatalf("keep 却改了既有行:%+v", got) } if n := countActiveRows(t, store, "persona.a"); n != 2 { t.Fatalf("活跃行数 = %d, want 2", n) } } // TestT1InvalidReplyFallsBackToCreate 是任务书 §5④(design/23 §15 D3-2 的 // 「任何解析不出枚举的回复 ⇒ 一律回落 create」):坏 JSON、非法枚举、编号越界 // 全部保守回落,**不 panic、不丢记忆、绝不猜一个最近的候选**。 func TestT1InvalidReplyFallsBackToCreate(t *testing.T) { t.Parallel() replies := []string{ "", // 空回复 "嗯……我觉得可以合并吧", // 压根不是 JSON `{"action":"link","target":1}`, // 非法的第四态 `{"action":"maybe","target":1}`, // 编出来的词 `{"action":"merge"}`, // 缺编号(=0 越界) `{"action":"merge","target":99}`, // 编号越界 `{"action":"modify","target":-1}`, // 负数编号 `{"action":"merge","target":"1"}`, // 编号是字符串(形状不对) `[{"action":"merge","target":1}]`, // 数组而不是对象 } for _, reply := range replies { reply := reply t.Run(reply, func(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) existing, fresh := seedT1GrayPair(t, store) r := newT1Reviewer(t, store, t1GrayEmbedder(), ReviewConfig{}, (&t1JudgeStub{reply: reply}).judge, nil) rev, err := r.Review(ctx, fresh) if err != nil { t.Fatalf("Review: %v", err) } if rev.Decision != IngestCreate { t.Fatalf("回复 %q 被判成 %q —— 解析不出枚举必须回落 create", reply, rev.Decision) } if rev.Reason != t1ReasonReplyInvalid { t.Fatalf("回复 %q 的留痕 reason = %q, want %q", reply, rev.Reason, t1ReasonReplyInvalid) } if got := readReviewRow(t, store, fresh.ID); got.status != tidyStatusActive { t.Fatalf("非法回复却改写了记忆:%+v", got) } if got := readReviewRow(t, store, existing.ID); got.mergeCount != 1 { t.Fatalf("非法回复却改了既有行:%+v", got) } }) } } // TestT1TimeoutAndErrorFallBackToCreate 是任务书 §5⑤(design/23 §15 D3-5 的 // fail-safe):模型超时 / 报错 ⇒ `create` + WARN + **零改写**,绝不阻塞、 // 绝不丢记忆。 func TestT1TimeoutAndErrorFallBackToCreate(t *testing.T) { t.Parallel() cases := map[string]*t1JudgeStub{ "超时": {delay: 5 * time.Second}, "上游报错": {fail: errors.New("upstream exploded")}, } for name, stub := range cases { name, stub := name, stub t.Run(name, func(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) existing, fresh := seedT1GrayPair(t, store) var buf bytes.Buffer log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})) // T1 子超时 30ms、单条审查 5s:超时必须由**子预算**触发(≤ 总预算)。 cfg := ReviewConfig{T1Timeout: 30 * time.Millisecond} r := newT1Reviewer(t, store, t1GrayEmbedder(), cfg, stub.judge, log) start := time.Now() rev, err := r.Review(ctx, fresh) if err != nil { t.Fatalf("Review: %v", err) } if elapsed := time.Since(start); elapsed > 3*time.Second { t.Fatalf("T1 失败拖了 %s:超时没有生效", elapsed) } if stub.count() != 1 { t.Fatalf("模型调用 = %d 次, want 恰为 1", stub.count()) } if rev.Decision != IngestCreate || rev.Reason != t1ReasonUnavailable { t.Fatalf("verdict = %+v, want create + %q", rev, t1ReasonUnavailable) } if got := readReviewRow(t, store, fresh.ID); got.status != tidyStatusActive { t.Fatalf("T1 失败却改写了新行:%+v", got) } if got := readReviewRow(t, store, existing.ID); got.mergeCount != 1 { t.Fatalf("T1 失败却改了既有行:%+v", got) } // 失败必须留下 WARN(绝不静默),且日志里不许出现记忆正文。 logged := buf.String() if !strings.Contains(logged, "t1 judge fell back to create") { t.Fatalf("T1 失败没有记 WARN:%q", logged) } for _, leaked := range []string{fixtureT1CodeAgreed, fixtureT1CodeViaChannel} { if strings.Contains(logged, leaked) { t.Fatalf("日志里泄漏了 %q:%q", leaked, logged) } } }) } }