From 1b4d0a552f2625058926e6b49257b742f72a6509 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 7 Oct 2026 14:10:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(memory):=20T1=20=E7=81=B0=E5=8C=BA?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E5=88=A4=E5=AE=9A=20=E2=80=94=E2=80=94=20D3?= =?UTF-8?q?=20=E5=85=A5=E5=BA=93=E5=89=8D=E5=AE=A1=E6=9F=A5=E8=A1=A5?= =?UTF-8?q?=E4=B8=8A=20LLM=20=E9=82=A3=E4=B8=80=E5=8D=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主人 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 行上限,无新逻辑) --- cmd/cyrene/wire_memory_review.go | 70 ++- cmd/cyrene/wire_memory_review_test.go | 100 +++++ internal/config/runtimeconfig/memorytidy.go | 24 +- internal/runtime/memory/preinsert.go | 111 ++--- .../runtime/memory/preinsert_async_test.go | 8 +- internal/runtime/memory/preinsert_config.go | 102 +++++ internal/runtime/memory/preinsert_judge.go | 24 +- internal/runtime/memory/preinsert_stats.go | 11 +- internal/runtime/memory/preinsert_t1.go | 299 +++++++++++++ .../runtime/memory/preinsert_t1_bound_test.go | 154 +++++++ internal/runtime/memory/preinsert_t1_test.go | 421 ++++++++++++++++++ 11 files changed, 1233 insertions(+), 91 deletions(-) create mode 100644 internal/runtime/memory/preinsert_config.go create mode 100644 internal/runtime/memory/preinsert_t1.go create mode 100644 internal/runtime/memory/preinsert_t1_bound_test.go create mode 100644 internal/runtime/memory/preinsert_t1_test.go diff --git a/cmd/cyrene/wire_memory_review.go b/cmd/cyrene/wire_memory_review.go index 8cd83f2..146a120 100644 --- a/cmd/cyrene/wire_memory_review.go +++ b/cmd/cyrene/wire_memory_review.go @@ -1,21 +1,32 @@ package main import ( + "context" + "errors" "fmt" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) +// errT1ModelNotReady 在 T1 需要模型而模型栈尚未就绪时返回。 +// +// 它是 **fail-safe** 信号,不是故障:审查据此回落 `create` + WARN,绝不阻塞、 +// 绝不丢记忆(design/23 §15 D3-5)。之所以可能存在这一刻:本服务在 +// `wireMemory` 阶段装配,模型栈在更晚的 `wireRuntime` 阶段才建好。 +var errT1ModelNotReady = errors.New("memory review: t1 judge: model adapter not ready") + // 本文件把 D3「入库前审查」(DevDocs/design/23-记忆整理与去重 §15.1 D3)接进 // 组合根。三件事只在这里定: // // - **形态**:记忆管理会话(复用既有的 sess-memorytidy 装配,见 // memory.TidySessionID)的有界后台队列,不是会话内派 subagent。它与整理 // 心跳并列启动、在 close 里先于关库停止; -// - **依赖面**:只给它记忆存储与 embedder —— 服务侧拿不到任何会话/历史读数, +// - **依赖面**:只给它记忆存储、embedder,以及 **T1 的一次性模型调用口** +// (memoryT1Judge,§15.1 D3-2 的灰区判定)。服务侧拿不到任何会话/历史读数, // 因此「独立会话、零上下文、不碰主会话上下文」(§15 D3)是结构性成立的, // 而不是靠自觉; // - **审计**:接到网关的 AuditEvent(口径 memory.review.ingest)。出口在调用 @@ -23,11 +34,12 @@ import ( // 一手法。未就绪时只记日志,绝不假装写过审计。 // // 关闭(MEMORY_REVIEW_ENABLED=false)时**不建、不接**:store 的写入路径因此 -// 逐字回到本次改动之前(纯增量,无破坏性变更)。 +// 逐字回到本次改动之前(纯增量、无破坏性变更)。 // -// 🔴 同步入库闸的开关(主人 2026-10-07 裁定)不在 env 里 —— `.env` 已冻结 -// (design/13 §2.1):它读 `$DATA_DIR/config/memory-tidy.json` 的 -// `{"ingestGate": false}`,与整理自动档同一个文件、**零新 env 键**。 +// 🔴 两个开关都不在 env 里(`.env` 已冻结,design/13 §2.1),它们读 +// `$DATA_DIR/config/memory-tidy.json`,与整理自动档同一个文件、**零新 env 键**: +// - `{"ingestGate": false}` ⇒ 一键回到「只插不审」(同步入库闸); +// - `{"t1Judge": false}` ⇒ 一键回到纯规则(T1 灰区判定,**零模型调用**)。 func (a *app) wireMemoryReview() error { cfg, err := config.LoadMemoryReviewConfig(envPath) if err != nil { @@ -39,6 +51,10 @@ func (a *app) wireMemoryReview() error { return nil } gateOff := a.memoryIngestGateOff() + // T1 灰区模型判定(design/23 §15.1 D3-2):代码默认**开**,`{"t1Judge": false}` + // 一键回到纯规则(零模型调用)。与入库闸同一个文件、同一套「没配 = 保持默认」 + // 口径,**零新 env 键**。 + t1Off := a.memoryT1JudgeOff() reviewer, err := memory.NewPreInsertReviewer(memory.ReviewConfig{ Enabled: cfg.Enabled, QueueSize: cfg.QueueSize, @@ -51,11 +67,15 @@ func (a *app) wireMemoryReview() error { // 一键回到「只插不审」(= 本次改动之前的异步行为)。预算与扫描上限走 // 代码默认值(闸的总墙钟预算必须有界,但它是**实现参数**不是运维旋钮)。 DisableIngestGate: gateOff, + // T1 的时长走代码默认值(8s,且被单条审查的 Timeout 收紧取小): + // 它是**实现参数**不是运维旋钮 —— 要让 T1 完全不花钱,就关掉它。 + DisableT1: t1Off, }, memory.ReviewDeps{ Store: a.memStore, Embedder: a.embedder, Audit: a.auditMemoryReview, Log: a.log, + Judge: a.memoryT1Judge(), }) if err != nil { return fmt.Errorf("memory ingest review: %w", err) @@ -79,6 +99,46 @@ func (a *app) memoryIngestGateOff() bool { return tuning.IngestGateOff() } +// memoryT1JudgeOff 报告 **T1 灰区模型判定**是否被配置显式关掉。口径与 +// memoryIngestGateOff 逐字相同:读不到/解不开 `memory-tidy.json` 时返回 false +// (= 保持代码默认的「开」)并记 WARN —— 坏配置绝不能让一处防护静默失效, +// 也绝不能让主人以为它关了却其实开着。 +func (a *app) memoryT1JudgeOff() bool { + tuning, path, err := a.readMemoryTidyTuning() + if err != nil { + logger.WithSource(a.log, "memory/review").Warn( + "memory t1 judge tuning unreadable, falling back to code default (enabled)", + "path", path, "error", err) + return false + } + return tuning.T1JudgeOff() +} + +// memoryT1Judge 把「调用时才解析的模型适配器」包成 T1 的一次性调用口。 +// +// 为什么是闭包而不是直接塞 `a.modelAdapter`:组合根的顺序是 **wireMemory(建 +// 记忆栈,含本服务)→ wireRuntime(建模型栈)**,装配这一刻 `a.modelAdapter` +// 还是 nil。闭包把解析推迟到调用时,于是既保住了「记忆模块只拿到一个调用口、 +// 拿不到模型栈」这条依赖面(§15 D3:独立会话、零上下文),又不必改动既有装配 +// 顺序 —— 与 a.gw 的晚组装是同一手法(见本文件顶部)。 +// +// 用的是**既有**的模型栈本身(含按角色路由与主→备→本地降级),不新造 LLM +// 客户端;`PersonaID` 留空即「无角色绑定」,由生效组的默认链服务它。模型栈仍 +// 未就绪时返回 errT1ModelNotReady:那是 fail-safe(回落 create + WARN)。 +// +// 读 `a.modelAdapter` 无锁是安全的:它在 wireRuntime 里被**写一次**,而任何 +// 读到它的路径(审查工作协程)都在 main 里 `memoryReview.Start` 之后才开始, +// 写入严格先于读取。 +func (a *app) memoryT1Judge() memory.T1Judge { + return func(ctx context.Context, req model.Request, opts model.Options) (model.Response, error) { + adapter := a.modelAdapter + if adapter == nil { + return model.Response{}, errT1ModelNotReady + } + return adapter.Chat(ctx, req, opts) + } +} + // auditMemoryReview 把一条审查结论写进网关审计链。它**只带决定、依据与 id**, // 绝不带任何记忆正文(design §15 D3-8「记忆正文不进日志」)。 func (a *app) auditMemoryReview(who, what, result string) { diff --git a/cmd/cyrene/wire_memory_review_test.go b/cmd/cyrene/wire_memory_review_test.go index aca7659..07b2720 100644 --- a/cmd/cyrene/wire_memory_review_test.go +++ b/cmd/cyrene/wire_memory_review_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "os" "path/filepath" "strings" @@ -13,6 +14,7 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // 本文件锁定 D3「入库前审查」(design/23 §15.1 D3)在**组合根**上的三件事: @@ -60,6 +62,12 @@ func TestMemoryReviewIsWired(t *testing.T) { // 的 `{"ingestGate": false}`(**零新 env 键**,`.env` 已冻结)。 "a.memoryIngestGateOff()", "DisableIngestGate: gateOff", + // T1 灰区模型判定(design/23 §15.1 D3-2):开关同样来自那个文件 + // (`{"t1Judge": false}`),模型出口**在调用时才解析**(本服务早于 + // 模型栈装配,与 a.gw 的晚组装同一手法)。 + "a.memoryT1JudgeOff()", + "DisableT1: t1Off", + "Judge: a.memoryT1Judge()", }, "main.go": { "a.memoryReview.Start(ctx)", @@ -131,6 +139,98 @@ func TestMemoryIngestGate_ReadsDataDirConfig(t *testing.T) { } } +// TestMemoryT1Judge_ReadsDataDirConfig 钉住 **T1 灰区模型判定**的开关确实来自 +// `$DATA_DIR/config/memory-tidy.json` 的 `t1Judge`(它没有 env 键:`.env` 已冻结) +// —— T1 是一处**会花掉模型额度**的动作,「一行配置关掉」必须真的成立。 +// +// 口径与入库闸同一条:没配 = 开(主人 2026-10-07 要的就是「真的走记忆管理会话」); +// 显式 false = 关(回落纯规则、零模型调用);坏文件 = 回落代码默认(开)并记 +// WARN,**绝不静默把一处防护关掉**。 +func TestMemoryT1Judge_ReadsDataDirConfig(t *testing.T) { + t.Parallel() + + empty := &app{cfg: &config.Config{DataDir: t.TempDir()}, log: tidyTestLog()} + if empty.memoryT1JudgeOff() { + t.Fatal("没配过时 T1 应当保持代码默认(开)") + } + + dataDir := t.TempDir() + dir := filepath.Join(dataDir, "config") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + path := filepath.Join(dir, "memory-tidy.json") + // 只写一个上限键:它不许把 T1 静默关掉(指针字段的存在理由)。 + if err := os.WriteFile(path, []byte(`{"autoApplyPerRound": 5}`), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + partial := &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()} + if partial.memoryT1JudgeOff() { + t.Fatal("只写了别的键时 T1 被静默关掉了(必须保持代码默认的「开」)") + } + + if err := os.WriteFile(path, []byte(`{"t1Judge": false}`), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + off := &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()} + if !off.memoryT1JudgeOff() { + t.Fatal("t1Judge: false 没被读到(T1 没有一键关掉的路径)") + } + + if err := os.WriteFile(path, []byte(`{"t1Judge": "nope"}`), 0o600); err != nil { + t.Fatalf("write broken: %v", err) + } + broken := &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()} + if broken.memoryT1JudgeOff() { + t.Fatal("坏配置把 T1 静默关掉了:必须回落代码默认(开)") + } +} + +// TestMemoryT1Judge_UsesTheWiredModelStack 钉住 T1 的模型出口**复用既有模型栈** +// (不新造 LLM 客户端),且返回一个闭包而不是当场取值 —— 本服务在 wireMemory +// 阶段装配,而 a.modelAdapter 要等更晚的 wireRuntime 才建好。 +func TestMemoryT1Judge_UsesTheWiredModelStack(t *testing.T) { + t.Parallel() + a := &app{log: tidyTestLog()} + judge := a.memoryT1Judge() + if judge == nil { + t.Fatal("memoryT1Judge 返回了 nil") + } + // 模型栈还没建好:必须 fail-safe(返回错误),绝不 panic、绝不假装成功。 + if _, err := judge(context.Background(), model.Request{}, model.Options{}); !errors.Is(err, errT1ModelNotReady) { + t.Fatalf("模型栈未就绪时 err = %v, want %v", err, errT1ModelNotReady) + } + // 接上模型栈后,同一个闭包就能发请求(晚解析的证据)。 + stub := &reviewAdapterStub{reply: "{}"} + a.modelAdapter = stub + if _, err := judge(context.Background(), model.Request{Purpose: "memory_t1_judge"}, model.Options{}); err != nil { + t.Fatalf("接上模型栈后调用失败:%v", err) + } + if stub.calls != 1 { + t.Fatalf("模型栈被调用 %d 次, want 1", stub.calls) + } +} + +// reviewAdapterStub 是最小的 model.Adapter 桩(只为验「闭包解析到既有的那个 +// 模型栈」这件事,不参与判定正确性)。 +type reviewAdapterStub struct { + calls int + reply string +} + +func (s *reviewAdapterStub) Name() string { return "stub" } + +func (s *reviewAdapterStub) Chat(context.Context, model.Request, model.Options) (model.Response, error) { + s.calls++ + return model.Response{Content: s.reply}, nil +} + +func (s *reviewAdapterStub) ChatStream( + ctx context.Context, req model.Request, onEvent func(model.StreamEvent), +) (model.Response, error) { + return s.Chat(ctx, req, model.Options{}) +} + // TestMemoryReviewSessionIsNotConversationLane 与整理同一条口径:审查会话不落在 // 主人的对话车道("web" / "web:")上,因此不改她的心情、不进主会话泳道。 func TestMemoryReviewSessionIsNotConversationLane(t *testing.T) { diff --git a/internal/config/runtimeconfig/memorytidy.go b/internal/config/runtimeconfig/memorytidy.go index ff63fb3..09bfedb 100644 --- a/internal/config/runtimeconfig/memorytidy.go +++ b/internal/config/runtimeconfig/memorytidy.go @@ -13,6 +13,10 @@ // 开关,必须能一键回到「只插不审」,而 `.env` 已冻结 ⇒ 它落在同一个文件里, // 不新开文件家族、不加 env 键。 // +// 2026-10-07 再追加 `t1Judge`(T1 灰区模型判定,design/23 §15.1 D3-2):理由仍然 +// 是同一条 —— 它是一处**会花掉模型额度**的开关,必须能一行配置关掉(关掉即回落 +// 纯规则、零模型调用),所以同样落在这里,不新开文件家族、不加 env 键。 +// // 边界与 backthink.go 同源(那是同一族的手续): // // 1. 本文件只管**文件形状 + 读取**:它不认识 `TidyConfig`,也不认识「零值回落 @@ -54,9 +58,9 @@ var ErrInvalidMemoryTidy = errors.New("runtimeconfig: invalid memory tidy config // MemoryTidyTuning 是 `memory-tidy.json` 的形状:记忆模块运行期的旋钮。 // 文件以「整理」命名,但它是**记忆模块运行期调优的同一处落点**(整理自动档 + -// 入库闸),不再为一个开关新开一个文件家族。 +// 入库闸 + T1 灰区判定),不再为一个开关新开一个文件家族。 // -// AutoApply / IngestGate 用**指针**是刻意的:它们的代码默认值都是「开」,而 +// AutoApply / IngestGate / T1Judge 用**指针**是刻意的:它们的代码默认值都是「开」,而 // JSON 的 `bool` 零值也是 false —— 用 bool 会让「这个键没写」与「显式写 // false」不可区分,于是任何只写上限的文件都会**静默把开关关掉**。指针让 // 「没写」= nil = 不覆盖默认。 @@ -73,6 +77,16 @@ type MemoryTidyTuning struct { // 2026-10-07 裁定)。nil = 没配(用代码默认值:开);false = 一键回到 // 「只插不审」(= 改动之前的异步行为);true = 显式开(与默认同义)。 IngestGate *bool `json:"ingestGate"` + // T1Judge 控制 **T1 灰区模型判定**(design/23 §15.1 D3-2:灰区 —— 相似 + // 但互不包含 —— 调一次轻量模型,只许回 `merge`/`modify`/`keep` + 理由、 + // **禁产新内容**)。nil = 没配(用代码默认值:开);false = 一键回到纯规则 + // (**零模型调用**,灰区仍然保守回落新建);true = 显式开(与默认同义)。 + // + // 2026-10-07 追加,与 IngestGate 同一个文件、同一个理由:它是一处**会花掉 + // 模型额度**的开关,必须能一行配置关掉,而 `.env` 已冻结(零新 env 键)。 + // 指针也是同一个理由:代码默认值是「开」,用 bool 会让「这个键没写」与 + // 「显式写 false」不可区分。 + T1Judge *bool `json:"t1Judge"` } // AutoApplyOff 报告自动档是否被这份配置**显式关掉**。nil(没配)返回 false —— @@ -87,6 +101,12 @@ func (t MemoryTidyTuning) IngestGateOff() bool { return t.IngestGate != nil && !*t.IngestGate } +// T1JudgeOff 报告 **T1 灰区模型判定**是否被这份配置显式关掉(口径同 AutoApplyOff: +// nil 是「没配」⇒ 用代码默认的「开」,不是「关」)。 +func (t MemoryTidyTuning) T1JudgeOff() bool { + return t.T1Judge != nil && !*t.T1Judge +} + // DefaultMemoryTidyPath 返回记忆整理调优的落点:`$DATA_DIR/config/memory-tidy.json`。 func DefaultMemoryTidyPath(dataDir string) string { return filepath.Join(dataDir, memoryTidyConfigSubdir, memoryTidyFileName) diff --git a/internal/runtime/memory/preinsert.go b/internal/runtime/memory/preinsert.go index 1ea5dfa..3bd4e13 100644 --- a/internal/runtime/memory/preinsert.go +++ b/internal/runtime/memory/preinsert.go @@ -28,9 +28,11 @@ import ( // 2. **失败即安全**:条目先落库 ⇒ 审查失败/超时/embedder 不可用时「记忆绝不 // 丢」是结构性成立的,不靠回滚(§15 D3-5)。失败路径**只记 WARN、绝不 // 改写任何一行**; -// 3. **零 LLM**:T0 是纯规则(design §15 D3-2 T0),T1 默认关且**本切片未 -// 实现**(§15.2 开放点 1 = T1 默认关),因此「T1 关闭时零模型调用」是 -// 结构性成立 —— 本服务的依赖里根本没有模型适配器。 +// 3. **规则优先、模型兜底**:T0 是纯规则(design §15 D3-2 T0),**恒为零模型 +// 调用**;T1(灰区调一次轻量模型,§15.1 D3-2)默认**开**,但只在「T0 判不出 +// 结论且存在灰区候选」时才发出**至多一次**调用(preinsert_t1.go),并且 +// 要有模型出口才成立 —— 两者任一缺席即是结构性零模型调用。 +// **同步入库闸(preinsert_gate.go)不碰 T1**,写入路径因此仍是零模型调用。 // // 代价(如实登记):极短窗口内库里会多一行待裁定的重复条目,随后被软合并 // (status='merged' + merged_into,可 revert,绝不物理删除,design §8)。 @@ -55,22 +57,15 @@ const ( // IngestRuleVersion 是入库前审查规则集的版本号,进每条审查留痕(§15 D3-8)。 // **改判定规则必须同时升它**,否则新结论与旧留痕无法区分。 -const IngestRuleVersion = "d3.1" +// +// 版本史: +// - `d3.1` 四态规则层(T0)+ 同步入库闸; +// - `d3.2` 加入 **T1 灰区模型判定**(preinsert_t1.go,design/23 §15.1 D3-2): +// 灰区(相似、互不包含、未被规则否决)在模型出口接上时会被改判成 +// merge/modify,因此 `reason` 上出现了 `t1_*` 族。 +const IngestRuleVersion = "d3.2" -// 默认值与审计口径。运算路径上不出现裸魔法数字(AGENTS §10)。 -const ( - // defaultReviewQueueSize 是有界审查队列长度:审查是「尽力而为」的旁路, - // 队列满时宁可少审一条(记忆早已落库),也绝不阻塞写入方。 - defaultReviewQueueSize = 256 - // defaultReviewScanLimit 是每次审查在同一分区内最多比对的既有条目数。 - defaultReviewScanLimit = 200 - // defaultReviewTimeout 是单条审查的墙钟上限。 - defaultReviewTimeout = 20 * time.Second - // reviewAuditWho / reviewAuditVerb 是审查留痕的审计口径:它是**系统任务**, - // 不是任何角色(design §4.1 C 的同一理由)。 - reviewAuditWho = "system/memoryreview" - reviewAuditVerb = "memory.review.ingest" -) +// 调优旋钮(ReviewConfig)与它们的默认值常量在 preinsert_config.go。 var ( // ErrReviewDisabled 在审查被配置显式关闭却又被直接调用时返回。 @@ -86,44 +81,7 @@ var ( ErrReviewItemNotActive = errors.New("memory/review: item is no longer active") ) -// ReviewConfig 携带入库前审查的调优旋钮。零值经 normalize 选取安全默认值。 -type ReviewConfig struct { - // Enabled 是总开关。关闭时组合根不建、不接、不跑 —— 写入路径行为逐字 - // 回到本次改动之前。 - Enabled bool - // QueueSize 是有界队列长度(≤0 用默认)。 - QueueSize int - // ScanLimit 是单次审查的候选上限(≤0 用默认)。 - ScanLimit int - // Timeout 是单条审查的墙钟上限(≤0 用默认)。 - Timeout time.Duration - // Threshold 是候选召回的余弦门,**复用记忆整理的既有阈值** - // (config.MemoryTidy.RecallThreshold,design §13 开放点 2 = 0.80): - // 入库前审查与夜间整理必须用同一把尺子,否则同一对条目会有两个结论。 - // 非正用设计稿默认值。 - Threshold float64 - // DisableIngestGate 关闭**同步入库闸**(任务书规格 3 的开关)。 - // - // 它是**反向**字段,且这是刻意的:bool 的零值必须落在「默认行为」上 —— - // 同步闸本机制的默认取向(主人 2026-10-07「把记忆整理功能接到昔涟和记忆库 - // 中间做审查,由整理功能进行入库」),若写成正向的 EnableIngestGate,任何没 - // 显式配过的地方(含测试与组合根构造的一半配置)都会被读成「关掉」,与默认 - // 相反。要一键回到「只插不审」(= 本次改动之前的异步行为),写 - // `$DATA_DIR/config/memory-tidy.json` 的 `{"ingestGate": false}`(**零新 - // env 键**:`.env` 已冻结,design/13 §2.1)。 - DisableIngestGate bool - // GateBudget 是同步入库闸的**总墙钟预算**(任务书规格 1:写入路径不许变慢)。 - // 非正用代码默认值 defaultReviewGateBudget。超预算一律 fail-open(照常插入 + - // 留痕),绝不静默丢。 - GateBudget time.Duration - // GateScanLimit 是同步入库闸的候选扫描上限。它比异步队列更紧(后者 - // ScanLimit 默认 200),因为它在**写入路径**上:非正用代码默认值 - // defaultReviewGateScanLimit,且最终还会被 cfg.ScanLimit 收紧取小。 - GateScanLimit int -} - // ReviewDeps 是注入给入库前审查的依赖。Store 必需;其余可空并优雅降级。 -// 刻意**没有**模型适配器:T0 是零 LLM 判定,T1 未实现(§15.2 开放点 1)。 type ReviewDeps struct { // Store 是记忆存储。必需。 Store *Store @@ -140,6 +98,16 @@ type ReviewDeps struct { // 生产口径(Store.listReviewCandidates,分区过滤写在 SQL 里)。测试用它 // 注入慢/坏的候选源,验证「预算有界」与「超时 fail-open」这两条。 GateCandidates func(ctx context.Context, p tidyPartition, excludeID string, limit int) ([]MemoryItem, error) + // Judge 是 **T1 灰区判定**的一次性模型调用口(design/23 §15.1 D3-2)。 + // + // 为 nil 时 T1 **结构性关闭**(零模型调用)—— 这正是「T1 关闭 ⇒ 零模型调用」 + // 从承诺变成事实的那条线。它只取模型适配器的 Chat 一面(T1 不流式、不重试、 + // 不并发),注入的是**既有** model.Adapter 的调用口(组合根装配的就是模型栈 + // 本身,含按角色路由与主→备→本地降级),不新造任何 LLM 客户端。 + // + // ⚠️ 它**只被异步审查层使用**(PreInsertReviewer.Review);同步入库闸 + // (preinsert_gate.go)的判定链里根本没有它,写入路径因此零模型调用。 + Judge T1Judge } // IngestReview 是一条入库前审查留痕(design/23 §15 D3-8)。它**不含记忆正文**: @@ -203,29 +171,6 @@ func NewPreInsertReviewer(cfg ReviewConfig, deps ReviewDeps) (*PreInsertReviewer }, nil } -// normalizeReviewConfig 用设计稿默认值填充零值字段。 -func normalizeReviewConfig(cfg ReviewConfig) ReviewConfig { - if cfg.QueueSize <= 0 { - cfg.QueueSize = defaultReviewQueueSize - } - if cfg.ScanLimit <= 0 { - cfg.ScanLimit = defaultReviewScanLimit - } - if cfg.Timeout <= 0 { - cfg.Timeout = defaultReviewTimeout - } - if cfg.Threshold <= 0 { - cfg.Threshold = defaultTidyRecallThreshold - } - if cfg.GateBudget <= 0 { - cfg.GateBudget = defaultReviewGateBudget - } - if cfg.GateScanLimit <= 0 { - cfg.GateScanLimit = defaultReviewGateScanLimit - } - return cfg -} - // gateEnabled 报告**同步入库闸**是否生效(任务书规格 3 的开关)。 // 总开关关闭或闸被显式关掉时它都为 false,此时写入路径逐字回到「只插不审」。 func (r *PreInsertReviewer) gateEnabled() bool { @@ -292,6 +237,12 @@ func (r *PreInsertReviewer) Start(ctx context.Context) { "sync_gate", r.gateEnabled(), "gate_budget", r.cfg.GateBudget.String(), "gate_scan_limit", r.gateScanLimit(), + // T1 是否有模型出口:`t1_judge=false` 有两种成因(配置关掉 / 没接模型), + // 分开写让「以为开着其实没接」在启动日志里就看得见。 + "t1_judge", r.t1Enabled(), + "t1_off_by_config", r.cfg.DisableT1, + "t1_timeout", r.t1Timeout().String(), + "t1_max_gray", defaultT1MaxGray, "sweep_interval", DefaultIngestReviewGCInterval.String(), "retention", DefaultIngestReviewRetention.String(), "max_reviews", DefaultIngestReviewMaxRows) @@ -409,6 +360,10 @@ func (r *PreInsertReviewer) Review(ctx context.Context, item MemoryItem) (Ingest return IngestReview{}, fmt.Errorf("memory/review: %w", err) } verdict := judgeIngest(item, cands) + // T1(灰区模型判定,design/23 §15.1 D3-2)**只挂在异步审查层**:规则层判不出 + // 结论且有灰区候选时,用至多一次模型调用改判。未启用 / 无出口 / 失败一律 + // 原样回落 create —— 它没有「改写记忆」的代码路径,因此失败绝不可能丢记忆。 + verdict = r.maybeJudgeT1(ctx, item, verdict) merged, err := r.applyVerdict(ctx, verdict, item, now) if err != nil { return IngestReview{}, fmt.Errorf("memory/review: apply: %w", err) diff --git a/internal/runtime/memory/preinsert_async_test.go b/internal/runtime/memory/preinsert_async_test.go index 122f4f6..2f75d7a 100644 --- a/internal/runtime/memory/preinsert_async_test.go +++ b/internal/runtime/memory/preinsert_async_test.go @@ -251,8 +251,12 @@ func TestReviewDisabledChangesNothing(t *testing.T) { } // TestReviewReusesTidySessionAndStaysZeroLLM 是 design/23 §15 D3 的装配口径: -// 复用既有 sess-memorytidy 装配(不造第二形态会话),且 T1 默认关 + 本切片 -// 未实现 ⇒ 灰区(相似但不包含)**不调任何模型**,只按 T0 回落 create。 +// 复用既有 sess-memorytidy 装配(不造第二形态会话),且**没接模型出口**时 +// ⇒ 灰区(相似但不包含)**不调任何模型**,只按 T0 回落 create。 +// +// ⚠️ 2026-10-07 起 T1(灰区模型判定,preinsert_t1.go)默认**开**:本条用例的 +// 「零模型调用」来自**结构性缺席**(ReviewDeps.Judge 为 nil),不是来自默认关。 +// 「T1 开启时真的会调一次模型」由 preinsert_t1_test.go 覆盖。 func TestReviewReusesTidySessionAndStaysZeroLLM(t *testing.T) { t.Parallel() ctx := context.Background() diff --git a/internal/runtime/memory/preinsert_config.go b/internal/runtime/memory/preinsert_config.go new file mode 100644 index 0000000..e806d99 --- /dev/null +++ b/internal/runtime/memory/preinsert_config.go @@ -0,0 +1,102 @@ +package memory + +import "time" + +// 本文件是 D3「入库前审查」(design/23 §15.1 D3)的**调优面**:默认值常量、 +// ReviewConfig 的旋钮与它们的零值归一化。它从 preinsert.go 拆出来只为守 +// AGENTS §10 的文件行数上限(≤500 行)与「模块按职责拆源文件」那条规范 —— +// 口径逐字未变。 +// +// 边界:本文件只管「旋钮长什么样 + 零值取什么默认值」,不认识服务本体 +// (PreInsertReviewer 的队列/工作协程在 preinsert.go),也不认识任何开关的 +// 运维来源(env 与 `$DATA_DIR/config/memory-tidy.json` 都在组合根)。 + +// 默认值与审计口径。运算路径上不出现裸魔法数字(AGENTS §10)。 +const ( + // defaultReviewQueueSize 是有界审查队列长度:审查是「尽力而为」的旁路, + // 队列满时宁可少审一条(记忆早已落库),也绝不阻塞写入方。 + defaultReviewQueueSize = 256 + // defaultReviewScanLimit 是每次审查在同一分区内最多比对的既有条目数。 + defaultReviewScanLimit = 200 + // defaultReviewTimeout 是单条审查的墙钟上限。 + defaultReviewTimeout = 20 * time.Second + // reviewAuditWho / reviewAuditVerb 是审查留痕的审计口径:它是**系统任务**, + // 不是任何角色(design §4.1 C 的同一理由)。 + reviewAuditWho = "system/memoryreview" + reviewAuditVerb = "memory.review.ingest" +) + +// ReviewConfig 携带入库前审查的调优旋钮。零值经 normalize 选取安全默认值。 +type ReviewConfig struct { + // Enabled 是总开关。关闭时组合根不建、不接、不跑 —— 写入路径行为逐字 + // 回到本次改动之前。 + Enabled bool + // QueueSize 是有界队列长度(≤0 用默认)。 + QueueSize int + // ScanLimit 是单次审查的候选上限(≤0 用默认)。 + ScanLimit int + // Timeout 是单条审查的墙钟上限(≤0 用默认)。 + Timeout time.Duration + // Threshold 是候选召回的余弦门,**复用记忆整理的既有阈值** + // (config.MemoryTidy.RecallThreshold,design §13 开放点 2 = 0.80): + // 入库前审查与夜间整理必须用同一把尺子,否则同一对条目会有两个结论。 + // 非正用设计稿默认值。 + Threshold float64 + // DisableIngestGate 关闭**同步入库闸**(任务书规格 3 的开关)。 + // + // 它是**反向**字段,且这是刻意的:bool 的零值必须落在「默认行为」上 —— + // 同步闸本机制的默认取向(主人 2026-10-07「把记忆整理功能接到昔涟和记忆库 + // 中间做审查,由整理功能进行入库」),若写成正向的 EnableIngestGate,任何没 + // 显式配过的地方(含测试与组合根构造的一半配置)都会被读成「关掉」,与默认 + // 相反。要一键回到「只插不审」(= 本次改动之前的异步行为),写 + // `$DATA_DIR/config/memory-tidy.json` 的 `{"ingestGate": false}`(**零新 + // env 键**:`.env` 已冻结,design/13 §2.1)。 + DisableIngestGate bool + // GateBudget 是同步入库闸的**总墙钟预算**(任务书规格 1:写入路径不许变慢)。 + // 非正用代码默认值 defaultReviewGateBudget。超预算一律 fail-open(照常插入 + + // 留痕),绝不静默丢。 + GateBudget time.Duration + // GateScanLimit 是同步入库闸的候选扫描上限。它比异步队列更紧(后者 + // ScanLimit 默认 200),因为它在**写入路径**上:非正用代码默认值 + // defaultReviewGateScanLimit,且最终还会被 cfg.ScanLimit 收紧取小。 + GateScanLimit int + // DisableT1 关闭 **T1 灰区模型判定**(design/23 §15.1 D3-2:灰区调一次轻量 + // 模型,只许回 merge/modify/keep + 理由、禁产新内容)。 + // + // 它是**反向**字段,理由与 DisableIngestGate 逐字相同:bool 的零值必须落在 + // 「默认行为」上,而 T1 的代码默认值是**开**(主人 2026-10-07:要的就是「真的 + // 走记忆管理会话」,T0 仍是第一道零成本闸)。若写成正向的 EnableT1,任何没 + // 显式配过的地方(含测试与组合根构造的一半配置)都会读成「关掉」,与默认相反。 + // 要一键回到纯规则(零模型调用),写 `$DATA_DIR/config/memory-tidy.json` 的 + // `{"t1Judge": false}`(**零新 env 键**:`.env` 已冻结,design/13 §2.1)。 + DisableT1 bool + // T1Timeout 是**单次** T1 模型调用的墙钟上限。非正用代码默认值 + // defaultT1Timeout;最终还会被 cfg.Timeout 收紧取小(见 t1Timeout)。 + T1Timeout time.Duration +} + +// normalizeReviewConfig 用设计稿默认值填充零值字段。 +func normalizeReviewConfig(cfg ReviewConfig) ReviewConfig { + if cfg.QueueSize <= 0 { + cfg.QueueSize = defaultReviewQueueSize + } + if cfg.ScanLimit <= 0 { + cfg.ScanLimit = defaultReviewScanLimit + } + if cfg.Timeout <= 0 { + cfg.Timeout = defaultReviewTimeout + } + if cfg.Threshold <= 0 { + cfg.Threshold = defaultTidyRecallThreshold + } + if cfg.GateBudget <= 0 { + cfg.GateBudget = defaultReviewGateBudget + } + if cfg.GateScanLimit <= 0 { + cfg.GateScanLimit = defaultReviewGateScanLimit + } + if cfg.T1Timeout <= 0 { + cfg.T1Timeout = defaultT1Timeout + } + return cfg +} diff --git a/internal/runtime/memory/preinsert_judge.go b/internal/runtime/memory/preinsert_judge.go index 01a5c57..37c6c4b 100644 --- a/internal/runtime/memory/preinsert_judge.go +++ b/internal/runtime/memory/preinsert_judge.go @@ -11,12 +11,17 @@ package memory // 新条目是既有条目的子集 ⇒ merge(并入既有,既有行存活); // 既有条目是新条目的子集 ⇒ modify(**新行承载更全的内容**,旧行置为被合并, // §15 D3-6「改写必须是新行 + 旧行置为被合并」); -// 3. 其余(互不包含 / 被规则否决 / 没有召回)⇒ create(normal 新建)。 +// 3. 其余(互不包含 / 被规则否决 / 没有召回)⇒ create(normal 新建)—— +// 其中「互不包含**且**未命中任何规则否决」的那些候选另行登记进 +// `ingestVerdict.Gray`,交**异步审查层**的 T1 再问一次 +// (preinsert_t1.go,design/23 §15.1 D3-2)。**本文件恒为零模型调用**。 // // 两条不可走样: // - **相似度只用来召回候选**,永不参与判定(design/23 §1.2「单阈值双向失效」); // - **规则否决一票否决**,且**绝不跨作用域/跨角色/跨项目**(§5.2-7/8/9 是硬门, // 不是权衡)—— 这里对每条候选再判一次,是纵深防御(分区过滤已在 SQL 里)。 +// ⇒ 被否决的候选**不进灰区**:T1 只能在**已通过硬门、且未被任何规则否决**的 +// 候选里选(§5.2 的「绝不合并清单」是主人红线,T1 同样不得越过)。 // ingestCandidate 是一条被召回的既有记忆及其召回分数。 type ingestCandidate struct { @@ -40,6 +45,14 @@ type ingestVerdict struct { Reason string // Veto 是命中的否决规则(未命中时为空)。它只影响依据,绝不改判决定。 Veto TidyVetoReason + // Gray 是**灰区候选**:相似度召回到了、互不包含、且**未命中任何规则否决** + // (含硬门)的那些既有条目,按召回分数降序(与 cands 同序)。 + // + // 它是「T0 判不出结论」的结构化输出,只由**异步审查层**消费(T1, + // design/23 §15.1 D3-2);同步入库闸 `planFromVerdict` 刻意不看它 —— + // 于是「写入路径零模型调用」不是一句承诺,而是这条数据流的结构性结果。 + // 被规则否决的候选**永不出现**在这里(§5.2 的红线对 T1 一样有效)。 + Gray []ingestCandidate } // judgeIngest 对一条新落库的记忆做四态判定。cands 已按召回分数降序 @@ -56,6 +69,9 @@ func judgeIngest(item MemoryItem, cands []ingestCandidate) ingestVerdict { return v } vetoed, vetoScore := VetoNone, 0.0 + // 灰区惰性收集:只有真的出现「互不包含且未被否决」的候选才分配, + // 同步写入路径上的常见情形(无候选 / 一眼真新)因此零额外分配。 + var gray []ingestCandidate for _, c := range cands { if veto, hit := tidyHardGate(item, c.item); hit { vetoed, vetoScore = keepStrongerVeto(vetoed, vetoScore, veto, c.score) @@ -67,9 +83,13 @@ func judgeIngest(item MemoryItem, cands []ingestCandidate) ingestVerdict { } if v.Veto != VetoNone { vetoed, vetoScore = keepStrongerVeto(vetoed, vetoScore, v.Veto, c.score) + continue } + gray = append(gray, c) + } + return ingestVerdict{ + Decision: IngestCreate, Reason: createReason(vetoed), Veto: vetoed, Mode: mode, Gray: gray, } - return ingestVerdict{Decision: IngestCreate, Reason: createReason(vetoed), Veto: vetoed, Mode: mode} } // judgeExactDuplicate 施加 §15 D3-2 T0-1:归一化后完全等 ⇒ skip。 diff --git a/internal/runtime/memory/preinsert_stats.go b/internal/runtime/memory/preinsert_stats.go index 899787a..3d57f4c 100644 --- a/internal/runtime/memory/preinsert_stats.go +++ b/internal/runtime/memory/preinsert_stats.go @@ -26,6 +26,13 @@ type ReviewStats struct { GateAbsorbed int // GateFailed 是闸因预算/候选源不可用而 fail-open 的次数(照常插入 + 留痕)。 GateFailed int + // T1Called 是 T1 实际发出过**模型调用**的次数。每次审查至多 +1 + // (defaultT1MaxGray 只是把多条灰区候选打包进同一次 prompt,不多发请求)。 + T1Called int + // T1Decided 是 T1 真的定下结论(merge/modify/keep)的次数。它与 T1Called + // 的差值就是「调了但没用上」(非法回复 / 超时 / 报错回落 create)—— + // 这个差值本身是可观测信号,因此两个数分开计。 + T1Decided int } // bump 在互斥下修改计数(工作协程、手动 Review 与同步闸都可能调用)。 @@ -68,7 +75,7 @@ func (r *PreInsertReviewer) auditWith(verb string, rev IngestReview) { // reviewStatsLabel 把计数渲染成一条稳定日志串。 func reviewStatsLabel(s ReviewStats) string { return fmt.Sprintf("reviewed=%d skip=%d merge=%d modify=%d create=%d dropped=%d failed=%d "+ - "gate_checked=%d gate_absorbed=%d gate_failed=%d", + "gate_checked=%d gate_absorbed=%d gate_failed=%d t1_called=%d t1_decided=%d", s.Reviewed, s.Skipped, s.Merged, s.Modified, s.Created, s.Dropped, s.Failed, - s.GateChecked, s.GateAbsorbed, s.GateFailed) + s.GateChecked, s.GateAbsorbed, s.GateFailed, s.T1Called, s.T1Decided) } diff --git a/internal/runtime/memory/preinsert_t1.go b/internal/runtime/memory/preinsert_t1.go new file mode 100644 index 0000000..1d29be0 --- /dev/null +++ b/internal/runtime/memory/preinsert_t1.go @@ -0,0 +1,299 @@ +package memory + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" +) + +// 本文件是 D3「入库前审查」的 **T1(灰区模型判定)**(design/23 §15.1 D3-2)。 +// +// 它补上 D3-2 一直缺的那一半:规则层(T0,preinsert_judge.go)判不出结论时 —— +// 候选**召回到了、互不包含、且未命中任何规则否决** —— 调**一次**轻量模型,把 +// 「一律新建」变成「有依据地并入 / 改写 / 保持新建」。真机实证的那一类 +// (「摄取通道验收暗号为『喵喵七号』」vs「验收暗号为『喵喵七号』,用于验证摄取 +// 通道」:同一事实换措辞、互不包含)正是它要收掉的。 +// +// 五条不可走样(逐条对应本目录既有的不变量,改坏任何一条都是架构偏离): +// +// 1. **只许回三态 + 理由**:`merge` / `modify` / `keep`。坏 JSON、非法枚举、 +// 编号越界、任何别的词 —— 一律回落 `create`(保守,**绝不猜**)。 +// 2. **禁产新内容**:模型只提供「选哪一条 + 什么动作 + 一句理由」,它的正文 / +// 摘要 / 改写文本**一个字都不采纳**。`merge` 落地时存活方是既有行(正文 +// 一字不改);`modify` 落地时存活方是**新行自己的原文**、既有行置为被合并 +// (§15 D3-6)—— 因此 T1 结构性地不可能把幻觉写进记忆库。 +// 3. **不越硬门、不越红线**:T1 只能在 `judgeIngest` 交出来的**灰区**候选里选, +// 而灰区已经滤掉了跨作用域/跨角色/跨项目/只读来源(硬门)与时效/数值/冲突/ +// 低置信(§5.2 的绝不合并清单)。`preinsert_judge.go` 文件头那条 +// 「**规则否决一票否决**」是**不可走样**项,T1 不提供任何翻案入口。 +// 4. **有界 + 不阻塞写入**:每次审查**最多一次**模型调用(灰区候选打包进同一个 +// prompt,最多 `defaultT1MaxGray` 条),且有自己的子超时(≤ 单条审查总预算)。 +// 超时 / 报错 / 非法回复 ⇒ `create` + WARN + **零改写**,绝不阻塞、绝不丢记忆。 +// T1 只挂在**异步审查层**(`PreInsertReviewer.Review`);同步入库闸 +// (preinsert_gate.go)不碰它 ⇒ 写入路径仍是零模型调用。 +// 5. **不新造 LLM 客户端**:注入的是**既有** `model.Adapter` 的调用口(组合根 +// 装配的就是模型栈本身,含按角色路由与主→备→本地降级),本文件不认识任何 +// 厂商协议,也不新增依赖。 +// +// 可观测(§15 D3-8):T1 是否参与、结论是什么,在留痕 `reason` 列上如实可辨 +// (`t1_merge` / `t1_modify` / `t1_keep` / `t1_reply_invalid` / `t1_unavailable`), +// 规则版本随之升到 `IngestRuleVersion`。模型的自由文本理由**既不落库也不落日志**: +// 留痕表按设计不存正文,而模型的理由可能改述正文 ⇒ 一律丢弃(它在 prompt 里的 +// 唯一作用,是逼模型先表态,从而把动作判得更稳)。 + +const ( + // defaultT1MaxGray 是打包进**一次** prompt 的灰区候选上限。灰区候选按召回 + // 分数降序,超出即截断(更相关的排在前面),因此截断不改变「谁最像」。 + defaultT1MaxGray = 8 + + // defaultT1Timeout 是单次 T1 模型调用的墙钟上限。它比单条审查的总预算 + // (defaultReviewTimeout = 20s)**小**:T1 是审查里的一步,不是审查本身, + // 它的超时只该让这一步失败并回落 create,不该吃掉整条审查的预算。 + defaultT1Timeout = 8 * time.Second + + // t1Purpose 是该次模型调用的计账标签(model.Request.Purpose), + // 让用量面板能把「灰区判定」与「对话」分开算账。 + t1Purpose = "memory_t1_judge" + + // T1 允许的动作枚举(模型只许回这三个,别的一律回落 create)。 + t1ActionMerge = "merge" + t1ActionModify = "modify" + t1ActionKeep = "keep" + + // T1 的留痕原因码(进 `reason` 列,结构化、可机器判定、与既有码同族)。 + t1ReasonMerge = "t1_merge" + t1ReasonModify = "t1_modify" + t1ReasonKeep = "t1_keep" + t1ReasonReplyInvalid = "t1_reply_invalid" + t1ReasonUnavailable = "t1_unavailable" +) + +// ErrT1ReplyInvalid 是模型回复解不出三态枚举(或候选编号越界)时的分类错误。 +// 它不是「模型挂了」,而是「这一次的回复不能用」—— 两者的回落结果相同 +// (create),但留痕原因码不同,运维才分得清该修 prompt 还是该查网络。 +var ErrT1ReplyInvalid = errors.New("memory/review: t1 reply is not a valid action") + +// T1Judge 是 T1 的一次性模型调用口。它是 `model.Adapter` 的**单方法视图** +// (只取 Chat):T1 只要一次非流式补全,不流式、不重试、不并发 —— 窄接口让 +// 这三条成为结构性事实,也让测试注入桩不必实现用不到的方法。 +// +// 它是一个函数类型而不是接口:与 ReviewDeps 既有的 Now / Audit / GateCandidates +// 同一形状,且组合根因此可以用**闭包**把「模型栈晚于本服务装配」这件事就地解决 +// (见 cmd/cyrene/wire_memory_review.go 的 memoryT1Judge)。 +type T1Judge func(ctx context.Context, req model.Request, opts model.Options) (model.Response, error) + +// t1Reply 是 T1 要求的线上形状:动作 + 候选编号(1 起;keep 写 0)+ 一句理由。 +// 编号而不是 id 是刻意的 —— 模型不该、也无法**编造**一个记忆 id。 +type t1Reply struct { + Action string `json:"action"` + Target int `json:"target"` + Reason string `json:"reason"` +} + +// t1Enabled 报告 T1 是否可用并已开启:总开关开着、T1 没被显式关掉、 +// 且模型出口**已接**。少任何一条都恒为零模型调用 —— 这就是 +// 「T1 关闭 ⇒ 零模型调用」从承诺变成结构的那条线。 +func (r *PreInsertReviewer) t1Enabled() bool { + return r != nil && r.cfg.Enabled && !r.cfg.DisableT1 && r.deps.Judge != nil +} + +// t1Timeout 返回单次 T1 调用的墙钟上限:非正取代码默认,且**不超过**单条审查 +// 的总预算(T1 超时只该让这一步回落,不该把审查本身拖长)。 +func (r *PreInsertReviewer) t1Timeout() time.Duration { + d := r.cfg.T1Timeout + if d <= 0 { + d = defaultT1Timeout + } + if r.cfg.Timeout > 0 && d > r.cfg.Timeout { + return r.cfg.Timeout + } + return d +} + +// maybeJudgeT1 在「T0 判不出结论 + 有灰区候选」时,用**一次**模型调用改判。 +// +// 任何非「模型给出合法三态」的情形都**原样返回 v**:未启用、没有模型出口、 +// 没有灰区候选、调用超时/报错、回复非法。因此「失败 ⇒ create + 零改写」是 +// 结构性成立的 —— 这条路径上根本没有改写的代码。 +func (r *PreInsertReviewer) maybeJudgeT1(ctx context.Context, item MemoryItem, v ingestVerdict) ingestVerdict { + if !r.t1Enabled() || len(v.Gray) == 0 { + return v + } + gray := v.Gray + if len(gray) > defaultT1MaxGray { + gray = gray[:defaultT1MaxGray] + } + reply, err := r.callT1(ctx, item, gray) + if err != nil { + // 区分「模型没答上」(超时/网络/上游错)与「答了但回复不能用」: + // 两者都回落 create,但留痕原因码必须分开,运维才分得清该查网络 + // 还是该修 prompt。 + if errors.Is(err, ErrT1ReplyInvalid) { + return r.t1Fallback(ctx, item, v, t1ReasonReplyInvalid, err) + } + return r.t1Fallback(ctx, item, v, t1ReasonUnavailable, err) + } + verdict, ok := t1Verdict(reply, item, gray, v) + if !ok { + return r.t1Fallback(ctx, item, v, t1ReasonReplyInvalid, ErrT1ReplyInvalid) + } + r.bump(func(s *ReviewStats) { s.T1Decided++ }) + return verdict +} + +// callT1 发起**唯一**的那次 T1 调用(每次审查至多一次)。它自带子超时,并把 +// 同一个预算也交给适配器(model.Options.Timeout)—— 进程内与 HTTP 侧各把一道, +// 一次灰区判定绝不可能拖长审查。模型正文(prompt 与响应)**不进日志**。 +func (r *PreInsertReviewer) callT1(ctx context.Context, item MemoryItem, gray []ingestCandidate) (t1Reply, error) { + budget := r.t1Timeout() + cctx, cancel := context.WithTimeout(ctx, budget) + defer cancel() + r.bump(func(s *ReviewStats) { s.T1Called++ }) + resp, err := r.deps.Judge(cctx, model.Request{ + Messages: buildT1Prompt(item, gray), + Purpose: t1Purpose, + }, model.Options{Timeout: budget}) + if err != nil { + return t1Reply{}, fmt.Errorf("memory/review: t1 judge call: %w", err) + } + return parseT1Reply(resp.Content) +} + +// t1Fallback 组装回落结论:决定**仍然是 create**(与 T0 逐字同结论),只把 +// `reason` 换成如实的原因码,并记一条 WARN。 +// +// 为什么失败也要改 reason:留痕要能回答「这一次 T1 到底有没有参与」 +// (§15 D3-8)。把失败悄悄写成 T0 的原因码,等于让 T1 的缺席与失败不可分辨。 +// 日志只带 id / 作用域 / 原因 / 错误,**绝不带记忆正文**。 +func (r *PreInsertReviewer) t1Fallback(ctx context.Context, item MemoryItem, v ingestVerdict, reason string, cause error) ingestVerdict { + v.Reason = reason + r.log.Warn("memory ingest review t1 judge fell back to create", + "item_id", item.ID, "scope", string(item.Scope), "reason", reason, + "canceled", ctx.Err() != nil, "error", cause) + return v +} + +// t1Verdict 把模型回复映射成判定结论。ok=false 表示回复不可用(非法枚举 / +// 编号越界),调用方据此回落 create —— **绝不猜、绝不挑一个最近的候选**。 +func t1Verdict(reply t1Reply, item MemoryItem, gray []ingestCandidate, base ingestVerdict) (ingestVerdict, bool) { + action := strings.ToLower(strings.TrimSpace(reply.Action)) + if action == t1ActionKeep { + // 保持新建:结论与 T0 完全一致(create),只留下「T1 看过并判 keep」 + // 这条证据 —— 它与「压根没调模型」是两件事。 + base.Reason = t1ReasonKeep + base.Gray = nil + return base, true + } + if action != t1ActionMerge && action != t1ActionModify { + return ingestVerdict{}, false + } + if reply.Target < 1 || reply.Target > len(gray) { + return ingestVerdict{}, false + } + c := gray[reply.Target-1] + if action == t1ActionMerge { + // 并入既有:存活方是既有行(正文一字不改),被吸收的是刚落库的新行。 + return ingestVerdict{ + Decision: IngestMerge, Existing: c.item, Absorbed: item, + Score: c.score, Mode: c.mode, Reason: t1ReasonMerge, + }, true + } + // 改写既有(§15 D3-6):**新行承载更全的内容**、既有行置为被合并。 + // 两行的正文都取自各自的既有原文 —— 模型给不出任何正文。 + return ingestVerdict{ + Decision: IngestModify, Existing: c.item, Absorbed: c.item, + Score: c.score, Mode: c.mode, Reason: t1ReasonModify, + }, true +} + +// buildT1Prompt 渲染这一次判定的两段消息:固定指令 + 「新记忆 vs 编号候选」。 +// 候选用**编号**(1 起)而不是 id:模型编不出、也不需要记忆 id。 +func buildT1Prompt(item MemoryItem, gray []ingestCandidate) []model.Message { + var b strings.Builder + b.WriteString("新记忆:") + b.WriteString(item.Content) + b.WriteString("\n\n既有候选:\n") + for i, c := range gray { + fmt.Fprintf(&b, "%d. %s\n", i+1, c.item.Content) + } + return []model.Message{ + model.TextMessage(model.RoleSystem, t1SystemPrompt), + model.TextMessage(model.RoleUser, b.String()), + } +} + +// t1SystemPrompt 是 T1 的固定指令。它把三件事写死: +// - 只许回三态 + 一句理由(别的词一律不许); +// - **禁产新内容**(不许输出记忆正文/摘要/改写文本); +// - 判据与**负例**(design/23 §15 D3-2「负例进 prompt」,Mem0 口径): +// 「相似但并列」必须 keep,拿不准必须 keep。 +// +// 负例刻意取自 design/23 §1.3 与 §5.2 的点名反例(红牛 vs 冰美式是「同主题但 +// 不同事实」,X vs 戒了X 是「新事实覆盖旧事实」),它们是 T1 最容易被相似度 +// 带偏的两处。 +const t1SystemPrompt = `你是记忆库的入库审查员。给你一条刚入库的新记忆,以及同一分区里若干条既有记忆候选。 +判断新记忆与既有候选的关系,只回一个 JSON 对象,不要任何其他文字、不要代码块。 + +动作只许取这三个值之一: +- "merge":新记忆与某条既有记忆说的是**同一件事、只是换了措辞**,且新记忆没有那条既有记忆之外的任何新信息 ⇒ 新记忆并入那条既有记忆(既有记忆的原文保持不变)。 +- "modify":某条既有记忆是**新记忆的不完整版本**(新记忆覆盖它、并含它没有的细节)⇒ 那条既有记忆退役,由新记忆承载。 +- "keep":其余一切情形(不同事实、不同偏好、不同对象、只是同一主题)⇒ 新记忆独立保留。 + +必须遵守的判据: +- 只有「同一件事换措辞」才可以 merge;「相似但并列」的两件不同事实必须 keep。反例:"主人喜欢喝红牛来提神" 与 "主人喜欢喝冰美式来提神" 是两种不同的偏好,必须 keep,即便它们很相似。 +- 一方带否定、另一方没有("X" 与 "已经戒了 X"),那是新事实覆盖旧事实、不是重复,必须 keep。 +- 描述某一次具体事件、或带具体日期/时间的记忆,不要与长期习惯互相合并,必须 keep。 +- 只是「都在说同一个领域」不算同一件事。 +- **拿不准就 keep**。宁可多留一条,也绝不把两件不同的事并成一条。 +- **绝对不要输出任何记忆正文、摘要或改写文本**,只回动作、候选编号和一句理由。 + +输出格式(严格 JSON,无代码块):{"action":"merge 或 modify 或 keep","target":候选编号,"reason":"一句话理由"} +keep 时 target 写 0。` + +// parseT1Reply 解析模型的 JSON 回复。被围栏包裹的 ```json 块会被剥离,前后夹带 +// 的解说文字也被忽略(只取第一个 `{` 到最后一个 `}`)—— 容忍格式噪声,但**不猜**: +// 仍取不到 JSON 对象就返回 ErrT1ReplyInvalid,由调用方回落 create。 +// +// 错误里**不带模型原文**:回复可能改述记忆正文,而错误会被记进日志。 +func parseT1Reply(s string) (t1Reply, error) { + body, ok := extractT1JSON(s) + if !ok { + return t1Reply{}, fmt.Errorf("%w: no json object in reply", ErrT1ReplyInvalid) + } + var reply t1Reply + if err := json.Unmarshal([]byte(body), &reply); err != nil { + return t1Reply{}, fmt.Errorf("%w: decode: %v", ErrT1ReplyInvalid, err) + } + return reply, nil +} + +// extractT1JSON 从模型回复里取出 JSON 对象文本:先剥 ``` 围栏,再取第一个 `{` +// 到最后一个 `}`。取不到成对花括号即视为格式非法。 +// +// 开头的 `[` 也是非法:本协议的线上形状是**对象**,而"取第一个 `{` 到最后一个 +// `}`"会把 `[{"action":"merge"}]` 这类数组回复里嵌的对象抠出来当成合法作答 —— +// 那正是「解析不出枚举就必须回落」要挡住的情形,因此显式拒掉。 +func extractT1JSON(s string) (string, bool) { + s = strings.TrimSpace(s) + if strings.HasPrefix(s, "```") { + s = strings.TrimPrefix(s, "```") + s = strings.TrimSuffix(s, "```") + s = strings.TrimSpace(s) + s = strings.TrimPrefix(s, "json") + s = strings.TrimSpace(s) + } + if strings.HasPrefix(s, "[") { + return "", false + } + start := strings.Index(s, "{") + end := strings.LastIndex(s, "}") + if start < 0 || end <= start { + return "", false + } + return s[start : end+1], true +} diff --git a/internal/runtime/memory/preinsert_t1_bound_test.go b/internal/runtime/memory/preinsert_t1_bound_test.go new file mode 100644 index 0000000..d5e0b22 --- /dev/null +++ b/internal/runtime/memory/preinsert_t1_bound_test.go @@ -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 +} diff --git a/internal/runtime/memory/preinsert_t1_test.go b/internal/runtime/memory/preinsert_t1_test.go new file mode 100644 index 0000000..4ad9021 --- /dev/null +++ b/internal/runtime/memory/preinsert_t1_test.go @@ -0,0 +1,421 @@ +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) + } + } + }) + } +}