Author SHA1 Message Date
AskaEth 21b906ffb0 feat(memory): D3 第五态 defer(入库审查)+ 提取留存规则闸 + 临时 TTL 配置面
主人原话:「收窄提取器,确实要给他一个主动式的记忆留存规则,不能想到什么都
往记忆里写」「.env 只做兜底用 …这些键优先从面板配置上取」「临时记忆到期删除」。
按 design/23 §18(D3 第五态 defer)、§19(到期处置)、design/03 §9.6
(tempTtlSec 默认 7 天 + 坏值口径)、§10(提取留存规则)落地。

- IngestDecision 闭集加第五态 defer:照常 active 落库 + 写短 expires_at 落
  临时层(不删行、不改正文,回滚 = 清空该列);IngestRuleVersion d3.2 → d3.3。
- 落点:ingestGatePlan 增 ExpiresAt + expiresAt()(走 expiryText/formatTime 的
  定宽 30 字节 UTC,与读侧时效闸同一套文本口径),由 finishIngest 在 defer 一态
  补写 expires_at(INSERT 与补写之间的短暂窗口如实登记,方向 = 宁可多留)。
  非 defer 三态一笔都不多写。
- T0 提取留存规则闸(extract_retention.go):design/03 §10.5 的 N1~N5 纯规则,
  接进 judgeIngest 的候选比对之前,命中即 defer + reason=t0_low_value,不进 T1;
  两道护栏(显式「记住」/ 持久信号)一票放行,fail-safe 方向 = 宁可多留。
- T1 闭集扩为 merge/modify/keep/defer(同一个 prompt、同一次调用,绝不新增
  第二次模型调用):只有关系判为真新时才问价值;defer 要求 target=0,否则判
  非法回落 create(reason=t1_reply_invalid)。
- 提取器提示词加固(design/03 §10.3 B):正向清单 + 反例清单 + 「允许返回 []」。
- 临时记忆 TTL:代码默认 tempTtlSec = 604800(7 天);面板
  $DATA_DIR/config/memory-tidy.json 的 tempTtlSec 优先(ApplyReviewTuning 逐键
  解析,坏值回落默认 + WARN,绝不写进 cfg)。该键在 .env 里没有出处且不新增
  (.env 已冻结)⇒ 实际生效链是「面板 > 代码默认」。启动播种同步该键(*int +
  omitempty,nil 省略、绝不写 null),保证播种文件与生效值逐字一致。
- preinsert_store.go 的 listReviewCandidatesSQL 原位注释:这条内部召回 SQL
  刻意不过时效闸的裁定与理由(design/03 §11.2 第 3 条)。
- 测试:defer 落库带短 expires_at(闸 + T1 两条路径)、写入口径与时效闸同源、
  TTL 坏值与回落、规则版本 d3.3、整理候选幂等键不含 IngestRuleVersion、
  提取规则正例 11 条 / 反例 11 条。

验收:go build ./... + go vet ./... + go test ./... 全绿;windows/darwin 交叉
编译通过(GOOS=darwin GOARCH=arm64 / GOOS=windows GOARCH=amd64)。
2026-10-13 20:22:48 +08:00
27 changed files with 1396 additions and 1071 deletions

No files matched your search

+38 -1
View File
@@ -41,7 +41,10 @@ var errT1ModelNotReady = errors.New("memory review: t1 judge: model adapter not
// - `{"ingestGate": false}` ⇒ 一键回到「只插不审」(同步入库闸); // - `{"ingestGate": false}` ⇒ 一键回到「只插不审」(同步入库闸);
// - `{"t1Judge": false}` ⇒ 一键回到纯规则(T1 灰区判定,**零模型调用**); // - `{"t1Judge": false}` ⇒ 一键回到纯规则(T1 灰区判定,**零模型调用**);
// - `{"t1PerWindow": 120, "t1WindowSec": 3600}` ⇒ 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 { func (a *app) wireMemoryReview() error {
cfg, err := config.LoadMemoryReviewConfig(envPath) cfg, err := config.LoadMemoryReviewConfig(envPath)
if err != nil { if err != nil {
@@ -61,6 +64,10 @@ func (a *app) wireMemoryReview() error {
// 交零值给 normalizeReviewConfig 回落**代码默认值**(120 次/60 分钟)—— // 交零值给 normalizeReviewConfig 回落**代码默认值**(120 次/60 分钟)——
// 坏配置绝不能让一处限速静默变成「无上限」。 // 坏配置绝不能让一处限速静默变成「无上限」。
t1PerWindow, t1WindowSec := a.memoryT1RateLimit() 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{ reviewer, err := memory.NewPreInsertReviewer(memory.ReviewConfig{
Enabled: cfg.Enabled, Enabled: cfg.Enabled,
QueueSize: cfg.QueueSize, QueueSize: cfg.QueueSize,
@@ -82,6 +89,9 @@ func (a *app) wireMemoryReview() error {
// (120 次/60 分钟)。**没有「无上限」这个状态**:要零花费请关 t1Judge。 // (120 次/60 分钟)。**没有「无上限」这个状态**:要零花费请关 t1Judge。
T1PerWindow: t1PerWindow, T1PerWindow: t1PerWindow,
T1Window: time.Duration(t1WindowSec) * time.Second, T1Window: time.Duration(t1WindowSec) * time.Second,
// 第五态 `defer` 的落点(design/23 §18.1):`expires_at` = 判定时刻 + 它。
// 零值 = 没配 ⇒ 由 normalizeReviewConfig 回落代码默认 7 天(tempTtlSec)。
TempTTL: tempTTL,
}, memory.ReviewDeps{ }, memory.ReviewDeps{
Store: a.memStore, Store: a.memStore,
Embedder: a.embedder, Embedder: a.embedder,
@@ -151,6 +161,33 @@ func (a *app) memoryT1RateLimit() (perWindow int, windowSec int) {
return tuning.T1PerWindow, tuning.T1WindowSec 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 的一次性调用口。 // memoryT1Judge 把「调用时才解析的模型适配器」包成 T1 的一次性调用口。
// //
// 为什么是闭包而不是直接塞 `a.modelAdapter`:组合根的顺序是 **wireMemory(建 // 为什么是闭包而不是直接塞 `a.modelAdapter`:组合根的顺序是 **wireMemory(建
@@ -157,6 +157,20 @@ type MemoryTidyTuning struct {
// 两端都没配 ⇒ 代码默认 23:00-07:00。 // 两端都没配 ⇒ 代码默认 23:00-07:00。
QuietStartHour *int `json:"quietStartHour"` QuietStartHour *int `json:"quietStartHour"`
QuietEndHour *int `json:"quietEndHour"` 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 —— // AutoApplyOff 报告自动档是否被这份配置**显式关掉**。nil(没配)返回 false ——
@@ -59,7 +59,7 @@ const (
var memoryTidySeedKeys = []string{ var memoryTidySeedKeys = []string{
"autoApply", "autoApplyPerRound", "ingestGate", "t1Judge", "autoApply", "autoApplyPerRound", "ingestGate", "t1Judge",
"t1PerWindow", "t1WindowSec", "intervalSec", "quietIntervalSec", "t1PerWindow", "t1WindowSec", "intervalSec", "quietIntervalSec",
"quietStartHour", "quietEndHour", "quietStartHour", "quietEndHour", "tempTtlSec",
} }
// MemoryTidySeed 是一次播种的可诊断产物(口径对齐 materialize.go 的 Result: // MemoryTidySeed 是一次播种的可诊断产物(口径对齐 materialize.go 的 Result:
@@ -189,6 +189,13 @@ func memoryTidySeedComplete(seed MemoryTidyTuning) error {
if seed.QuietEndHour == nil { if seed.QuietEndHour == nil {
missing = append(missing, "quietEndHour") missing = append(missing, "quietEndHour")
} }
// tempTtlSec 同样要求非 nil:它是 `*int` + `omitempty`,**nil 会让该键从播种
// 文件里消失**,于是「播种文件与当时的生效值逐字一致」这句话就不再成立 ——
// 文件少一个键、读回来却是代码默认值,主人打开文件无从判断到底配没配。
// 快照侧(memory.TidyTuningSnapshot)恒填它,因此这条不会拦住正常路径。
if seed.TempTtlSec == nil {
missing = append(missing, "tempTtlSec")
}
if len(missing) > 0 { if len(missing) > 0 {
return fmt.Errorf("runtimeconfig: memory tidy seed missing keys: %s", strings.Join(missing, ", ")) 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 { if *seed.QuietEndHour < 0 || *seed.QuietEndHour > 23 {
return fmt.Errorf("runtimeconfig: memory tidy seed quietEndHour = %d (want 0..23)", *seed.QuietEndHour) 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 return nil
} }
@@ -24,9 +24,14 @@ import (
// 用例全部落在**临时目录**(`t.TempDir()`):生产那份 `data/config/memory-tidy.json` // 用例全部落在**临时目录**(`t.TempDir()`):生产那份 `data/config/memory-tidy.json`
// 是主人手工建的,播种逻辑只在文件缺失时触发,用例不许依赖它存在与否。 // 是主人手工建的,播种逻辑只在文件缺失时触发,用例不许依赖它存在与否。
// completeSeedTuning 造一份「十键齐全」的播种内容。值刻意都**不是**代码默认值 // completeSeedTuning 造一份「十一键齐全」的播种内容。值刻意都**不是**代码默认值
// (autoApply=false / 3600 / 10800),这样「播的是调用方给的快照」与「播了一份硬编码 // (autoApply=false / 3600 / 10800 / tempTtlSec=259200),这样「播的是调用方给的
// 的默认值」在断言里一眼可分。 // 快照」与「播了一份硬编码的默认值」在断言里一眼可分。
//
// 它必须给出**全部** `memoryTidySeedKeys`(含 tempTtlSec):该键带 `omitempty`,
// nil 时会被整键省略 —— 那正是「没配 ⇒ 用代码默认」的正常形态,但本用例要验的是
// 「键齐全时逐一落盘」,因此这里显式给值(见 TestSeedMemoryTidy_SeedsMissingFile
// 的键集合断言)。
func completeSeedTuning() MemoryTidyTuning { func completeSeedTuning() MemoryTidyTuning {
return MemoryTidyTuning{ return MemoryTidyTuning{
AutoApply: boolSeedPtr(false), AutoApply: boolSeedPtr(false),
@@ -39,6 +44,7 @@ func completeSeedTuning() MemoryTidyTuning {
QuietIntervalSec: intSeedPtr(10800), QuietIntervalSec: intSeedPtr(10800),
QuietStartHour: intSeedPtr(23), QuietStartHour: intSeedPtr(23),
QuietEndHour: intSeedPtr(7), QuietEndHour: intSeedPtr(7),
TempTtlSec: intSeedPtr(259200),
} }
} }
@@ -1,9 +1,11 @@
package runtimeconfig package runtimeconfig
import ( import (
"encoding/json"
"errors" "errors"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
) )
@@ -278,3 +280,54 @@ func TestLoadMemoryTidyTuning_ScheduleKeys(t *testing.T) {
t.Fatalf("intervalSec 写成字符串应当返回 ErrInvalidMemoryTidy,得到 %v", err) 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)
}
}
+20 -76
View File
@@ -11,7 +11,6 @@ import (
"net/http" "net/http"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/config"
@@ -39,24 +38,23 @@ const (
// DefaultRequestTimeout 是**单次** /embeddings 调用的墙钟上限,独立于 HTTP 客户端 // DefaultRequestTimeout 是**单次** /embeddings 调用的墙钟上限,独立于 HTTP 客户端
// 那一层的 180s 总超时(httpRequestTimeout)。 // 那一层的 180s 总超时(httpRequestTimeout)。
// //
// 它**不是**单独取值:批次上限(DefaultBatchLimit / DefaultBatchChars)与它由 // 依据(是实测数据,不是拍脑袋的调优):
// budget.go 里**同一个不等式**推出 —— 单次请求的耗时上界不得超过超时的一半。 // - 上层「入库前审查」的单条总预算是 20s(memory.defaultReviewTimeout),embedding
// 改这一行就等于改了批次上限,反之亦然;两侧永远是配套的。 // 只是它内部的第一步。2026-10-07 实测(本机 llama.cpp + Qwen3-Embedding-0.6B,
// // 服务空闲):单条文本约 60ms;**64 条一批约 2.1~2.9s**;审查最坏情况
// 为什么每次调用必须有自己的上限:上层「入库前审查」的单条总预算是 20s // (ScanLimit=200 + 新条目 1)要 4 批 ⇒ 约 8~10s,已经占掉 20s 预算的一半,
// (memory.defaultReviewTimeout),embedding 只是它内部的第一步,后面还有 T1 灰区 // 留给后面的 T1 灰区判定(defaultT1Timeout=8s)的余量很薄。一次卡死的 HTTP
// 判定(8s)。一次卡死的 HTTP 不能把整条审查的 20s 都赌掉。真慢就**快速失败**, // 就能把这点余量连同整条审查一起吃掉(实测日志里
// 调用方按**既有语义**降级(审查降级到词项余弦并如实留痕 degraded), // `memory/review: context deadline exceeded` 正是这个形态);
// **绝不静默丢记忆** —— 本常量只砍等待时间,不改任何判定规则。 // - 所以每次调用必须有**自己的**上限:卡住的那一批到点就失败并降级,不能把整条
// // 审查的 20s 都赌在一次 HTTP 上;
// 5s 保持不变的依据:本次故障(2026-10-13)的成因不是这个数太小,而是批次太大 —— // - 5s 的取值:相对实测的 64 条一批 2.9s 留约 1.7 倍余量,能吸收并发争用带来的
// 64 条的实测耗时 7.5~8.5s 已经越过它,客户端放弃、服务端还在算,把整台本机 // 抖动,同时远小于 20s 的审查预算。若日后批次变大或服务变慢,先调这里而不是
// embedding 服务顶住,连带单条请求(写向量索引)也一起超时。所以修法是**把批次 // 调审查总预算;
// 收进预算内**,而不是把预算放大到迁就批次(后者会让真挂死的请求也拖住整轮)。 // - 真慢就**快速失败**:调用方按**既有语义**降级(审查降级到词项余弦并如实留痕
// // degraded),**绝不静默丢记忆** —— 本常量只砍等待时间,不改任何判定规则;
// 冷启动另有 warmupTimeout(见 budget.go),但它**只放宽本进程的第一个请求**、 // - 已知代价:llama.cpp 首次加载模型可能超过 5s,那一次调用会失败并降级,模型
// 且一次性花掉:失败不会让它续期,因此「服务一直挂死」这件事**不会**变成 // 常驻后的下一次调用即恢复正常。降级本就是既有行为,因此可接受。
// 「每次调用都慢」—— 第二个请求起就回到本常量。
const DefaultRequestTimeout = 5 * time.Second const DefaultRequestTimeout = 5 * time.Second
// openAICompatibleRequest is the JSON body sent to an OpenAI-compatible // openAICompatibleRequest is the JSON body sent to an OpenAI-compatible
@@ -95,11 +93,6 @@ type APIEmbedder struct {
cfg config.EmbeddingConfig cfg config.EmbeddingConfig
client *http.Client client *http.Client
log *slog.Logger log *slog.Logger
// warmupLeft 是**一次性**冷启动放宽的开关:为真时下一个请求用 warmupTimeout,
// 取走即置假(CompareAndSwap ⇒ 并发下也只有一个请求能拿到)。
// 它**不是**「未成功就一直放宽」—— 失败也照样花掉这一次,因此服务挂死时
// 只会慢一次,之后每个请求都按 DefaultRequestTimeout 快速失败(见 budget.go)。
warmupLeft atomic.Bool
mu sync.RWMutex mu sync.RWMutex
dim int dim int
@@ -138,16 +131,12 @@ func WithLogger(l *slog.Logger) APIOption {
} }
// NewAPI builds an APIEmbedder from cfg using the default HTTP client. // NewAPI builds an APIEmbedder from cfg using the default HTTP client.
// 单次超时不可由选项更换:它是 budget.go 那条推演的输入,批次上限与它同源,
// 因此**只允许改常量**(改了之后批次上限自动跟着改,测试的编译期/常量断言立刻复核)。
func NewAPI(cfg config.EmbeddingConfig, opts ...APIOption) *APIEmbedder { func NewAPI(cfg config.EmbeddingConfig, opts ...APIOption) *APIEmbedder {
e := &APIEmbedder{ e := &APIEmbedder{
cfg: cfg, cfg: cfg,
client: defaultHTTPClient(), client: defaultHTTPClient(),
log: slog.New(slog.NewTextHandler(io.Discard, nil)), log: slog.New(slog.NewTextHandler(io.Discard, nil)),
} }
// 冷启动放宽是一个**一次性**许可:见 APIEmbedder.warmupLeft。
e.warmupLeft.Store(true)
for _, opt := range opts { for _, opt := range opts {
opt(e) opt(e)
} }
@@ -166,10 +155,8 @@ func (e *APIEmbedder) Dim() int {
// returned vectors aligned by index. It returns ErrNoAPIURL for a blank URL // returned vectors aligned by index. It returns ErrNoAPIURL for a blank URL
// and a wrapped error for any HTTP or decode failure. // and a wrapped error for any HTTP or decode failure.
// //
// texts 长度不受调用方控制:本方法按 budget.go 推出来的上限(条数 / 字符总量) // 单次调用带独立的、比上层审查总预算(20s)短得多的超时(DefaultRequestTimeout),
// **自己切片**,分多次请求顺序发出,因此「一次请求的耗时 < 单次超时」这条不变式 // 卡住即快速失败,把预算留给后续步骤;失败语义与降级口径不变。
// 对任何调用方都成立 —— 调用方给多大的切片都不会把服务端顶住。中途任一段失败即
// 整体失败(返回已发请求的错误),绝不半途改用混合表示。
func (e *APIEmbedder) Embed(ctx context.Context, texts []string) ([]Vector, error) { func (e *APIEmbedder) Embed(ctx context.Context, texts []string) ([]Vector, error) {
if len(texts) == 0 { if len(texts) == 0 {
return []Vector{}, nil return []Vector{}, nil
@@ -181,40 +168,7 @@ func (e *APIEmbedder) Embed(ctx context.Context, texts []string) ([]Vector, erro
return nil, ErrNoAPIURL return nil, ErrNoAPIURL
} }
// 切片用的上限由**稳态**超时推出(budget.go 的 DefaultRequestTimeout): callCtx, cancel := context.WithTimeout(ctx, DefaultRequestTimeout)
// 冷启动那一次只放宽**截止时刻**,批形状不变 —— 否则首个请求会按更宽的预算
// 切出一个巨大的批,正好把冷启动的模型顶得更久。
items, chars := batchLimitsFor(DefaultRequestTimeout)
out := make([]Vector, 0, len(texts))
for _, chunk := range chunkTexts(texts, items, chars) {
vectors, err := e.embedChunk(ctx, chunk)
if err != nil {
return nil, err
}
out = append(out, vectors...)
}
return out, nil
}
// callTimeout 返回本次请求该用的单次超时,并**当场花掉**一次性冷启动放宽许可
// (成功、失败都算花掉)。因此:
// - 本进程的第一个请求最多等 warmupTimeout(冷启动),
// - 从第二个请求起一律 DefaultRequestTimeout —— 服务挂死时不会每次都慢。
//
// 返回值 relaxed 表示这一次是不是拿到了放宽,供失败时留痕。
func (e *APIEmbedder) callTimeout() (timeout time.Duration, relaxed bool) {
if e.warmupLeft.CompareAndSwap(true, false) {
return warmupTimeout, true
}
return DefaultRequestTimeout, false
}
// embedChunk 发**一次** /embeddings 请求。chunk 必须已经落在 batchLimitsFor 推出来
// 的边界内(Embed 负责切),因此它的耗时上界不超过单次超时的一半。
func (e *APIEmbedder) embedChunk(ctx context.Context, texts []string) ([]Vector, error) {
// 先取超时(取走即花掉那次放宽),再建 ctx:放宽许可与这次请求一一对应。
timeout, relaxed := e.callTimeout()
callCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel() defer cancel()
req, err := e.newEmbeddingRequest(callCtx, texts) req, err := e.newEmbeddingRequest(callCtx, texts)
@@ -224,16 +178,6 @@ func (e *APIEmbedder) embedChunk(ctx context.Context, texts []string) ([]Vector,
resp, err := e.client.Do(req) resp, err := e.client.Do(req)
if err != nil { if err != nil {
// 冷启动放宽被花掉且没成功时留一条 WARN:它是「本进程第一次请求」这个
// 判断唯一能被事后复核的地方(此后一切失败都按常态超时快速失败)。
// 只记两个预算与错误(错误文本里已带端点,不另记 URL,避免重复落一条
// 可能含 userinfo 的地址)。
if relaxed {
e.log.Warn("embedding: one-shot cold-start allowance spent without a successful response",
"warmup_timeout", warmupTimeout.String(),
"steady_timeout", DefaultRequestTimeout.String(),
"error", err)
}
return nil, fmt.Errorf("embedding: request: %w", err) return nil, fmt.Errorf("embedding: request: %w", err)
} }
defer func() { _ = resp.Body.Close() }() defer func() { _ = resp.Body.Close() }()
+2 -24
View File
@@ -222,11 +222,6 @@ const reviewBudgetRef = 20 * time.Second
// 审查总预算:否则一次卡死的 embedding 连接就能吃光整条审查的预算,排在后面的 // 审查总预算:否则一次卡死的 embedding 连接就能吃光整条审查的预算,排在后面的
// T1 灰区判定连执行机会都没有(2026-10-07 实测故障形态)。同时验证该超时确实 // T1 灰区判定连执行机会都没有(2026-10-07 实测故障形态)。同时验证该超时确实
// 打在了发出去的请求上。 // 打在了发出去的请求上。
//
// 🔴 冷启动放宽(warmupTimeout)必须一并守住同一条界:它只发生一次,但它那一次的
// 等待**也**不能吃光整轮审查 —— 否则「一次挂死 = 整轮审查没有预算给 T1」这个形态
// 会从常态超时挪到放宽预算上原样复现(reviewer round1 的 p1)。因此这里对
// warmupTimeout 做与 DefaultRequestTimeout **同一条**断言。
func TestDefaultRequestTimeout(t *testing.T) { func TestDefaultRequestTimeout(t *testing.T) {
t.Parallel() t.Parallel()
@@ -241,19 +236,6 @@ func TestDefaultRequestTimeout(t *testing.T) {
t.Fatalf("DefaultRequestTimeout = %s, want 5s(值变了要连带复核审查预算口径)", t.Fatalf("DefaultRequestTimeout = %s, want 5s(值变了要连带复核审查预算口径)",
DefaultRequestTimeout) DefaultRequestTimeout)
} }
// 冷启动放宽的两条界:仍在常态超时的**同一量级**(不得成倍放大),
// 且严格小于审查总预算(一次冷启动不许吃光整轮)。
if warmupTimeout <= 0 {
t.Fatalf("warmupTimeout = %s, 必须为正", warmupTimeout)
}
if warmupTimeout > 2*DefaultRequestTimeout {
t.Fatalf("冷启动放宽 %s 超过常态超时 %s 的两倍 —— 它必须是同一量级的一次性放宽",
warmupTimeout, DefaultRequestTimeout)
}
if warmupTimeout >= reviewBudgetRef {
t.Fatalf("冷启动放宽 %s 必须严格小于审查总预算 %s(否则一次冷启动就吃光整轮审查)",
warmupTimeout, reviewBudgetRef)
}
var slack time.Duration var slack time.Duration
rt := roundTripFunc(func(r *http.Request) (*http.Response, error) { rt := roundTripFunc(func(r *http.Request) (*http.Response, error) {
@@ -274,12 +256,8 @@ func TestDefaultRequestTimeout(t *testing.T) {
config.EmbeddingConfig{APIURL: "http://127.0.0.1:11434/v1/embeddings"}, config.EmbeddingConfig{APIURL: "http://127.0.0.1:11434/v1/embeddings"},
WithRoundTripper(rt), WithRoundTripper(rt),
) )
// 第一次是冷启动调用(warmupTimeout),结果由它自己的测试断言;这里连发两次, if _, err := emb.Embed(context.Background(), []string{"hi"}); err != nil {
// 只看稳态那一次打出去的 deadline。 t.Fatalf("Embed() error: %v", err)
for i := 0; i < 2; i++ {
if _, err := emb.Embed(context.Background(), []string{"hi"}); err != nil {
t.Fatalf("第 %d 次 Embed() error: %v", i+1, err)
}
} }
if slack <= 0 || slack > DefaultRequestTimeout { if slack <= 0 || slack > DefaultRequestTimeout {
t.Fatalf("请求 deadline 余量 = %s, want (0, %s]", slack, DefaultRequestTimeout) t.Fatalf("请求 deadline 余量 = %s, want (0, %s]", slack, DefaultRequestTimeout)
-145
View File
@@ -1,145 +0,0 @@
package embedding
import "time"
// 本文件是**单次 /embeddings 调用的预算推演**:批次上限与单次超时必须由**同一个
// 式子**得出。任一侧单独调整都会让「一次请求的实测耗时 < 单次超时」这条不变式
// 静默失效 —— 而失效的后果不是报错,是**服务端还在算、客户端已经放弃**:被放弃的
// 那一批仍占着本机 llama.cpp 的算力,后面每一个请求(包括写索引时的单条请求)都
// 排在它后面等到自己的超时,于是整条记忆写入路径被拖住、召回全线降级。
//
// ## 不变式
//
// requestOverhead + 一次请求的字符总量 × perCharBudget ≤ DefaultRequestTimeout / callBudgetShare
//
// 左半是**推演出来的耗时上界**,右半是该超时下允许用掉的一半(另一半是这台机器的
// 抖动余量)。批次上限由解这个不等式得到(见 DefaultBatchLimit / DefaultBatchChars)。
//
// ⚠️ 这条不等式**只按稳态超时(DefaultRequestTimeout)推导,且只约束批次形状**;
// 冷启动那次放宽(warmupTimeout)**不改批形状**,只放宽进程第一个请求的**截止
// 时刻**,而且只发生一次(见 warmupTimeout 的说明)。批形状与超时仍然同源:
// 无论第一个请求的截止多宽,它带的条数/字符数永远按稳态超时算出来。
//
// ## 实测依据(2026-10-13,本机 = AMD RX 6600 XT + llama.cpp b11371 +
// Qwen3-Embedding-0.6B Q8_0,端点 127.0.0.1:11434,**服务器空闲**,正文取自活库
// data/cyrene.db:短正文约 33 汉字、长正文约 168 汉字;每档 5~6 次取最坏)
//
// n=1 短 0.066~0.185s 长 0.740s
// n=8 短 0.86~1.11s
// n=12 短 1.27s 长 5.47s
// n=16 短 2.20~3.31s
// n=64 短 7.49~8.49s(改前的批次上限)
//
// 拟合:单次请求耗时 ≈ 固定开销 + 字符总量 × 每字符成本;固定开销实测约 50ms,
// 每字符成本实测 2.7~3.9ms。**同一批 n≥32 起出现过数倍放大**(8.97s / 26.6s),
// 因此大批次的耗时不可预测 —— 这也是「余量取一半」而不是「卡着边界」的依据。
//
// 复跑命令(读活库正文、只连本机 embedding 服务、不写任何库):
//
// python - <<'PY'
// import json,sqlite3,time,urllib.request
// URL="http://127.0.0.1:11434/v1/embeddings"; MODEL="<model id>"
// t=[r[0] for r in sqlite3.connect("data/cyrene.db").execute(
// "select content from memory_items order by id limit 64")]
// for b in (t[:1],t[:8],t[:12],t[:16],t[:64]):
// body=json.dumps({"model":MODEL,"input":b}).encode()
// r=urllib.request.Request(URL,data=body,headers={"Content-Type":"application/json"})
// s=time.perf_counter(); urllib.request.urlopen(r,timeout=300).read()
// print(len(b), round(time.perf_counter()-s,3))
// PY
const (
// requestOverhead 是单次 HTTP 调用的固定开销上界(实测 ~50ms,向上取整)。
requestOverhead = 100 * time.Millisecond
// perCharBudget 是**每字符**的墙钟预算上界(实测 2.7~3.9 ms/字符,取 5ms)。
perCharBudget = 5 * time.Millisecond
// perItemBudget 是**每条文本**的墙钟预算上界:按活库里最长的正文(168 汉字,
// 最坏摊销 0.456 s/条)向上取整到 500ms。
perItemBudget = 500 * time.Millisecond
// callBudgetShare 表示一次请求最多用掉超时的 1/2:另一半是抖动余量
// (见上面 n≥32 的数倍放大)。调大它 = 余量变小,必须连带复核实测。
callBudgetShare = 2
// DefaultBatchLimit 是单次请求的**条数**上限,由上面三个常量推出来(唯一推演):
// (5s/2 − 100ms) / 500ms = 4。
DefaultBatchLimit = (int(DefaultRequestTimeout/callBudgetShare) - int(requestOverhead)) / int(perItemBudget)
// DefaultBatchChars 是单次请求的**字符总量**上限,同一个不等式换成每字符预算:
// (5s/2 − 100ms) / 5ms = 480。它防的是「条数很少但每条很长」:
// 4 条 × 168 汉字 = 672 字符已越过它,会被再切一刀。
DefaultBatchChars = (int(DefaultRequestTimeout/callBudgetShare) - int(requestOverhead)) / int(perCharBudget)
// warmupTimeout 是**一次性的**冷启动放宽:本 embedder 生命周期里的**第一个**
// 请求可以拿到这个截止(模型加载 / GPU 缓冲分配还没发生过),此后**每一个**
// 请求(含刚失败的那个之后的下一个)一律回到 DefaultRequestTimeout。
//
// 🔴 它**不是**「只要还没成功过就一直放宽」—— 那个口径会在服务挂死时把
// fail-fast 变成 fail-slow(每次都等 warmupTimeout),并把审查的 20s 预算、
// 索引写入的 5s 预算整段吃掉。一次性 + 8s 两条一起才是有界的:
// - 一次性:放宽最多花掉**一个**请求的时长,最坏情形之后立刻回到 5s;
// - 8s:与常态 5s **同一量级**(1.6×),且严格小于审查单条总预算 20s
// (memory.defaultReviewTimeout)⇒ 一次冷启动不可能吃光整轮审查,
// 后面的 T1 灰区判定(8s)仍有预算。这条不等式由测试守住
// (api_url_test.go 的 TestDefaultRequestTimeout)。
//
// 依据:2026-10-13 部署后第一分钟的生产日志里有一条
// `vector embed for index failed ... embed 1 texts ... context deadline exceeded`
// —— 模型还没热时 5s 不够;而稳态实测单条只要 0.03~0.19s(见上面的曲线),
// 所以放宽只需要覆盖「进程的第一个请求」这一刻。
warmupTimeout = 8 * time.Second
)
// callBudget 返回在 timeout 这个单次超时下,一次请求允许用掉的墙钟预算。
func callBudget(timeout time.Duration) time.Duration {
if timeout <= 0 {
timeout = DefaultRequestTimeout
}
return timeout/callBudgetShare - requestOverhead
}
// batchLimitsFor 由**同一个超时**推出一对批次上限(条数 / 字符总量)。
// 它保证:按 (条数 ≤ items 且 字符总量 ≤ chars) 切出来的任何一段,其耗时上界
// 都不超过该超时的一半。两者都至少为 1 —— 单条文本本身越界时独占一段,
// 正文**绝不截断**(截断会让向量描述的不是这条记忆)。
func batchLimitsFor(timeout time.Duration) (items, chars int) {
budget := callBudget(timeout)
items = int(budget / perItemBudget)
if items < 1 {
items = 1
}
chars = int(budget / perCharBudget)
if chars < 1 {
chars = 1
}
return items, chars
}
// chunkTexts 按「条数 ≤ maxItems 且 字符总量 ≤ maxChars」把 texts 切成若干段。
// 单条文本的 rune 数超过 maxChars 时它独占一段(见 batchLimitsFor 的口径)。
// 空输入返回 nil。
func chunkTexts(texts []string, maxItems, maxChars int) [][]string {
if len(texts) == 0 {
return nil
}
if maxItems < 1 {
maxItems = 1
}
if maxChars < 1 {
maxChars = 1
}
var out [][]string
start := 0
chars := 0
for i, text := range texts {
n := len([]rune(text))
// 段内已有内容,再加这一条就越界 ⇒ 先收口,本段从这一条重新开始。
if i > start && (i-start >= maxItems || chars+n > maxChars) {
out = append(out, texts[start:i])
start, chars = i, 0
}
chars += n
}
return append(out, texts[start:])
}
-393
View File
@@ -1,393 +0,0 @@
package embedding
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// 本文件把 budget.go 的**不变式**钉成可执行的断言,两态各一条:
//
// - 「批内耗时 < 超时」⇒ 不降级:DerivedBatchCompletesWithinTimeout;
// - 「批内耗时 ≥ 超时」⇒ 超时(本次故障形态):LegacyBatchOf64TimesOut;
//
// 以及**变异自证**:把批上限调回旧值 64(或把推演调松)会让哪条测试红,
// 见 TestBatchBudget_InvariantHolds 与 TestAPIEmbedder_LegacyBatchOf64TimesOut。
//
// 假客户端用的是**实测曲线**(不是随口编的常数):单次请求耗时
// ≈ measuredOverhead + 字符总量 × measuredPerChar,取值见下面两个常量
// (2026-10-13 本机实测:固定开销 ~50ms、每字符 2.7~3.9ms,此处取上界)。
const (
measuredOverhead = 50 * time.Millisecond
measuredPerChar = 4 * time.Millisecond
latencyModelChars = 33 // 活库短正文的平均长度(实测 33 汉字)
)
// latencyTripper 是一个按上面那条曲线**真的睡够时间**的 RoundTripper:
// 它模拟「服务端会算完,不管你客户端还在不在等」。这正是本次故障的形态 ——
// 客户端 5s 放弃、服务端还在算,后面每个请求都排在它后面。
type latencyTripper struct {
mu sync.Mutex
requests int
maxItems int
maxChars int
// abandoned 计数「客户端先放弃、服务端还在算」的次数 —— 本次故障的形态。
// 修好之后它必须是 0(见 TestAPIEmbedder_OversizedBatchIsSplit 的断言)。
abandoned int
// firstSleep 非零时,第一次请求额外多睡这么久(模拟冷启动的模型加载)。
firstSleep time.Duration
}
func (l *latencyTripper) RoundTrip(r *http.Request) (*http.Response, error) {
body, err := io.ReadAll(r.Body)
if err != nil {
return nil, err
}
var req openAICompatibleRequest
if err := json.Unmarshal(body, &req); err != nil {
return nil, err
}
chars := 0
for _, s := range req.Input {
chars += len([]rune(s))
}
l.mu.Lock()
l.requests++
if len(req.Input) > l.maxItems {
l.maxItems = len(req.Input)
}
if chars > l.maxChars {
l.maxChars = chars
}
first := l.requests == 1
extra := l.firstSleep
l.mu.Unlock()
wait := measuredOverhead + time.Duration(chars)*measuredPerChar
if first && extra > 0 {
wait += extra
}
select {
case <-time.After(wait):
case <-r.Context().Done():
// 客户端放弃了:服务端那一侧不会因为客户端走了就把已排队的算力还回来
// (实测的级联堆积正是从这一步开始的),因此这里明确记一笔。
l.mu.Lock()
l.abandoned++
l.mu.Unlock()
return nil, r.Context().Err()
}
resp := openAICompatibleResponse{Data: make([]openAICompatibleDatum, 0, len(req.Input))}
for i := range req.Input {
resp.Data = append(resp.Data, openAICompatibleDatum{Index: i, Embedding: []float32{1, 0}})
}
raw, err := json.Marshal(resp)
if err != nil {
return nil, err
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(string(raw))),
Header: make(http.Header),
}, nil
}
// shortTexts 造 n 条与活库短正文等长的文本。
func shortTexts(n int) []string {
out := make([]string, n)
line := strings.Repeat("河", latencyModelChars)
for i := range out {
out[i] = line
}
return out
}
// TestBatchBudget_InvariantHolds 是**推演本身**的断言(不连任何网络):
// 由超时推出来的一对上限,必须使「一次请求的耗时上界 ≤ 超时的一半」成立。
//
// 变异自证:把 DefaultBatchLimit 的推演改成旧口径(条数写回 64)⇒ 第一条断言红;
// 若改成「把超时放大到盖住 64 条」⇒ 最后一条断言红(旧配对必须仍是越界的)。
func TestBatchBudget_InvariantHolds(t *testing.T) {
t.Parallel()
if DefaultBatchLimit < 1 || DefaultBatchChars < 1 {
t.Fatalf("推出来的上限必须 ≥ 1:条数=%d 字符=%d", DefaultBatchLimit, DefaultBatchChars)
}
share := DefaultRequestTimeout / callBudgetShare
byItems := requestOverhead + time.Duration(DefaultBatchLimit)*perItemBudget
if byItems > share {
t.Fatalf("条数上限 %d 的耗时上界 %s 超过超时的一半 %s(推演被调松了)",
DefaultBatchLimit, byItems, share)
}
byChars := requestOverhead + time.Duration(DefaultBatchChars)*perCharBudget
if byChars > share {
t.Fatalf("字符上限 %d 的耗时上界 %s 超过超时的一半 %s(推演被调松了)",
DefaultBatchChars, byChars, share)
}
// 旧配对(批次 64 + 5s 超时)必须仍被判为越界 —— 那正是 2026-10-13 的故障形态:
// 实测 64 条 7.5~8.5s > 5s。这里用同一套预算常量复核它仍然出界。
legacy := requestOverhead + 64*perItemBudget
if legacy <= DefaultRequestTimeout {
t.Fatalf("旧配对(64 条)的耗时上界 %s 竟然落在单次超时 %s 之内 —— "+
"说明超时被放大到迁就批次了,本次修法不允许", legacy, DefaultRequestTimeout)
}
}
// TestChunkTexts_RespectsBothLimits 钉住切片:条数与字符总量两道界都要守,
// 单条本身越界的文本独占一段且**不被截断**。
func TestChunkTexts_RespectsBothLimits(t *testing.T) {
t.Parallel()
got := chunkTexts(shortTexts(9), 2, 100)
if len(got) != 5 {
t.Fatalf("2 条/段 切 9 条 = %d 段,want 5", len(got))
}
for i, seg := range got {
if len(seg) > 2 {
t.Fatalf("第 %d 段有 %d 条,超过条数上限 2", i, len(seg))
}
}
// 字符界绑定:4 条 33 字符 = 132 字符 ⇒ 100 字符/段时必须收口(3 条 = 99 字符满,
// 余下 1 条自成一段)。
byChars := chunkTexts(shortTexts(4), 64, 100)
if len(byChars) != 2 {
t.Fatalf("100 字符/段 切 4×33 字符 = %d 段,want 2", len(byChars))
}
for i, seg := range byChars {
total := 0
for _, s := range seg {
total += len([]rune(s))
}
if total > 100 {
t.Fatalf("第 %d 段 %d 字符,超过字符上限 100", i, total)
}
}
// 超长单条:不切碎、不截断,独占一段。
huge := strings.Repeat("长", 500)
segs := chunkTexts([]string{huge, "短"}, 64, 100)
if len(segs) != 2 || segs[0][0] != huge {
t.Fatalf("超长单条应独占一段且不改写,got %d 段", len(segs))
}
if chunkTexts(nil, 4, 100) != nil {
t.Fatal("空输入应返回 nil")
}
}
// TestAPIEmbedder_DerivedBatchCompletesWithinTimeout 钉**不降级态**:
// 按推出来的上限发一次(Embed 内部切成 1 段),在实测曲线下必须在单次超时内拿到向量。
func TestAPIEmbedder_DerivedBatchCompletesWithinTimeout(t *testing.T) {
t.Parallel()
rt := &latencyTripper{}
emb := NewAPI(config.EmbeddingConfig{APIURL: "http://127.0.0.1:11434/v1/embeddings"},
WithRoundTripper(rt))
texts := shortTexts(DefaultBatchLimit)
vectors, err := emb.Embed(context.Background(), texts)
if err != nil {
t.Fatalf("上限内的批不该超时:%v", err)
}
if len(vectors) != len(texts) {
t.Fatalf("got %d vectors, want %d", len(vectors), len(texts))
}
if rt.requests != 1 {
t.Fatalf("上限内的批应发 1 次请求,实际 %d 次", rt.requests)
}
}
// TestAPIEmbedder_OversizedBatchIsSplit 钉**不变式与调用方无关**:
// 调用方给 3 倍上限的切片时,Embed 自己切成多段、每段都在界内,向量顺序与入参一致。
func TestAPIEmbedder_OversizedBatchIsSplit(t *testing.T) {
t.Parallel()
rt := &latencyTripper{}
emb := NewAPI(config.EmbeddingConfig{APIURL: "http://127.0.0.1:11434/v1/embeddings"},
WithRoundTripper(rt))
texts := shortTexts(DefaultBatchLimit * 3)
vectors, err := emb.Embed(context.Background(), texts)
if err != nil {
t.Fatalf("超大切片应被切开而不是超时:%v", err)
}
if len(vectors) != len(texts) {
t.Fatalf("got %d vectors, want %d(切开后必须按序拼回)", len(vectors), len(texts))
}
if rt.requests < 3 {
t.Fatalf("3 倍上限应至少切成 3 段,实际 %d 段", rt.requests)
}
if rt.maxItems > DefaultBatchLimit {
t.Fatalf("有请求带了 %d 条,超过推出来的条数上限 %d", rt.maxItems, DefaultBatchLimit)
}
if rt.maxChars > DefaultBatchChars {
t.Fatalf("有请求带了 %d 字符,超过推出来的字符上限 %d", rt.maxChars, DefaultBatchChars)
}
// 🔴 本次修复的核心不变式,确定性断言(不依赖机器负载):
// 没有任何一次请求发生「客户端先放弃、服务端还在算」——那正是级联堆积的起点。
if rt.abandoned != 0 {
t.Fatalf("有 %d 次请求被客户端先放弃(服务端还在算)⇒ 不变式没守住", rt.abandoned)
}
// 服务端侧的同一条不变式:任何一次请求的建模耗时(实测曲线)都落在单次超时内。
if cost := measuredOverhead + time.Duration(rt.maxChars)*measuredPerChar; cost > DefaultRequestTimeout {
t.Fatalf("最大一次请求的实测曲线耗时 %s 越过单次超时 %s", cost, DefaultRequestTimeout)
}
}
// TestAPIEmbedder_LegacyBatchOf64TimesOut 是**变异自证**的另一半:同样是实测曲线,
// 旧配对(一次请求 64 条 + 5s 超时)必须**超时**。它证明把上限写回 64 不是「保守」,而是
// 必然把客户端与服务端撕裂(客户端走了、服务端还在算 ⇒ 后续请求全排队)。
func TestAPIEmbedder_LegacyBatchOf64TimesOut(t *testing.T) {
t.Parallel()
rt := &latencyTripper{}
emb := NewAPI(config.EmbeddingConfig{APIURL: "http://127.0.0.1:11434/v1/embeddings"},
WithRoundTripper(rt))
// 先把一次性冷启动放宽花掉,否则第一次调用按 warmupTimeout 算、不会超时。
if _, err := emb.Embed(context.Background(), shortTexts(1)); err != nil {
t.Fatalf("预热调用失败:%v", err)
}
start := time.Now()
_, err := emb.embedChunk(context.Background(), shortTexts(64))
elapsed := time.Since(start)
if err == nil {
t.Fatalf("旧配对(64 条 + %s 超时)在实测曲线下不该成功", DefaultRequestTimeout)
}
if elapsed > DefaultRequestTimeout+2*time.Second {
t.Fatalf("超时保护没生效:等了 %s", elapsed)
}
}
// TestAPIEmbedder_WarmupIsOneShot 钉 p1 的核心:冷启动放宽**一次性**,三种失败形态
// 下都必须是「第一次最多等 warmupTimeout,第二次起一律 DefaultRequestTimeout」。
//
// 这是 reviewer 在 round1 打回的那条:旧实现 `warmed` 只在 2xx 之后才翻,于是
// 「服务接受连接但永不回包」时**每一次**调用都等完整放宽预算(30s),把 fail-fast
// 变成 fail-slow —— 一次挂死就能吃光审查的 20s 预算、把索引写入拖成 8×30s。
func TestAPIEmbedder_WarmupIsOneShot(t *testing.T) {
t.Parallel()
cases := []struct {
name string
rt func(t *testing.T) http.RoundTripper
}{
{
name: "连接被拒",
rt: func(t *testing.T) http.RoundTripper {
return roundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, errors.New("dial tcp 127.0.0.1:11434: connectex: 目标计算机积极拒绝")
})
},
},
{
name: "HTTP 500",
rt: func(t *testing.T) http.RoundTripper {
return roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusInternalServerError,
Body: io.NopCloser(strings.NewReader("server exploded")),
Header: make(http.Header),
}, nil
})
},
},
{
name: "接受连接但永不回包",
rt: func(t *testing.T) http.RoundTripper {
return roundTripFunc(func(r *http.Request) (*http.Response, error) {
<-r.Context().Done() // 只等服务端超时,永不回包
return nil, r.Context().Err()
})
},
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
emb := NewAPI(config.EmbeddingConfig{APIURL: "http://127.0.0.1:11434/v1/embeddings"},
WithRoundTripper(tc.rt(t)))
// 第一次:放宽预算。断言上界 = warmupTimeout(不得再宽),
// 下界只对会真正等到截止的那一种(挂死)有意义。
start := time.Now()
_, err := emb.Embed(context.Background(), shortTexts(1))
first := time.Since(start)
if err == nil {
t.Fatal("这一组失败形态不该成功")
}
t.Logf("首次耗时 = %s", first.Round(time.Millisecond))
if first > warmupTimeout+time.Second {
t.Fatalf("首次耗时 %s 超过冷启动放宽 %s", first, warmupTimeout)
}
// 第二次:**必须**回到常态超时 —— 一次挂死绝不许变成每次都慢。
start = time.Now()
if _, err := emb.Embed(context.Background(), shortTexts(1)); err == nil {
t.Fatal("这一组失败形态不该成功")
}
second := time.Since(start)
t.Logf("第二次耗时 = %s(常态超时 %s)", second.Round(time.Millisecond), DefaultRequestTimeout)
if second > DefaultRequestTimeout+time.Second {
t.Fatalf("第二次耗时 %s 超过常态超时 %s ⇒ 放宽成了长期的", second, DefaultRequestTimeout)
}
// 放宽许可必须已经被花掉(它是一次性的,任何路径都不许给它续期)。
if emb.warmupLeft.Load() {
t.Fatal("第二次请求之后放宽许可仍为真 —— 一次性的约束被绕过了")
}
})
}
}
// TestAPIEmbedder_WarmupCoversColdStartOnce 钉放宽的**正面**作用:第一次请求
// (模型加载 / GPU 缓冲分配)可以比常态超时多等一点,只要它落在 warmupTimeout
// 之内;而这一刻过去之后,请求立刻回到常态超时(deadline 断言)。
func TestAPIEmbedder_WarmupCoversColdStartOnce(t *testing.T) {
t.Parallel()
rt := &latencyTripper{firstSleep: DefaultRequestTimeout + time.Second}
emb := NewAPI(config.EmbeddingConfig{APIURL: "http://127.0.0.1:11434/v1/embeddings"},
WithRoundTripper(rt))
if _, err := emb.Embed(context.Background(), shortTexts(1)); err != nil {
t.Fatalf("冷启动第一次调用应被 warmupTimeout(%s) 兜住:%v", warmupTimeout, err)
}
if emb.warmupLeft.Load() {
t.Fatal("放宽许可应在第一次请求时就被花掉")
}
var slack time.Duration
steady := roundTripFunc(func(r *http.Request) (*http.Response, error) {
dl, ok := r.Context().Deadline()
if !ok {
t.Error("稳态请求没有 deadline")
} else {
slack = time.Until(dl)
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"data":[{"index":0,"embedding":[1]}]}`)),
Header: make(http.Header),
}, nil
})
emb.client = &http.Client{Transport: steady}
if _, err := emb.Embed(context.Background(), shortTexts(1)); err != nil {
t.Fatalf("稳态调用失败:%v", err)
}
if slack <= 0 || slack > DefaultRequestTimeout {
t.Fatalf("稳态单次超时余量 = %s, want (0, %s]", slack, DefaultRequestTimeout)
}
}
@@ -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", "转换成", "截图", "打开这个", "关掉这个",
"搜一下", "查一下", "翻译一下", "订个闹钟", "设个闹钟",
}
}
@@ -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)
}
}
+34 -1
View File
@@ -15,7 +15,40 @@ import (
// 以便 parseCandidates 确定性地反序列化。溯源(source_conversation) // 以便 parseCandidates 确定性地反序列化。溯源(source_conversation)
// 刻意不在其中:它由持久化层盖真实 session/message id,绝不由模型生成 // 刻意不在其中:它由持久化层盖真实 session/message id,绝不由模型生成
// (裁决 04.4.3,见 source.go)。 // (裁决 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 数组,不要任何其他文字。每个元素形如: 只输出一个 JSON 数组,不要任何其他文字。每个元素形如:
{"content":"记忆内容","scope":"user|persona|project","kind":"relationship|world_event","tags":["标签"],"confidence":0.9,"project_id":"可选项目id"} {"content":"记忆内容","scope":"user|persona|project","kind":"relationship|world_event","tags":["标签"],"confidence":0.9,"project_id":"可选项目id"}
去噪去重:只保留值得长期记住的,忽略寒暄与临时信息。` 去噪去重:只保留值得长期记住的,忽略寒暄与临时信息。`
+36 -1
View File
@@ -35,6 +35,14 @@ import (
// 调用还要先过**全局限速**(默认 120 次/小时,preinsert_t1_limit.go): // 调用还要先过**全局限速**(默认 120 次/小时,preinsert_t1_limit.go):
// 超限即跳过 T1、回落 create + `t1_throttled`。 // 超限即跳过 T1、回落 create + `t1_throttled`。
// **同步入库闸(preinsert_gate.go)不碰 T1**,写入路径因此仍是零模型调用。 // **同步入库闸(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)。 // (status='merged' + merged_into,可 revert,绝不物理删除,design §8)。
@@ -55,6 +63,19 @@ const (
// IngestCreate 表示确属新条目(互不包含 / 被规则否决 / 没有召回): // IngestCreate 表示确属新条目(互不包含 / 被规则否决 / 没有召回):
// 正常新建,不做任何改写(§15 D3-2 T0-3)。 // 正常新建,不做任何改写(§15 D3-2 T0-3)。
IngestCreate IngestDecision = "create" 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)。 // IngestRuleVersion 是入库前审查规则集的版本号,进每条审查留痕(§15 D3-8)。
@@ -65,7 +86,11 @@ const (
// - `d3.2` 加入 **T1 灰区模型判定**(preinsert_t1.go,design/23 §15.1 D3-2): // - `d3.2` 加入 **T1 灰区模型判定**(preinsert_t1.go,design/23 §15.1 D3-2):
// 灰区(相似、互不包含、未被规则否决)在模型出口接上时会被改判成 // 灰区(相似、互不包含、未被规则否决)在模型出口接上时会被改判成
// merge/modify,因此 `reason` 上出现了 `t1_*` 族。 // 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。 // 调优旋钮(ReviewConfig)与它们的默认值常量在 preinsert_config.go。
@@ -402,6 +427,16 @@ func (r *PreInsertReviewer) applyVerdict(ctx context.Context, v ingestVerdict, i
if v.Decision == IngestCreate { if v.Decision == IngestCreate {
return nil, nil 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 == "" { if v.Absorbed.ID == "" || v.Existing.ID == "" {
return nil, fmt.Errorf("memory/review: %s without a matched existing item", v.Decision) return nil, fmt.Errorf("memory/review: %s without a matched existing item", v.Decision)
} }
@@ -36,6 +36,16 @@ const (
// 留痕 `t1_throttled`,记忆一条不丢、一个字不改。 // 留痕 `t1_throttled`,记忆一条不丢、一个字不改。
defaultT1PerWindow = 120 defaultT1PerWindow = 120
defaultT1Window = 60 * time.Minute 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 选取安全默认值。 // ReviewConfig 携带入库前审查的调优旋钮。零值经 normalize 选取安全默认值。
@@ -99,6 +109,16 @@ type ReviewConfig struct {
T1PerWindow int T1PerWindow int
// T1Window 是 T1PerWindow 的时间窗。非正用代码默认值 defaultT1Window。 // T1Window 是 T1PerWindow 的时间窗。非正用代码默认值 defaultT1Window。
T1Window time.Duration 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 用设计稿默认值填充零值字段。 // normalizeReviewConfig 用设计稿默认值填充零值字段。
@@ -133,5 +153,11 @@ func normalizeReviewConfig(cfg ReviewConfig) ReviewConfig {
if cfg.T1Window <= 0 { if cfg.T1Window <= 0 {
cfg.T1Window = defaultT1Window 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 return cfg
} }
@@ -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 赋值 —— 幂等键的口径变了")
}
}
+80
View File
@@ -53,6 +53,11 @@ const (
// gateTraceAbsorbed 是闸真的做了决定(落 merged)的留痕原因码。 // gateTraceAbsorbed 是闸真的做了决定(落 merged)的留痕原因码。
gateTraceAbsorbed = "gate:absorbed" gateTraceAbsorbed = "gate:absorbed"
// gateTraceDeferred 是**第五态**在闸侧做决定的留痕原因码(`expires_at` 已写)。
// 与 gateTraceAbsorbed 分开:前者写的是 status/merged_into,后者只写
// expires_at 一列 —— 「闸当场折回了多少重复」与「闸当场放进了多少临时记忆」
// 是两条不同的观测线,加成一个数会让面板读不出分型。
gateTraceDeferred = "gate:deferred"
// gateTraceBudgetExceeded 是闸被墙钟预算截断的 fail-open 原因码。 // gateTraceBudgetExceeded 是闸被墙钟预算截断的 fail-open 原因码。
gateTraceBudgetExceeded = "gate:budget_exceeded" gateTraceBudgetExceeded = "gate:budget_exceeded"
// gateTraceUnavailable 是候选源不可用(读取失败)的 fail-open 原因码。 // gateTraceUnavailable 是候选源不可用(读取失败)的 fail-open 原因码。
@@ -72,6 +77,14 @@ const (
// ingestPlanAbsorbOld 是确定性判据命中且**既有行是子集**(modify):新行正常 // ingestPlanAbsorbOld 是确定性判据命中且**既有行是子集**(modify):新行正常
// insert(它承载更全的内容),既有行置为被合并(design §15 D3-6 的「改写」)。 // insert(它承载更全的内容),既有行置为被合并(design §15 D3-6 的「改写」)。
ingestPlanAbsorbOld ingestPlanAction = "absorb_old" 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 // ingestGatePlan 是同步入库闸的落库计划。零值(Action="")不存在:planIngest
@@ -97,6 +110,23 @@ type ingestGatePlan struct {
// Elapsed 是闸在写路径上花掉的墙钟。它只进日志与留痕,**不参与任何判定** // Elapsed 是闸在写路径上花掉的墙钟。它只进日志与留痕,**不参与任何判定**
// (否则同一批数据会有两个结论,幂等性就没了)。 // (否则同一批数据会有两个结论,幂等性就没了)。
Elapsed time.Duration 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。 // absorbedID 返回应该写进新行 `merged_into` 的存活行 id。
@@ -181,6 +211,13 @@ func (r *PreInsertReviewer) planFromVerdict(item MemoryItem, v ingestVerdict) in
plan.Survivor = item plan.Survivor = item
plan.Absorbed = v.Existing plan.Absorbed = v.Existing
plan.Trace = gateTraceAbsorbed 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: default:
// 真新 / 拿不准:正常 insert,并投既有异步队列(用语义召回再判一次)。 // 真新 / 拿不准:正常 insert,并投既有异步队列(用语义召回再判一次)。
plan.Action = ingestPlanInsert plan.Action = ingestPlanInsert
@@ -189,6 +226,20 @@ func (r *PreInsertReviewer) planFromVerdict(item MemoryItem, v ingestVerdict) in
return plan 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)+ 投既有队列 + 留痕。 // failOpenPlan 组装 fail-open 计划:照常插入(active)+ 投既有队列 + 留痕。
// 「绝不静默丢记忆」不是一句承诺,而是这条路径的结构性结果。 // 「绝不静默丢记忆」不是一句承诺,而是这条路径的结构性结果。
func (r *PreInsertReviewer) failOpenPlan(reason string, start time.Time) ingestGatePlan { 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 { if plan.Action == ingestPlanInsert {
return 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() now := time.Now().UTC()
var err error var err error
switch plan.Action { switch plan.Action {
@@ -16,6 +16,11 @@ package memory
// `ingestVerdict.Gray`,交**异步审查层**的 T1 再问一次 // `ingestVerdict.Gray`,交**异步审查层**的 T1 再问一次
// (preinsert_t1.go,design/23 §15.1 D3-2)。**本文件恒为零模型调用**。 // (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「单阈值双向失效」); // - **相似度只用来召回候选**,永不参与判定(design/23 §1.2「单阈值双向失效」);
// - **规则否决一票否决**,且**绝不跨作用域/跨角色/跨项目**(§5.2-7/8/9 是硬门, // - **规则否决一票否决**,且**绝不跨作用域/跨角色/跨项目**(§5.2-7/8/9 是硬门,
@@ -68,6 +73,18 @@ func judgeIngest(item MemoryItem, cands []ingestCandidate) ingestVerdict {
if v, ok := judgeExactDuplicate(item, cands); ok { if v, ok := judgeExactDuplicate(item, cands); ok {
return v 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 vetoed, vetoScore := VetoNone, 0.0
// 灰区惰性收集:只有真的出现「互不包含且未被否决」的候选才分配, // 灰区惰性收集:只有真的出现「互不包含且未被否决」的候选才分配,
// 同步写入路径上的常见情形(无候选 / 一眼真新)因此零额外分配。 // 同步写入路径上的常见情形(无候选 / 一眼真新)因此零额外分配。
+19 -4
View File
@@ -17,6 +17,10 @@ type ReviewStats struct {
Merged int Merged int
Modified int Modified int
Created int Created int
// Deferred 是**异步审查层**判出第五态 defer 的条数(T0 规则闸 / T1 同一次
// 调用都可能给出它)。它与 Created 分列:两者都留下新行,但只有 defer 带
// 短 `expires_at`,它是「临时层新增」的观测量。
Deferred int
Dropped int Dropped int
Failed int Failed int
// GateChecked 是**同步入库闸**实际做了一次判定的条数(跳过闸的条目不计)。 // GateChecked 是**同步入库闸**实际做了一次判定的条数(跳过闸的条目不计)。
@@ -26,6 +30,11 @@ type ReviewStats struct {
GateAbsorbed int GateAbsorbed int
// GateFailed 是闸因预算/候选源不可用而 fail-open 的次数(照常插入 + 留痕)。 // GateFailed 是闸因预算/候选源不可用而 fail-open 的次数(照常插入 + 留痕)。
GateFailed int GateFailed int
// GateDeferred 是闸当场判定为**第五态 defer** 并**真的写成了** `expires_at`
// 的次数(design/23 §18.1)。它与 GateAbsorbed 分列:「当场折回重复」与
// 「当场放进临时层」是两条不同的观测线,加成一个数面板就读不出分型。
// 只统计写成功的(写失败时那行停在长期层,不是临时记忆)。
GateDeferred int
// T1Called 是 T1 实际发出过**模型调用**的次数。每次审查至多 +1 // T1Called 是 T1 实际发出过**模型调用**的次数。每次审查至多 +1
// (defaultT1MaxGray 只是把多条灰区候选打包进同一次 prompt,不多发请求)。 // (defaultT1MaxGray 只是把多条灰区候选打包进同一次 prompt,不多发请求)。
T1Called int T1Called int
@@ -58,6 +67,10 @@ func (r *PreInsertReviewer) recordDecision(rev IngestReview) {
s.Merged++ s.Merged++
case IngestModify: case IngestModify:
s.Modified++ s.Modified++
case IngestDefer:
// 第五态单独一档:它与 create 都「留下了新行」,但一个有 TTL、
// 一个没有 —— 混进 Created 就看不出「有多少记忆是临时的」。
s.Deferred++
default: default:
s.Created++ s.Created++
} }
@@ -79,8 +92,10 @@ func (r *PreInsertReviewer) auditWith(verb string, rev IngestReview) {
// reviewStatsLabel 把计数渲染成一条稳定日志串。 // reviewStatsLabel 把计数渲染成一条稳定日志串。
func reviewStatsLabel(s ReviewStats) string { func reviewStatsLabel(s ReviewStats) string {
return fmt.Sprintf("reviewed=%d skip=%d merge=%d modify=%d create=%d dropped=%d failed=%d "+ 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_failed=%d t1_called=%d t1_decided=%d t1_throttled=%d", "gate_checked=%d gate_absorbed=%d gate_deferred=%d gate_failed=%d "+
s.Reviewed, s.Skipped, s.Merged, s.Modified, s.Created, s.Dropped, s.Failed, "t1_called=%d t1_decided=%d t1_throttled=%d",
s.GateChecked, s.GateAbsorbed, s.GateFailed, s.T1Called, s.T1Decided, s.T1Throttled) 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)
} }
@@ -23,6 +23,11 @@ import (
// ingestStatusMerged 是「已被合并」的行状态(design/23 §10 的 status 列)。 // ingestStatusMerged 是「已被合并」的行状态(design/23 §10 的 status 列)。
const ingestStatusMerged = "merged" 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, const listReviewCandidatesSQL = `SELECT id, persona_id, role_id, project_id, scope, kind,
content, tags, confidence, source_conversation, created_at, updated_at, content, tags, confidence, source_conversation, created_at, updated_at,
decay_weight FROM memory_items 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 = ?` 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 const insertIngestReviewSQL = `INSERT INTO memory_ingest_reviews
(id, item_id, persona_id, scope, project_id, decision, reason, veto, target_id, (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) 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") 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:一个事务里做「标记被吸收 + // absorbIngestItem 把 absorbed 软合并进 survivor:一个事务里做「标记被吸收 +
// 刷新存活行的合并元信息」。两条 UPDATE 都只碰 status/merged_*/merge_count/ // 刷新存活行的合并元信息」。两条 UPDATE 都只碰 status/merged_*/merge_count/
// confidence/updated_at,**绝不改 content**(design §8 的可回滚口径)。 // confidence/updated_at,**绝不改 content**(design §8 的可回滚口径)。
+68 -16
View File
@@ -21,8 +21,10 @@ import (
// //
// 五条不可走样(逐条对应本目录既有的不变量,改坏任何一条都是架构偏离): // 五条不可走样(逐条对应本目录既有的不变量,改坏任何一条都是架构偏离):
// //
// 1. **只许回三态 + 理由**:`merge` / `modify` / `keep`。坏 JSON、非法枚举、 // 1. **只许回四态 + 理由**:`merge` / `modify` / `keep` / `defer`。坏 JSON、
// 编号越界、任何别的词 —— 一律回落 `create`(保守,**绝不猜**)。 // 非法枚举、编号越界、任何别的词 —— 一律回落 `create`(保守,**绝不猜**)。
// `defer`(第五态,§18.2)还多一条不变式:**target 必须为 0** —— 它声称
// 「关系判为真新」,而一个指向某条候选的 defer 自相矛盾,同样判非法回落。
// 2. **禁产新内容**:模型只提供「选哪一条 + 什么动作 + 一句理由」,它的正文 / // 2. **禁产新内容**:模型只提供「选哪一条 + 什么动作 + 一句理由」,它的正文 /
// 摘要 / 改写文本**一个字都不采纳**。`merge` 落地时存活方是既有行(正文 // 摘要 / 改写文本**一个字都不采纳**。`merge` 落地时存活方是既有行(正文
// 一字不改);`modify` 落地时存活方是**新行自己的原文**、既有行置为被合并 // 一字不改);`modify` 落地时存活方是**新行自己的原文**、既有行置为被合并
@@ -45,8 +47,9 @@ import (
// 厂商协议,也不新增依赖。 // 厂商协议,也不新增依赖。
// //
// 可观测(§15 D3-8):T1 是否参与、结论是什么,在留痕 `reason` 列上如实可辨 // 可观测(§15 D3-8):T1 是否参与、结论是什么,在留痕 `reason` 列上如实可辨
// (`t1_merge` / `t1_modify` / `t1_keep` / `t1_reply_invalid` / `t1_unavailable` / // (`t1_merge` / `t1_modify` / `t1_keep` / `t1_defer` / `t1_reply_invalid` /
// `t1_throttled`),规则版本随之升到 `IngestRuleVersion`。模型的自由文本理由 // `t1_unavailable` / `t1_throttled`),规则版本随之升到 `IngestRuleVersion`
// (`d3.3` 起含第五态)。模型的自由文本理由
// **既不落库也不落日志**:留痕表按设计不存正文,而模型的理由可能改述正文 ⇒ 一律 // **既不落库也不落日志**:留痕表按设计不存正文,而模型的理由可能改述正文 ⇒ 一律
// 丢弃(它在 prompt 里的唯一作用,是逼模型先表态,从而把动作判得更稳)。 // 丢弃(它在 prompt 里的唯一作用,是逼模型先表态,从而把动作判得更稳)。
@@ -64,10 +67,18 @@ const (
// 让用量面板能把「灰区判定」与「对话」分开算账。 // 让用量面板能把「灰区判定」与「对话」分开算账。
t1Purpose = "memory_t1_judge" 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" t1ActionMerge = "merge"
t1ActionModify = "modify" t1ActionModify = "modify"
t1ActionKeep = "keep" t1ActionKeep = "keep"
t1ActionDefer = "defer"
// T1 的留痕原因码(进 `reason` 列,结构化、可机器判定、与既有码同族)。 // T1 的留痕原因码(进 `reason` 列,结构化、可机器判定、与既有码同族)。
t1ReasonMerge = "t1_merge" t1ReasonMerge = "t1_merge"
@@ -75,6 +86,10 @@ const (
t1ReasonKeep = "t1_keep" t1ReasonKeep = "t1_keep"
t1ReasonReplyInvalid = "t1_reply_invalid" t1ReasonReplyInvalid = "t1_reply_invalid"
t1ReasonUnavailable = "t1_unavailable" t1ReasonUnavailable = "t1_unavailable"
// t1ReasonDefer 是第五态在留痕 `reason` 列上的编码(design/23 §18.4 的闭集)。
// 它与 t1_keep 分列:两者都是「关系判为真新」,但只有 defer 会写短
// `expires_at` —— 面板要能一眼看出「有多少条被判成了临时」。
t1ReasonDefer = "t1_defer"
// t1ReasonThrottled 是**全局限速**命中时的原因码(preinsert_t1_limit.go, // t1ReasonThrottled 是**全局限速**命中时的原因码(preinsert_t1_limit.go,
// design/23 §15.4)。它与上面两个失败码同族、同样只改 `reason`:决定恒 // design/23 §15.4)。它与上面两个失败码同族、同样只改 `reason`:决定恒
// `create`、零改写。与它们的唯一区别是语义 —— 失败是「模型没答上」, // `create`、零改写。与它们的唯一区别是语义 —— 失败是「模型没答上」,
@@ -82,7 +97,16 @@ const (
t1ReasonThrottled = "t1_throttled" 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 还是该查网络。 // (create),但留痕原因码不同,运维才分得清该修 prompt 还是该查网络。
var ErrT1ReplyInvalid = errors.New("memory/review: t1 reply is not a valid action") 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 base.Gray = nil
return base, true 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 { if action != t1ActionMerge && action != t1ActionModify {
return ingestVerdict{}, false return ingestVerdict{}, false
} }
@@ -271,32 +311,44 @@ func buildT1Prompt(item MemoryItem, gray []ingestCandidate) []model.Message {
} }
// t1SystemPrompt 是 T1 的固定指令。它把三件事写死: // t1SystemPrompt 是 T1 的固定指令。它把三件事写死:
// - 只许回三态 + 一句理由(别的词一律不许); // - 只许回**四态** + 一句理由(别的词一律不许);
// - **禁产新内容**(不许输出记忆正文/摘要/改写文本); // - **禁产新内容**(不许输出记忆正文/摘要/改写文本);
// - 判据与**负例**(design/23 §15 D3-2「负例进 prompt」,Mem0 口径): // - 判据与**负例**(design/23 §15 D3-2「负例进 prompt」,Mem0 口径):
// 「相似但并列」必须 keep,拿不准必须 keep。 // 「相似但并列」必须 keep,拿不准必须 keep。
// //
// 2026-10-13 起它同时承载**第五态的价值判定**(design/23 §18.2):prompt 里明写
// 「先问关系、只有关系判为真新时才问价值」,四个动作**一次回答** —— 因此
// 「第五态与 T1 在同一次调用里问」是这条 prompt 的结构性事实,不是约定:**没有
// 第二次模型调用**。
//
// 负例刻意取自 design/23 §1.3 与 §5.2 的点名反例(红牛 vs 冰美式是「同主题但 // 负例刻意取自 design/23 §1.3 与 §5.2 的点名反例(红牛 vs 冰美式是「同主题但
// 不同事实」,X vs 戒了X 是「新事实覆盖旧事实」),它们是 T1 最容易被相似度 // 不同事实」,X vs 戒了X 是「新事实覆盖旧事实」),它们是 T1 最容易被相似度
// 带偏的两处。 // 带偏的两处。
const t1SystemPrompt = `你是记忆库的入库审查员。给你一条刚入库的新记忆,以及同一分区里若干条既有记忆候选。 const t1SystemPrompt = `你是记忆库的入库审查员。给你一条刚入库的新记忆,以及同一分区里若干条既有记忆候选。
判断新记忆与既有候选的关系,只回一个 JSON 对象,不要任何其他文字、不要代码块。 要回答的是**两个问题**,但只回一个 JSON 对象,不要任何其他文字、不要代码块。
动作只许取这三个值之一: 第一问(关系):这条新记忆和某条既有候选,是不是在说同一件事?
第二问(价值):只有当第一问的答案是「谁都不像、它是新的一件事」时,才再问一句——**它值不值得长期留在记忆库里**?
若第一问答的是「和某条候选是同一件事」,第二问就不适用(留不留由那条既有记忆决定),直接回 merge 或 modify。
动作只许取这四个值之一:
- "merge":新记忆与某条既有记忆说的是**同一件事、只是换了措辞**,且新记忆没有那条既有记忆之外的任何新信息 ⇒ 新记忆并入那条既有记忆(既有记忆的原文保持不变)。 - "merge":新记忆与某条既有记忆说的是**同一件事、只是换了措辞**,且新记忆没有那条既有记忆之外的任何新信息 ⇒ 新记忆并入那条既有记忆(既有记忆的原文保持不变)。
- "modify":某条既有记忆是**新记忆的不完整版本**(新记忆覆盖它、并含它没有的细节)⇒ 那条既有记忆退役,由新记忆承载。 - "modify":某条既有记忆是**新记忆的不完整版本**(新记忆覆盖它、并含它没有的细节)⇒ 那条既有记忆退役,由新记忆承载。
- "keep":其余一切情形(不同事实、不同偏好、不同对象、只是同一主题)⇒ 新记忆独立保留。 - "keep":是**新的一件事**,而且值得**长期**记住 ⇒ 新记忆独立长期保留。
- "defer":是**新的一件事**,但**不值得长期记住**(只是一次性的、短期的、过了这阵子就没用的信息)⇒ 新记忆先留着,过一阵子自动消失。
必须遵守的判据: 必须遵守的判据:
- 只有「同一件事换措辞」才可以 merge;「相似但并列」的两件不同事实必须 keep。反例:"主人喜欢喝红牛来提神" 与 "主人喜欢喝冰美式来提神" 是两种不同的偏好,必须 keep,即便它们很相似。 - 只有「同一件事换措辞」才可以 merge;「相似但并列」的两件不同事实必须 keep 或 defer。反例:"主人喜欢喝红牛来提神" 与 "主人喜欢喝冰美式来提神" 是两种不同的偏好,必须 keep,即便它们很相似。
- 一方带否定、另一方没有("X" 与 "已经戒了 X"),那是新事实覆盖旧事实、不是重复,必须 keep。 - 一方带否定、另一方没有("X" 与 "已经戒了 X"),那是新事实覆盖旧事实、不是重复,必须 keep 或 defer。
- 描述某一次具体事件、或带具体日期/时间的记忆,不要与长期习惯互相合并,必须 keep。 - 描述某一次具体事件、或带具体日期/时间的记忆,不要与长期习惯互相合并。
- 只是「都在说同一个领域」不算同一件事。 - 只是「都在说同一个领域」不算同一件事。
- **拿不准就 keep**。宁可多留一条,也绝不把两件不同的事并成一条。 - **长期事实、稳定偏好、习惯、约定与承诺、关系里程碑、主人的明确要求 = keep**(它们长期有用)。
- **只对「这一次」有用的信息 = defer**,例如:一次性的安排或请求、当前临时的状态、时效性数据、转瞬即逝的琐事。
- **拿不准就 keep**。宁可多留一条长期记忆,也绝不把两件不同的事并成一条;更不要因为拿不准就把一条真记忆判成临时的。
- **绝对不要输出任何记忆正文、摘要或改写文本**,只回动作、候选编号和一句理由。 - **绝对不要输出任何记忆正文、摘要或改写文本**,只回动作、候选编号和一句理由。
输出格式(严格 JSON,无代码块):{"action":"merge 或 modify 或 keep","target":候选编号,"reason":"一句话理由"} 输出格式(严格 JSON,无代码块):{"action":"merge 或 modify 或 keep 或 defer","target":候选编号,"reason":"一句话理由"}
keep 时 target 写 0。` 回 keep 或 defer 时 target 写 0(这两者都表示「不与任何候选合并」)。`
// parseT1Reply 解析模型的 JSON 回复。被围栏包裹的 ```json 块会被剥离,前后夹带 // parseT1Reply 解析模型的 JSON 回复。被围栏包裹的 ```json 块会被剥离,前后夹带
// 的解说文字也被忽略(只取第一个 `{` 到最后一个 `}`)—— 容忍格式噪声,但**不猜**: // 的解说文字也被忽略(只取第一个 `{` 到最后一个 `}`)—— 容忍格式噪声,但**不猜**:
@@ -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
}
+6 -23
View File
@@ -119,17 +119,12 @@ func (t *Tidier) RunOnce(ctx context.Context) (TidyStats, error) {
return stats, err return stats, err
} }
stats.RunID = runID stats.RunID = runID
// ledger 是**本轮实际发生**的召回来源记账口(不是配置里那一种):生产轮可以
// 多分区混合(有的走语义、有的降级),因此汇总行只能由实测记录推出。
ledger := &recallLedger{}
failures := t.scanPartitions(ctx, partitions, &stats, tidyRoundPolicy{ failures := t.scanPartitions(ctx, partitions, &stats, tidyRoundPolicy{
sink: func(ctx context.Context, cands []TidyCandidate, stats *TidyStats) { sink: func(ctx context.Context, cands []TidyCandidate, stats *TidyStats) {
t.persistCandidates(ctx, runID, cands, stats) t.persistCandidates(ctx, runID, cands, stats)
}, },
recall: func(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget) ([]tidyPair, error) { recall: t.fallbackRecall,
return t.fallbackRecall(ctx, p, items, stats, budget, ledger)
},
}) })
// 轮末自动落地(2026-10-07 主人口径):规则层判为高置信的待定候选直接落地, // 轮末自动落地(2026-10-07 主人口径):规则层判为高置信的待定候选直接落地,
@@ -155,7 +150,7 @@ func (t *Tidier) RunOnce(ctx context.Context) (TidyStats, error) {
failures = append(failures, err) failures = append(failures, err)
} }
t.logRun(stats, run, ledger) t.logRun(stats, run)
t.auditAutoApply(auto) t.auditAutoApply(auto)
switch { switch {
@@ -170,14 +165,7 @@ func (t *Tidier) RunOnce(ctx context.Context) (TidyStats, error) {
// logRun 输出本轮的可观测口径:扫描 N / 候选 M / 建议 K / 否决原因分布。 // logRun 输出本轮的可观测口径:扫描 N / 候选 M / 建议 K / 否决原因分布。
// 它带 source(memory/tidy),因此这条汇总永远可回溯到整理子系统。 // 它带 source(memory/tidy),因此这条汇总永远可回溯到整理子系统。
// func (t *Tidier) logRun(stats TidyStats, run TidyRun) {
// ledger 给出的是**本轮实际发生**的召回来源(不是配置态)。这一条是 M6 2026-10-13
// 的 medium finding 的落点:旧实现按「有没有配 embedder」写 recall_mode,全量降级
// 在汇总行里被写成 embedding。现在:
// - recall_mode = "":没有任何分区真正算过相似度(没有候选可比);
// - recall_mode = lexical ∧ recall_degraded_partitions > 0:语义不可用、降级了;
// - recall_mode = mixed:多分区里一部分走了语义、一部分降级 —— 不再被抹平。
func (t *Tidier) logRun(stats TidyStats, run TidyRun, ledger *recallLedger) {
fields := []any{ fields := []any{
"run_id", run.ID, "run_id", run.ID,
"rule_version", run.RuleVersion, "rule_version", run.RuleVersion,
@@ -193,8 +181,7 @@ func (t *Tidier) logRun(stats TidyStats, run TidyRun, ledger *recallLedger) {
"kept", stats.Kept, "kept", stats.Kept,
"errors", run.Errors, "errors", run.Errors,
"duration_ms", run.FinishedAt.Sub(run.StartedAt).Milliseconds(), "duration_ms", run.FinishedAt.Sub(run.StartedAt).Milliseconds(),
"recall_mode", ledger.label(), "recall_mode", tidyRecallMode(t.deps.Embedder != nil),
"recall_degraded_partitions", ledger.degradedPartitions(),
"auto_apply", t.cfg.autoApplyEnabled(), "auto_apply", t.cfg.autoApplyEnabled(),
"auto_applied", stats.AutoApplied, "auto_applied", stats.AutoApplied,
"auto_merge", stats.AutoMerged, "auto_merge", stats.AutoMerged,
@@ -245,15 +232,11 @@ func (t *Tidier) scanPartitions(ctx context.Context, partitions []tidyPartition,
// fallbackRecall 是**生产轮**的召回:相似度来源不可用时降级到零依赖的词项 // fallbackRecall 是**生产轮**的召回:相似度来源不可用时降级到零依赖的词项
// 余弦(离线/无 embedding 配置时可跑,宁可比语义路径更保守),并记一条 WARN。 // 余弦(离线/无 embedding 配置时可跑,宁可比语义路径更保守),并记一条 WARN。
// 两个来源**不可混用阈值**:阈值只有一个(design §13 开放点 2 的 0.80)。 // 两个来源**不可混用阈值**:阈值只有一个(design §13 开放点 2 的 0.80)。
// func (t *Tidier) fallbackRecall(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget) ([]tidyPair, error) {
// 降级这一笔**必须记进 ledger**:汇总行/面板上的召回来源只能由实测记录推出, pairs, err := t.recallPairs(ctx, p, items, stats, budget)
// 否则「全量降级」会被报成「向量正常」(M6 2026-10-13 的 medium finding)。
func (t *Tidier) fallbackRecall(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget, ledger *recallLedger) ([]tidyPair, error) {
pairs, err := t.recallPairs(ctx, p, items, stats, budget, ledger)
if err == nil { if err == nil {
return pairs, nil return pairs, nil
} }
ledger.noteLexical(true)
t.log.Warn("memory tidy embedding recall unavailable, falling back to lexical", t.log.Warn("memory tidy embedding recall unavailable, falling back to lexical",
"partition", p.label(), "error", err) "partition", p.label(), "error", err)
return t.collectPairs(p, items, t.cfg.RecallThreshold, return t.collectPairs(p, items, t.cfg.RecallThreshold,
+1 -17
View File
@@ -4,7 +4,6 @@ import (
"time" "time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
) )
// 本文件收拢记忆整理(design/23-记忆整理与去重)全部的标识、默认值与旋钮, // 本文件收拢记忆整理(design/23-记忆整理与去重)全部的标识、默认值与旋钮,
@@ -75,17 +74,7 @@ const (
// tidyEmbedBatch 是单次 Embed 调用的文本条数上限,避免把整个分区的文本 // tidyEmbedBatch 是单次 Embed 调用的文本条数上限,避免把整个分区的文本
// 一次性交给远端 embedding API。 // 一次性交给远端 embedding API。
// tidyEmbedBatch = 64
// 🔴 它**不是**拍出来的数字,而是 internal/runtime/embedding 里**同一条推演**
// 的结果(budget.go:一次请求的耗时上界 ≤ 单次超时的一半 ⇒ 批上限 = 超时的一半
// ÷ 单条预算上界)。2026-10-13:旧值 64 的实测耗时 7.5~8.5s 已越过客户端 5s
// 单次超时 ⇒ 客户端放弃、服务端继续算 ⇒ 本机 embedding 服务被顶住,单条请求
// (写向量索引)也跟着超时。把批上限与超时绑到同一条推演上,就不会再出现
// 「只调一侧、另一侧静默失效」。
//
// 批上限在 embedder 内部**还有一道强制**(Embed 自己切片),因此这里即使被改大
// 也不会把服务端顶住;这一行只是让调用方别去构造注定被切开的超大切片。
tidyEmbedBatch = embedding.DefaultBatchLimit
// minTidyContentRunes 是参与比对的最小内容长度(按 rune)。过短的内容 // minTidyContentRunes 是参与比对的最小内容长度(按 rune)。过短的内容
// (如单字)没有可判定的事实要素,进比对只会产出噪声,直接跳过。 // (如单字)没有可判定的事实要素,进比对只会产出噪声,直接跳过。
@@ -137,11 +126,6 @@ const (
maxTidyRejectBatchLimit = 10000 maxTidyRejectBatchLimit = 10000
) )
// 编译期不变式(2026-10-13):批上限一旦被调回超过 embedding 推出来的值
// (例:写回旧值 64),下面这行常量表达式会变成负数、uint 溢出 ⇒ **编译失败**。
// 这条断言把「批上限与超时同源」从注释里的承诺变成编译器守的门。
const _ = uint(embedding.DefaultBatchLimit - tidyEmbedBatch)
// TidyConfig 携带记忆整理的调优旋钮。零值经 normalize 选取安全默认值, // TidyConfig 携带记忆整理的调优旋钮。零值经 normalize 选取安全默认值,
// 因此半填的 Config 仍可用。 // 因此半填的 Config 仍可用。
type TidyConfig struct { type TidyConfig struct {
+6 -34
View File
@@ -52,15 +52,11 @@ type TidyDryRun struct {
// 「完整的建议」展示)。 // 「完整的建议」展示)。
func (t *Tidier) DryRun(ctx context.Context) (TidyDryRun, error) { func (t *Tidier) DryRun(ctx context.Context) (TidyDryRun, error) {
started := t.now() started := t.now()
out := TidyDryRun{} out := TidyDryRun{RecallMode: tidyRecallMode(t.deps.Embedder != nil)}
stats, partitions, err := t.beginRound(ctx) stats, partitions, err := t.beginRound(ctx)
if err != nil { if err != nil {
return out, err return out, err
} }
// ledger 是**本轮实际发生**的召回来源:试算会写进 RecallMode 返回给面板。
// 旧实现按「有没有配 embedder」写(tidyRecallMode(deps.Embedder != nil)),
// 那会让面板把「向量不可用」读成「向量正常」(M6 2026-10-13 的 medium finding)。
ledger := &recallLedger{}
// 收集出口:闭包只在 scanPartitions 的**串行**循环里被调用,因此无需加锁。 // 收集出口:闭包只在 scanPartitions 的**串行**循环里被调用,因此无需加锁。
collected := make([]TidyCandidate, 0) collected := make([]TidyCandidate, 0)
@@ -68,38 +64,25 @@ func (t *Tidier) DryRun(ctx context.Context) (TidyDryRun, error) {
sink: func(_ context.Context, cands []TidyCandidate, _ *TidyStats) { sink: func(_ context.Context, cands []TidyCandidate, _ *TidyStats) {
collected = append(collected, cands...) collected = append(collected, cands...)
}, },
recall: func(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget) ([]tidyPair, error) { recall: t.strictRecall,
return t.strictRecall(ctx, p, items, stats, budget, ledger)
},
} }
failures := t.scanPartitions(ctx, partitions, &stats, policy) failures := t.scanPartitions(ctx, partitions, &stats, policy)
out.Stats = stats out.Stats = stats
out.Candidates = collected out.Candidates = collected
// 如实上报**实际发生**的来源:空串 = 没有任何分区真正算过相似度(「没候选」)。
// 「尝试过但向量源不可用」是另一件事,由 recall_unavailable_partitions 单独给出
// (见 logDryRun 的说明与 recallLedger.unavailable)。
out.RecallMode = ledger.label()
out.DurationMS = t.now().Sub(started).Milliseconds() out.DurationMS = t.now().Sub(started).Milliseconds()
// 汇总行**成败都记**(与 RunOnce 同一口径):失败那一路的
// `recall_unavailable_partitions` 正是「向量不可用」与「没候选」的唯一区分点,
// 只在成功时记就等于把这条口径丢掉。
t.logDryRun(out, ledger)
if len(failures) > 0 { if len(failures) > 0 {
return out, fmt.Errorf("memory/tidy: dry-run finished with errors: %w", errors.Join(failures...)) return out, fmt.Errorf("memory/tidy: dry-run finished with errors: %w", errors.Join(failures...))
} }
t.logDryRun(out)
return out, nil return out, nil
} }
// strictRecall 是**试算轮**的召回:语义召回不可用时**不**降级,如实上报。 // strictRecall 是**试算轮**的召回:语义召回不可用时**不**降级,如实上报。
// 日志只记分区与错误,绝不带记忆正文(候选预览里已含正文)。 // 日志只记分区与错误,绝不带记忆正文(候选预览里已含正文)。
// func (t *Tidier) strictRecall(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget) ([]tidyPair, error) {
// 失败时要往 ledger 记一笔 unavailable:否则「尝试过但向量源不可用」与「没有候选 pairs, err := t.recallPairs(ctx, p, items, stats, budget)
// 可比」在观测面上同值(reviewer round1 的 p2)。
func (t *Tidier) strictRecall(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget, ledger *recallLedger) ([]tidyPair, error) {
pairs, err := t.recallPairs(ctx, p, items, stats, budget, ledger)
if err != nil { if err != nil {
ledger.noteUnavailable()
t.log.Warn("memory tidy dry-run recall unavailable", t.log.Warn("memory tidy dry-run recall unavailable",
"partition", p.label(), "items", len(items), "error", err) "partition", p.label(), "items", len(items), "error", err)
return nil, fmt.Errorf("%w: %w", ErrTidyRecallUnavailable, err) return nil, fmt.Errorf("%w: %w", ErrTidyRecallUnavailable, err)
@@ -109,17 +92,7 @@ func (t *Tidier) strictRecall(ctx context.Context, p tidyPartition, items []Memo
// logDryRun 输出试算的观测口径。它带 source(memory/tidy),**不带任何记忆 // logDryRun 输出试算的观测口径。它带 source(memory/tidy),**不带任何记忆
// 正文** —— 只记计数、分区数与截断标签。 // 正文** —— 只记计数、分区数与截断标签。
// func (t *Tidier) logDryRun(out TidyDryRun) {
// 🔴 `recall_mode` + `recall_unavailable_partitions` 一起才把三件事分开
// (reviewer round1 的 p2:它们曾同值):
// - mode=""、unavailable=0:本轮**没有候选可比**(压根没算过相似度);
// - mode=""、unavailable>0:**尝试过召回但向量源不可用**(严格口径不降级,
// 调用方拿到的是 `ErrTidyRecallUnavailable` ⇒ HTTP 500 `tidy_recall_unavailable`);
// - mode=embedding/lexical:真的用该来源算过。
//
// 没有为此扩 `recallMode` 的取值集合:试算失败时**不序列化** result(响应是错误
// 体),一个新取值只会出现在这行日志里,却要动一次 API 契约与文档 —— 不划算。
func (t *Tidier) logDryRun(out TidyDryRun, ledger *recallLedger) {
t.log.Info("memory tidy dry-run finished", t.log.Info("memory tidy dry-run finished",
"rule_version", TidyRuleVersion, "rule_version", TidyRuleVersion,
"partitions", out.Stats.Partitions, "partitions", out.Stats.Partitions,
@@ -131,7 +104,6 @@ func (t *Tidier) logDryRun(out TidyDryRun, ledger *recallLedger) {
"veto_reasons", tidyReasonsLabel(out.Stats.VetoReasons), "veto_reasons", tidyReasonsLabel(out.Stats.VetoReasons),
"errors", out.Stats.Errors, "errors", out.Stats.Errors,
"recall_mode", out.RecallMode, "recall_mode", out.RecallMode,
"recall_unavailable_partitions", ledger.unavailablePartitions(),
"duration_ms", out.DurationMS) "duration_ms", out.DurationMS)
} }
+9 -108
View File
@@ -25,105 +25,8 @@ const (
tidyRecallEmbedding = "embedding" tidyRecallEmbedding = "embedding"
// tidyRecallLexical 是本轮相似度来自零依赖的词项余弦。 // tidyRecallLexical 是本轮相似度来自零依赖的词项余弦。
tidyRecallLexical = "lexical" tidyRecallLexical = "lexical"
// tidyRecallMixed 是多分区的一轮里**部分**分区走了语义、部分降级到词项余弦。
// 它取代「挑一个代表值报上去」的做法:降级一旦发生就必须看得见。
tidyRecallMixed = "mixed"
) )
// recallLedger 记录**一轮里实际发生**的召回来源(不是配置里那一种)。
//
// 为什么需要它(M6 2026-10-13 的 medium finding):整理轮与试算轮的 `recall_mode`
// 曾按「有没有配 embedder」写出去(旧实现 tidyRecallMode(hasEmbedder bool)),于是
// **全量降级**在汇总行/面板里被写成 `embedding` —— 汇总与事实相反,运维据此判读会
// 把「向量不可用」读成「向量正常工作」。本类型把口径翻过来:只在**真的用了**那条
// 路的地方记一笔,label() 完全由这些实测记录推出。
type recallLedger struct {
// embedding / lexical 是**真正用该来源算过相似度**的分区数。
embedding int
lexical int
// degraded 是**因相似度来源不可用**而降级到词项余弦的分区数(不是「没配
// embedder」那种配置形态)。它把「没候选」(label 为空串)与「向量不可用」
// (label 为 lexical 且 degraded > 0)分开 —— 这两件事在运维上不是一回事。
degraded int
// unavailable 是**尝试过召回但向量源不可用、且本轮不降级**(试算轮的严格口径)
// 的分区数。它与 degraded 的区别是「有没有改用词项余弦」:生产轮会降级
// (degraded),试算轮只报错。
//
// 🔴 它存在的理由(reviewer round1 的 p2):试算轮在向量源不可用时
// `RecallMode` 与「没有候选可比」**同为空串**,两者在那个字段上分不开。
// 现在 provenance 由 unavailablePartitions() 单独给出,日志行能区分;
// 调用方(面板)那条路本来就有明确错误码(500 tidy_recall_unavailable),
// 因此**没有**为此扩 recallMode 的取值集合(见 logDryRun 的说明)。
unavailable int
}
// noteEmbedding 记一笔「这个分区真的用了语义 embedding」。
func (l *recallLedger) noteEmbedding() {
if l != nil {
l.embedding++
}
}
// noteLexical 记一笔「这个分区用了词项余弦」;degraded 表示它是**降级**来的
// (embedder 报错 / 向量条数错位),而不是「本来就没配 embedder」。
func (l *recallLedger) noteLexical(degraded bool) {
if l == nil {
return
}
l.lexical++
if degraded {
l.degraded++
}
}
// noteUnavailable 记一笔「这个分区尝试召回但向量源不可用,且本轮没有降级」
// (试算轮的严格口径:宁可报错,也不把「没算出来」说成「算过了,没有」)。
func (l *recallLedger) noteUnavailable() {
if l != nil {
l.unavailable++
}
}
// label 返回本轮**实际**的召回来源:
// - "":没有任何分区真正算过相似度(没有候选可比)—— 「没候选」不是「降级」;
// - embedding / lexical:所有算过的分区都走同一条路;
// - mixed:两种都有(多分区里有一部分降级)。
//
// ⚠️ 「尝试过但不可用」**不改变** label:它没有产出任何相似度,报 embedding /
// lexical 都是假的;它的存在由 unavailablePartitions() 单独给出(见 recallLedger
// 的 unavailable 字段说明)。
func (l *recallLedger) label() string {
if l == nil {
return ""
}
switch {
case l.embedding == 0 && l.lexical == 0:
return ""
case l.embedding == 0:
return tidyRecallLexical
case l.lexical == 0:
return tidyRecallEmbedding
default:
return tidyRecallMixed
}
}
// degradedPartitions 返回本轮因向量不可用而降级的分区数。
func (l *recallLedger) degradedPartitions() int {
if l == nil {
return 0
}
return l.degraded
}
// unavailablePartitions 返回本轮「尝试召回但向量源不可用、且没有降级」的分区数。
func (l *recallLedger) unavailablePartitions() int {
if l == nil {
return 0
}
return l.unavailable
}
// tidyPair 是一条被召回的候选对。 // tidyPair 是一条被召回的候选对。
type tidyPair struct { type tidyPair struct {
a, b MemoryItem a, b MemoryItem
@@ -132,23 +35,18 @@ type tidyPair struct {
mode string mode string
} }
// recallPairs 在分区内召回候选对。条目少于 2 条时为空操作(无候选、不报错), // recallPairs 在分区内召回候选对。条目少于 2 条时为空操作(无候选、不报错)。
// 且**不往 ledger 记任何一笔** —— 「没有候选可比」不是「走哪条路召回」。
// //
// 相似度来源不可用时(embedder 报错、向量条数错位)返回错误 —— 回退决策不在 // 相似度来源不可用时(embedder 报错、向量条数错位)返回错误 —— 回退决策不在
// 这里:生产轮把它降级成词项余弦,试算轮如实上报(见 tidyRecall 策略)。 // 这里:生产轮把它降级成词项余弦,试算轮如实上报(见 tidyRecall 策略)。
// //
// ledger 是**实际发生**的来源记账口(见 recallLedger):它只在真正选定相似度
// 函数的那一行记账,因此谁也无法把它报成「配置里那一种」。
//
// 返回的对按「分数降序 + id」确定性排序,使每分区候选上限的截断可复现 // 返回的对按「分数降序 + id」确定性排序,使每分区候选上限的截断可复现
// (同一批数据两次运行产出同一批候选 ⇒ 幂等,design §4.1 A)。 // (同一批数据两次运行产出同一批候选 ⇒ 幂等,design §4.1 A)。
func (t *Tidier) recallPairs(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget, ledger *recallLedger) ([]tidyPair, error) { func (t *Tidier) recallPairs(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget) ([]tidyPair, error) {
if len(items) < 2 { if len(items) < 2 {
return nil, nil return nil, nil
} }
if t.deps.Embedder == nil { if t.deps.Embedder == nil {
ledger.noteLexical(false)
return t.collectPairs(p, items, t.cfg.RecallThreshold, return t.collectPairs(p, items, t.cfg.RecallThreshold,
lexicalSimFunc(items), tidyRecallLexical, stats, budget), nil lexicalSimFunc(items), tidyRecallLexical, stats, budget), nil
} }
@@ -159,7 +57,6 @@ func (t *Tidier) recallPairs(ctx context.Context, p tidyPartition, items []Memor
if len(vecs) != len(items) { if len(vecs) != len(items) {
return nil, ErrTidyEmbedCount return nil, ErrTidyEmbedCount
} }
ledger.noteEmbedding()
return t.collectPairs(p, items, t.cfg.RecallThreshold, return t.collectPairs(p, items, t.cfg.RecallThreshold,
vectorSimFunc(vecs), tidyRecallEmbedding, stats, budget), nil vectorSimFunc(vecs), tidyRecallEmbedding, stats, budget), nil
} }
@@ -308,6 +205,10 @@ func clampUnit(v float64) float64 {
return v return v
} }
// 旧的 tidyRecallMode(hasEmbedder bool) 已删除(M6 2026-10-13 的 medium finding): // tidyRecallMode 报告本轮相似度的来源,便于运维判断阈值是否在预期口径上。
// 它按**配置态**报「本轮相似度来源」,把全量降级掩盖成 embedding。现在的口径是 func tidyRecallMode(hasEmbedder bool) string {
// recallLedger —— 只报**实际发生**的那一种,见本文件顶部的 recallLedger。 if hasEmbedder {
return tidyRecallEmbedding
}
return tidyRecallLexical
}
@@ -1,216 +0,0 @@
package memory
import (
"bytes"
"context"
"errors"
"log/slog"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
)
// 本文件钉 M6(2026-10-13)的 medium finding:整理轮/试算轮的 `recall_mode`
// 必须是**实际发生**的那一种,不是配置态。
//
// 旧实现(tidyRecallMode(hasEmbedder bool))按「有没有配 embedder」写,于是
// 「配了但是向量不可用 ⇒ 全量降级」在汇总行里被写成 embedding —— 汇总与事实相反。
//
// 「配了但不可用」的 embedder 复用 tidy_dryrun_test.go 的 failingEmbedder
// (正是线上那 2568 次 `embedding recall unavailable` 的形态)。
// newLogCaptureTidier 构建一个把汇总行写进 buffer 的整理器(便于断言日志字段)。
func newLogCaptureTidier(t *testing.T, store *Store, cfg TidyConfig, emb embedding.Embedder) (*Tidier, *bytes.Buffer) {
t.Helper()
buf := &bytes.Buffer{}
tr, err := NewTidier(cfg, TidyDeps{
Store: store,
Embedder: emb,
Log: slog.New(slog.NewTextHandler(buf, nil)),
})
if err != nil {
t.Fatalf("NewTidier: %v", err)
}
return tr, buf
}
// TestRecallLedger_LabelComesFromActualUse 钉记账口本身:label 完全由「真的用了
// 哪条路」推出,混合与降级都不许被抹平。
func TestRecallLedger_LabelComesFromActualUse(t *testing.T) {
t.Parallel()
cases := []struct {
name string
notes func(l *recallLedger)
want string
degraded int
}{
{name: "没算过(没候选)", notes: func(*recallLedger) {}, want: ""},
{name: "全部语义", notes: func(l *recallLedger) { l.noteEmbedding() }, want: tidyRecallEmbedding},
{name: "全部词项", notes: func(l *recallLedger) { l.noteLexical(false) }, want: tidyRecallLexical},
{
name: "降级到词项",
notes: func(l *recallLedger) { l.noteLexical(true) },
want: tidyRecallLexical,
degraded: 1,
},
{
name: "多分区混合(部分降级)",
notes: func(l *recallLedger) { l.noteEmbedding(); l.noteLexical(true) },
want: tidyRecallMixed,
degraded: 1,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var l recallLedger
tc.notes(&l)
if got := l.label(); got != tc.want {
t.Fatalf("label = %q, want %q", got, tc.want)
}
if got := l.degradedPartitions(); got != tc.degraded {
t.Fatalf("degraded = %d, want %d", got, tc.degraded)
}
})
}
}
// TestTidyRunLog_RecallModeReportsDegradation 是 finding 的正面回归:配了 embedder
// 但它报错 ⇒ 汇总行必须写 `recall_mode=lexical` 且 `recall_degraded_partitions=1`,
// **绝不能**写成 embedding。
func TestTidyRunLog_RecallModeReportsDegradation(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
seedDryRunFixture(t, store)
emb := failingEmbedder{err: errors.New("embedding: request: context deadline exceeded")}
tr, buf := newLogCaptureTidier(t, store, tidyTestCfg(0.80), emb)
if _, err := tr.RunOnce(ctx); err != nil {
t.Fatalf("RunOnce 应当降级跑完而不是失败:%v", err)
}
line := lastLogLine(t, buf, "memory tidy run finished")
if !strings.Contains(line, "recall_mode=lexical") {
t.Fatalf("降级轮的汇总行必须写 lexical,实际:%s", line)
}
if strings.Contains(line, "recall_mode=embedding") {
t.Fatalf("降级轮被报成了 embedding(正是本 finding 的形态):%s", line)
}
if !strings.Contains(line, "recall_degraded_partitions=1") {
t.Fatalf("降级分区数没写出来:%s", line)
}
}
// TestTidyRunLog_RecallModeReportsEmbeddingWhenHealthy 是反面:embedder 正常时
// 汇总行必须如实写 embedding、降级数为 0(不能因为「怕误报」而一律写降级)。
func TestTidyRunLog_RecallModeReportsEmbeddingWhenHealthy(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
seedDryRunFixture(t, store)
tr, buf := newLogCaptureTidier(t, store, tidyTestCfg(0.80), dryRunFixtureEmbedder())
if _, err := tr.RunOnce(ctx); err != nil {
t.Fatalf("RunOnce: %v", err)
}
line := lastLogLine(t, buf, "memory tidy run finished")
if !strings.Contains(line, "recall_mode=embedding") {
t.Fatalf("健康轮必须写 embedding,实际:%s", line)
}
if !strings.Contains(line, "recall_degraded_partitions=0") {
t.Fatalf("健康轮的降级数必须为 0:%s", line)
}
}
// TestTidyRunLog_RecallModeEmptyWhenNoPairs 钉「没候选」与「向量不可用」可区分:
// 分区里只有一条(压根没有对可比)时,来源是**空串**,而不是任何一条召回路径。
func TestTidyRunLog_RecallModeEmptyWhenNoPairs(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
seedTidyItem(t, store, habitItem(fixtureHabitShort, base, 0.9))
emb := failingEmbedder{err: errors.New("embedding: request: context deadline exceeded")}
tr, buf := newLogCaptureTidier(t, store, tidyTestCfg(0.80), emb)
if _, err := tr.RunOnce(ctx); err != nil {
t.Fatalf("RunOnce: %v", err)
}
line := lastLogLine(t, buf, "memory tidy run finished")
if !strings.Contains(line, `recall_mode=""`) {
t.Fatalf("没有候选可比时来源应为空串(区别于降级),实际:%s", line)
}
if !strings.Contains(line, "recall_degraded_partitions=0") {
t.Fatalf("没有候选可比不算降级:%s", line)
}
}
// TestTidyDryRun_UnavailableIsDistinctFromNoCandidates 钉 reviewer round1 p2:
// 试算轮「尝试过召回但向量源不可用」与「没有候选可比」**必须能区分**。
//
// 两者的 `RecallMode` 都是空串(都不曾产出相似度,报 embedding/lexical 都是假的),
// 区分点在 `recall_unavailable_partitions`:不可用时为 1,没候选时为 0。调用方
// (面板)另有一条更硬的区分 —— 失败那一路不序列化 result,直接是
// `500 tidy_recall_unavailable`。
func TestTidyDryRun_UnavailableIsDistinctFromNoCandidates(t *testing.T) {
t.Parallel()
t.Run("向量源不可用", func(t *testing.T) {
t.Parallel()
store := newTestStore(t)
seedDryRunFixture(t, store)
emb := failingEmbedder{err: errors.New("embedding: request: context deadline exceeded")}
tr, buf := newLogCaptureTidier(t, store, tidyTestCfg(0.80), emb)
out, err := tr.DryRun(context.Background())
if !errors.Is(err, ErrTidyRecallUnavailable) {
t.Fatalf("试算轮必须如实上报不可用,err = %v", err)
}
if out.RecallMode == tidyRecallEmbedding {
t.Fatalf("向量不可用却把 RecallMode 报成 embedding(配置态口径):%q", out.RecallMode)
}
line := lastLogLine(t, buf, "memory tidy dry-run finished")
if !strings.Contains(line, "recall_unavailable_partitions=1") {
t.Fatalf("汇总行必须写出「尝试过但不可用」的计数,实际:%s", line)
}
})
t.Run("没有候选可比", func(t *testing.T) {
t.Parallel()
store := newTestStore(t)
base := tidyTestTime(t)
seedTidyItem(t, store, habitItem(fixtureHabitShort, base, 0.9))
emb := failingEmbedder{err: errors.New("embedding: request: context deadline exceeded")}
tr, buf := newLogCaptureTidier(t, store, tidyTestCfg(0.80), emb)
if _, err := tr.DryRun(context.Background()); err != nil {
t.Fatalf("没有对可比时试算不该报错:%v", err)
}
line := lastLogLine(t, buf, "memory tidy dry-run finished")
if !strings.Contains(line, "recall_unavailable_partitions=0") {
t.Fatalf("没候选不是「不可用」,计数必须为 0,实际:%s", line)
}
if !strings.Contains(line, `recall_mode=""`) {
t.Fatalf("没候选时来源为空串,实际:%s", line)
}
})
}
// lastLogLine 返回 buffer 里最后一条含 marker 的日志行。
func lastLogLine(t *testing.T, buf *bytes.Buffer, marker string) string {
t.Helper()
lines := strings.Split(strings.TrimSpace(buf.String()), "\n")
for i := len(lines) - 1; i >= 0; i-- {
if strings.Contains(lines[i], marker) {
return lines[i]
}
}
t.Fatalf("日志里没有找到 %q:\n%s", marker, buf.String())
return ""
}
+14 -8
View File
@@ -4,7 +4,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
) )
// 本文件是「启动播种」的**值来源**:把 `$DATA_DIR/config/memory-tidy.json` 十键的 // 本文件是「启动播种」的**值来源**:把 `$DATA_DIR/config/memory-tidy.json` 十一键的
// **当前生效值**拍成一份完整快照,交给 runtimeconfig.SeedMemoryTidy 落盘。 // **当前生效值**拍成一份完整快照,交给 runtimeconfig.SeedMemoryTidy 落盘。
// //
// 它回应主人 2026-10-07 的两条口径: // 它回应主人 2026-10-07 的两条口径:
@@ -24,12 +24,12 @@ import (
// 的配置,只是侥幸等价;取归一化后的值,等价性才是结构性的(见 // 的配置,只是侥幸等价;取归一化后的值,等价性才是结构性的(见
// TestTidyTuningSnapshot_PanelRoundTripKeepsEffectiveConfig)。 // TestTidyTuningSnapshot_PanelRoundTripKeepsEffectiveConfig)。
// //
// 为什么只有一处实现:十键的默认值在 memory 侧各只有一处定义 // 为什么只有一处实现:十一键的默认值在 memory 侧各只有一处定义
// (tidy_config.go / preinsert_config.go 的 default* 常量),快照直接复用那两个 // (tidy_config.go / preinsert_config.go 的 default* 常量),快照直接复用那两个
// normalize,**不另写一份默认值** —— 否则播种写下去的就是第二份「差不多」的默认值, // normalize,**不另写一份默认值** —— 否则播种写下去的就是第二份「差不多」的默认值,
// 会与运行期悄悄分叉。 // 会与运行期悄悄分叉。
// //
// 十键与它们的生效来源: // 十一键与它们的生效来源:
// //
// 键 生效来源(无面板文件时) 快照值 // 键 生效来源(无面板文件时) 快照值
// autoApply DisableAutoApply 的反向(代码默认开) true // autoApply DisableAutoApply 的反向(代码默认开) true
@@ -38,12 +38,13 @@ import (
// t1Judge DisableT1 的反向(默认开) true // t1Judge DisableT1 的反向(默认开) true
// t1PerWindow defaultT1PerWindow 120 // t1PerWindow defaultT1PerWindow 120
// t1WindowSec defaultT1Window 3600 // t1WindowSec defaultT1Window 3600
// tempTtlSec ReviewConfig.TempTTL(默认 7 天 = 604800) 归一化值
// intervalSec TidyConfig.IntervalSec(env 或 86400) 归一化值 // intervalSec TidyConfig.IntervalSec(env 或 86400) 归一化值
// quietIntervalSec 缺省 = intervalSec 归一化值 // quietIntervalSec 缺省 = intervalSec 归一化值
// quietStartHour TidyConfig.QuietStartHour(env 或 23) 归一化值 // quietStartHour TidyConfig.QuietStartHour(env 或 23) 归一化值
// quietEndHour TidyConfig.QuietEndHour(env 或 7) 归一化值 // quietEndHour TidyConfig.QuietEndHour(env 或 7) 归一化值
// TidyTuningSnapshot 拍下十键的**当前生效值**,返回一份可直接落盘的面板快照。 // TidyTuningSnapshot 拍下十一键的**当前生效值**,返回一份可直接落盘的面板快照。
// //
// 入参是**已经叠过面板层的**两个配置(组合根的次序:TidyConfigFromConfig → // 入参是**已经叠过面板层的**两个配置(组合根的次序:TidyConfigFromConfig →
// ApplyTidyScheduleTuning → 本函数;审查侧同理),本函数只做最后一步归一化并翻成 // ApplyTidyScheduleTuning → 本函数;审查侧同理),本函数只做最后一步归一化并翻成
@@ -51,10 +52,11 @@ import (
// //
// - tidy 侧四键(intervalSec / quietIntervalSec / quietStartHour / quietEndHour) // - tidy 侧四键(intervalSec / quietIntervalSec / quietStartHour / quietEndHour)
// 与自动档两键(autoApply / autoApplyPerRound); // 与自动档两键(autoApply / autoApplyPerRound);
// - review 侧四键(ingestGate / t1Judge / t1PerWindow / t1WindowSec): // - review 侧五键(ingestGate / t1Judge / t1PerWindow / t1WindowSec /
// 这四项在 `.env` 里**没有出处**(`.env` 已冻结),只由面板文件与代码默认决定, // tempTtlSec):这五项在 `.env` 里**没有出处**(`.env` 已冻结),只由面板文件
// 而播种只发生在**面板文件不存在**时 —— 那正是「零值 + normalizeReviewConfig」 // 与代码默认决定,而播种只发生在**面板文件不存在**时 —— 那正是「零值 +
// 这一条路径,与运行期(wireMemoryReview 的三个 helper)逐字一致。 // normalizeReviewConfig」这一条路径,与运行期(wireMemoryReview 的四个 helper)
// 逐字一致。
// //
// 两个开关是**反向**字段(DisableX),快照要把它们翻回正向的 JSON 键:文件里的 // 两个开关是**反向**字段(DisableX),快照要把它们翻回正向的 JSON 键:文件里的
// `"autoApply": true` 才是「没配时到底开不开」的如实陈述。 // `"autoApply": true` 才是「没配时到底开不开」的如实陈述。
@@ -69,6 +71,9 @@ func TidyTuningSnapshot(tidy TidyConfig, review ReviewConfig) runtimeconfig.Memo
quietIntervalSec := tidy.QuietIntervalSec quietIntervalSec := tidy.QuietIntervalSec
quietStartHour := tidy.QuietStartHour quietStartHour := tidy.QuietStartHour
quietEndHour := tidy.QuietEndHour quietEndHour := tidy.QuietEndHour
// 临时记忆 TTL(design/03 §9.6):normalize 之后恒为正(7 天 = 604800),
// 因此快照写下去的**必然是有效值**,不会播出一份自己读回都要 WARN 的配置。
tempTtlSec := int(review.TempTTL.Seconds())
return runtimeconfig.MemoryTidyTuning{ return runtimeconfig.MemoryTidyTuning{
AutoApply: &autoApply, AutoApply: &autoApply,
@@ -77,6 +82,7 @@ func TidyTuningSnapshot(tidy TidyConfig, review ReviewConfig) runtimeconfig.Memo
T1Judge: &t1Judge, T1Judge: &t1Judge,
T1PerWindow: review.T1PerWindow, T1PerWindow: review.T1PerWindow,
T1WindowSec: int(review.T1Window.Seconds()), T1WindowSec: int(review.T1Window.Seconds()),
TempTtlSec: &tempTtlSec,
IntervalSec: &intervalSec, IntervalSec: &intervalSec,
QuietIntervalSec: &quietIntervalSec, QuietIntervalSec: &quietIntervalSec,
QuietStartHour: &quietStartHour, QuietStartHour: &quietStartHour,