主人 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 行上限,无新逻辑)
155 lines
5.7 KiB
Go
155 lines
5.7 KiB
Go
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
|
||
}
|