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 }