Files
Cyrene_v2/internal/runtime/memory/preinsert_t1_bound_test.go
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

155 lines
5.7 KiB
Go
Raw Permalink 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"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件是 T1(design/23 §15.1 D3-2)的**边界**测试:硬门隔离与调用次数有界。
// 三态落地、失败回落与开关口径在 preinsert_t1_test.go(桩与夹具也在那里)。
// 拆两份只为守 AGENTS §10 的文件行数上限,口径一字未改。
// TestT1NeverSeesIsolatedCandidates 是任务书 §5⑥(design/23 §6/§5.2-7/8/9 的
// 隔离红线):跨角色/跨作用域/跨项目,以及只读来源(群聊/陌生人)的亲缘候选
// **在硬门就被挡掉**,T1 连看都看不到 —— 因此它连一次模型调用都不会发。
func TestT1NeverSeesIsolatedCandidates(t *testing.T) {
t.Parallel()
base := tidyTestTime(t)
mine := habitItem(fixtureT1CodeViaChannel, base, 0.9)
// 判定内核层:这些候选**不进灰区**(T1 的输入面),且各自带上否決原因。
isolated := map[string]MemoryItem{
"跨角色": otherPersona(habitItem(fixtureT1CodeAgreed, base, 0.9)),
"跨作用域": otherScope(habitItem(fixtureT1CodeAgreed, base, 0.9)),
"跨项目": otherProject(habitItem(fixtureT1CodeAgreed, base, 0.9)),
"只读来源": groupSource(habitItem(fixtureT1CodeAgreed, base, 0.9)),
}
for name, cand := range isolated {
cand := cand
t.Run(name, func(t *testing.T) {
t.Parallel()
v := judgeIngest(func() MemoryItem { it := mine; it.ID = "new"; return it }(),
[]ingestCandidate{{item: cand, score: 0.99, mode: tidyRecallEmbedding}})
if v.Decision != IngestCreate {
t.Fatalf("隔离候选被判成 %q", v.Decision)
}
if v.Veto == VetoNone {
t.Fatalf("隔离候选没有被硬门否决:%+v", v)
}
if len(v.Gray) != 0 {
t.Fatalf("隔离候选进了灰区(T1 会看到它):%+v", v.Gray)
}
})
}
// 装配层:库里真放着这些亲缘条目时,审查仍然**一次模型都不调**。
store := newTestStore(t)
seedTidyItem(t, store, otherPersona(habitItem(fixtureT1CodeAgreed, base, 0.9)))
seedTidyItem(t, store, groupSource(habitItem(fixtureT1CodeAgreed, base.Add(time.Second), 0.9)))
fresh := seedTidyItem(t, store, mine)
judge := &t1JudgeStub{panics: true, reply: `{"action":"merge","target":1}`}
r := newT1Reviewer(t, store, t1GrayEmbedder(), ReviewConfig{}, judge.judge, nil)
rev := t1ReviewGuarded(t, r, fresh)
if judge.count() != 0 {
t.Fatalf("只有隔离候选时仍调了 %d 次模型", judge.count())
}
if rev.Decision != IngestCreate {
t.Fatalf("隔离候选下 verdict = %+v, want create", rev)
}
}
// TestT1CallsAreBoundedPerReview 是任务书 §5⑦(design/23 §15 D3-4 的有界):
// 每次审查的 T1 调用次数**封顶 1**:再多的灰区候选也只打包进**同一个** prompt,
// 且打包条数不超过 defaultT1MaxGray。
func TestT1CallsAreBoundedPerReview(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
// 12 条同内容候选:它们与新条目各自都是灰区(互不包含),因此灰区候选 ≥ 12。
for i := 0; i < 12; i++ {
seedTidyItem(t, store, habitItem(fixtureT1CodeAgreed, base.Add(time.Duration(i)*time.Second), 0.9))
}
fresh := seedTidyItem(t, store, habitItem(fixtureT1CodeViaChannel, base.Add(time.Hour), 0.9))
judge := &t1JudgeStub{reply: `{"action":"keep","target":0}`}
r := newT1Reviewer(t, store, t1GrayEmbedder(), ReviewConfig{}, judge.judge, nil)
if _, err := r.Review(ctx, fresh); err != nil {
t.Fatalf("Review: %v", err)
}
if judge.count() != 1 {
t.Fatalf("一次审查发了 %d 次模型调用, want 1", judge.count())
}
user := t1UserText(t1JudgeRequest(t, judge))
if !strings.Contains(user, "8. ") {
t.Fatalf("prompt 没有把 %d 条灰区候选打包进去:%q", defaultT1MaxGray, user)
}
if strings.Contains(user, "9. ") {
t.Fatalf("prompt 超出发包上限(>%d 条):%q", defaultT1MaxGray, user)
}
if req := t1JudgeRequest(t, judge); req.Purpose != t1Purpose {
t.Fatalf("计账标签 = %q, want %q", req.Purpose, t1Purpose)
}
if st := r.Stats(); st.T1Called != 1 || st.T1Decided != 1 {
t.Fatalf("计数 = %+v, want T1Called=1 T1Decided=1", st)
}
}
// t1JudgeRequest 取出桩收到的最后一个模型请求。
func t1JudgeRequest(t *testing.T, s *t1JudgeStub) model.Request {
t.Helper()
return s.lastReq(t)
}
// t1UserText 把请求里的 user 段拼成纯文本(system 段是固定指令,不参与断言)。
func t1UserText(req model.Request) string {
var b strings.Builder
for _, m := range req.Messages {
if m.Role != model.RoleUser {
continue
}
for _, c := range m.Content {
b.WriteString(c.Text)
}
}
return b.String()
}
// t1ReviewGuarded 跑一次同步审查,并把「桩被调用即 panic」转成测试失败而不是
// 让整个测试二进制崩掉(否则同包其它用例会一起变红,掩盖真正的失败点)。
func t1ReviewGuarded(t *testing.T, r *PreInsertReviewer, item MemoryItem) IngestReview {
t.Helper()
var (
rev IngestReview
err error
)
func() {
defer func() {
if p := recover(); p != nil {
t.Fatalf("T1 不该被调用,却收到了调用:%v", p)
}
}()
rev, err = r.Review(context.Background(), item)
}()
if err != nil {
t.Fatalf("Review: %v", err)
}
return rev
}
// otherPersona / otherScope / otherProject / groupSource 各自只改一个分区键,
// 用来逐条验证硬门(§5.2-7/8/9)。
func otherPersona(it MemoryItem) MemoryItem { it.PersonaID = "persona.b"; return it }
func otherScope(it MemoryItem) MemoryItem { it.Scope = ScopeUser; return it }
func otherProject(it MemoryItem) MemoryItem { it.ProjectID = "proj.x"; return it }
func groupSource(it MemoryItem) MemoryItem {
it.SourceConversation = FormatSourceConversation("sess-qq-group-1", "m-1")
return it
}