feat(memory): T1 灰区模型判定 —— D3 入库前审查补上 LLM 那一半
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

主人 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 行上限,无新逻辑)
This commit is contained in:
AskaEth committed 2026-10-07 14:10:33 +08:00
1 parent 5b8c7a3ef3
commit 1b4d0a552f
11 files changed
+1233 -91

No files matched your search

@@ -0,0 +1,154 @@
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
}