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

+65 -5
View File
@@ -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) {
+100
View File
@@ -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:<uuid>")上,因此不改她的心情、不进主会话泳道。
func TestMemoryReviewSessionIsNotConversationLane(t *testing.T) {
+22 -2
View File
@@ -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)
+33 -78
View File
@@ -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)
@@ -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()
+102
View File
@@ -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
}
+22 -2
View File
@@ -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。
+9 -2
View File
@@ -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)
}
+299
View File
@@ -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
}
@@ -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
}
@@ -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)
}
}
})
}
}