diff --git a/cmd/cyrene/wire_memory_review.go b/cmd/cyrene/wire_memory_review.go index 84f19c8..eff1b7b 100644 --- a/cmd/cyrene/wire_memory_review.go +++ b/cmd/cyrene/wire_memory_review.go @@ -41,7 +41,10 @@ var errT1ModelNotReady = errors.New("memory review: t1 judge: model adapter not // - `{"ingestGate": false}` ⇒ 一键回到「只插不审」(同步入库闸); // - `{"t1Judge": false}` ⇒ 一键回到纯规则(T1 灰区判定,**零模型调用**); // - `{"t1PerWindow": 120, "t1WindowSec": 3600}` ⇒ T1 的**全局限速**额度 -// (design/23 §15.4);缺位 = 代码默认 120 次/60 分钟。 +// (design/23 §15.4);缺位 = 代码默认 120 次/60 分钟; +// - `{"tempTtlSec": 604800}` ⇒ 第五态 `defer` 的**临时记忆存活秒数** +// (design/03 §9.6);缺位/坏值 = 代码默认 7 天。**它没有 `.env` 档** +// (`.env` 已冻结,本键在那里从来没有出处)⇒ 实际是「面板 > 代码默认」。 func (a *app) wireMemoryReview() error { cfg, err := config.LoadMemoryReviewConfig(envPath) if err != nil { @@ -61,6 +64,10 @@ func (a *app) wireMemoryReview() error { // 交零值给 normalizeReviewConfig 回落**代码默认值**(120 次/60 分钟)—— // 坏配置绝不能让一处限速静默变成「无上限」。 t1PerWindow, t1WindowSec := a.memoryT1RateLimit() + // 临时记忆 TTL(design/03 §9.6):面板 `tempTtlSec` 优先,缺位/坏值回落代码 + // 默认 **7 天**(经 normalizeReviewConfig 落地)。**中间那一档(`.env`)在本键 + // 上结构性为空** —— `.env` 已冻结(design/13 §2.1),这个键在那里从来没有出处。 + tempTTL := a.memoryTempTTL() reviewer, err := memory.NewPreInsertReviewer(memory.ReviewConfig{ Enabled: cfg.Enabled, QueueSize: cfg.QueueSize, @@ -82,6 +89,9 @@ func (a *app) wireMemoryReview() error { // (120 次/60 分钟)。**没有「无上限」这个状态**:要零花费请关 t1Judge。 T1PerWindow: t1PerWindow, T1Window: time.Duration(t1WindowSec) * time.Second, + // 第五态 `defer` 的落点(design/23 §18.1):`expires_at` = 判定时刻 + 它。 + // 零值 = 没配 ⇒ 由 normalizeReviewConfig 回落代码默认 7 天(tempTtlSec)。 + TempTTL: tempTTL, }, memory.ReviewDeps{ Store: a.memStore, Embedder: a.embedder, @@ -151,6 +161,33 @@ func (a *app) memoryT1RateLimit() (perWindow int, windowSec int) { return tuning.T1PerWindow, tuning.T1WindowSec } +// memoryTempTTL 返回**临时记忆 TTL**(design/03 §9.6 的 `tempTtlSec`):面板 +// `$DATA_DIR/config/memory-tidy.json` 的值优先,缺位 / 坏值一律回落**代码默认 +// 7 天**(返回零值即「不覆盖」,由 NewPreInsertReviewer 的 normalizeReviewConfig +// 落地 —— 与 memoryT1RateLimit 返回零值的口径逐字相同)。 +// +// 解析与坏值 WARN 都交给 `memory.ApplyReviewTuning`(与 tidy_tuning.go 对 +// TidyConfig 是同一手法):runtimeconfig 只管**文件形状**,而「缺位 / 坏值怎么 +// 回落」属于 ReviewConfig 的归一化语义 —— 那里是**唯一**实现点,组合根不再抄一遍 +// (抄一遍就意味着两条路径各自决定「什么是坏值」)。 +// +// 读不到 / 解不开那个文件时返回零值 + WARN:坏配置绝不能让 TTL 静默变成 +// 「不过期」,也不能让每条临时记忆**落库即过期**(一个 ≤0 的 TTL 正是那个形态)。 +// +// 🔴 中间那一档(`.env`)在本键上**结构性为空**:`.env` 已冻结 +// (design/13 §2.1「不加、不改、不删」),而这个键在那里**从来没有出处**。 +// 因此本键的实际生效链是「**面板 > 代码默认**」——不是漏配,设计原文 +// (design/03 §9.6)写的就是这一档。 +func (a *app) memoryTempTTL() time.Duration { + tuning, path, err := a.readMemoryTidyTuning() + log := logger.WithSource(a.log, "memory/review") + if err != nil { + log.Warn("memory temp ttl tuning unreadable, falling back to code default (7 days)", + "path", path, "error", err) + } + return memory.ApplyReviewTuning(memory.ReviewConfig{}, tuning, log).TempTTL +} + // memoryT1Judge 把「调用时才解析的模型适配器」包成 T1 的一次性调用口。 // // 为什么是闭包而不是直接塞 `a.modelAdapter`:组合根的顺序是 **wireMemory(建 diff --git a/internal/config/runtimeconfig/memorytidy.go b/internal/config/runtimeconfig/memorytidy.go index ed667c0..3708531 100644 --- a/internal/config/runtimeconfig/memorytidy.go +++ b/internal/config/runtimeconfig/memorytidy.go @@ -157,6 +157,20 @@ type MemoryTidyTuning struct { // 两端都没配 ⇒ 代码默认 23:00-07:00。 QuietStartHour *int `json:"quietStartHour"` QuietEndHour *int `json:"quietEndHour"` + + // TempTtlSec 是**临时记忆**的存活秒数(第五态 `defer` 写进 `expires_at` 的 + // TTL,design/23 §18.1 / design/03 §9.6)。nil = 没配 ⇒ 代码默认 + // **604800 秒(7 天)**;写非正数 ⇒ 坏值,回落代码默认 + WARN + // (一个 ≤0 的 TTL 会让每条临时记忆**落库即过期**,与「不过期」恰好相反)。 + // + // 🔴 **它在 `.env` 里没有出处,也刻意不新增**(`.env` 已冻结,design/13 + // §2.1):所以这一键的实际生效链是「**面板 > 代码默认**」——「面板 > `.env` > + // 代码默认」里的中间那一档在 TTL 上是**结构性为空**,不是漏配。 + // + // **指针 + `omitempty`** 的理由与 AutoApply 同源、但更强一层:nil = 没配, + // 而没配时该键从**播种文件**里**整键省略**(播种文档明禁写 `null` —— 一份 + // 带 null 的配置对主人是噪音)。只有面板/人工显式写了才出现这个键。 + TempTtlSec *int `json:"tempTtlSec,omitempty"` } // AutoApplyOff 报告自动档是否被这份配置**显式关掉**。nil(没配)返回 false —— diff --git a/internal/config/runtimeconfig/memorytidy_seed.go b/internal/config/runtimeconfig/memorytidy_seed.go index d770a88..22b3f0b 100644 --- a/internal/config/runtimeconfig/memorytidy_seed.go +++ b/internal/config/runtimeconfig/memorytidy_seed.go @@ -59,7 +59,7 @@ const ( var memoryTidySeedKeys = []string{ "autoApply", "autoApplyPerRound", "ingestGate", "t1Judge", "t1PerWindow", "t1WindowSec", "intervalSec", "quietIntervalSec", - "quietStartHour", "quietEndHour", + "quietStartHour", "quietEndHour", "tempTtlSec", } // MemoryTidySeed 是一次播种的可诊断产物(口径对齐 materialize.go 的 Result: @@ -189,6 +189,13 @@ func memoryTidySeedComplete(seed MemoryTidyTuning) error { if seed.QuietEndHour == nil { missing = append(missing, "quietEndHour") } + // tempTtlSec 同样要求非 nil:它是 `*int` + `omitempty`,**nil 会让该键从播种 + // 文件里消失**,于是「播种文件与当时的生效值逐字一致」这句话就不再成立 —— + // 文件少一个键、读回来却是代码默认值,主人打开文件无从判断到底配没配。 + // 快照侧(memory.TidyTuningSnapshot)恒填它,因此这条不会拦住正常路径。 + if seed.TempTtlSec == nil { + missing = append(missing, "tempTtlSec") + } if len(missing) > 0 { return fmt.Errorf("runtimeconfig: memory tidy seed missing keys: %s", strings.Join(missing, ", ")) } @@ -213,5 +220,12 @@ func memoryTidySeedComplete(seed MemoryTidyTuning) error { if *seed.QuietEndHour < 0 || *seed.QuietEndHour > 23 { return fmt.Errorf("runtimeconfig: memory tidy seed quietEndHour = %d (want 0..23)", *seed.QuietEndHour) } + // tempTtlSec(临时记忆 TTL,design/03 §9.6)的**非 nil 要求**见上面 missing + // 那一段(理由:nil 会让键从文件里消失,而播种要「与生效值逐字一致」)。 + // 这里再加一道值域:≤0 的 TTL 会让每条临时记忆**落库即过期**(与「不过期」 + // 恰好相反),播出去等于播了一份自己读回都要 WARN 的配置。 + if *seed.TempTtlSec <= 0 { + return fmt.Errorf("runtimeconfig: memory tidy seed tempTtlSec = %d (want > 0)", *seed.TempTtlSec) + } return nil } diff --git a/internal/config/runtimeconfig/memorytidy_seed_test.go b/internal/config/runtimeconfig/memorytidy_seed_test.go index 5a87188..0e8242b 100644 --- a/internal/config/runtimeconfig/memorytidy_seed_test.go +++ b/internal/config/runtimeconfig/memorytidy_seed_test.go @@ -24,9 +24,14 @@ import ( // 用例全部落在**临时目录**(`t.TempDir()`):生产那份 `data/config/memory-tidy.json` // 是主人手工建的,播种逻辑只在文件缺失时触发,用例不许依赖它存在与否。 -// completeSeedTuning 造一份「十键齐全」的播种内容。值刻意都**不是**代码默认值 -// (autoApply=false / 3600 / 10800),这样「播的是调用方给的快照」与「播了一份硬编码 -// 的默认值」在断言里一眼可分。 +// completeSeedTuning 造一份「十一键齐全」的播种内容。值刻意都**不是**代码默认值 +// (autoApply=false / 3600 / 10800 / tempTtlSec=259200),这样「播的是调用方给的 +// 快照」与「播了一份硬编码的默认值」在断言里一眼可分。 +// +// 它必须给出**全部** `memoryTidySeedKeys`(含 tempTtlSec):该键带 `omitempty`, +// nil 时会被整键省略 —— 那正是「没配 ⇒ 用代码默认」的正常形态,但本用例要验的是 +// 「键齐全时逐一落盘」,因此这里显式给值(见 TestSeedMemoryTidy_SeedsMissingFile +// 的键集合断言)。 func completeSeedTuning() MemoryTidyTuning { return MemoryTidyTuning{ AutoApply: boolSeedPtr(false), @@ -39,6 +44,7 @@ func completeSeedTuning() MemoryTidyTuning { QuietIntervalSec: intSeedPtr(10800), QuietStartHour: intSeedPtr(23), QuietEndHour: intSeedPtr(7), + TempTtlSec: intSeedPtr(259200), } } diff --git a/internal/config/runtimeconfig/memorytidy_test.go b/internal/config/runtimeconfig/memorytidy_test.go index 2027492..9145162 100644 --- a/internal/config/runtimeconfig/memorytidy_test.go +++ b/internal/config/runtimeconfig/memorytidy_test.go @@ -1,9 +1,11 @@ package runtimeconfig import ( + "encoding/json" "errors" "os" "path/filepath" + "strings" "testing" ) @@ -278,3 +280,54 @@ func TestLoadMemoryTidyTuning_ScheduleKeys(t *testing.T) { t.Fatalf("intervalSec 写成字符串应当返回 ErrInvalidMemoryTidy,得到 %v", err) } } + +// TestLoadMemoryTidyTuning_TempTtlSec 锁定**临时记忆 TTL**这一键(design/03 §9.6: +// 默认 7 天 = 604800 秒,放 `$DATA_DIR/config/memory-tidy.json`,**零新 env 键**)。 +// +// 三条口径与既有指针键同源、但多一条 `omitempty`: +// +// ① 没写 ⇒ nil(「没配」⇒ 上层用代码默认,**不是**「配成 0」); +// ② 写正数 ⇒ 原值读出; +// ③ 写 0 ⇒ **仍是「有值」**(0 由 memory 侧判为坏值并回落 + WARN,本层不拦 —— +// 与 intervalSec 同一口径:读取层只管形状,好坏由归一化层判); +// ④ nil 时**序列化整键省略**(`omitempty`):播种文档明禁写 `null`,一份带 null +// 的配置对主人是噪音。 +func TestLoadMemoryTidyTuning_TempTtlSec(t *testing.T) { + t.Parallel() + + // ① 没写 ⇒ nil。 + bare, err := LoadMemoryTidyTuning(writeMemoryTidyFile(t, `{"autoApply": true}`)) + if err != nil { + t.Fatalf("LoadMemoryTidyTuning(bare): %v", err) + } + if bare.TempTtlSec != nil { + t.Fatalf("没写 tempTtlSec 应当是 nil(= 用代码默认),得到 %v", *bare.TempTtlSec) + } + + // ② 写正数 ⇒ 原值。 + got, err := LoadMemoryTidyTuning(writeMemoryTidyFile(t, `{"tempTtlSec": 259200}`)) + if err != nil { + t.Fatalf("LoadMemoryTidyTuning(tempTtlSec): %v", err) + } + if got.TempTtlSec == nil || *got.TempTtlSec != 259200 { + t.Fatalf("tempTtlSec = %v, want 259200", got.TempTtlSec) + } + + // ③ 写 0 ⇒ 仍是「有值」(好坏由 memory 侧的归一化判)。 + zero, err := LoadMemoryTidyTuning(writeMemoryTidyFile(t, `{"tempTtlSec": 0}`)) + if err != nil { + t.Fatalf("LoadMemoryTidyTuning(zero): %v", err) + } + if zero.TempTtlSec == nil || *zero.TempTtlSec != 0 { + t.Fatalf("显式写 0 的 tempTtlSec 应当被读出来(好坏由上层判),得到 %v", zero.TempTtlSec) + } + + // ④ nil ⇒ 序列化时整键省略(不会写出 `"tempTtlSec": null`)。 + raw, err := json.Marshal(MemoryTidyTuning{}) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + if strings.Contains(string(raw), "tempTtlSec") { + t.Fatalf("nil 的 tempTtlSec 不该被序列化出来(omitempty),得到 %s", raw) + } +} diff --git a/internal/runtime/memory/extract_retention.go b/internal/runtime/memory/extract_retention.go new file mode 100644 index 0000000..f9ffd8f --- /dev/null +++ b/internal/runtime/memory/extract_retention.go @@ -0,0 +1,294 @@ +package memory + +import ( + "strings" +) + +// 本文件是**提取留存规则闸**(design/03 §10,即 design/23 §15.1 D2 点名的 +// 「另立规格」)的 A 级实现:**纯规则 + 词项匹配**,对一条**刚落库的**新记忆判 +// 「它是不是结构上显然不值得长期留存」(design/03 §10.5 的反例清单 N1~N5)。 +// +// 它回应主人的两条原话:「**收窄提取器,确实要给他一个主动式的记忆留存规则, +// 不能想到什么都往记忆里写**」与「**临时记忆到期删除**」。 +// +// 🔴 落点硬约束(design/03 §10.3):**不许新造第二条写路径**。本闸**不新开入口**, +// 而是接进既有的唯一写路径 —— `judgeIngest`(preinsert_judge.go)在**候选比对 +// 之前**先过一遍这里,命中即返回与 design/23 §18.1 第五态**同一枚举 `defer`、 +// 同一落点 `expires_at`**。于是「提取得宽」由**同一条**入库路径收敛,不存在 +// 绕过闸的写口;命中者**不进 T1**(§10.6:规则闸拦下的不进 T1,避免「模型推翻 +// 规则」的架构回归)。 +// +// 判据为什么必须是纯函数而不是提示词(design/03 §10.2):现行全仓唯一的留存判据 +// 是一句自然语言(`llmextractor.go` 的 extractSystemPrompt),那**违反 AGENTS §9 +// 「判据靠元数据,不靠提示词」**,且**不可验收**(提示词产出是自由文本,测试只能 +// 断言「模型有没有听话」)。B 级提示词加固仍在做(正例 + 反例清单进 prompt, +// 见 llmextractor.go),但**验收防线在本文件**。 +// +// 🔴 **fail-safe 方向 = 宁可多留,不可误弃**(design/23 §18.3)。因此每一条规则都 +// 写得**保守**:宁可有噪声漏过闸(那条走 T1 / 长期层),也不把一条真记忆判成临时 +// 层(到期会被删)。落实为两道**护栏**: +// +// 1. **正向清单一票否决**:内容里出现任何「长期事实 / 稳定偏好 / 关系里程碑 / +// 项目约定」的信号(复用 tidyHabitMarkers 的「持久」词族 + 本文件的正向词), +// 直接放行 —— 「主人喜欢用喵打招呼」虽然短,但它有「喜欢」,不是寒暄; +// 2. **显式「记住」一票否决**:内容里出现「记住 / 记下 / 别忘了」时放行 —— +// 主人明确要求记的东西,闸不许替他改成临时记忆。 +// +// 一处**刻意不实现**(如实登记,不是遗漏):design/03 §10.5 的 **N6(助手自己的 +// 输出 / 发言人归属)**。它是**来源属性**(这句话是谁说的),不是内容属性;本闸 +// 只拿到落库条目(结构化溯源里没有「谁说的」这一位),无法机器判定。归属由 B 级 +// prompt 的负例承担(「不要记助手自己说过的话」),上游提取侧若要硬判,需另立 +// 结构化字段 —— 那超出本切片授权。**N7(不确定推断)**同理交给 T1(§10.5 明写 +// 「拿不准交第五态」),不进本闸。 + +// retentionRule 是一条提取留存反例规则的匹配结果(留痕/日志用;`retentionNone` +// 表示「不是显然不值得记的」)。 +type retentionRule string + +const ( + // retentionNone 表示没有任何反例规则命中 ⇒ 照常走既有判定(可能长期留存)。 + retentionNone retentionRule = "" + // retentionChatter 对 design/03 §10.5 的 **N1 寒暄 / 致谢**。 + retentionChatter retentionRule = "n1_chatter" + // retentionEmotion 对应 **N2 情绪波动**。 + retentionEmotion retentionRule = "n2_emotion" + // retentionOneOffAsk 对应 **N3 一次性琐事**(祈使 + 无持续实体)。 + retentionOneOffAsk retentionRule = "n3_one_off_ask" + // retentionTimelyData 对应 **N4 时效性数据**(当前时间 / 天气)。 + retentionTimelyData retentionRule = "n4_timely_data" + // retentionOneOffTask 对应 **N5 一次性任务指令**。 + retentionOneOffTask retentionRule = "n5_one_off_task" +) + +// retentionLowValueReason 是第五态在**留痕 `reason` 列**上的编码(而不是上面那些 +// 规则码)。它是刻意的:design/23 §18.4 把 `reason` 的闭集钉成 +// `{t0_low_value, t1_defer, …}` —— 面板与运维按它分「谁判的 defer」,而**具体命中 +// 哪条反例规则**属于更细的观测面,进日志、不进留痕列(留痕表的列宽与语义不改)。 +const retentionLowValueReason = "t0_low_value" + +// 规则的**保守阈值**:只对「短到装不下一个事实」的内容开火。 +const ( + // retentionMaxChatterRunes 是「只可能是寒暄」的长度上限(rune 计,不是字节: + // 中文 1 rune = 3 字节,按字节算会把阈值放大三倍)。 + retentionMaxChatterRunes = 16 + // retentionMaxEmotionRunes 是纯情绪陈述的长度上限。 + retentionMaxEmotionRunes = 24 + // retentionMaxImperativeRunes 是一次性指令的长度上限。它比前两者宽: + // 「帮我把 D 盘那个文档转成 pdf」这类真·一次性任务本来就长一些。 + retentionMaxImperativeRunes = 40 +) + +// judgeExtractionRetention 对一条新记忆的正文施加提取留存规则(design/03 §10.5 +// 的 N1~N5)。命中返回该规则码(调用方据此判 `defer`),否则返回 retentionNone。 +// +// 它**不读任何状态、不写任何东西、零模型调用**:入参只有正文,出参只有规则码, +// 因此在写路径上是微秒级且可完全单测(design/03 §10.3 A 级的「零成本」要求)。 +func judgeExtractionRetention(content string) retentionRule { + text := normalizeRetentionText(content) + if text == "" { + // 空 / 纯空白内容在 Add 的 normalize 就被拒了,走不到这里;真走到了也只 + // 放行(**fail-safe**:闸的职责是「拦住显然不值得记的」,不是「替上游 + // 判断该不该存在」—— 判成 defer 才是替它做决定)。 + return retentionNone + } + if len([]rune(text)) > retentionMaxImperativeRunes { + // 长内容一律放行:它已经装得下可检索的事实,闸只拦「显然不值得记」。 + return retentionNone + } + // 护栏①:显式「记住」是主人的直接指令,闸不许替他改判。 + if containsAny(text, retentionRememberDirectives()) { + return retentionNone + } + // 护栏②:任何「持久」信号(习惯 / 稳定偏好 / 约定 / 长期事实)一票放行。 + if containsAny(text, tidyHabitMarkers()) || containsAny(text, retentionDurableMarkers()) { + return retentionNone + } + // **情绪先于寒暄**是刻意的:「心情不错」既是情绪陈述、又含致谢词表里的 + // 「不错」——先判情绪才能给出更具体的规则码(两条规则都判 defer,差别只在 + // 「为什么」,而那正是留痕/日志要回答的)。 + if rule := judgeRetentionEmotion(text); rule != retentionNone { + return rule + } + if rule := judgeRetentionChatter(text); rule != retentionNone { + return rule + } + if rule := judgeRetentionTimelyData(text); rule != retentionNone { + return rule + } + return judgeRetentionOneOff(text) +} + +// judgeRetentionChatter 施加 **N1**:短且只由寒暄/致谢词构成的内容。 +// +// 「短」是这条规则的**安全阀**:寒暄的真实形态就是一句话里没有别的信息, +// 一旦它长到装得下事实,就不该由本闸判(交 T1 / 长期层)。 +func judgeRetentionChatter(text string) retentionRule { + if len([]rune(text)) > retentionMaxChatterRunes { + return retentionNone + } + if !containsAny(text, retentionChatterWords()) { + return retentionNone + } + // 带数字的短句一律放行:「7 点」「3 月 5 日」这类是事实的载体, + // 哪怕句子再短(design/23 §5.2-2 把数值当硬门,同一个取向)。 + if hasRetentionDigit(text) { + return retentionNone + } + return retentionChatter +} + +// judgeRetentionEmotion 施加 **N2**:短的情绪陈述(「今天好累」「心情不错」)。 +func judgeRetentionEmotion(text string) retentionRule { + if len([]rune(text)) > retentionMaxEmotionRunes { + return retentionNone + } + if !containsAny(text, retentionEmotionWords()) { + return retentionNone + } + if hasRetentionDigit(text) { + return retentionNone + } + return retentionEmotion +} + +// judgeRetentionTimelyData 施加 **N4**:时效性查询(当前时间 / 天气)。 +// +// 它的词项是**整句形态**而不是单词(「几点」「天气」),因为「天气」单独出现可能 +// 是主人对天气的**稳定偏好**(「主人怕冷,阴天心情差」)—— 那种句子带持久信号, +// 已被护栏②放行;这里只吃「现在几点」「明天天气怎么样」这类纯查询。 +func judgeRetentionTimelyData(text string) retentionRule { + if containsAny(text, retentionTimelyQueries()) { + return retentionTimelyData + } + return retentionNone +} + +// judgeRetentionOneOff 施加 **N3 / N5**:一次性的祈使请求(「帮我订个 7 点的闹钟」 +// 「把这个文件转成 pdf」)。 +// +// 判据是「**以祈使词开头或含一次性动作词**」而不是「含动词」:后者会把 +// 「主人喜欢把这个文件转成 pdf」(稳定偏好)也拦下 —— 那种句子带持久信号, +// 已被护栏②放行。 +func judgeRetentionOneOff(text string) retentionRule { + if hasRetentionPrefix(text, retentionAskPrefixes()) { + return retentionOneOffAsk + } + if containsAny(text, retentionOneOffActions()) { + return retentionOneOffTask + } + return retentionNone +} + +// normalizeRetentionText 规范化正文供词项匹配:转小写(对 ASCII 词如 `hi` 有用, +// 中文不受影响)、把连续空白折叠成**一个空格**、去掉结尾的标点。 +// +// 空白**折叠**而不是**删除**是刻意的:删光会让相邻两个英文单词粘成一个词, +// 于是 `as hi` 里冒出子串 `hi` 而被误判成寒暄 —— 折叠则保住词边界。 +// 词项表里因此同时列了 `转成 pdf` 与 `转成pdf` 两种写法。 +func normalizeRetentionText(content string) string { + text := strings.ToLower(strings.TrimSpace(content)) + text = strings.Join(strings.Fields(text), " ") + return strings.TrimRight(text, "。!?!?.,,、~~ ") +} + +// containsAny 报告 text 是否含 markers 里任一词项。 +func containsAny(text string, markers []string) bool { + for _, m := range markers { + if m != "" && strings.Contains(text, m) { + return true + } + } + return false +} + +// hasRetentionPrefix 报告 text 是否以 prefixes 里任一词开头。 +func hasRetentionPrefix(text string, prefixes []string) bool { + for _, p := range prefixes { + if p != "" && strings.HasPrefix(text, p) { + return true + } + } + return false +} + +// hasRetentionDigit 报告 text 是否含阿拉伯数字(数值是事实的载体,带数值即放行)。 +func hasRetentionDigit(text string) bool { + for _, r := range text { + if r >= '0' && r <= '9' { + return true + } + } + return false +} + +// 下列词表一律是**函数**而不是包级变量(AGENTS §10 禁包级全局变量; +// 同 tidySystemSessions / tidyHabitMarkers 的手法)。 + +// retentionRememberDirectives 是「主人明确要求记住」的信号(护栏①)。 +func retentionRememberDirectives() []string { + return []string{"记住", "记下", "别忘了", "不要忘", "别忘记"} +} + +// retentionDurableMarkers 是正向清单(design/03 §10.4)的词族里、**不被 +// tidyHabitMarkers 覆盖**的那些:长期事实 / 关系里程碑 / 项目约定 / 能力边界。 +func retentionDurableMarkers() []string { + return []string{ + // 长期事实 + "生日", "纪念日", "住", "职业", "工作", "家庭", "家人", "孩子", "父母", + "过敏", "忌口", "健康状况", "疾病", "吃药", "身高", "体重", "年龄", + // 关系里程碑 / 约定 + "约定", "承诺", "答应", "说好", "暗号", "口令", "称呼", + // 项目约定 + "项目", "迁移号", "端口", "路径", "命名", "编号", "约定俗成", + // 能力边界(主人明确的「不要做 X」) + "不要做", "别做", "禁止", "不许", + // 稳定偏好(补 tidyHabitMarkers 之外的) + "口味", "爱好", "讨厌", "不喜欢", "偏爱", "嗜好", + } +} + +// retentionChatterWords 是 N1 的寒暄 / 致谢词表。 +func retentionChatterWords() []string { + return []string{ + "谢谢", "多谢", "感谢", "辛苦", "麻烦你了", "早上好", "早安", "中午好", + "下午好", "晚上好", "晚安", "你好", "您好", "在吗", "收到", "好的", + "好呀", "好嘞", "嗯嗯", "哈哈", "嘿嘿", "呵呵", "么么", "拜拜", "再见", + "加油", "不错", "真棒", "太棒了", "hi", "hello", "hey", "thx", "thanks", + } +} + +// retentionEmotionWords 是 N2 的情绪词表。 +func retentionEmotionWords() []string { + return []string{ + "好累", "累了", "开心", "高兴", "难过", "不开心", "焦虑", "压力大", + "心情", "疲惫", "难受", "无聊", "郁闷", "沮丧", "生气", "委屈", + "烦死", "好烦", "烦躁", "兴奋", "紧张", "害怕", "孤单", "寂寞", + } +} + +// retentionTimelyQueries 是 N4 的时效性**查询整句**词表(不是单词,理由见 +// judgeRetentionTimelyData)。 +func retentionTimelyQueries() []string { + return []string{ + "现在几点", "几点了", "现在时间", "当前时间", + "今天天气", "明天天气", "天气怎么样", "天气如何", "天气怎样", + "温度多少", "多少度", "气温多少", + } +} + +// retentionAskPrefixes 是 N3 的祈使开头词表。 +func retentionAskPrefixes() []string { + return []string{ + "帮我", "请帮", "麻烦帮", "帮个忙", "提醒我", "叫我", "替我", + "给我订", "给我买", "帮我查", "帮我搜", "帮我订", "帮我买", + } +} + +// retentionOneOffActions 是 N5 的一次性动作词表(位置不限 —— 它们描述的是 +// 「转一次文件」这类有始有终的动作,而不是可以长期成立的偏好)。 +func retentionOneOffActions() []string { + return []string{ + "转成pdf", "转成 pdf", "转换成", "截图", "打开这个", "关掉这个", + "搜一下", "查一下", "翻译一下", "订个闹钟", "设个闹钟", + } +} diff --git a/internal/runtime/memory/extract_retention_test.go b/internal/runtime/memory/extract_retention_test.go new file mode 100644 index 0000000..2508d52 --- /dev/null +++ b/internal/runtime/memory/extract_retention_test.go @@ -0,0 +1,147 @@ +package memory + +import ( + "testing" +) + +// 本文件是**提取留存规则闸**(extract_retention.go,design/03 §10)的验收用例集: +// 正例(值得长期记 ⇒ 一条都不许被拦)+ 反例(显然不值得记 ⇒ 必须被拦成第五态) +// 各远超 3 条,并逐条钉住**命中哪一条反例规则**(N1~N5)。 +// +// 为什么正例与反例**一样重要**:本闸判错的方向不对称 —— 漏判只是「库里多一条 +// 噪声」(有 T1 与整理兜底),误判却会把一条真记忆丢进临时层(到期删除)。 +// design/23 §18.3 的 fail-safe 方向是「**宁可多留,不可误弃**」,所以「正例必须 +// 放行」是比「反例必须拦下」更硬的要求。 +// +// 「闸真在起作用」而不是「恰好如此」的证据是 TestJudgeIngestDefersLowValueItem: +// 它在**同一个判定链**上既断言低价值条目拿到 `defer`+`t0_low_value`,又断言它 +// **不进灰区**(于是 T1 一个 token 都花不出去);把闸摘掉时那条断言会立刻变红 +// (判成 create 且灰区非空)。 + +// retentionPositiveCases 是正向清单(design/03 §10.4)的脱敏样本与**两道护栏** +// 的样本:它们必须**全部**放行(retentionNone)。 +func retentionPositiveCases() []struct { + name string + content string +} { + return []struct { + name string + content string + }{ + {"长期事实", "主人住在杭州"}, + {"稳定偏好", "主人喜欢喝冰美式来提神"}, + {"习惯", "主人常在深夜与助手聊天,有熬夜习惯"}, + {"关系里程碑", "主人与助手约定验收暗号喵喵七号"}, + {"项目约定", "迁移号约定从 0023 起"}, + {"能力边界", "主人说过不要在群里提她的工作"}, + {"健康约束", "主人对花生过敏"}, + {"带数值的偏好", "主人每天要喝 2 杯咖啡"}, + // 护栏①:显式「记住」是主人的直接指令,短句也不许被拦。 + {"显式记住(短)", "记住主人周日去看牙医"}, + // 护栏②:短寒暄词出现,但整句带持久信号 ⇒ 放行。 + {"寒暄词+持久信号", "主人喜欢用喵打招呼"}, + // 长度安全阀:寒暄词出现,但句子长到装得下事实 ⇒ 放行。 + {"长句里的致谢", "谢谢你今天特地提醒我这件事,真是帮了大忙了"}, + } +} + +// retentionNegativeCases 是反例清单(design/03 §10.5)的脱敏样本,逐条钉住 +// 命中的规则码。 +func retentionNegativeCases() []struct { + name string + content string + rule retentionRule +} { + return []struct { + name string + content string + rule retentionRule + }{ + {"N1 致谢", "谢谢", retentionChatter}, + {"N1 寒暄", "早上好呀", retentionChatter}, + {"N1 晚安", "晚安~", retentionChatter}, + {"N2 情绪", "今天好累", retentionEmotion}, + {"N2 情绪(带标点)", "心情不错!", retentionEmotion}, + {"N4 时效查询", "现在几点了", retentionTimelyData}, + {"N4 天气", "明天天气怎么样", retentionTimelyData}, + {"N3 一次性琐事", "帮我订个 7 点的闹钟", retentionOneOffAsk}, + {"N3 一次性提醒", "提醒我明天把快递取回来", retentionOneOffAsk}, + {"N5 一次性任务", "把这个文件转成 pdf", retentionOneOffTask}, + {"N5 一次性查询", "查一下明天的航班", retentionOneOffTask}, + } +} + +// TestExtractionRetention_PositivesAreNeverDeferred 钉住正向清单一票放行: +// 任何一条正例被判成 defer(= 进临时层、到期删除)都是**记忆丢失**, +// 因此这里逐条断言 retentionNone。 +func TestExtractionRetention_PositivesAreNeverDeferred(t *testing.T) { + t.Parallel() + for _, tc := range retentionPositiveCases() { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if rule := judgeExtractionRetention(tc.content); rule != retentionNone { + t.Fatalf("正例 %q 被拦成 %q —— 那是把真记忆丢进临时层(fail-safe 方向被违反)", + tc.content, rule) + } + }) + } +} + +// TestExtractionRetention_NegativesAreDeferred 钉住反例清单:每条都必须命中 +// 它该命中的规则码(而不是「反正被拦了」——规则码要能解释**为什么**)。 +func TestExtractionRetention_NegativesAreDeferred(t *testing.T) { + t.Parallel() + for _, tc := range retentionNegativeCases() { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if rule := judgeExtractionRetention(tc.content); rule != tc.rule { + t.Fatalf("反例 %q 命中 %q, want %q", tc.content, rule, tc.rule) + } + }) + } +} + +// TestExtractionRetention_UpperCaseASCII 钉住 ASCII 寒暄词(中英混排): +// 规范化会把 `HI` / `Thanks` 折成小写再匹配。 +func TestExtractionRetention_UpperCaseASCII(t *testing.T) { + t.Parallel() + for _, content := range []string{"HI", "Thanks", "hello~"} { + if rule := judgeExtractionRetention(content); rule != retentionChatter { + t.Fatalf("%q 命中 %q, want %q(ASCII 词应先折小写)", content, rule, retentionChatter) + } + } +} + +// TestJudgeIngestDefersLowValueItem 是**接线**断言:留存规则闸真的接在判定链上, +// 命中的新条目拿到第五态 `defer` + 留痕原因码 `t0_low_value`,且**不进灰区** +// (design/03 §10.6:规则闸拦下的不进 T1)。 +func TestJudgeIngestDefersLowValueItem(t *testing.T) { + t.Parallel() + item := habitItem("谢谢啦", tidyTestTime(t), 0.9) + // 故意给一条「相似候选」:它本该进灰区,但留存规则先命中 ⇒ 灰区必须为空, + // 于是 T1 一个 token 都花不出去。 + cands := []ingestCandidate{{item: habitItem("谢谢", tidyTestTime(t), 0.9), score: 0.9, mode: tidyRecallLexical}} + v := judgeIngest(item, cands) + if v.Decision != IngestDefer { + t.Fatalf("低价值条目 decision = %q, want %q", v.Decision, IngestDefer) + } + if v.Reason != retentionLowValueReason { + t.Fatalf("低价值条目 reason = %q, want %q", v.Reason, retentionLowValueReason) + } + if len(v.Gray) != 0 { + t.Fatalf("低价值条目仍收集了 %d 条灰区候选 —— T1 会被白调一次(§10.6 不许)", len(v.Gray)) + } +} + +// TestJudgeIngestKeepsValuableItemAsCreate 是**变异用例**的对偶:同一条判定链上, +// 值得长期留的条目仍然走既有四态(这里是 create),证明闸只拦「显然不值得记」。 +func TestJudgeIngestKeepsValuableItemAsCreate(t *testing.T) { + t.Parallel() + item := habitItem(fixtureHabitShort, tidyTestTime(t), 0.9) + v := judgeIngest(item, nil) + if v.Decision != IngestCreate { + t.Fatalf("正常条目 decision = %q, want %q(闸不许误伤)", v.Decision, IngestCreate) + } +} diff --git a/internal/runtime/memory/llmextractor.go b/internal/runtime/memory/llmextractor.go index f7b85eb..2329a0c 100644 --- a/internal/runtime/memory/llmextractor.go +++ b/internal/runtime/memory/llmextractor.go @@ -15,7 +15,40 @@ import ( // 以便 parseCandidates 确定性地反序列化。溯源(source_conversation) // 刻意不在其中:它由持久化层盖真实 session/message id,绝不由模型生成 // (裁决 04.4.3,见 source.go)。 -const extractSystemPrompt = `你是记忆提取器。从对话中提取值得长期记住的记忆项(用户偏好、习惯、约定、重要事件)。 +// +// 2026-10-13(design/03 §10.3 的 **B 级:提示词加固**):prompt 里补上 +// **正向清单一句话 + 反例清单一句话 + 明确允许返回 `[]`**。回应主人原话 +// 「**收窄提取器,确实要给他一个主动式的记忆留存规则,不能想到什么都往记忆里 +// 写**」。 +// +// 🔴 为什么提示词**只是辅助**(design/03 §10.2):提示词产出是自由文本,**不可 +// 验收**(测试只能断言「模型有没有听话」),且判据落在提示词上违反 AGENTS §9 +// 「判据靠元数据,不靠提示词」。**真正的防线是 A 级规则闸**(extract_retention.go +// → judgeIngest,纯函数、可单测、在唯一写路径上);这里的两份清单是**同源口径的 +// 第二道**,让模型与规则闸朝同一个方向使劲,而不是各写各的。 +// +// 「允许返回 `[]`」是 Mem0 官方口径(design/23 §2:官方原话「Include examples +// that should produce []」)—— 不写这一句,模型会为了「有产出」而硬凑。 +const extractSystemPrompt = `你是记忆提取器。从对话中提取**值得长期记住**的记忆项。 + +只保留这些(正向清单): +- 长期事实:身份、住址、职业、家庭成员、健康约束(过敏、忌口、长期用药)。 +- 稳定偏好:口味、作息、喜欢什么、讨厌什么。 +- 关系里程碑:约定、承诺、共同经历、纪念日、称呼习惯。 +- 项目约定:编号规则、暗号、路径、命名约定。 +- 能力边界:主人明确说过的「不要做 X」。 + +下面这些**不要提取**(反例清单): +- 寒暄与致谢:「谢谢」「早上好」「辛苦啦」「晚安」。 +- 情绪波动:「今天好累」「心情不错」。 +- 一次性琐事与任务指令:「帮我订个 7 点的闹钟」「把这个文件转成 pdf」。 +- 时效性数据:当前时间、天气、「现在几点了」。 +- 助手自己说过的话 —— 只提取主人这边的长期事实与偏好。 +- 不确定的推断(「他可能喜欢 X」):拿不准就不要提取。 + +**允许返回空数组 []**:一轮对话里没有值得长期记住的内容时,正确做法就是回 +[],不要为了凑数而提取。 + 只输出一个 JSON 数组,不要任何其他文字。每个元素形如: {"content":"记忆内容","scope":"user|persona|project","kind":"relationship|world_event","tags":["标签"],"confidence":0.9,"project_id":"可选项目id"} 去噪去重:只保留值得长期记住的,忽略寒暄与临时信息。` diff --git a/internal/runtime/memory/preinsert.go b/internal/runtime/memory/preinsert.go index 0d28f98..589aaba 100644 --- a/internal/runtime/memory/preinsert.go +++ b/internal/runtime/memory/preinsert.go @@ -35,6 +35,14 @@ import ( // 调用还要先过**全局限速**(默认 120 次/小时,preinsert_t1_limit.go): // 超限即跳过 T1、回落 create + `t1_throttled`。 // **同步入库闸(preinsert_gate.go)不碰 T1**,写入路径因此仍是零模型调用。 +// 4. **第五态 `defer` 的判定在两层各有一处来源**(design/23 §18.2): +// **T0 提取留存规则闸**(`extract_retention.go` → `judgeIngest`,纯规则、 +// 零模型)拦「结构上显然不值得留」的(`reason=t0_low_value`);**T1 则把 +// defer 收进同一个闭集**(`merge/modify/keep/defer`,**同一次调用**、 +// 同一个 prompt、同一套候选编号),只在关系判为「真新」时才问「值不值得 +// 长期留」(`reason=t1_defer`)。**绝不新增第二次模型调用**。 +// 两条失败路径都回落 `create`(= 当作长期记住),fail-safe 方向恒为 +// 「**宁可多留,不可误弃**」(§18.3)。 // // 代价(如实登记):极短窗口内库里会多一行待裁定的重复条目,随后被软合并 // (status='merged' + merged_into,可 revert,绝不物理删除,design §8)。 @@ -55,6 +63,19 @@ const ( // IngestCreate 表示确属新条目(互不包含 / 被规则否决 / 没有召回): // 正常新建,不做任何改写(§15 D3-2 T0-3)。 IngestCreate IngestDecision = "create" + // IngestDefer 是**第五态**(design/23 §18.1):与既有记忆不冲突(真新), + // 但不值得长期留存 ⇒ **照常插入**,只把 `expires_at` 写成「现在 + 临时 TTL」 + // (默认 7 天,见 tempTtlSec)落进**临时层**:到期由读侧时效闸隐藏 + // (design/03 §9.6)、再由后台清扫物理删除(design/23 §19)。 + // + // 它与其余四态的正交关系(§18.2 的不变式):`defer` ≡「关系判为**真新** + // ∧ 价值判为**短期**」。因此它**只在被判为真新时**才可能产生 —— 已被既有 + // 记忆代表的那条(merge/modify/skip)留不留由既有行决定,与价值判定无关。 + // + // 🔴 仍是**软动作**:只写 `expires_at` 一列,**不删行、不改正文**;回滚 = + // 清空该列(§18.4)。选 `defer` 而非 `drop` 是 fail-safe 取向的落点 + // (§18.3「宁可多留,不可误弃」):最坏后果只是「多留 7 天」。 + IngestDefer IngestDecision = "defer" ) // IngestRuleVersion 是入库前审查规则集的版本号,进每条审查留痕(§15 D3-8)。 @@ -65,7 +86,11 @@ const ( // - `d3.2` 加入 **T1 灰区模型判定**(preinsert_t1.go,design/23 §15.1 D3-2): // 灰区(相似、互不包含、未被规则否决)在模型出口接上时会被改判成 // merge/modify,因此 `reason` 上出现了 `t1_*` 族。 -const IngestRuleVersion = "d3.2" +// - `d3.3` 加入 **第五态 `defer`**(design/23 §18.1):T0 提取留存规则闸 +// (`t0_low_value`)与 T1 的同一次调用(`t1_defer`)都能给出它,落点 = +// 照常插入 + 写短 `expires_at`。**升版是硬要求**:同一对条目在 d3.2 下是 +// `create`、在 d3.3 下可能是 `defer`,不升版新旧留痕无法区分(§18.4)。 +const IngestRuleVersion = "d3.3" // 调优旋钮(ReviewConfig)与它们的默认值常量在 preinsert_config.go。 @@ -402,6 +427,16 @@ func (r *PreInsertReviewer) applyVerdict(ctx context.Context, v ingestVerdict, i if v.Decision == IngestCreate { return nil, nil } + if v.Decision == IngestDefer { + // 第五态(design/23 §18.1):条目**早已落库**(异步审查跑在写入之后), + // 这里只把 `expires_at` 写成「现在 + 临时 TTL」把它放进临时层。 + // **不删行、不改正文、不碰任何其它行** ⇒ 回滚 = 清空该列(§18.4)。 + // 写不进去就如实返回错误(fail-safe:行留在长期层 = 宁可多留)。 + if err := r.deps.Store.setIngestExpiry(ctx, item.ID, expiryText(r.deferDeadline(now))); err != nil { + return nil, err + } + return nil, nil + } if v.Absorbed.ID == "" || v.Existing.ID == "" { return nil, fmt.Errorf("memory/review: %s without a matched existing item", v.Decision) } diff --git a/internal/runtime/memory/preinsert_config.go b/internal/runtime/memory/preinsert_config.go index 8627033..0a32e76 100644 --- a/internal/runtime/memory/preinsert_config.go +++ b/internal/runtime/memory/preinsert_config.go @@ -36,6 +36,16 @@ const ( // 留痕 `t1_throttled`,记忆一条不丢、一个字不改。 defaultT1PerWindow = 120 defaultT1Window = 60 * time.Minute + + // tempTtlSec 是**临时记忆**(第五态 `defer`,design/23 §18.1)的默认存活秒数: + // **7 天 = 604800 秒**。它是 design/03 §9.6 [替主人定 · Q3] 定死的代码默认值 + // (设计稿点名的常量名就是 `tempTtlSec = 604800`)。 + // + // 为什么「多久算临时」不进代码死值:7 天是保守默认,而它本质是**主人的口味** + // 而不是判据 ⇒ 面板可调,走 `$DATA_DIR/config/memory-tidy.json` 的 + // `tempTtlSec`(与既有整理旋钮同一个文件、同一套「面板 > 代码默认」优先级, + // 见 ApplyReviewTuning)。**`.env` 不加键**(已冻结,design/13 §2.1)。 + tempTtlSec = 604800 ) // ReviewConfig 携带入库前审查的调优旋钮。零值经 normalize 选取安全默认值。 @@ -99,6 +109,16 @@ type ReviewConfig struct { T1PerWindow int // T1Window 是 T1PerWindow 的时间窗。非正用代码默认值 defaultT1Window。 T1Window time.Duration + // TempTTL 是**临时记忆**的存活时长(design/23 §18.1 第五态 `defer` / + // design/03 §9.6):`defer` 命中时,落库行的 `expires_at` = 当时 + TempTTL。 + // 非正用代码默认值 **7 天**(tempTtlSec = 604800)。 + // + // 🔴 **非正绝不放行**(design/03 §9.6 的明文口径):一个 ≤0 的 TTL 会让每条 + // defer 记忆**落库即过期**(读侧立刻隐藏、后台清扫下一轮就删)—— 那是坏配置 + // 而不是「不过期」,与既有 `panelPositiveSec` 同一口径(tidy_tuning.go)。 + // 因此面板写了坏值时由 ApplyReviewTuning 记 WARN 并**不覆盖**默认值,这里 + // 再兜一道(任何绕过解析器的构造路径同样拿不到 ≤0)。 + TempTTL time.Duration } // normalizeReviewConfig 用设计稿默认值填充零值字段。 @@ -133,5 +153,11 @@ func normalizeReviewConfig(cfg ReviewConfig) ReviewConfig { if cfg.T1Window <= 0 { cfg.T1Window = defaultT1Window } + // 临时记忆的 TTL 同样在这里回落(design/03 §9.6:「坏值(≤0)绝不放行」)。 + // 面板侧的那道 WARN 在 ApplyReviewTuning;这里是把「未归一的 ≤0 直达 + // 判据」这条静默失败形态堵死 —— 与 T1 限流两项同一教训。 + if cfg.TempTTL <= 0 { + cfg.TempTTL = time.Duration(tempTtlSec) * time.Second + } return cfg } diff --git a/internal/runtime/memory/preinsert_defer_test.go b/internal/runtime/memory/preinsert_defer_test.go new file mode 100644 index 0000000..ec03015 --- /dev/null +++ b/internal/runtime/memory/preinsert_defer_test.go @@ -0,0 +1,387 @@ +package memory + +import ( + "bytes" + "context" + "log/slog" + "os" + "strings" + "testing" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" +) + +// 本文件是**第五态 `defer`**(design/23 §18)与**临时 TTL 配置面** +// (design/03 §9.6)的验收用例集。它逐条覆盖任务书的验收点: +// +// 1. defer 落库带**短** `expires_at`(闸路径 + 异步 T1 路径); +// 2. 写进去的 `expires_at` 与读侧时效闸**同一套规范文本** +// (定宽 30 字节 UTC —— 写错布局会让闸静默判错,本仓有历史事故); +// 3. TTL 坏值(≤0)**绝不放行**:回落代码默认 7 天 + WARN; +// 4. `IngestRuleVersion` 升到 `d3.3`(改判定规则必升版,否则新旧留痕无法区分); +// 5. defer 是**软动作**:回滚 = 清空 `expires_at`,行与正文一字不动。 + +// readIngestExpiry 直读一行记忆的 `expires_at`(该列不在 Store.Get 的读侧面上: +// 读侧只拿它做 SQL 判据,不回表 SELECT)。 +func readIngestExpiry(t *testing.T, store *Store, id string) string { + t.Helper() + var exp string + if err := store.db.QueryRowContext(context.Background(), + `SELECT expires_at FROM memory_items WHERE id = ?`, id).Scan(&exp); err != nil { + t.Fatalf("read expires_at %s: %v", id, err) + } + return exp +} + +// personaListHas 报告一条记忆是否出现在 ListByPersona(**过时效闸**)的结果里。 +func personaListHas(t *testing.T, store *Store, personaID, id string) bool { + t.Helper() + items, err := store.ListByPersona(context.Background(), personaID) + if err != nil { + t.Fatalf("ListByPersona: %v", err) + } + for _, it := range items { + if it.ID == id { + return true + } + } + return false +} + +// newRealClockReviewer 构建一个用**真实墙钟**的审查服务(闸开着)。读侧时效闸比 +// 的是真实当下,因此「这条临时记忆现在还看得见吗」这类断言必须用真实时钟,否则 +// 固定夹具时刻(2026-10-01)算出来的有效期早就过去了。 +func newRealClockReviewer(t *testing.T, store *Store, ttl time.Duration) *PreInsertReviewer { + t.Helper() + r, err := NewPreInsertReviewer(ReviewConfig{ + Enabled: true, QueueSize: 8, ScanLimit: 50, Timeout: 5 * time.Second, + Threshold: 0.80, TempTTL: ttl, + }, ReviewDeps{ + Store: store, + Now: func() time.Time { return time.Now() }, + Log: testLogger(), + }) + if err != nil { + t.Fatalf("NewPreInsertReviewer: %v", err) + } + return r +} + +// deferFixtureItem 是一条**留存规则闸必然判 defer** 的条目(短寒暄,同分区、 +// owner 来源)。 +func deferFixtureItem(content string, at time.Time) MemoryItem { + return gateFixtureItem(content, at) +} + +// ttlPanelPtr 造「面板里显式写了 tempTtlSec」的指针(nil 表示键没写)。 +func ttlPanelPtr(v int) *int { return &v } + +// TestIngestGateDeferWritesShortExpiresAt 是任务书验收点①的**闸路径**: +// 低价值条目照常 active 落库(一个字不丢),`expires_at` = 判定时刻 + 临时 TTL, +// 留痕是 `defer` / `t0_low_value` / 规则版本 `d3.3`。 +func TestIngestGateDeferWritesShortExpiresAt(t *testing.T) { + t.Parallel() + store := newTestStore(t) + ttl := time.Hour + base := tidyTestTime(t) + r := newGateReviewer(t, store, ReviewConfig{TempTTL: ttl}, nil) + store.AttachIngestReviewer(r) + + got := seedTidyItem(t, store, deferFixtureItem("谢谢啦", base)) + + want := expiryText(base.Add(ttl)) + if exp := readIngestExpiry(t, store, got.ID); exp != want { + t.Fatalf("expires_at = %q, want %q(= 判定时刻 + 临时 TTL)", exp, want) + } + if exp := readIngestExpiry(t, store, got.ID); len(exp) != storedTimeLen { + t.Fatalf("expires_at 宽度 = %d 字节, want %d(读侧是纯文本比较,宽度必须同源)", + len(exp), storedTimeLen) + } + row := readReviewRow(t, store, got.ID) + if row.status != tidyStatusActive || row.content != "谢谢啦" { + t.Fatalf("defer 行 = %+v, want 仍在册且正文一字不动(软动作)", row) + } + + rev := gateRecordFor(t, store, got.ID) + if rev.Decision != IngestDefer || rev.Reason != retentionLowValueReason { + t.Fatalf("闸留痕 = %+v, want decision=defer reason=%q", rev, retentionLowValueReason) + } + if rev.RuleVer != "d3.3" { + t.Fatalf("闸留痕规则版本 = %q, want d3.3", rev.RuleVer) + } + if s := r.Stats(); s.GateDeferred != 1 { + t.Fatalf("闸计数 = %+v, want gate_deferred=1", s) + } +} + +// TestIngestGateDeferIsNotEnqueuedForAsyncReview 钉住「defer 已是终局判断」: +// 它**不投**异步审查队列 —— 再送语义审查只会把这条临时记忆并进一条长期记忆、 +// 把临时性抹掉(design/23 §18.1 的落点)。 +func TestIngestGateDeferIsNotEnqueuedForAsyncReview(t *testing.T) { + t.Parallel() + store := newTestStore(t) + base := tidyTestTime(t) + r := newGateReviewer(t, store, ReviewConfig{TempTTL: time.Hour}, nil) + store.AttachIngestReviewer(r) + + seedTidyItem(t, store, deferFixtureItem("谢谢啦", base)) + if n := len(r.queue); n != 0 { + t.Fatalf("defer 条目被投进了异步队列(队列长度 %d)—— defer 是终局判断,不该再送审", n) + } + + // 对偶:正常条目仍照旧投队列(证明上面那条不是因为队列压根不工作)。 + seedTidyItem(t, store, deferFixtureItem(fixtureHabitShort, base.Add(time.Minute))) + if n := len(r.queue); n != 1 { + t.Fatalf("正常条目未被投进异步队列(队列长度 %d, want 1)", n) + } +} + +// TestT1DeferWritesExpiresAtOnKeptNewRow 是验收点①的**异步 T1 路径**:T1 在 +// **同一次调用**里回 `defer` ⇒ 新行保留、正文不动、既有行不动,只写短 TTL。 +func TestT1DeferWritesExpiresAtOnKeptNewRow(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := newTestStore(t) + existing, fresh := seedT1GrayPair(t, store) + ttl := 2 * time.Hour + + judge := &t1JudgeStub{reply: `{"action":"defer","target":0,"reason":"只是一次性的信息"}`} + r := newT1Reviewer(t, store, t1GrayEmbedder(), ReviewConfig{TempTTL: ttl}, judge.judge, nil) + + rev, err := r.Review(ctx, fresh) + if err != nil { + t.Fatalf("Review: %v", err) + } + if rev.Decision != IngestDefer || rev.Reason != t1ReasonDefer { + t.Fatalf("verdict = %+v, want defer + %q", rev, t1ReasonDefer) + } + if judge.count() != 1 { + t.Fatalf("模型调用 = %d 次, want 恰为 1(第五态与关系判定同一次调用)", judge.count()) + } + want := expiryText(tidyTestTime(t).Add(ttl)) + if exp := readIngestExpiry(t, store, fresh.ID); exp != want { + t.Fatalf("expires_at = %q, want %q", exp, want) + } + row := readReviewRow(t, store, fresh.ID) + if row.status != tidyStatusActive || row.content != fixtureT1CodeViaChannel { + t.Fatalf("新行 = %+v, want 仍在册且承载自己的原文", row) + } + if got := readReviewRow(t, store, existing.ID); got.mergeCount != 1 { + t.Fatalf("既有行被改了:%+v(defer 只该碰新行的 expires_at 一列)", got) + } +} + +// TestT1DeferWithTargetIsReplyInvalid 钉住 §18.2 的不变式「defer ≡ 真新 ∧ 短期」: +// 一个**指向候选**的 defer 自相矛盾(既说和它像、又说这条独立)⇒ 判非法回复、 +// 回落 `create`(**宁可多留**),绝不猜。 +func TestT1DeferWithTargetIsReplyInvalid(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := newTestStore(t) + _, fresh := seedT1GrayPair(t, store) + + judge := &t1JudgeStub{reply: `{"action":"defer","target":1,"reason":"自相矛盾"}`} + r := newT1Reviewer(t, store, t1GrayEmbedder(), ReviewConfig{TempTTL: time.Hour}, judge.judge, nil) + + rev, err := r.Review(ctx, fresh) + if err != nil { + t.Fatalf("Review: %v", err) + } + if rev.Decision != IngestCreate || rev.Reason != t1ReasonReplyInvalid { + t.Fatalf("verdict = %+v, want create + %q", rev, t1ReasonReplyInvalid) + } + if exp := readIngestExpiry(t, store, fresh.ID); exp != "" { + t.Fatalf("非法 defer 却写了 expires_at = %q(回落路径必须零改写)", exp) + } +} + +// TestDeferExpiryHiddenByReadGateAndRollbackRestores 把第五态接到**读侧时效闸** +// (design/03 §9.6 第 ① 步隐藏)与**回滚**(§18.4:清空 expires_at 即升回长期)上: +// 未到期可见 ⇒ 置成过去即隐藏 ⇒ 清空又可见,且 `Get`(刻意不过闸)始终读得到。 +func TestDeferExpiryHiddenByReadGateAndRollbackRestores(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := newTestStore(t) + r := newRealClockReviewer(t, store, time.Hour) + store.AttachIngestReviewer(r) + + got := seedTidyItem(t, store, deferFixtureItem("谢谢啦", time.Now().UTC())) + if !personaListHas(t, store, got.PersonaID, got.ID) { + t.Fatal("刚落的临时记忆应当在有效期内可见(TTL = 1h)") + } + + if err := store.setIngestExpiry(ctx, got.ID, expiryText(time.Now().UTC().Add(-time.Minute))); err != nil { + t.Fatalf("setIngestExpiry: %v", err) + } + if personaListHas(t, store, got.PersonaID, got.ID) { + t.Fatal("已到期的临时记忆不该出现在读路径上(时效闸失效)") + } + if _, err := store.Get(ctx, got.ID); err != nil { + t.Fatalf("Get 刻意不过时效闸(撤销/清算要读得到过期行),却报错:%v", err) + } + + // 回滚(design/23 §18.4):清空 expires_at ⇒ 升回长期,立即可见。 + if err := store.setIngestExpiry(ctx, got.ID, ""); err != nil { + t.Fatalf("setIngestExpiry(清空): %v", err) + } + if !personaListHas(t, store, got.PersonaID, got.ID) { + t.Fatal("清空 expires_at 后应当升回长期(回滚是无损的)") + } + if row := readReviewRow(t, store, got.ID); row.content != "谢谢啦" || row.status != tidyStatusActive { + t.Fatalf("回滚改了正文/状态:%+v", row) + } +} + +// TestIngestGatePlanExpiresAtUsesSameLayoutAsTimeGate 是写入/读取**同一套规范文本** +// 的钉子(塔在派工里点名要求补的那条)。 +// +// 读侧闸是**纯文本比较**(timegate.go 的 timeGateClause,不碰 SQLite 时间函数), +// 所以写侧的布局一旦分叉,闸会**静默判错**:本仓历史事故里,`' '`(0x20) 分隔的 +// `datetime('now')` 与 `'T'`(0x54) 分隔的列值比较,会让**任何当日行都「大于当下」**。 +func TestIngestGatePlanExpiresAtUsesSameLayoutAsTimeGate(t *testing.T) { + t.Parallel() + at := tidyTestTime(t).UTC() + + // 写入侧:defer 计划给出的文本必须就是 expiryText/formatTime 的产出。 + if got := (ingestGatePlan{ExpiresAt: at}).expiresAt(); got != formatTime(at) { + t.Fatalf("plan.expiresAt() = %q, want formatTime 的产出 %q", got, formatTime(at)) + } + if got := (ingestGatePlan{ExpiresAt: at}).expiresAt(); len(got) != storedTimeLen { + t.Fatalf("plan.expiresAt() 宽度 = %d, want %d", len(got), storedTimeLen) + } + // 零值 = 不设过期(长期/日常记忆)⇒ 空串,恒可见。 + if got := (ingestGatePlan{}).expiresAt(); got != "" { + t.Fatalf("零值计划的 expiresAt() = %q, want 空串", got) + } + + // 反证:同一瞬间换一种布局,文本序就与时间序脱钩(读侧判据随之失效)。 + sqliteLayout := at.Format("2006-01-02 15:04:05") + if formatTime(at) == sqliteLayout { + t.Fatal("两种布局不该相同 —— 那这条反证就没有意义了") + } + if !(formatTime(at) > sqliteLayout) { + t.Fatalf("定宽布局应恒大于空格分隔布局('T' > ' '),实得 %q vs %q", formatTime(at), sqliteLayout) + } + if sqliteLayout[0:4] != formatTime(at)[0:4] { + t.Fatal("两种布局的日期前缀应当相同,否则说明取的时间不是同一瞬间") + } +} + +// TestIngestRuleVersionIsD33 钉住任务书验收点④:改判定规则必升版 +// (design/23 §18.4),否则新旧留痕无法区分。 +func TestIngestRuleVersionIsD33(t *testing.T) { + t.Parallel() + if IngestRuleVersion != "d3.3" { + t.Fatalf("IngestRuleVersion = %q, want d3.3(第五态改变了判定闭集)", IngestRuleVersion) + } +} + +// TestApplyReviewTuningTempTTL 钉住验收点③:TTL 的三档优先级与**坏值绝不放行**。 +// +// 三档优先级在 TTL 这一键上实际是「**面板 > 代码默认**」:`.env` 已冻结 +// (design/13 §2.1),本键在那里**从来就没有出处** —— 中间那一档在结构上是空的, +// 这不是漏配(design/03 §9.6 写的就是「面板 > 代码默认」)。 +func TestApplyReviewTuningTempTTL(t *testing.T) { + t.Parallel() + const fallbackSec = 604800 // 7 天(tempTtlSec) + + cases := []struct { + name string + panel *int + wantTTL time.Duration + wantWarn bool + }{ + {"键没写 ⇒ 代码默认", nil, 7 * 24 * time.Hour, false}, + {"面板写正数 ⇒ 用面板值", ttlPanelPtr(3600), time.Hour, false}, + {"面板写 0 ⇒ 坏值回落默认 + WARN", ttlPanelPtr(0), 7 * 24 * time.Hour, true}, + {"面板写负数 ⇒ 坏值回落默认 + WARN", ttlPanelPtr(-3600), 7 * 24 * time.Hour, true}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + log := slog.New(slog.NewTextHandler(&buf, nil)) + got := normalizeReviewConfig(ApplyReviewTuning( + ReviewConfig{}, runtimeconfig.MemoryTidyTuning{TempTtlSec: tc.panel}, log)) + if got.TempTTL != tc.wantTTL { + t.Fatalf("TempTTL = %v, want %v", got.TempTTL, tc.wantTTL) + } + hasWarn := strings.Contains(buf.String(), "临时记忆 TTL 不可用") + if hasWarn != tc.wantWarn { + t.Fatalf("WARN 出现 = %t, want %t(日志:%s)", hasWarn, tc.wantWarn, buf.String()) + } + if got.TempTTL <= 0 { + t.Fatal("归一化后仍是非正 TTL —— 那会让每条 defer 记忆落库即过期") + } + }) + } + + // 代码默认值本身就是 design/03 §9.6 点名的 604800 秒。 + if normalizeReviewConfig(ReviewConfig{}).TempTTL != time.Duration(fallbackSec)*time.Second { + t.Fatalf("代码默认 TTL = %v, want %ds", normalizeReviewConfig(ReviewConfig{}).TempTTL, fallbackSec) + } + if tempTtlSec != fallbackSec { + t.Fatalf("tempTtlSec = %d, want %d", tempTtlSec, fallbackSec) + } +} + +// TestNewPreInsertReviewerNormalizesBadTempTTL 钉住「坏值不靠调用方自觉」: +// 任何绕过解析器、直接把 ≤0 塞进 ReviewConfig 的路径,也在构造期被归一化掉 +// (与 T1 限流两项同一教训,design/12 §N C.5 G3)。 +func TestNewPreInsertReviewerNormalizesBadTempTTL(t *testing.T) { + t.Parallel() + store := newTestStore(t) + for _, ttl := range []time.Duration{0, -time.Hour} { + r, err := NewPreInsertReviewer(ReviewConfig{Enabled: true, TempTTL: ttl}, ReviewDeps{ + Store: store, Log: testLogger(), + }) + if err != nil { + t.Fatalf("NewPreInsertReviewer: %v", err) + } + if got := r.cfg.TempTTL; got != 7*24*time.Hour { + t.Fatalf("ReviewConfig.TempTTL = %v 未回落默认,实得 %v", ttl, got) + } + } +} + +// TestIngestRuleVersionIsNotInTheTidyIdempotencyKey 是**防漂移缝断言**,回答一条 +// 跨机制的实体问题:`IngestRuleVersion` 由 d3.2 升到 d3.3 之后,**既有 pending 整理 +// 候选会不会凭幂等键复制出重复行**? +// +// 答案:**不会**,因为这是两个独立的常量 —— +// +// IngestRuleVersion ("d3.3") 入库审查(本切片)→ 只进 memory_ingest_reviews.rule_version +// TidyRuleVersion ("t0.1") 整理(design/23 §17.6)→ **进整理候选的幂等键** +// +// 幂等键的算式是 `tidyIdempotencyKey`(tidy_candidate.go):hash(persona_id, scope, +// project_id, sorted(source_ids), action, **c.RuleVersion**),而候选的 RuleVersion 由 +// 构造器写成 TidyRuleVersion。生产库实测(2026-10-13,只读):`memory_tidy_candidates` +// 的 64 条 pending 全部 `rule_version='t0.1'`,`memory_ingest_reviews` 里则是 +// `d3.1` / `d3.2` —— 两张表**从不共享**这一个值。 +// +// design/23 §17.6 记录过的那次「升版 ⇒ 既有候选凭新键复制一份重复行」是 +// **TidyRuleVersion** 的语义(那也是它当时刻意不升的原因),不是这一个。 +// 若将来有人把 IngestRuleVersion 接进那个键(或把两个常量合并),本用例会红。 +func TestIngestRuleVersionIsNotInTheTidyIdempotencyKey(t *testing.T) { + t.Parallel() + if IngestRuleVersion == TidyRuleVersion { + t.Fatalf("两个版本号不该相等(都是 %q):它们分属入库审查与整理两条机制", + IngestRuleVersion) + } + src, err := os.ReadFile("tidy_candidate.go") + if err != nil { + t.Fatalf("读 tidy_candidate.go: %v", err) + } + text := string(src) + if !strings.Contains(text, "string(c.Action), c.RuleVersion") { + t.Fatal("整理候选幂等键的算式变了 —— 请复核 IngestRuleVersion 是否意外进了键") + } + if strings.Contains(text, "IngestRuleVersion") { + t.Fatal("tidy_candidate.go 里出现了 IngestRuleVersion:两个版本号开始互相污染") + } + if strings.Count(text, "TidyRuleVersion") < 2 { + t.Fatal("整理候选的 RuleVersion 不再由 TidyRuleVersion 赋值 —— 幂等键的口径变了") + } +} diff --git a/internal/runtime/memory/preinsert_gate.go b/internal/runtime/memory/preinsert_gate.go index a6b81bb..564d261 100644 --- a/internal/runtime/memory/preinsert_gate.go +++ b/internal/runtime/memory/preinsert_gate.go @@ -53,6 +53,11 @@ const ( // gateTraceAbsorbed 是闸真的做了决定(落 merged)的留痕原因码。 gateTraceAbsorbed = "gate:absorbed" + // gateTraceDeferred 是**第五态**在闸侧做决定的留痕原因码(`expires_at` 已写)。 + // 与 gateTraceAbsorbed 分开:前者写的是 status/merged_into,后者只写 + // expires_at 一列 —— 「闸当场折回了多少重复」与「闸当场放进了多少临时记忆」 + // 是两条不同的观测线,加成一个数会让面板读不出分型。 + gateTraceDeferred = "gate:deferred" // gateTraceBudgetExceeded 是闸被墙钟预算截断的 fail-open 原因码。 gateTraceBudgetExceeded = "gate:budget_exceeded" // gateTraceUnavailable 是候选源不可用(读取失败)的 fail-open 原因码。 @@ -72,6 +77,14 @@ const ( // ingestPlanAbsorbOld 是确定性判据命中且**既有行是子集**(modify):新行正常 // insert(它承载更全的内容),既有行置为被合并(design §15 D3-6 的「改写」)。 ingestPlanAbsorbOld ingestPlanAction = "absorb_old" + // ingestPlanDefer 是**第五态**(design/23 §18.1):真新、但不值得长期留存 ⇒ + // 行**照常 active 落库**(一个字不丢),只是紧接着把 `expires_at` 写成 + // 「现在 + 临时 TTL」,使它落进临时层 —— 到期由读侧时效闸隐藏 + // (design/03 §9.6)、再由后台清扫物理删除(design/23 §19)。 + // + // 它**不投异步审查队列**(Async=false):defer 已经是这条记忆的**终局判断** + // (「不值得长期留」),再送语义审查只会把它并进一条长期记忆、把临时性抹掉。 + ingestPlanDefer ingestPlanAction = "defer" ) // ingestGatePlan 是同步入库闸的落库计划。零值(Action="")不存在:planIngest @@ -97,6 +110,23 @@ type ingestGatePlan struct { // Elapsed 是闸在写路径上花掉的墙钟。它只进日志与留痕,**不参与任何判定** // (否则同一批数据会有两个结论,幂等性就没了)。 Elapsed time.Duration + // ExpiresAt 是第五态(defer)要写进新行 `expires_at` 的时刻:**零值 = 不设 + // 过期**(长期 / 日常记忆,落库为空串,四条列表读路径恒可见)。 + // + // 它是**时刻**而不是「TTL 秒数」:判定的那一刻就把 TTL 换算掉,落库侧因此 + // 不需要认识任何配置,也不会出现「写的时候又算一次 TTL」的双口径。 + ExpiresAt time.Time +} + +// expiresAt 返回新行落库时的 `expires_at` 文本,与 `insertStatus()` / +// `absorbedID()` 是同族:把「计划 → 要写进 INSERT 的值」这一步收在计划自己身上。 +// +// 🔴 **编码必须走 expiryText → formatTime**(定宽 30 字节 UTC),不许另写一种 +// 布局:读侧时效闸是**纯文本比较**(timegate.go 的 timeGateClause,不碰 SQLite +// 时间函数),布局一旦分叉就会**静默判错**(本仓已有历史事故,timegate_test.go +// 钉着那条反证)。零值 ⇒ 空串 ⇒ 该行恒可见。 +func (p ingestGatePlan) expiresAt() string { + return expiryText(p.ExpiresAt) } // absorbedID 返回应该写进新行 `merged_into` 的存活行 id。 @@ -181,6 +211,13 @@ func (r *PreInsertReviewer) planFromVerdict(item MemoryItem, v ingestVerdict) in plan.Survivor = item plan.Absorbed = v.Existing plan.Trace = gateTraceAbsorbed + case IngestDefer: + // 第五态(design/23 §18.1):行照常 active 落库(一个字不丢、可 revert), + // 只把 expires_at 写成「现在 + 临时 TTL」;**不投异步队列**(defer 已是 + // 终局判断,再送语义审查只会把临时性抹掉)。 + plan.Action = ingestPlanDefer + plan.ExpiresAt = r.deferDeadline(r.now().UTC()) + plan.Trace = gateTraceDeferred default: // 真新 / 拿不准:正常 insert,并投既有异步队列(用语义召回再判一次)。 plan.Action = ingestPlanInsert @@ -189,6 +226,20 @@ func (r *PreInsertReviewer) planFromVerdict(item MemoryItem, v ingestVerdict) in return plan } +// deferDeadline 把「现在」换算成第五态要写进 `expires_at` 的时刻(现在 + 临时 +// TTL,design/03 §9.6)。 +// +// TTL 由 `cfg.TempTTL` 决定,**它已经过 normalize**(`NewPreInsertReviewer` 里 +// normalizeReviewConfig 先跑),因此这里拿到的恒为正 —— 「≤0 的 TTL 让每条记忆 +// 落库即过期」这条坏配置形态在构造期就被回落掉了(normalizeReviewConfig 兜底 + +// ApplyReviewTuning 的 WARN)。 +// +// 闸(同步)与异步审查层**共用这一个函数**:同一份配置、同一种时刻语义,因此 +// 同一条记忆无论被哪一层判成 defer,拿到的有效期都来自同一个算式。 +func (r *PreInsertReviewer) deferDeadline(now time.Time) time.Time { + return now.UTC().Add(r.cfg.TempTTL) +} + // failOpenPlan 组装 fail-open 计划:照常插入(active)+ 投既有队列 + 留痕。 // 「绝不静默丢记忆」不是一句承诺,而是这条路径的结构性结果。 func (r *PreInsertReviewer) failOpenPlan(reason string, start time.Time) ingestGatePlan { @@ -298,6 +349,35 @@ func (s *Store) finishIngest(ctx context.Context, item MemoryItem, plan ingestGa if plan.Action == ingestPlanInsert { return } + if plan.Action == ingestPlanDefer { + // 第五态(design/23 §18.1):新行已经在 Add 里照常插好了(active、正文 + // 一字不差),这里只把 `expires_at` 补成计划给出的时刻 —— 与 + // absorb_new / absorb_old 是**同一范式**:INSERT 落行、finishIngest 落 + // 「那一行的派生列」。 + // + // 为什么不是「INSERT 直接带上 expires_at」:store.go 的 INSERT 读的是 + // `expiryText(item.ExpiresAt)`(由**调用方**置位),而 defer 的判断发生在 + // 闸里(`planIngest` 拿的是 item 的**副本**),计划的值够不到那条 INSERT。 + // + // 🔴 **代价与短暂窗口(如实登记,不静默)**:这一态因此多出一笔按主键的 + // UPDATE(微秒级),且 INSERT 与这笔补写之间有一个**极短的窗口** —— + // 若进程恰在此刻中断,该行会以 `expires_at=''`(**永不过期**)留在库里。 + // 方向是刻意的:它正是 fail-safe 的「**宁可多留,不可误弃**」 + // (design/23 §18.3)—— 停在长期层只是多占一条记忆,而反向失败 + // (把该留的判成临时)会丢信息。写失败同样只记 WARN、**不让 Add 失败**。 + // + // 非 defer 的三态**一笔都不多写**:absorb_new / absorb_old 本来就有它们的 + // 第二笔(合并登记 / 置为被合并),insert 在函数开头就返回了。 + if err := s.setIngestExpiry(ctx, item.ID, plan.expiresAt()); err != nil { + s.log.Warn("memory ingest gate defer failed, item left long-lived", + "id", item.ID, "scope", string(item.Scope), "error", err) + return + } + if r := s.ingestReviewer(); r != nil { + r.bump(func(st *ReviewStats) { st.GateDeferred++ }) + } + return + } now := time.Now().UTC() var err error switch plan.Action { diff --git a/internal/runtime/memory/preinsert_judge.go b/internal/runtime/memory/preinsert_judge.go index 37c6c4b..1fd145f 100644 --- a/internal/runtime/memory/preinsert_judge.go +++ b/internal/runtime/memory/preinsert_judge.go @@ -16,6 +16,11 @@ package memory // `ingestVerdict.Gray`,交**异步审查层**的 T1 再问一次 // (preinsert_t1.go,design/23 §15.1 D3-2)。**本文件恒为零模型调用**。 // +// 另外在**候选比对之前**插一道**提取留存规则闸**(extract_retention.go, +// design/03 §10.5 的 N1~N5):新条目自己就「显然不值得长期留存」时直接给第五态 +// `defer`(`reason=t0_low_value`),**不进候选比对、不进 T1**。它是纯规则、零模型 +// 调用,因此不改变上面那句「本文件恒为零模型调用」。 +// // 两条不可走样: // - **相似度只用来召回候选**,永不参与判定(design/23 §1.2「单阈值双向失效」); // - **规则否决一票否决**,且**绝不跨作用域/跨角色/跨项目**(§5.2-7/8/9 是硬门, @@ -68,6 +73,18 @@ func judgeIngest(item MemoryItem, cands []ingestCandidate) ingestVerdict { if v, ok := judgeExactDuplicate(item, cands); ok { return v } + // 提取留存规则闸(design/03 §10.5 的 N1~N5):新条目**自己**就「显然不值得 + // 长期留存」时直接给第五态 `defer`,**不进候选比对、不进 T1** + // (design/23 §10.6:规则闸拦下的不进 T1,避免「模型推翻规则」)。 + // + // 它排在**精确重复之后**是刻意的:已有等价条目时 `skip` 只更新既有行、 + // **一行都不新增**,比 `defer`(新增一行临时记忆)更省也更干净。 + if rule := judgeExtractionRetention(item.Content); rule != retentionNone { + return ingestVerdict{ + Decision: IngestDefer, Reason: retentionLowValueReason, Mode: mode, + Veto: VetoNone, Gray: nil, + } + } vetoed, vetoScore := VetoNone, 0.0 // 灰区惰性收集:只有真的出现「互不包含且未被否决」的候选才分配, // 同步写入路径上的常见情形(无候选 / 一眼真新)因此零额外分配。 diff --git a/internal/runtime/memory/preinsert_stats.go b/internal/runtime/memory/preinsert_stats.go index d4a8dac..35451fd 100644 --- a/internal/runtime/memory/preinsert_stats.go +++ b/internal/runtime/memory/preinsert_stats.go @@ -17,6 +17,10 @@ type ReviewStats struct { Merged int Modified int Created int + // Deferred 是**异步审查层**判出第五态 defer 的条数(T0 规则闸 / T1 同一次 + // 调用都可能给出它)。它与 Created 分列:两者都留下新行,但只有 defer 带 + // 短 `expires_at`,它是「临时层新增」的观测量。 + Deferred int Dropped int Failed int // GateChecked 是**同步入库闸**实际做了一次判定的条数(跳过闸的条目不计)。 @@ -26,6 +30,11 @@ type ReviewStats struct { GateAbsorbed int // GateFailed 是闸因预算/候选源不可用而 fail-open 的次数(照常插入 + 留痕)。 GateFailed int + // GateDeferred 是闸当场判定为**第五态 defer** 并**真的写成了** `expires_at` + // 的次数(design/23 §18.1)。它与 GateAbsorbed 分列:「当场折回重复」与 + // 「当场放进临时层」是两条不同的观测线,加成一个数面板就读不出分型。 + // 只统计写成功的(写失败时那行停在长期层,不是临时记忆)。 + GateDeferred int // T1Called 是 T1 实际发出过**模型调用**的次数。每次审查至多 +1 // (defaultT1MaxGray 只是把多条灰区候选打包进同一次 prompt,不多发请求)。 T1Called int @@ -58,6 +67,10 @@ func (r *PreInsertReviewer) recordDecision(rev IngestReview) { s.Merged++ case IngestModify: s.Modified++ + case IngestDefer: + // 第五态单独一档:它与 create 都「留下了新行」,但一个有 TTL、 + // 一个没有 —— 混进 Created 就看不出「有多少记忆是临时的」。 + s.Deferred++ default: s.Created++ } @@ -79,8 +92,10 @@ 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 t1_called=%d t1_decided=%d t1_throttled=%d", - s.Reviewed, s.Skipped, s.Merged, s.Modified, s.Created, s.Dropped, s.Failed, - s.GateChecked, s.GateAbsorbed, s.GateFailed, s.T1Called, s.T1Decided, s.T1Throttled) + return fmt.Sprintf("reviewed=%d skip=%d merge=%d modify=%d create=%d defer=%d dropped=%d failed=%d "+ + "gate_checked=%d gate_absorbed=%d gate_deferred=%d gate_failed=%d "+ + "t1_called=%d t1_decided=%d t1_throttled=%d", + s.Reviewed, s.Skipped, s.Merged, s.Modified, s.Created, s.Deferred, s.Dropped, s.Failed, + s.GateChecked, s.GateAbsorbed, s.GateDeferred, s.GateFailed, + s.T1Called, s.T1Decided, s.T1Throttled) } diff --git a/internal/runtime/memory/preinsert_store.go b/internal/runtime/memory/preinsert_store.go index 2d41d06..46e72c6 100644 --- a/internal/runtime/memory/preinsert_store.go +++ b/internal/runtime/memory/preinsert_store.go @@ -23,6 +23,11 @@ import ( // ingestStatusMerged 是「已被合并」的行状态(design/23 §10 的 status 列)。 const ingestStatusMerged = "merged" +// 🔴 这条内部召回 SQL **刻意只过 status 闸、不过时效闸**(design/03 §11.2 第 3 条, +// 先例见 design/23 §10.1 ①;主会话 2026-10-13 裁定「保持不过时效闸」):它比对的是 +// **正文**,与可见性无关 —— 加闸会让过期但未清扫的临时行退出比对,同一事实遂以 +// 「过期行 + 新行」并存(正是主人报的「反复新建」)。过期行由后台清扫物理删除 +// (design/23 §19),**不会复活**。姊妹注释在 tidy_store.go 的 listTidyPartitionItemsSQL。 const listReviewCandidatesSQL = `SELECT id, persona_id, role_id, project_id, scope, kind, content, tags, confidence, source_conversation, created_at, updated_at, decay_weight FROM memory_items @@ -46,6 +51,12 @@ const selectMergedIntoSQL = `SELECT merged_into FROM memory_items const selectItemStatusSQL = `SELECT status FROM memory_items WHERE id = ?` +// setIngestExpirySQL 是第五态(design/23 §18.1)的落点:**只写 expires_at 一列**。 +// 不碰 status / merged_* / content,因此它天然是「软动作」——回滚 = 把该列清空 +// (§18.4);行一行不删(临时层的物理删除是 design/23 §19 的另一条有审计的 +// 清扫路径,不在这里)。 +const setIngestExpirySQL = `UPDATE memory_items SET expires_at = ? WHERE id = ?` + const insertIngestReviewSQL = `INSERT INTO memory_ingest_reviews (id, item_id, persona_id, scope, project_id, decision, reason, veto, target_id, merged_ids, score, recall_mode, degraded, rule_version, session_id, created_at) @@ -115,6 +126,26 @@ func (s *Store) listReviewCandidates(ctx context.Context, p tidyPartition, exclu return scanItems(rows, "review candidate") } +// setIngestExpiry 把一条记忆的 `expires_at` 写成 expiryText(**定宽 30 字节 UTC**), +// 空白串表示「不设过期」。它是第五态 defer 的**唯一**写口,两处调用: +// +// - 同步入库闸(preinsert_gate.go 的 finishIngest):INSERT 之后补这一列; +// - 异步审查层(preinsert.go 的 applyVerdict):T1 判 defer 时改这一列。 +// +// 🔴 **编码必须与读侧同源**:读侧时效闸是纯文本比较(timegate.go),因此这里 +// 只许走 expiryText —— 写成别的布局(RFC3339 带 `+08:00`、秒精度、空格分隔) +// 会让闸**静默**判错(早八小时的列值可能被判成「大于当下」,B 仓历史事故, +// timegate_test.go 钉着那条反证)。 +func (s *Store) setIngestExpiry(ctx context.Context, id, expiresAt string) error { + if strings.TrimSpace(id) == "" { + return ErrNotFound + } + if _, err := s.db.ExecContext(ctx, setIngestExpirySQL, expiresAt, id); err != nil { + return fmt.Errorf("memory: set ingest expiry: %w", err) + } + return nil +} + // absorbIngestItem 把 absorbed 软合并进 survivor:一个事务里做「标记被吸收 + // 刷新存活行的合并元信息」。两条 UPDATE 都只碰 status/merged_*/merge_count/ // confidence/updated_at,**绝不改 content**(design §8 的可回滚口径)。 diff --git a/internal/runtime/memory/preinsert_t1.go b/internal/runtime/memory/preinsert_t1.go index cddee1d..4a93a17 100644 --- a/internal/runtime/memory/preinsert_t1.go +++ b/internal/runtime/memory/preinsert_t1.go @@ -21,8 +21,10 @@ import ( // // 五条不可走样(逐条对应本目录既有的不变量,改坏任何一条都是架构偏离): // -// 1. **只许回三态 + 理由**:`merge` / `modify` / `keep`。坏 JSON、非法枚举、 -// 编号越界、任何别的词 —— 一律回落 `create`(保守,**绝不猜**)。 +// 1. **只许回四态 + 理由**:`merge` / `modify` / `keep` / `defer`。坏 JSON、 +// 非法枚举、编号越界、任何别的词 —— 一律回落 `create`(保守,**绝不猜**)。 +// `defer`(第五态,§18.2)还多一条不变式:**target 必须为 0** —— 它声称 +// 「关系判为真新」,而一个指向某条候选的 defer 自相矛盾,同样判非法回落。 // 2. **禁产新内容**:模型只提供「选哪一条 + 什么动作 + 一句理由」,它的正文 / // 摘要 / 改写文本**一个字都不采纳**。`merge` 落地时存活方是既有行(正文 // 一字不改);`modify` 落地时存活方是**新行自己的原文**、既有行置为被合并 @@ -45,8 +47,9 @@ import ( // 厂商协议,也不新增依赖。 // // 可观测(§15 D3-8):T1 是否参与、结论是什么,在留痕 `reason` 列上如实可辨 -// (`t1_merge` / `t1_modify` / `t1_keep` / `t1_reply_invalid` / `t1_unavailable` / -// `t1_throttled`),规则版本随之升到 `IngestRuleVersion`。模型的自由文本理由 +// (`t1_merge` / `t1_modify` / `t1_keep` / `t1_defer` / `t1_reply_invalid` / +// `t1_unavailable` / `t1_throttled`),规则版本随之升到 `IngestRuleVersion` +// (`d3.3` 起含第五态)。模型的自由文本理由 // **既不落库也不落日志**:留痕表按设计不存正文,而模型的理由可能改述正文 ⇒ 一律 // 丢弃(它在 prompt 里的唯一作用,是逼模型先表态,从而把动作判得更稳)。 @@ -64,10 +67,18 @@ const ( // 让用量面板能把「灰区判定」与「对话」分开算账。 t1Purpose = "memory_t1_judge" - // T1 允许的动作枚举(模型只许回这三个,别的一律回落 create)。 + // T1 允许的动作枚举(模型只许回这四个,别的一律回落 create)。 + // + // `defer` 是 2026-10-13 加入的**第五态**(design/23 §18.2)—— 它**不是第二次 + // 调用**:关系判定(merge/modify/keep)与价值判定(值不值得长期留)被写进 + // **同一个 prompt**,由**同一次**补全回答。语义上 `defer` ≡「关系判为**真新** + // ∧ 价值判为**短期**」,因此它要求 target=0(真新就没得可指),而**当关系 + // 判成 merge/modify 时价值判定不适用**(这条已被既有记忆代表,留不留由既有行 + // 决定,§18.2 的「分工」)。 t1ActionMerge = "merge" t1ActionModify = "modify" t1ActionKeep = "keep" + t1ActionDefer = "defer" // T1 的留痕原因码(进 `reason` 列,结构化、可机器判定、与既有码同族)。 t1ReasonMerge = "t1_merge" @@ -75,6 +86,10 @@ const ( t1ReasonKeep = "t1_keep" t1ReasonReplyInvalid = "t1_reply_invalid" t1ReasonUnavailable = "t1_unavailable" + // t1ReasonDefer 是第五态在留痕 `reason` 列上的编码(design/23 §18.4 的闭集)。 + // 它与 t1_keep 分列:两者都是「关系判为真新」,但只有 defer 会写短 + // `expires_at` —— 面板要能一眼看出「有多少条被判成了临时」。 + t1ReasonDefer = "t1_defer" // t1ReasonThrottled 是**全局限速**命中时的原因码(preinsert_t1_limit.go, // design/23 §15.4)。它与上面两个失败码同族、同样只改 `reason`:决定恒 // `create`、零改写。与它们的唯一区别是语义 —— 失败是「模型没答上」, @@ -82,7 +97,16 @@ const ( t1ReasonThrottled = "t1_throttled" ) -// ErrT1ReplyInvalid 是模型回复解不出三态枚举(或候选编号越界)时的分类错误。 +// 一处**刻意不实现**(如实登记):design/23 §18.3 的失败原因码 +// `t1_value_unavailable` / `t1_keep_unavailable`。关系判定与价值判定被写进 +// **同一次**补全(§18.2 要求「不新增第二次调用」),因此「回复解不开」时**结构上 +// 无法分辨**是哪一问失败 —— 硬造第二个码只会得到一个自己也不确定的标签。既有 +// 的 `t1_reply_invalid` / `t1_unavailable` 已经如实描述了那条路径(且 §18.2 对 +// 「非法 defer」明写的处置就是 `t1_reply_invalid`)。真要分开观测,得先有第二次 +// 调用,而那正是本条明令禁止的。 + +// ErrT1ReplyInvalid 是模型回复解不出四态枚举(或候选编号越界,或 defer 带了 +// 非 0 的 target)时的分类错误。 // 它不是「模型挂了」,而是「这一次的回复不能用」—— 两者的回落结果相同 // (create),但留痕原因码不同,运维才分得清该修 prompt 还是该查网络。 var ErrT1ReplyInvalid = errors.New("memory/review: t1 reply is not a valid action") @@ -232,6 +256,22 @@ func t1Verdict(reply t1Reply, item MemoryItem, gray []ingestCandidate, base inge base.Gray = nil return base, true } + if action == t1ActionDefer { + // 第五态(design/23 §18.2):关系判为**真新**、但价值判为**短期** ⇒ + // 照常保留这条新记忆,只给它一个短 `expires_at`(落临时层)。 + // + // 🔴 **target 必须为 0**(与 keep 同形):§18.2 的不变式是 + // 「defer ≡ 真新 ∧ 短期」,一个指向某条候选的 defer 自相矛盾(既说 + // 「和它像」又说「这条独立」)。出现这种回复 ⇒ 判非法、回落 create + // (reason=t1_reply_invalid,§18.2 明写的处置),**绝不猜**。 + if reply.Target != 0 { + return ingestVerdict{}, false + } + base.Decision = IngestDefer + base.Reason = t1ReasonDefer + base.Gray = nil + return base, true + } if action != t1ActionMerge && action != t1ActionModify { return ingestVerdict{}, false } @@ -271,32 +311,44 @@ func buildT1Prompt(item MemoryItem, gray []ingestCandidate) []model.Message { } // t1SystemPrompt 是 T1 的固定指令。它把三件事写死: -// - 只许回三态 + 一句理由(别的词一律不许); +// - 只许回**四态** + 一句理由(别的词一律不许); // - **禁产新内容**(不许输出记忆正文/摘要/改写文本); // - 判据与**负例**(design/23 §15 D3-2「负例进 prompt」,Mem0 口径): // 「相似但并列」必须 keep,拿不准必须 keep。 // +// 2026-10-13 起它同时承载**第五态的价值判定**(design/23 §18.2):prompt 里明写 +// 「先问关系、只有关系判为真新时才问价值」,四个动作**一次回答** —— 因此 +// 「第五态与 T1 在同一次调用里问」是这条 prompt 的结构性事实,不是约定:**没有 +// 第二次模型调用**。 +// // 负例刻意取自 design/23 §1.3 与 §5.2 的点名反例(红牛 vs 冰美式是「同主题但 // 不同事实」,X vs 戒了X 是「新事实覆盖旧事实」),它们是 T1 最容易被相似度 // 带偏的两处。 const t1SystemPrompt = `你是记忆库的入库审查员。给你一条刚入库的新记忆,以及同一分区里若干条既有记忆候选。 -判断新记忆与既有候选的关系,只回一个 JSON 对象,不要任何其他文字、不要代码块。 +要回答的是**两个问题**,但只回一个 JSON 对象,不要任何其他文字、不要代码块。 -动作只许取这三个值之一: +第一问(关系):这条新记忆和某条既有候选,是不是在说同一件事? +第二问(价值):只有当第一问的答案是「谁都不像、它是新的一件事」时,才再问一句——**它值不值得长期留在记忆库里**? +若第一问答的是「和某条候选是同一件事」,第二问就不适用(留不留由那条既有记忆决定),直接回 merge 或 modify。 + +动作只许取这四个值之一: - "merge":新记忆与某条既有记忆说的是**同一件事、只是换了措辞**,且新记忆没有那条既有记忆之外的任何新信息 ⇒ 新记忆并入那条既有记忆(既有记忆的原文保持不变)。 - "modify":某条既有记忆是**新记忆的不完整版本**(新记忆覆盖它、并含它没有的细节)⇒ 那条既有记忆退役,由新记忆承载。 -- "keep":其余一切情形(不同事实、不同偏好、不同对象、只是同一主题)⇒ 新记忆独立保留。 +- "keep":是**新的一件事**,而且值得**长期**记住 ⇒ 新记忆独立长期保留。 +- "defer":是**新的一件事**,但**不值得长期记住**(只是一次性的、短期的、过了这阵子就没用的信息)⇒ 新记忆先留着,过一阵子自动消失。 必须遵守的判据: -- 只有「同一件事换措辞」才可以 merge;「相似但并列」的两件不同事实必须 keep。反例:"主人喜欢喝红牛来提神" 与 "主人喜欢喝冰美式来提神" 是两种不同的偏好,必须 keep,即便它们很相似。 -- 一方带否定、另一方没有("X" 与 "已经戒了 X"),那是新事实覆盖旧事实、不是重复,必须 keep。 -- 描述某一次具体事件、或带具体日期/时间的记忆,不要与长期习惯互相合并,必须 keep。 +- 只有「同一件事换措辞」才可以 merge;「相似但并列」的两件不同事实必须 keep 或 defer。反例:"主人喜欢喝红牛来提神" 与 "主人喜欢喝冰美式来提神" 是两种不同的偏好,必须 keep,即便它们很相似。 +- 一方带否定、另一方没有("X" 与 "已经戒了 X"),那是新事实覆盖旧事实、不是重复,必须 keep 或 defer。 +- 描述某一次具体事件、或带具体日期/时间的记忆,不要与长期习惯互相合并。 - 只是「都在说同一个领域」不算同一件事。 -- **拿不准就 keep**。宁可多留一条,也绝不把两件不同的事并成一条。 +- **长期事实、稳定偏好、习惯、约定与承诺、关系里程碑、主人的明确要求 = keep**(它们长期有用)。 +- **只对「这一次」有用的信息 = defer**,例如:一次性的安排或请求、当前临时的状态、时效性数据、转瞬即逝的琐事。 +- **拿不准就 keep**。宁可多留一条长期记忆,也绝不把两件不同的事并成一条;更不要因为拿不准就把一条真记忆判成临时的。 - **绝对不要输出任何记忆正文、摘要或改写文本**,只回动作、候选编号和一句理由。 -输出格式(严格 JSON,无代码块):{"action":"merge 或 modify 或 keep","target":候选编号,"reason":"一句话理由"} -keep 时 target 写 0。` +输出格式(严格 JSON,无代码块):{"action":"merge 或 modify 或 keep 或 defer","target":候选编号,"reason":"一句话理由"} +回 keep 或 defer 时 target 写 0(这两者都表示「不与任何候选合并」)。` // parseT1Reply 解析模型的 JSON 回复。被围栏包裹的 ```json 块会被剥离,前后夹带 // 的解说文字也被忽略(只取第一个 `{` 到最后一个 `}`)—— 容忍格式噪声,但**不猜**: diff --git a/internal/runtime/memory/preinsert_tuning.go b/internal/runtime/memory/preinsert_tuning.go new file mode 100644 index 0000000..316f486 --- /dev/null +++ b/internal/runtime/memory/preinsert_tuning.go @@ -0,0 +1,70 @@ +package memory + +import ( + "log/slog" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" + "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" +) + +// 本文件是**入库前审查调优面**的解析点:把 `$DATA_DIR/config/memory-tidy.json` +// 的面板旋钮叠到 ReviewConfig 上 —— 与 tidy_tuning.go 对 TidyConfig 做的事同源、 +// 同一条口径(主人 2026-10-07:「**`.env` 只做兜底用** —— 后端自动创建独立的面板 +// 配置文件之后就走面板配置文件,这些键优先从面板配置上取」)。 +// +// 为什么解析放在 memory 而不是 runtimeconfig:runtimeconfig 只负责**文件形状与 +// 读取**(它的头注释把这条边界写死了),而「缺位/坏值怎么回落」属于 ReviewConfig +// 的归一化语义 —— 与 normalizeReviewConfig 同一层,因此两者可以一起被单测覆盖。 +// +// 为什么不是「读盘时逐键容错」:整份文件解不开(含某键类型写错)时**全部键一起** +// 回落,这是本文件家族既有的粒度(见 runtimeconfig.LoadMemoryTidyTuning 与它的 +// 用例③);本文件处理的是**类型正确但值不可用**(非正数)那一档。 + +// ApplyReviewTuning 把面板调优旋钮叠到 ReviewConfig 上,返回叠加后的配置 +// (**未归一化**:调用方随后交给 NewPreInsertReviewer / normalizeReviewConfig +// 填代码默认值,与组合根对 TidyConfig 的既有顺序一致)。 +// +// 目前只有一键,逐键独立,三档优先级是: +// +// 键 面板(memory-tidy.json) 下一档(.env) 代码默认 +// tempTtlSec tempTtlSec **无此键**(结构性为空) 604800(7 天) +// +// 🔴 **中档(`.env`)在结构上是空的**,这是**刻意**的、不是漏配:`.env` 已冻结 +// (design/13 §2.1「不加、不改、不删」),而「多久算临时」是本次新增的能力, +// 它在 `.env` 里**从来就没有键**。因此实际生效链是「面板 > 代码默认」——报回报时 +// 不许把它写成「面板 > .env > 代码默认」而让读者以为 `.env` 那档还有东西可调。 +// +// 坏值(非正秒数)**绝不写进 cfg**:回落代码默认 + 记一条 WARN。一个 ≤0 的 TTL +// 会让每条 defer 记忆**落库即过期**(读侧立刻隐藏、后台清扫下一轮就删)——那是 +// 坏配置而不是「不过期」,但也不许因此让启动失败(design/03 §9.6)。 +func ApplyReviewTuning(cfg ReviewConfig, panel runtimeconfig.MemoryTidyTuning, log *slog.Logger) ReviewConfig { + log = logger.WithSource(baseTidyLog(log), "memory/review") + + if sec := panelPositiveTTLSec(panel.TempTtlSec, "tempTtlSec", log); sec > 0 { + cfg.TempTTL = time.Duration(sec) * time.Second + } + return cfg +} + +// panelPositiveTTLSec 解析**临时记忆 TTL** 旋钮,返回 0 表示「不覆盖」(调用方原样 +// 留着上一档的值,最终由 normalizeReviewConfig 落到代码默认 7 天)。 +// +// 三种情形分得很清,因为它们该有不同的观测形状(与 tidy_tuning.go 的 +// panelPositiveSec 逐字同构): +// +// - `nil`(键没写)⇒ 0,**静默**:没配不是错,正是「代码默认兜底」那一档; +// - 非正数(写了 0 或负数)⇒ 0 + **WARN**:它会让每条临时记忆落库即过期 +// (与「不过期」恰好相反),属于坏配置,必须留下痕迹; +// - 正数 ⇒ 原值。 +func panelPositiveTTLSec(v *int, key string, log *slog.Logger) int { + if v == nil { + return 0 + } + if *v <= 0 { + log.Warn("memory-tidy.json 的临时记忆 TTL 不可用,回落代码默认(非正 TTL 会让临时记忆落库即过期)", + "key", key, "value", *v, "fallback_sec", tempTtlSec) + return 0 + } + return *v +} diff --git a/internal/runtime/memory/tidy_snapshot.go b/internal/runtime/memory/tidy_snapshot.go index ef4f8f2..999598a 100644 --- a/internal/runtime/memory/tidy_snapshot.go +++ b/internal/runtime/memory/tidy_snapshot.go @@ -4,7 +4,7 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" ) -// 本文件是「启动播种」的**值来源**:把 `$DATA_DIR/config/memory-tidy.json` 十键的 +// 本文件是「启动播种」的**值来源**:把 `$DATA_DIR/config/memory-tidy.json` 十一键的 // **当前生效值**拍成一份完整快照,交给 runtimeconfig.SeedMemoryTidy 落盘。 // // 它回应主人 2026-10-07 的两条口径: @@ -24,12 +24,12 @@ import ( // 的配置,只是侥幸等价;取归一化后的值,等价性才是结构性的(见 // TestTidyTuningSnapshot_PanelRoundTripKeepsEffectiveConfig)。 // -// 为什么只有一处实现:十键的默认值在 memory 侧各只有一处定义 +// 为什么只有一处实现:十一键的默认值在 memory 侧各只有一处定义 // (tidy_config.go / preinsert_config.go 的 default* 常量),快照直接复用那两个 // normalize,**不另写一份默认值** —— 否则播种写下去的就是第二份「差不多」的默认值, // 会与运行期悄悄分叉。 // -// 十键与它们的生效来源: +// 十一键与它们的生效来源: // // 键 生效来源(无面板文件时) 快照值 // autoApply DisableAutoApply 的反向(代码默认开) true @@ -38,12 +38,13 @@ import ( // t1Judge DisableT1 的反向(默认开) true // t1PerWindow defaultT1PerWindow 120 // t1WindowSec defaultT1Window 3600 +// tempTtlSec ReviewConfig.TempTTL(默认 7 天 = 604800) 归一化值 // intervalSec TidyConfig.IntervalSec(env 或 86400) 归一化值 // quietIntervalSec 缺省 = intervalSec 归一化值 // quietStartHour TidyConfig.QuietStartHour(env 或 23) 归一化值 // quietEndHour TidyConfig.QuietEndHour(env 或 7) 归一化值 -// TidyTuningSnapshot 拍下十键的**当前生效值**,返回一份可直接落盘的面板快照。 +// TidyTuningSnapshot 拍下十一键的**当前生效值**,返回一份可直接落盘的面板快照。 // // 入参是**已经叠过面板层的**两个配置(组合根的次序:TidyConfigFromConfig → // ApplyTidyScheduleTuning → 本函数;审查侧同理),本函数只做最后一步归一化并翻成 @@ -51,10 +52,11 @@ import ( // // - tidy 侧四键(intervalSec / quietIntervalSec / quietStartHour / quietEndHour) // 与自动档两键(autoApply / autoApplyPerRound); -// - review 侧四键(ingestGate / t1Judge / t1PerWindow / t1WindowSec): -// 这四项在 `.env` 里**没有出处**(`.env` 已冻结),只由面板文件与代码默认决定, -// 而播种只发生在**面板文件不存在**时 —— 那正是「零值 + normalizeReviewConfig」 -// 这一条路径,与运行期(wireMemoryReview 的三个 helper)逐字一致。 +// - review 侧五键(ingestGate / t1Judge / t1PerWindow / t1WindowSec / +// tempTtlSec):这五项在 `.env` 里**没有出处**(`.env` 已冻结),只由面板文件 +// 与代码默认决定,而播种只发生在**面板文件不存在**时 —— 那正是「零值 + +// normalizeReviewConfig」这一条路径,与运行期(wireMemoryReview 的四个 helper) +// 逐字一致。 // // 两个开关是**反向**字段(DisableX),快照要把它们翻回正向的 JSON 键:文件里的 // `"autoApply": true` 才是「没配时到底开不开」的如实陈述。 @@ -69,6 +71,9 @@ func TidyTuningSnapshot(tidy TidyConfig, review ReviewConfig) runtimeconfig.Memo quietIntervalSec := tidy.QuietIntervalSec quietStartHour := tidy.QuietStartHour quietEndHour := tidy.QuietEndHour + // 临时记忆 TTL(design/03 §9.6):normalize 之后恒为正(7 天 = 604800), + // 因此快照写下去的**必然是有效值**,不会播出一份自己读回都要 WARN 的配置。 + tempTtlSec := int(review.TempTTL.Seconds()) return runtimeconfig.MemoryTidyTuning{ AutoApply: &autoApply, @@ -77,6 +82,7 @@ func TidyTuningSnapshot(tidy TidyConfig, review ReviewConfig) runtimeconfig.Memo T1Judge: &t1Judge, T1PerWindow: review.T1PerWindow, T1WindowSec: int(review.T1Window.Seconds()), + TempTtlSec: &tempTtlSec, IntervalSec: &intervalSec, QuietIntervalSec: &quietIntervalSec, QuietStartHour: &quietStartHour,