Files
Cyrene_v2/internal/runtime/memory/preinsert_t1_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

422 lines
15 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 (
"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)
}
}
})
}
}