feat(memory): M45 提取器时间性元数据位 temporal 驱动留存判定
背景(M37 复审的原话):「真正卡住上游的是提取器的改写:一次性事件被写成 30~80 字的第三人称长期陈述……这不是阈值问题,是判定链缺信息位。」本片补的 就是那一位,不动 N1~N7 的任何阈值。 产出侧 - temporal.go:TemporalKind 闭集(one_off / recurring / durable / unknown, 空串 = 未分型),normalizeTemporal 只认四个字面值,其余一律归 unknown(绝不猜 同义词);提取提示词追加该枚举(模型只负责标注,判定在 Go 闭集 switch 里)。 - llmextractor:候选解析后归一并随 MemoryItem 带出;非法/缺失值不报错、不拖累 同批其他候选。 落库 - 迁移 0027_memory_temporal(up: ADD COLUMN temporal TEXT NOT NULL DEFAULT ''; down: DROP COLUMN,只撤结构、不改写任何行数据)+ NewStore 的自举前滚 (duplicate column 是预期路径);Store.Add 的同一笔 INSERT 写该列,判定与 留痕不分叉。 判定(仍是 resolveRetention 一处收口,judgeIngest 一字未改) - one_off ⇒ temp(+ expires_at),但先过 temporalSanityVeto 六条语义安全阀: 显式「记住」/ 在陈述约定 / 习惯词 / 硬性长期事实词 / 永久化 / 状态改变 —— 命中即不给结论、落回 N1~N7。依据是离线重放的实测:去掉「记住+约定」两条时 21 条长期约定会被判 temp(4.80%),全开后在最近 500 条窗口内翻转条数为 0。 - durable / recurring ⇒ long 且不再跑规则;unknown / 未分型 ⇒ 落回 N1~N7 (不许因为「正文看着像今天的事」就判 temp)。 - IngestRuleVersion d3.4 → d3.5(判定结论会变,留痕必须可分)。 验证 - go build ./... + go vet ./... 全绿;go test -count=1 ./internal/runtime/memory/... ./internal/db/... 全绿;memory -race 全绿。 - 新增 temporal_test.go(验收点 ①~⑤ + 两条交叉护栏 + 闭集/归一化)与 temporal_migration_test.go(0027 up/down/同形/真实启动顺序)。变异实测: 摘掉 one_off 支路 ⇒ ① 全红;关掉 temporalSanityVeto ⇒ 三条护栏用例全红。 - 迁移测试的「回滚 N 步」改为从 schema_migrations 现算(migrateDownToBefore), 新加一条迁移不再打红 0025/0026 的用例。
This commit is contained in:
1 parent
af61b8ccfe
commit
91101dedfb
15 files changed
+1130
-37
No files matched your search
@@ -0,0 +1,18 @@
|
||||
-- 0027 的回滚:只撤结构(删列),**不改写任何一行的数据**。
|
||||
--
|
||||
-- 回滚的代价(如实登记):`temporal` 这一列的值全部丢失。它对本迁移之前写入的行
|
||||
-- 本来就恒为 `''`(未分型),因此丢的只是「本迁移之后提取器标出的时间性位」。
|
||||
--
|
||||
-- 真正需要说明的是**判定行为**:`retention_tier` / `expires_at` 是另两列,本回滚
|
||||
-- **刻意不动它们**。
|
||||
-- - 已被判成 temp 的行(其中一部分是 `temporal=one_off` 带来的)保持原状:读侧
|
||||
-- 时效闸与后台清扫都只认 `expires_at`(纯 SQL,不依赖本列),因此回滚后它们的
|
||||
-- 行为**逐字不变**,到期照旧消失、照旧被清扫;
|
||||
-- - 若回滚时顺手把那些行的 `expires_at` 清空,那是一次 schema 回滚**改数据**,
|
||||
-- 会把已经判定的临时记忆全部升回永久,同时还留在 `retention_tier='temp'` 上
|
||||
-- —— 那正是「temp ⇔ 非空 expires_at」这条不变量的反面。
|
||||
-- 要重判档位就重跑判定:列会以默认值 `''` 重建,历史行回到「未分型」。
|
||||
--
|
||||
-- SQLite 的 DROP COLUMN 要求该列不被索引/视图/触发器引用:本列没有索引(见 up),
|
||||
-- 因此无需先删索引(0026 的 down 因为带索引才要以 DROP INDEX 开头)。
|
||||
ALTER TABLE memory_items DROP COLUMN temporal;
|
||||
@@ -0,0 +1,43 @@
|
||||
-- 0027_memory_temporal:提取器产出侧的**时间性元数据位**(切片 M45)。
|
||||
--
|
||||
-- 为什么需要这一列(而不是再调一次词项阈值):M37 复审的量化结论是「defer 命中
|
||||
-- 2/500 = 0.4%」不是阈值太严,而是**判定链缺信息位** —— 真正卡住上游的是提取器的
|
||||
-- 改写:一次性事件被写成 30~80 字的第三人称长期陈述,规则闸只看落库正文,因此
|
||||
-- 看不见「这是今天的一次性事件」这一位。实测反证:把 N6 的时点词窗口从 12 放宽到
|
||||
-- 24 字,命中 2→14,多出的 11 条逐条都是**同一长期约定的重复陈述**(「一起晒太阳」
|
||||
-- 暂时搁置 ×5、「傍晚出门走十分钟…今天没走成明天补上」×6)—— 放宽阈值等于 7 天后
|
||||
-- 把约定本身删掉,违背 fail-safe。所以补的是信息位,不是阈值。
|
||||
--
|
||||
-- 为什么是**列**而不是新增一种 kind:`kind`(关于什么)、`scope`(谁的)、
|
||||
-- `retention_tier`(留多久)、本条 `temporal`(**说的是一次发生的事,还是反复/长期
|
||||
-- 成立的事**)是四根正交的轴。时间性是**提取器对原始对话的判断结果**,它必须能被
|
||||
-- 单独存下来(事后巡检「模型当时标了什么」、以及将来回填),塞进 kind 就丢了这条
|
||||
-- 观测线;靠提示词里的自然语言描述则违反 AGENTS §9「判据靠元数据,不靠提示词」。
|
||||
--
|
||||
-- 列值闭集(代码侧定义:internal/runtime/memory/temporal.go 的 TemporalKind):
|
||||
-- one_off 一次性事件(今天 / 这次 / 某一次)⇒ 判定为 temp + 非空 expires_at
|
||||
-- recurring 反复发生的规律(每天 / 每周 / 每次都…)⇒ long
|
||||
-- durable 长期成立的事实 / 偏好 / 约定 / 称呼 / 身份 ⇒ long
|
||||
-- unknown 模型拿不准或没给出合法值 ⇒ **没有结论**,交给既有 N1~N7 规则判
|
||||
-- '' 尚未分型:本迁移之前的历史行,以及非提取器的写入路径(默认值)
|
||||
--
|
||||
-- 🔴 默认值 `''` 而**不是** `unknown`:两者在判定上走同一条路(都落到旧规则),但
|
||||
-- 含义不同 —— `''` 是「没问过」,`unknown` 是「问过,模型拿不准」。把历史行写成
|
||||
-- `unknown` 会让「模型答了什么」这个观测面从第一行起就掺假。本迁移**刻意不回填**:
|
||||
-- 历史行没有对应的提取器输出,拿正文反推「它当时是不是一次性事件」正是本片要消灭
|
||||
-- 的那种猜测。
|
||||
--
|
||||
-- 幂等性:`ALTER TABLE ADD COLUMN` 在 SQLite 里**没有** IF NOT EXISTS 形式,因此本
|
||||
-- 迁移与 0019 / 0026 同一手法 —— 依赖启动顺序「先 MigrateUp、后 NewStore」
|
||||
-- (cmd/cyrene/wire.go):生产库里这一步先跑,随后 NewStore 的自举前滚
|
||||
-- (internal/runtime/memory/temporal_schema.go 的 ensureTemporalSchema)走到
|
||||
-- "duplicate column name" 是预期路径。列的形状必须与自举 DDL
|
||||
-- (internal/runtime/memory/store_schema.go)**逐字同形**,由
|
||||
-- internal/runtime/memory/temporal_migration_test.go 钉住。
|
||||
--
|
||||
-- 本列**不建索引**:目前没有任何按时间性位的读路径(它是写入时的判定输入 +
|
||||
-- 事后巡检的留痕列)。将来整理侧真要按它筛,再补索引不迟(两条路径一起加)。
|
||||
--
|
||||
-- 命名口径:表名复数(AGENTS §10 已登记例外)、列名与代码的 JSON 键同名
|
||||
-- (`temporal`),与 0026 的 `retention_tier` 同族。
|
||||
ALTER TABLE memory_items ADD COLUMN temporal TEXT NOT NULL DEFAULT '';
|
||||
@@ -116,7 +116,11 @@ func judgeExtractionRetention(content string) retentionRule {
|
||||
}
|
||||
// 护栏①:显式「记住」是主人的直接指令,闸不许替他改判。它排在最前,因此
|
||||
// 组合口径(护栏②)不会把主人的直接指令改成临时记忆。
|
||||
if containsAny(text, retentionRememberDirectives()) {
|
||||
//
|
||||
// 切片 M45 起它的判据抽成 retentionExplicitRemember(**一处实现、两处调用**):
|
||||
// 提取器的 `temporal=one_off` 元数据位(temporal.go 的 temporalVerdict)同样
|
||||
// 不许推翻这条护栏 —— 否则「记住我明天三点开会」会被 7 天后删掉。
|
||||
if retentionExplicitRemember(text) {
|
||||
return retentionNone
|
||||
}
|
||||
// 护栏②(2026-10-24 改**组合口径**,M27 体检报告 §7 第 5 批-1):以前是
|
||||
@@ -172,6 +176,19 @@ func judgeExtractionRetention(content string) retentionRule {
|
||||
return retentionNone
|
||||
}
|
||||
|
||||
// retentionExplicitRemember 报告正文里有没有**显式要求记住**的措辞
|
||||
// (「记住 / 记下 / 别忘了」,词表见 retentionRememberDirectives)。
|
||||
//
|
||||
// 它是护栏①的判据,也由「时间性元数据位」那一路复用(temporal.go 的
|
||||
// temporalVerdict):**一条判据只有一份实现**,否则「主人的直接指令一票否决」
|
||||
// 会在两条判定路上给出不同结论。
|
||||
//
|
||||
// 入参可以是原始正文,也可以已由 normalizeRetentionText 规范化过 ——
|
||||
// 规范化是幂等的(折叠空白 + 去尾标点),重复施加不改变结果。
|
||||
func retentionExplicitRemember(content string) bool {
|
||||
return containsAny(normalizeRetentionText(content), retentionRememberDirectives())
|
||||
}
|
||||
|
||||
// retentionHasAnchor 报告正文里有没有「这条记忆可能长期成立」的锚点词
|
||||
// (习惯族 tidyHabitMarkers + 长期事实族 retentionDurableMarkers)。
|
||||
//
|
||||
|
||||
@@ -29,6 +29,14 @@ import (
|
||||
//
|
||||
// 「允许返回 `[]`」是 Mem0 官方口径(design/23 §2:官方原话「Include examples
|
||||
// that should produce []」)—— 不写这一句,模型会为了「有产出」而硬凑。
|
||||
//
|
||||
// 🆕 2026-10-27(切片 M45):prompt 里追加**时间性位** `temporal`(四个枚举值)。
|
||||
// 它**不是**「又一段靠自然语言描述判据」的提示词加固:模型只负责**标注**一个值为
|
||||
// 四个枚举之一,**判定**(one_off ⇒ temp / durable ⇒ long / unknown ⇒ 走旧规则)
|
||||
// 是 temporal.go 里的一段 Go 闭集 switch,落库是 `temporal` 列(迁移 0027)——
|
||||
// 因此 AGENTS §9「判据靠元数据,不靠提示词」在这里是**结构性成立**的:即便模型
|
||||
// 完全不听这段说明,最坏结果也只是它一律答 unknown(= 回到本片之前的旧规则),
|
||||
// 不会产生任何新的误判路径。
|
||||
const extractSystemPrompt = `你是记忆提取器。从对话中提取**值得长期记住**的记忆项。
|
||||
|
||||
只保留这些(正向清单):
|
||||
@@ -49,18 +57,33 @@ const extractSystemPrompt = `你是记忆提取器。从对话中提取**值得
|
||||
**允许返回空数组 []**:一轮对话里没有值得长期记住的内容时,正确做法就是回
|
||||
[],不要为了凑数而提取。
|
||||
|
||||
每条还要标一个**时间性位** "temporal",只能取下面四个英文值之一:
|
||||
- "one_off":这条说的是**某一次**发生的事(今天 / 昨天 / 这次 / 某个具体日子),
|
||||
即使你已经把它写成「用户……」这样的陈述句 —— 一次性事件标成 durable 会让它
|
||||
永远留着,标成 one_off 会让它进临时层、到期自动消失,所以只有确实只发生过
|
||||
一次时才标它;
|
||||
- "recurring":说的是**反复发生**的规律(每天 / 每周 / 每次都…);
|
||||
- "durable":说的是**长期成立**的事实 / 偏好 / 约定 / 称呼 / 身份;
|
||||
- "unknown":拿不准,或者上面三个都不贴切。
|
||||
|
||||
temporal 只许填这四个英文值之一(不要换成中文、不要自造新值);拿不准就填
|
||||
"unknown" —— 标错方向的代价不对称:标成 one_off 会让本该留着的记忆被提前删掉。
|
||||
|
||||
只输出一个 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","temporal":"one_off|recurring|durable|unknown","tags":["标签"],"confidence":0.9,"project_id":"可选项目id"}
|
||||
去噪去重:只保留值得长期记住的,忽略寒暄与临时信息;确实发生过的**一次性事件**
|
||||
如果值得记,也照实提取,但必须把 temporal 标成 one_off(它会进临时层)。`
|
||||
|
||||
// llmCandidate 是提取器要求 LLM 输出的线上形状:每条候选
|
||||
// 记忆一个 JSON 对象。模型未给出合法值时,Scope/Kind 取提取器配置的
|
||||
// 默认值。不含 source_conversation:
|
||||
// 默认值;Temporal 则**一律归 unknown**(temporal.go 的 normalizeTemporal,
|
||||
// 绝不猜:猜成 one_off 的方向是把该留的记忆删掉)。不含 source_conversation:
|
||||
// 模型即使回填该键也会被忽略(结构化溯源只来自持久化层)。
|
||||
type llmCandidate struct {
|
||||
Content string `json:"content"`
|
||||
Scope string `json:"scope"`
|
||||
Kind string `json:"kind"`
|
||||
Temporal string `json:"temporal"`
|
||||
Tags []string `json:"tags"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
ProjectID string `json:"project_id"`
|
||||
@@ -240,6 +263,10 @@ func (e *LLMExtractor) dedupeAndBuild(cands []llmCandidate) []MemoryItem {
|
||||
Content: content,
|
||||
Tags: c.Tags,
|
||||
Confidence: c.Confidence,
|
||||
// 时间性位(迁移 0027)在**产出侧**归一:模型只能选四个值之一,
|
||||
// 其余(空 / 非法 / 自造)一律归 unknown —— 解析失败绝不报错,
|
||||
// 也不拖累同批的其他候选(见 temporal.go 的 normalizeTemporal)。
|
||||
Temporal: normalizeTemporal(c.Temporal),
|
||||
}
|
||||
if !it.Scope.valid() || !it.Kind.valid() {
|
||||
continue
|
||||
|
||||
@@ -97,7 +97,13 @@ const (
|
||||
// 规则。**规则内容与阈值未改**(同一份 N1~N7),但**判据的作用范围变了**:
|
||||
// 档位与 `expires_at` 现在对**每一行**在同一笔 INSERT 里落下,且不再依赖同步闸
|
||||
// 是否开启 —— 同一对条目在 d3.3 与 d3.4 下的落库形态因此可能不同,按规矩升版。
|
||||
const IngestRuleVersion = "d3.4"
|
||||
// - `d3.5`(切片 M45)加入提取器的**时间性元数据位**(`temporal`,迁移 0027):
|
||||
// `one_off` ⇒ temp(⇒ 第五态 defer)、`durable` / `recurring` ⇒ long、
|
||||
// `unknown` / 未分型 ⇒ 照旧走 N1~N7。**N1~N7 的规则内容与阈值一字未改**,但
|
||||
// 同一段正文在 d3.4 与 d3.5 下可能落到不同档位(d3.4 只能靠词判,d3.5 还能
|
||||
// 读到「这是不是一次性事件」这一位)—— 结论会变,按规矩升版,否则新旧留痕
|
||||
// 无法区分。
|
||||
const IngestRuleVersion = "d3.5"
|
||||
|
||||
// 调优旋钮(ReviewConfig)与它们的默认值常量在 preinsert_config.go。
|
||||
|
||||
|
||||
@@ -19,7 +19,8 @@ import (
|
||||
// 2. 写进去的 `expires_at` 与读侧时效闸**同一套规范文本**
|
||||
// (定宽 30 字节 UTC —— 写错布局会让闸静默判错,本仓有历史事故);
|
||||
// 3. TTL 坏值(≤0)**绝不放行**:回落代码默认 7 天 + WARN;
|
||||
// 4. `IngestRuleVersion` 升到 `d3.4`(改判定规则必升版,否则新旧留痕无法区分);
|
||||
// 4. `IngestRuleVersion` 随判定变更升版(改判定规则必升版,否则新旧留痕无法区分;
|
||||
// 当前是 `d3.5`,M37 合入时是 `d3.4`);
|
||||
// 5. defer 是**软动作**:回滚 = 清空 `expires_at`,行与正文一字不动。
|
||||
|
||||
// readIngestExpiry 直读一行记忆的 `expires_at`(该列不在 Store.Get 的读侧面上:
|
||||
@@ -107,8 +108,8 @@ func TestIngestGateDeferWritesShortExpiresAt(t *testing.T) {
|
||||
if rev.Decision != IngestDefer || rev.Reason != retentionLowValueReason {
|
||||
t.Fatalf("闸留痕 = %+v, want decision=defer reason=%q", rev, retentionLowValueReason)
|
||||
}
|
||||
if rev.RuleVer != "d3.4" {
|
||||
t.Fatalf("闸留痕规则版本 = %q, want d3.4", rev.RuleVer)
|
||||
if rev.RuleVer != IngestRuleVersion {
|
||||
t.Fatalf("闸留痕规则版本 = %q, want %q(留痕版本必须与常量同源,不写死)", rev.RuleVer, IngestRuleVersion)
|
||||
}
|
||||
if s := r.Stats(); s.GateDeferred != 1 {
|
||||
t.Fatalf("闸计数 = %+v, want gate_deferred=1", s)
|
||||
@@ -268,16 +269,20 @@ func TestIngestGatePlanExpiresAtUsesSameLayoutAsTimeGate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestIngestRuleVersionIsD34 钉住任务书验收点④:改判定规则必升版
|
||||
// TestIngestRuleVersionIsD35 钉住任务书验收点④:改判定规则必升版
|
||||
// (design/23 §18.4),否则新旧留痕无法区分。
|
||||
//
|
||||
// d3.4(切片 M37)把「判 defer」与「判 retention_tier=temp」合并成同一个判定,
|
||||
// 规则内容未改但判据的作用范围变了(每一行都在同一笔 INSERT 里落档位与有效期,
|
||||
// 且不再依赖同步闸是否开启),因此按规矩升版。
|
||||
func TestIngestRuleVersionIsD34(t *testing.T) {
|
||||
//
|
||||
// d3.5(切片 M45)给判定链补上「时间性元数据位」:`one_off` ⇒ temp、
|
||||
// `durable` / `recurring` ⇒ long、`unknown` ⇒ 旧规则。N1~N7 的规则与阈值一字未改,
|
||||
// 但同一段正文的结论会变(d3.4 只能靠词判),因此同样按规矩升版。
|
||||
func TestIngestRuleVersionIsD35(t *testing.T) {
|
||||
t.Parallel()
|
||||
if IngestRuleVersion != "d3.4" {
|
||||
t.Fatalf("IngestRuleVersion = %q, want d3.4(档位与 defer 合并为同一判定)", IngestRuleVersion)
|
||||
if IngestRuleVersion != "d3.5" {
|
||||
t.Fatalf("IngestRuleVersion = %q, want d3.5(时间性元数据位接入判定链)", IngestRuleVersion)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,41 @@ func migrateUp(t *testing.T, d *sql.DB) {
|
||||
}
|
||||
}
|
||||
|
||||
// migrateDownToBefore 把库回滚到 id **之前**(即 id 与它之后已应用的每条都撤掉)。
|
||||
//
|
||||
// 步数从 `schema_migrations` 账本现算,而不是写死一个常数:写死的口径每加一条
|
||||
// 迁移就要改一次(0026 加进来时本文件的 0025 用例的 `MigrateDown(2)`、0027 加进来时
|
||||
// 0026 用例的 `MigrateDown(1)` 都是这么被打红的)—— 而「回滚到某条之前」这个
|
||||
// 语义本身与后面还压着几条无关。
|
||||
func migrateDownToBefore(t *testing.T, d *sql.DB, id string) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
rows, err := d.QueryContext(ctx, `SELECT id FROM schema_migrations ORDER BY id`)
|
||||
if err != nil {
|
||||
t.Fatalf("list applied migrations: %v", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
steps := 0
|
||||
for rows.Next() {
|
||||
var got string
|
||||
if err := rows.Scan(&got); err != nil {
|
||||
t.Fatalf("scan applied migration: %v", err)
|
||||
}
|
||||
if got >= id {
|
||||
steps++
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("iterate applied migrations: %v", err)
|
||||
}
|
||||
if steps == 0 {
|
||||
t.Fatalf("迁移 %s 不在账本里,无法回滚到它之前", id)
|
||||
}
|
||||
if err := db.MigrateDown(ctx, d, steps, testLogger()); err != nil {
|
||||
t.Fatalf("MigrateDown(%d) 回到 %s 之前: %v", steps, id, err)
|
||||
}
|
||||
}
|
||||
|
||||
// tableExists 报告表是否存在。
|
||||
func tableExists(t *testing.T, d *sql.DB, name string) bool {
|
||||
t.Helper()
|
||||
@@ -141,7 +176,6 @@ func countRawReviewRows(t *testing.T, d *sql.DB) int {
|
||||
// 索引、down 删表、再 up 能重建(回滚不留半状态)。
|
||||
func TestMigration0025CreatesAndRollsBackIngestReviewTable(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
d := openRawMemoryDB(t)
|
||||
migrateUp(t, d)
|
||||
|
||||
@@ -162,11 +196,10 @@ func TestMigration0025CreatesAndRollsBackIngestReviewTable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 回滚:本表之上还压着一条 0026(`memory_retention_tier`,切片 M37),因此这里
|
||||
// 回滚**两条**才轮到 0025 —— 迁移账本按 id 升序、down 按降序撤,这是既有语义。
|
||||
if err := db.MigrateDown(ctx, d, 2, testLogger()); err != nil {
|
||||
t.Fatalf("MigrateDown(2): %v", err)
|
||||
}
|
||||
// 回滚:0025 之上还压着 0026(`memory_retention_tier`,切片 M37)与 0027
|
||||
// (`temporal`,切片 M45),因此要回到 0025 **之前**才轮得到它 —— 步数由
|
||||
// migrateDownToBefore 从账本现算,不写死常数(见该 helper 的注释)。
|
||||
migrateDownToBefore(t, d, ingestReviewMigrationID)
|
||||
if tableExists(t, d, ingestReviewTable) {
|
||||
t.Fatalf("回滚后 %s 应已被删除", ingestReviewTable)
|
||||
}
|
||||
|
||||
@@ -95,8 +95,18 @@ type retentionVerdict struct {
|
||||
// 2. **显式给了有效期**(`item.ExpiresAt` 非零)⇒ 这一行会到期 ⇒ 它就是临时记忆。
|
||||
// 两者是同一根轴的两种表达,因此这里不是「推导」,而是同一个事实的补全。
|
||||
// 3. **受限来源**(群聊 / 陌生人)⇒ 不分型(空串)。见文件头注 ②。
|
||||
// 4. **留存规则命中**(design/03 §10.5 的 N1~N7)⇒ temp,并带回规则码。
|
||||
// 5. 其余 ⇒ long(默认落点,fail-safe)。
|
||||
// 4. **提取器的时间性元数据位**(迁移 0027、temporal.go)⇒ `one_off` 判 temp
|
||||
// (先过 temporalSanityVeto:正文自己还在说「长期事实 / 反复规律 / 一份约定 /
|
||||
// 主人明确要求记住」时不给结论,落回旧规则);`durable` / `recurring` 判 long
|
||||
// 并**不再跑规则**;`unknown` 与未分型 ⇒ **没有结论**,落到下一步。
|
||||
// 🔴 `unknown` 绝不等于「一次性」:不许因为正文看着像今天的事就判 temp
|
||||
// (fail-safe 方向:宁可多留)。
|
||||
// 5. **留存规则命中**(design/03 §10.5 的 N1~N7)⇒ temp,并带回规则码。
|
||||
// 6. 其余 ⇒ long(默认落点,fail-safe)。
|
||||
//
|
||||
// 排在第 3 步之后的理由与它自己一致:受限来源的留存由来源面决定(后台到期清扫
|
||||
// 跳过它们,expire.go 的 `nonOwnerSource`),若时间性位把它们判成 temp,就会造出
|
||||
// 一条「对读面隐藏、却永远不会被删」的行 —— 那是本条要避免的形态。
|
||||
//
|
||||
// 🔴 「判第五态 `defer`」与「判 `retention_tier=temp`」**就是这一步**:闸与异步审查
|
||||
// 都只读 `item.RetentionTier`(preinsert_judge.go),不再各自跑一遍规则。M27/M37 的
|
||||
@@ -115,6 +125,9 @@ func resolveRetention(item MemoryItem) (retentionVerdict, error) {
|
||||
if nonOwnerSource(item.SourceConversation) {
|
||||
return retentionVerdict{Tier: RetentionTierUnset}, nil
|
||||
}
|
||||
if verdict, ok := temporalVerdict(item); ok {
|
||||
return verdict, nil
|
||||
}
|
||||
if rule := judgeExtractionRetention(item.Content); rule != retentionNone {
|
||||
return retentionVerdict{Tier: RetentionTemp, Rule: rule}, nil
|
||||
}
|
||||
|
||||
@@ -7,8 +7,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||||
)
|
||||
|
||||
// 本文件钉住**迁移 0026(`memory_retention_tier`)与 Store 自举 DDL 的同形与可回滚**,
|
||||
@@ -69,10 +67,9 @@ func TestMigration0026AddsColumnOverExistingRows(t *testing.T) {
|
||||
d := openRawMemoryDB(t)
|
||||
migrateUp(t, d)
|
||||
|
||||
// 造出「既有部署」:把 0026 撤掉(列与索引消失),再按**旧形态**写入两行。
|
||||
if err := db.MigrateDown(ctx, d, 1, testLogger()); err != nil {
|
||||
t.Fatalf("MigrateDown(1): %v", err)
|
||||
}
|
||||
// 造出「既有部署」:回到 0026 **之前**(列与索引消失),再按**旧形态**写入两行。
|
||||
// 步数由账本现算:0027(`temporal`,切片 M45)压在上面,写死 1 会撤错那条。
|
||||
migrateDownToBefore(t, d, retentionMigrationID)
|
||||
if hasMemoryColumn(t, d, retentionTierColumn) || hasMemoryIndex(t, d, retentionTierIndex) {
|
||||
t.Fatalf("0026 回滚后 %s / %s 应已消失", retentionTierColumn, retentionTierIndex)
|
||||
}
|
||||
@@ -111,9 +108,7 @@ func TestMigration0026AddsColumnOverExistingRows(t *testing.T) {
|
||||
}
|
||||
|
||||
// 回滚:只撤结构,不改写任何一行的其它数据。
|
||||
if err := db.MigrateDown(ctx, d, 1, testLogger()); err != nil {
|
||||
t.Fatalf("MigrateDown(1, 二次): %v", err)
|
||||
}
|
||||
migrateDownToBefore(t, d, retentionMigrationID)
|
||||
if hasMemoryColumn(t, d, retentionTierColumn) || hasMemoryIndex(t, d, retentionTierIndex) {
|
||||
t.Fatalf("二次回滚后 %s / %s 应已消失", retentionTierColumn, retentionTierIndex)
|
||||
}
|
||||
@@ -181,11 +176,18 @@ func TestMigration0026ShapeMatchesStoreBootstrap(t *testing.T) {
|
||||
t.Fatalf("%s 侧的 %s 列形状不是 TEXT NOT NULL DEFAULT 空串:%v", name, retentionTierColumn, cols)
|
||||
}
|
||||
}
|
||||
// 自举 DDL 里新列写在最后一列(新建库的期望形状)——迁移路径上它后面可能还有
|
||||
// 自举追加的 project_id,因此只对自举侧断言顺序。
|
||||
// 自举 DDL 的尾序(新建库的期望形状):`… retention_tier, temporal`。
|
||||
// 迁移路径上这两列之后可能还有自举追加的 project_id,因此只对自举侧断言顺序。
|
||||
// (0027 之后 retention_tier **不再是**最后一列 —— temporal 追加在它之后。)
|
||||
bootCols := tableColumns(t, bootstrapped, "memory_items")
|
||||
if last := bootCols[len(bootCols)-1]; !strings.Contains(last, "|"+retentionTierColumn+"|") {
|
||||
t.Fatalf("自举建的 memory_items 最后一列 = %q, want %s", last, retentionTierColumn)
|
||||
if len(bootCols) < 2 {
|
||||
t.Fatalf("自举建的 memory_items 列数 = %d, want ≥ 2", len(bootCols))
|
||||
}
|
||||
tail := bootCols[len(bootCols)-2:]
|
||||
if !strings.Contains(tail[0], "|"+retentionTierColumn+"|") ||
|
||||
!strings.Contains(tail[1], "|"+temporalColumn+"|") {
|
||||
t.Fatalf("自举建的 memory_items 尾两列 = %v, want …, %s, %s",
|
||||
tail, retentionTierColumn, temporalColumn)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -135,6 +135,17 @@ type MemoryItem struct {
|
||||
// 非空 = 调用方的显式断言,判据不许推翻它。读回来时:
|
||||
// **空串 = 尚未分型**(0026 之前的历史行 / 受限来源新行),绝不是某一档。
|
||||
RetentionTier RetentionTier
|
||||
|
||||
// Temporal 是**提取器给出的时间性元数据位**(迁移 0027、temporal.go):
|
||||
// one_off / recurring / durable / unknown,空串 = 未分型(历史行与
|
||||
// 非提取器写入路径)。它只由提取器在**产出侧**填,判定侧
|
||||
// (resolveRetention)读它一位,不让任何规则去猜「这句话说的是不是今天的事」。
|
||||
//
|
||||
// ⚠️ **与 ExpiresAt 同样是「只写不读」的列**:`Add` 把它写进 `temporal` 列,
|
||||
// 但四条列表读路径的 SELECT 与 scanItem 都不读它 —— 改扫描口径要动全部读点
|
||||
// (rules.go / store_list.go / tidy_store.go / preinsert_store.go,共 14→15 列),
|
||||
// 那是另一件事,不在本片授权范围。要按时间性位巡检/回填时直读该列即可。
|
||||
Temporal TemporalKind
|
||||
}
|
||||
|
||||
// 区分 memory 包各类失败的哨兵错误。每个都在
|
||||
@@ -161,6 +172,10 @@ var (
|
||||
// 丢弃其中一半。
|
||||
ErrRetentionConflict = errors.New("memory: retention tier conflicts with expires_at")
|
||||
|
||||
// ErrInvalidTemporal 在条目的 Temporal(时间性元数据位,迁移 0027)不是闭集内
|
||||
// 的值时返回。空串**不在**此列:它是「尚未分型」这一合法状态。
|
||||
ErrInvalidTemporal = errors.New("memory: invalid temporal kind")
|
||||
|
||||
// ErrNotFound 在条目 id 不存在时返回。
|
||||
ErrNotFound = errors.New("memory: item not found")
|
||||
|
||||
@@ -246,6 +261,10 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error)
|
||||
if err := ensureRetentionSchema(ctx, db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 时间性元数据位(迁移 0027):只补列、不建索引、不回填(见 temporal_schema.go)。
|
||||
if err := ensureTemporalSchema(ctx, db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := normalizeMemoryTimestamps(ctx, db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -284,6 +303,11 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error)
|
||||
// 「档位 → expires_at」换算。两列因此**同一个判定、同一笔 INSERT**落下,
|
||||
// 不存在「判了 temp 却没写有效期」的中间态 —— M2 时代登记过的「INSERT 与补写
|
||||
// expires_at 之间的短窗口」在本片被结构性关闭(不再有第二笔 UPDATE)。
|
||||
//
|
||||
// 🆕 切片 M45:`item.Temporal`(时间性元数据位,迁移 0027)也随**这一笔** INSERT
|
||||
// 落下(`temporal` 列)。它的判定作用已经折算进上面的档位(temporal.go 的
|
||||
// temporalVerdict 是 resolveRetention 的第 4 步),列值本身是**留痕**:记录提取器
|
||||
// 当时标了什么,供事后巡检「模型答的是不是 one_off」—— 判定与留痕因此不会分叉。
|
||||
func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) {
|
||||
if err := item.normalize(); err != nil {
|
||||
return MemoryItem{}, err
|
||||
@@ -315,12 +339,13 @@ func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) {
|
||||
_, err = s.db.ExecContext(ctx, `INSERT INTO memory_items
|
||||
(id, persona_id, role_id, project_id, scope, kind, content, tags, confidence,
|
||||
source_conversation, created_at, updated_at, decay_weight, status, merged_into,
|
||||
expires_at, retention_tier)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
expires_at, retention_tier, temporal)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
item.ID, item.PersonaID, item.RoleID, item.ProjectID, string(item.Scope), string(item.Kind),
|
||||
item.Content, string(tagsJSON), item.Confidence, item.SourceConversation,
|
||||
formatTime(item.CreatedAt), formatTime(item.UpdatedAt), item.DecayWeight,
|
||||
plan.insertStatus(), plan.absorbedID(), plan.expiresAt(), plan.retention())
|
||||
plan.insertStatus(), plan.absorbedID(), plan.expiresAt(), plan.retention(),
|
||||
string(item.Temporal))
|
||||
if err != nil {
|
||||
return MemoryItem{}, fmt.Errorf("memory: insert item: %w", err)
|
||||
}
|
||||
@@ -428,6 +453,13 @@ func (it *MemoryItem) validate() error {
|
||||
if !it.RetentionTier.valid() {
|
||||
return fmt.Errorf("%w: %q", ErrInvalidRetentionTier, it.RetentionTier)
|
||||
}
|
||||
// 时间性位(迁移 0027)同样只接受闭集内的值:它是**枚举元数据**,一个自造的
|
||||
// 值("sometimes")既判不出档位、又不该静默落库 —— 提取器已经把模型输出归一
|
||||
// 到 unknown(temporal.go 的 normalizeTemporal),走到这里还是非法值只能是
|
||||
// 程序错误,直接拒。
|
||||
if !it.Temporal.valid() {
|
||||
return fmt.Errorf("%w: %q", ErrInvalidTemporal, it.Temporal)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,8 @@ CREATE TABLE IF NOT EXISTS memory_items (
|
||||
occurred_at TEXT NOT NULL DEFAULT '',
|
||||
valid_to TEXT NOT NULL DEFAULT '',
|
||||
expires_at TEXT NOT NULL DEFAULT '',
|
||||
retention_tier TEXT NOT NULL DEFAULT ''
|
||||
retention_tier TEXT NOT NULL DEFAULT '',
|
||||
temporal TEXT NOT NULL DEFAULT ''
|
||||
)`
|
||||
|
||||
const createMemoryItemsPersonaIndex = `
|
||||
@@ -43,6 +44,11 @@ CREATE INDEX IF NOT EXISTS idx_memory_items_persona_id_created_at
|
||||
// (回填分型的入口)都吃它。列**必须**排在 CREATE TABLE 的最后:迁移的
|
||||
// `ALTER TABLE ADD COLUMN` 也把新列追加在末尾,两份 DDL 才逐列同形
|
||||
// (retention_migration_test.go 钉住这条同形,少一列就是「新库有、老库无」的漂移)。
|
||||
//
|
||||
// ⚠️ 0027 之后 `retention_tier` **不再是最后一列**:`temporal`(迁移 0027)追加在
|
||||
// 它之后,两条路径的尾序都是 `… expires_at, retention_tier, temporal`。新列一律
|
||||
// 追加在末尾这条口径不变,只是「末尾」会随迁移推进(那条同形断言比对**列集合**,
|
||||
// 另外对尾序做一条显式断言)。
|
||||
const createMemoryItemsRetentionTierIndex = `
|
||||
CREATE INDEX IF NOT EXISTS idx_memory_items_retention_tier
|
||||
ON memory_items (retention_tier)`
|
||||
@@ -0,0 +1,197 @@
|
||||
package memory
|
||||
|
||||
import "strings"
|
||||
|
||||
// 本文件是**提取器产出侧的时间性元数据位**(`temporal`,迁移 0027):它回答
|
||||
// 「这条记忆说的是**一次发生的事**,还是**反复 / 长期成立的事**」。
|
||||
//
|
||||
// 为什么需要这一位(M37 复审的结论,逐字):「真正卡住上游的是**提取器的改写**:
|
||||
// 一次性事件被写成 30~80 字的第三人称长期陈述(同时带『近期/目前』和持久词),
|
||||
// 规则闸看不到『这是今天的一次性事件』这一位。这不是阈值问题,是**判定链缺信息位**。」
|
||||
//
|
||||
// 词项规则(extract_retention.go 的 N1~N7)只能看**落库后的正文**,而正文是提取器
|
||||
// 改写过的陈述:M37 实测里多出来的 11 条命中,逐条都是「同一条一次性安排的重复
|
||||
// 陈述」(「一起晒太阳」×5、「傍晚出门走十分钟…今天没走成明天补上」×6)—— 正文里
|
||||
// 连时点词都没有,把时点窗口从 12 放宽到 24 字只会**把约定本身判成临时并删掉**。
|
||||
// 因此补的不是阈值,而是这一位。
|
||||
//
|
||||
// 🔴 **它是枚举元数据,不是提示词描述**(AGENTS §9「判据靠元数据,不靠提示词」):
|
||||
// 模型只能在下面四个值里选一个,落库是**列值**(TEXT),判定走 Go 闭集 switch ——
|
||||
// 不是让规则去理解一句自然语言。模型给不出合法值时**一律归 `unknown`**(绝不猜成
|
||||
// `one_off`:猜错的方向是「本该留着的被提前删掉」,不可逆)。
|
||||
//
|
||||
// 闭集(与迁移 0027 的列值口径逐字一致):
|
||||
//
|
||||
// one_off 一次性事件(今天 / 这次 / 某一次发生的事)⇒ temp + expires_at
|
||||
// recurring 反复发生的规律(每天 / 每周 / 每次都…)⇒ long
|
||||
// durable 长期成立的事实 / 偏好 / 约定 / 称呼 / 身份 ⇒ long
|
||||
// unknown 模型拿不准,或没给出合法值 ⇒ **没有结论**,交给 N1~N7 旧规则
|
||||
// '' 尚未分型:0027 之前的历史行,以及**非提取器**的写入路径(调用方显式
|
||||
// 给了档位的那些)—— 它不是一个业务取值,只是列的默认值
|
||||
//
|
||||
// 🔴 `unknown` 与 `”` 在判定上**走同一条路**(都落到旧规则),但在口径上是两件事:
|
||||
// `unknown` 是「问过模型了,它拿不准」;`”` 是「没问过」。把两者折成一个值会让
|
||||
// 「模型到底答了什么」这个观测面当场消失,而那正是本片要验证的东西。
|
||||
type TemporalKind string
|
||||
|
||||
const (
|
||||
// TemporalUnset 是「尚未分型」:历史行与非提取器写入路径。**它不是业务取值**,
|
||||
// 判定上与 TemporalUnknown 同路(落到旧规则),但含义不同(见文件头注)。
|
||||
TemporalUnset TemporalKind = ""
|
||||
|
||||
// TemporalOneOff 是一次性事件。它是**唯一**会把记忆推进临时层的时间性位:
|
||||
// 命中 ⇒ `retention_tier=temp` + 非空 `expires_at`(7 天后从读面消失、随后被
|
||||
// 后台清扫物理删除)。因此这个值只许模型在**明确知道**「这是某一次发生的事」
|
||||
// 时给出 —— 拿不准必须给 unknown。
|
||||
TemporalOneOff TemporalKind = "one_off"
|
||||
|
||||
// TemporalRecurring 是反复发生的规律(「每天散步十分钟」「每周三开会」)。
|
||||
// 「反复」= 将来还会再发生,因此不许进临时层。
|
||||
TemporalRecurring TemporalKind = "recurring"
|
||||
|
||||
// TemporalDurable 是长期成立的事实 / 偏好 / 约定 / 称呼 / 身份。
|
||||
TemporalDurable TemporalKind = "durable"
|
||||
|
||||
// TemporalUnknown 是「问过模型了,它拿不准」。**它绝不是「一次性」的同义词**:
|
||||
// 判定上它与未分型一样落到 N1~N7,不许因为「正文看着像今天的事」就判 temp ——
|
||||
// 那等于把本片要消灭的猜测重新引回判定链(fail-safe 方向:宁可多留)。
|
||||
TemporalUnknown TemporalKind = "unknown"
|
||||
)
|
||||
|
||||
// valid 报告 t 是否落在闭集内(**含**未分型的空串:它是合法的列值,不是错误)。
|
||||
func (t TemporalKind) valid() bool {
|
||||
switch t {
|
||||
case TemporalUnset, TemporalOneOff, TemporalRecurring, TemporalDurable, TemporalUnknown:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// assigned 报告 t 是否**已由提取器分型**(即非空串)。它是「未分型」的唯一判据,
|
||||
// 与 RetentionTier.assigned 同族,调用方不许用 `t == TemporalUnknown` 之类的
|
||||
// 比较去替代它(那是「问过但拿不准」,不是「没问过」)。
|
||||
func (t TemporalKind) assigned() bool { return t != TemporalUnset }
|
||||
|
||||
// normalizeTemporal 把模型输出的自由文本归一到上面四个值。
|
||||
//
|
||||
// 🔴 **只做大小写折叠与首尾空白裁剪,绝不做同义词猜测**(「一次性」「once」→ 都不认):
|
||||
// 认不出来的答案就是解析失败,一律落 TemporalUnknown —— 这正是任务书
|
||||
// 「模型只能选那四个值,解析失败一律归 unknown(绝不猜)」的字面实现。
|
||||
// 猜同义词的代价是单向的:猜成 one_off 就删记忆,猜成别的只是多留一条。
|
||||
func normalizeTemporal(s string) TemporalKind {
|
||||
switch TemporalKind(strings.ToLower(strings.TrimSpace(s))) {
|
||||
case TemporalOneOff:
|
||||
return TemporalOneOff
|
||||
case TemporalRecurring:
|
||||
return TemporalRecurring
|
||||
case TemporalDurable:
|
||||
return TemporalDurable
|
||||
default:
|
||||
// 空值、非法值、拼写错误、模型自造的新值:全部归 unknown(**不报错**)。
|
||||
// 一条候选记忆的价值不取决于它的时间性位是否写对,因此解析失败绝不
|
||||
// 让整批提取失败(那会静默丢掉同批的其他候选)。
|
||||
return TemporalUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// temporalRetentionTempRule 是「时间性位把条目推进临时层」时进日志的规则码。
|
||||
//
|
||||
// 它**刻意不编号成 n8**:N1~N7 是**词项**规则(看正文),这一条看的是**元数据位**,
|
||||
// 两者混在一套编号里会让「谁命中过」这条观测线读不出「是词判的还是模型标的」。
|
||||
// 与 retentionLowValueReason 的分工不变:规则码只进日志,留痕列仍是闭集里的
|
||||
// `t0_low_value`(extract_retention.go 的口径)。
|
||||
const retentionTemporalTempRule retentionRule = "temporal_one_off"
|
||||
|
||||
// temporalVerdict 把时间性位换算成档位结论(**唯一**一处换算,只由
|
||||
// `resolveRetention` 调用,见 retention.go 的判定顺序第 4 步)。
|
||||
//
|
||||
// - `one_off` ⇒ temp —— 但先过 temporalSanityVeto(语义安全阀):正文自己还在说
|
||||
// 「长期成立 / 反复发生 / 一份约定 / 主人明确要求记住」时,这一位**不给结论**,
|
||||
// 落回 N1~N7。它是本片唯一会**收紧**判定的方向,因此阀门全开在 fail-safe 一侧。
|
||||
// - `durable` / `recurring` ⇒ long,并且**不再跑规则**:模型已经回答了「这不是
|
||||
// 一次性事件」,此时规则再判 temp 就是把「判定链缺的那一位」又丢掉。方向
|
||||
// 仍是 fail-safe(多留)。
|
||||
// - `unknown` / 未分型 ⇒ 不给结论,交给 N1~N7(任务书验收点③:不许因为
|
||||
// 「看着像今天的事」就判 temp)。
|
||||
func temporalVerdict(item MemoryItem) (retentionVerdict, bool) {
|
||||
switch item.Temporal {
|
||||
case TemporalOneOff:
|
||||
if temporalSanityVeto(item.Content) {
|
||||
return retentionVerdict{}, false
|
||||
}
|
||||
return retentionVerdict{Tier: RetentionTemp, Rule: retentionTemporalTempRule}, true
|
||||
case TemporalDurable, TemporalRecurring:
|
||||
return retentionVerdict{Tier: RetentionLong}, true
|
||||
default:
|
||||
return retentionVerdict{}, false
|
||||
}
|
||||
}
|
||||
|
||||
// temporalSanityVeto 是 `one_off` 支路的**语义安全阀**:正文里出现「这条说的是
|
||||
// 长期成立的东西」的任何一类信号时,元数据位**不给结论**(落回 N1~N7)。
|
||||
//
|
||||
// 它回答的是一个具体的分工问题:这一位补的是**位置判据**那一块(N6 要求时点词出现在
|
||||
// 句首 12 字内,而提取器改写出的第三人称长句根本不满足),**不是**把 N6 的语义安全阀
|
||||
// 也一起换掉。判「这不是一次性事件」的词项证据在任何一遍判定里都同样有效,因此这里
|
||||
// **原样复用同一批词表**,只让「这一次到底是不是一次性」由模型来答。
|
||||
//
|
||||
// 六条各自的依据(前两条是既有红线,后四条是 2026-10-27 对生产库 active 集合的离线
|
||||
// 重放实测):
|
||||
//
|
||||
// 1. **显式「记住 / 记下 / 别忘了」** —— extract_retention.go 护栏①,主人的直接
|
||||
// 指令不许被任何判据改成临时记忆;
|
||||
// 2. **在陈述约定 / 承诺** —— M37 量化:放宽时点窗口多命中的 11 条逐条都是「同一
|
||||
// 长期约定的重复陈述」,删掉的就是约定本身;
|
||||
// 3. **习惯词**(每天 / 通常 / 喜欢…)—— 说的是「反复发生」,与一次性互斥;
|
||||
// 4. **硬性长期事实词**(住 / 宿舍 / 工作 / 职业 / 称呼 / 项目 / 生日 / 过敏…)——
|
||||
// 加这条之前,最近 500 条里的 18 条「上界候选」有 12 条是「近期住宿舍(昨天住
|
||||
// 宿舍)」「项目已接近收尾」这类**被时点词捎带的状态事实**;加上之后窗口内的
|
||||
// 上界从 4.2% 降到 0%(实测见 M45 的量报告),而全量 active 的上界从 5.4% 降到
|
||||
// 2.8% —— 这一条是「宁可多留」方向上最便宜的一刀;
|
||||
// 5. **永久化标记**(以后 / 往后 / 从此…);
|
||||
// 6. **状态改变标记**(换成 / 变成 / 改成…:新状态会持续)。
|
||||
//
|
||||
// ⚠️ **刻意不收「具体约束」这一条**(与 N6 的第三/四条不同):数值与频次并不排他 ——
|
||||
// 「帮我订个 7 点的闹钟」恰恰是最典型的一次性事件,把数值当否决会让这一位对真事件
|
||||
// 彻底失效。
|
||||
func temporalSanityVeto(content string) bool {
|
||||
text := normalizeRetentionText(content)
|
||||
if retentionExplicitRemember(text) || retentionStatesAgreement(text) {
|
||||
return true
|
||||
}
|
||||
if containsAny(text, tidyHabitMarkers()) || containsAny(text, retentionHardDurableMarkers()) {
|
||||
return true
|
||||
}
|
||||
if containsAny(text, retentionPermanenceMarkers()) || containsAny(text, retentionStateChangeMarkers()) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// retentionStatesAgreement 报告正文是否在**陈述一项约定 / 承诺**(而不是在记录
|
||||
// 一次发生的事)。
|
||||
//
|
||||
// 它是 `one_off` 支路的第二条例外,语料依据逐条可查(2026-10-27 对生产库最近
|
||||
// 500 条 active 的离线重放 + M37 复审的量化):被判成一次性事件的高危句式不是
|
||||
// 「今天下雨了」那种流水账,而是
|
||||
//
|
||||
// 「和主人有『晚饭后出门走十分钟』的约定;今天没走成,算欠着,明天补上」
|
||||
// 「两人之间有一个『晒太阳』的约定,目前暂缓,等主人想起来再提」
|
||||
//
|
||||
// —— 句子的**主语是一份长期约定**,时点只修饰其中一次执行。M37 已经量过:把 N6
|
||||
// 的时点窗口放宽到 24 字,这 11 条会一起命中,而它们要删的正是约定本身。因此这条
|
||||
// 例外不是「又一条词规则」,而是把 M37 那条实测结论固化成交付物。
|
||||
//
|
||||
// 词表刻意与 retentionDurableMarkers 的「关系里程碑 / 约定」族同源,但**独立列出**
|
||||
// (+「约好」):那一族还会被别的判定用到,两者的职责不同 —— 这里只回答
|
||||
// 「这句话是不是在说一份约定」。
|
||||
func retentionStatesAgreement(content string) bool {
|
||||
text := normalizeRetentionText(content)
|
||||
for _, marker := range []string{"约定", "承诺", "答应", "说好", "约好"} {
|
||||
if strings.Contains(text, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 本文件钉住**迁移 0027(`memory_temporal`)与 Store 自举 DDL 的同形与可回滚**,
|
||||
// 手法照 retention_migration_test.go 对 0026 的那一套(同一批 helpers)。
|
||||
//
|
||||
// 真实启动顺序是「先 MigrateUp、后 NewStore」(cmd/cyrene/wire.go),因此新列有两份
|
||||
// 真源:迁移(权威)与 NewStore 的幂等前滚(任意 *sql.DB 上的自举,temporal_schema.go)。
|
||||
// 少一列就是「新库有、老库无」的漂移,因此两条判据都必须有:
|
||||
//
|
||||
// 1. TestMigration0027AddsColumnOverExistingRows —— 迁移落在**已有数据**上:行不丢、
|
||||
// 列补上、默认值是空串(未分型);down 真的把列删掉且**不改写任何一行的其它
|
||||
// 数据**(尤其是 `retention_tier` / `expires_at`,见 down 文件的口径);再 up 能重建。
|
||||
// 2. TestMigration0027ShapeMatchesStoreBootstrap —— 新列在两份 DDL 里**逐字同形**
|
||||
// (TEXT NOT NULL DEFAULT 空串),且自举侧尾序是 `… retention_tier, temporal`。
|
||||
|
||||
// temporalColumn 是被测列名(迁移与自举 DDL 里的字面量)。
|
||||
const temporalColumn = "temporal"
|
||||
|
||||
// temporalMigrationID 是 0027 在 schema_migrations 里的记账 id(= 文件名去掉 .up.sql)。
|
||||
const temporalMigrationID = "0027_memory_temporal"
|
||||
|
||||
// TestMigration0027AddsColumnOverExistingRows 钉住迁移本体落在**既有数据**上的两条:
|
||||
// 行不丢、默认值是空串(未分型);以及再 up 后历史行回到「未分型」。
|
||||
func TestMigration0027AddsColumnOverExistingRows(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
d := openRawMemoryDB(t)
|
||||
migrateUp(t, d)
|
||||
if !hasMemoryColumn(t, d, temporalColumn) {
|
||||
t.Fatalf("迁移后 memory_items 应带 %s 列", temporalColumn)
|
||||
}
|
||||
|
||||
// 造出「0027 之前的既有部署」:回到 0027 之前,按旧形态写入两行。
|
||||
migrateDownToBefore(t, d, temporalMigrationID)
|
||||
if hasMemoryColumn(t, d, temporalColumn) {
|
||||
t.Fatalf("0027 回滚后 %s 应已消失", temporalColumn)
|
||||
}
|
||||
insertLegacyMemoryRow(t, d, "legacy-1", "一条 0027 之前就存在的记忆")
|
||||
insertLegacyMemoryRow(t, d, "legacy-2", "另一条 0027 之前就存在的记忆")
|
||||
|
||||
// 升级:0027 落在**已有数据**上。
|
||||
migrateUp(t, d)
|
||||
if !hasMemoryColumn(t, d, temporalColumn) {
|
||||
t.Fatalf("迁移后 memory_items 应带 %s 列", temporalColumn)
|
||||
}
|
||||
var rows int
|
||||
if err := d.QueryRowContext(ctx, `SELECT COUNT(*) FROM memory_items`).Scan(&rows); err != nil {
|
||||
t.Fatalf("count rows: %v", err)
|
||||
}
|
||||
if rows != 2 {
|
||||
t.Fatalf("迁移后行数 = %d, want 2(既有行一行都不能丢)", rows)
|
||||
}
|
||||
for _, id := range []string{"legacy-1", "legacy-2"} {
|
||||
var nat, content string
|
||||
if err := d.QueryRowContext(ctx,
|
||||
`SELECT temporal, content FROM memory_items WHERE id = ?`, id).
|
||||
Scan(&nat, &content); err != nil {
|
||||
t.Fatalf("read %s: %v", id, err)
|
||||
}
|
||||
if nat != "" {
|
||||
t.Fatalf("既有行 %s 的时间性位 = %q, want 空串(历史行必须是「未分型」)", id, nat)
|
||||
}
|
||||
if content == "" {
|
||||
t.Fatalf("既有行 %s 的正文丢了", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigration0027DownKeepsRowData 钉住 down 的**唯一**语义:只撤结构。判成 temp 的
|
||||
// 行在回滚之后档位与时效必须**一个字节都没变**(回滚改数据 = 把已判定的临时记忆升回
|
||||
// 永久,正是 down 文件里登记的那条反面)。
|
||||
func TestMigration0027DownKeepsRowData(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
d := openRawMemoryDB(t)
|
||||
migrateUp(t, d)
|
||||
|
||||
if _, err := d.ExecContext(ctx, `INSERT INTO memory_items
|
||||
(id, persona_id, scope, kind, content, created_at, updated_at,
|
||||
retention_tier, expires_at, temporal)
|
||||
VALUES ('temp-1', 'persona.a', 'persona', 'relationship', '一条临时记忆',
|
||||
`+legacyTimeLiteral+`, `+legacyTimeLiteral+`, 'temp', ?, 'one_off')`,
|
||||
formatTime(time.Date(2026, 10, 30, 0, 0, 0, 0, time.UTC))); err != nil {
|
||||
t.Fatalf("seed temp row: %v", err)
|
||||
}
|
||||
|
||||
migrateDownToBefore(t, d, temporalMigrationID)
|
||||
if hasMemoryColumn(t, d, temporalColumn) {
|
||||
t.Fatalf("回滚后 %s 应已消失", temporalColumn)
|
||||
}
|
||||
var tier, expires string
|
||||
if err := d.QueryRowContext(ctx,
|
||||
`SELECT retention_tier, expires_at FROM memory_items WHERE id = 'temp-1'`).
|
||||
Scan(&tier, &expires); err != nil {
|
||||
t.Fatalf("read retention columns after down: %v", err)
|
||||
}
|
||||
if tier != string(RetentionTemp) || expires == "" {
|
||||
t.Fatalf("down 改写了档位/时效 = (%q, %q), want (%q, 非空) —— 回滚只撤结构",
|
||||
tier, expires, RetentionTemp)
|
||||
}
|
||||
|
||||
// 再 up:列以默认值重建,既有行回到「未分型」。
|
||||
migrateUp(t, d)
|
||||
if !hasMemoryColumn(t, d, temporalColumn) {
|
||||
t.Fatalf("再次迁移后应重建 %s 列", temporalColumn)
|
||||
}
|
||||
var untyped int
|
||||
if err := d.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM memory_items WHERE temporal = ''`).Scan(&untyped); err != nil {
|
||||
t.Fatalf("count untyped: %v", err)
|
||||
}
|
||||
if untyped != 1 {
|
||||
t.Fatalf("重建后未分型行 = %d, want 1", untyped)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigration0027ShapeMatchesStoreBootstrap 是**漂移护栏**:迁移建出的
|
||||
// memory_items(列集合)与自举 DDL 建出的必须一致,且新列在两份 DDL 里的形状
|
||||
// 逐字相同(TEXT NOT NULL DEFAULT 空串)。
|
||||
//
|
||||
// 列集合的等价性由 0026 的 TestMigration0026ShapeMatchesStoreBootstrap 一并覆盖
|
||||
// (它比对整表列集合);这里只钉本列自己的形状与自举侧的尾序。
|
||||
func TestMigration0027ShapeMatchesStoreBootstrap(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
|
||||
migrated := openRawMemoryDB(t)
|
||||
migrateUp(t, migrated)
|
||||
|
||||
bootstrapped := openRawMemoryDB(t)
|
||||
if _, err := NewStore(ctx, bootstrapped, testLogger()); err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
|
||||
for name, d := range map[string]*sql.DB{"迁移": migrated, "自举": bootstrapped} {
|
||||
cols := sortedColumns(tableColumns(t, d, "memory_items"))
|
||||
if !slices.Contains(cols, temporalColumn+"|TEXT|1|''|0") {
|
||||
t.Fatalf("%s 侧的 %s 列形状不是 TEXT NOT NULL DEFAULT 空串:%v", name, temporalColumn, cols)
|
||||
}
|
||||
}
|
||||
bootCols := tableColumns(t, bootstrapped, "memory_items")
|
||||
if len(bootCols) < 2 {
|
||||
t.Fatalf("自举建的 memory_items 列数 = %d, want ≥ 2", len(bootCols))
|
||||
}
|
||||
tail := bootCols[len(bootCols)-2:]
|
||||
if !strings.Contains(tail[0], "|"+retentionTierColumn+"|") ||
|
||||
!strings.Contains(tail[1], "|"+temporalColumn+"|") {
|
||||
t.Fatalf("自举建的 memory_items 尾两列 = %v, want …, %s, %s",
|
||||
tail, retentionTierColumn, temporalColumn)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigration0027ThenStoreBootstrapIsARegularPath 覆盖**真实启动顺序**
|
||||
// (cmd/cyrene/wire.go:先 MigrateUp、后 NewStore):0027 先建好列,随后 Store 的
|
||||
// 自举前滚走到 "duplicate column name" —— 那是**预期路径**,必须无害;重复建 Store
|
||||
// 同样幂等,且写入路径可用(时间性位随同一笔 INSERT 落下)。
|
||||
func TestMigration0027ThenStoreBootstrapIsARegularPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
d := openRawMemoryDB(t)
|
||||
migrateUp(t, d)
|
||||
|
||||
store, err := NewStore(ctx, d, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore(列已由迁移建好): %v", err)
|
||||
}
|
||||
after := tableColumns(t, d, "memory_items")
|
||||
if !hasMemoryColumn(t, d, temporalColumn) {
|
||||
t.Fatalf("自举之后 %s 列消失了", temporalColumn)
|
||||
}
|
||||
if _, err := NewStore(ctx, d, testLogger()); err != nil {
|
||||
t.Fatalf("NewStore(第二次): %v", err)
|
||||
}
|
||||
if got := tableColumns(t, d, "memory_items"); !slices.Equal(got, after) {
|
||||
t.Fatalf("第二次自举改变了表结构:\n got=%v\nwant=%v", got, after)
|
||||
}
|
||||
|
||||
item := habitItem(temporalOneOffCorpus()[2], tidyTestTime(t), 0.9)
|
||||
item.Temporal = TemporalOneOff
|
||||
got, err := store.Add(ctx, item)
|
||||
if err != nil {
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
if nat := readTemporalColumn(t, store, got.ID); nat != TemporalOneOff {
|
||||
t.Fatalf("迁移建列后经 Store 落库的时间性位 = %q, want %q", nat, TemporalOneOff)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 本文件是「时间性元数据位」轴(迁移 0027)的 schema 前滚:给**既有**的
|
||||
// memory_items 补 `temporal` 列。
|
||||
//
|
||||
// 与 retention_schema.go 同一手法、同一原因:`createMemoryItemsTable` 是
|
||||
// `CREATE TABLE IF NOT EXISTS`,只对**新库**生效 —— 既有部署的表早就建好了,
|
||||
// 新列不会自己长出来。记忆存储必须自包含、能在任意 *sql.DB 上使用,因此这条
|
||||
// 前滚不能只靠 `internal/db/migrations`。
|
||||
//
|
||||
// 🔴 与迁移的分工(两份真源,必须同形):权威机制是
|
||||
// `internal/db/migrations/0027_memory_temporal`;这里是「任意 *sql.DB」的前滚。
|
||||
// 启动顺序是「先 MigrateUp、后 NewStore」(cmd/cyrene/wire.go),因此生产库里
|
||||
// 0027 先落地、这里走到 `duplicate column name` 是**预期路径**。
|
||||
//
|
||||
// 本列**不建索引**:目前没有任何按时间性位的读路径(它是写入侧的判定输入 +
|
||||
// 事后巡检用的留痕列),提前建索引只是给每笔写入加一份没人用的维护成本。
|
||||
// 将来整理侧真要按它筛(例如「把 one_off 的行捞出来复核」)再加索引不迟,
|
||||
// 那时两条路径(迁移 + 本文件)一起加。
|
||||
|
||||
// ensureTemporalSchema 幂等地给 memory_items 补上时间性位列(列缺失时 ALTER,
|
||||
// 已存在时忽略 SQLite 的 "duplicate column name" —— 那是预期路径,不是错误)。
|
||||
//
|
||||
// 默认值 = 空串 = 尚未分型:既有行因此**行为逐字不变**(resolveRetention 对
|
||||
// 未分型的处理与 0027 之前完全一样,只跑 N1~N7)。本前滚刻意**不做**任何回填
|
||||
// 判定:历史行没有对应的提取器输出,拿正文反推「它当时是一次性事件吗」正是
|
||||
// 本片要消灭的那种猜测。
|
||||
func ensureTemporalSchema(ctx context.Context, db *sql.DB) error {
|
||||
_, err := db.ExecContext(ctx, `ALTER TABLE memory_items
|
||||
ADD COLUMN temporal TEXT NOT NULL DEFAULT ''`)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(err.Error(), "duplicate column name") {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("memory: roll forward temporal column: %w", err)
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// 本文件是**时间性元数据位**(迁移 0027、temporal.go)的验收用例集,逐条对应
|
||||
// 任务书的验收点:
|
||||
//
|
||||
// ① `one_off` 的夹具语料 ⇒ 全链落库 `(temp, 非空 expires_at)`;
|
||||
// ② `durable` / `recurring` ⇒ `(long, 空)`;
|
||||
// ③ `unknown` 且内容像一次性 ⇒ **仍按旧规则**(本条不含新的猜测:命中的规则码
|
||||
// 必须是 N* 词项规则的码,绝不是时间性位的码);
|
||||
// ④ 模型返回非法值 / 空值 ⇒ 归 `unknown`,且**不报错**、不拖累同批候选;
|
||||
// ⑤ 变异:把 temporalVerdict 的 one_off 支路摘掉 ⇒ ① 必红(下面三条 ① 的断言
|
||||
// 都以「档位是 temp」为判据,摘掉支路后它们全部退化成 long)。
|
||||
//
|
||||
// 🔴 这份语料的一半(`one_off` 那三条)**刻意挑成旧规则的非命中形态**:它们在
|
||||
// `Temporal` 为空时判 long、带上 `one_off` 时判 temp —— 于是「这一位真的改了什么」
|
||||
// 可以被单独观察,不会与 N1~N7 的效果混在一起(M37 的实测缺口正是「分不清是谁判的」)。
|
||||
|
||||
// temporalOneOffCorpus 是一次性事件的夹具语料:长句陈述形态、无句首时点词、
|
||||
// **不含约定/承诺词**(含约定词的句子由 retentionStatesAgreement 挡回旧规则,
|
||||
// 见 TestTemporalOneOffDoesNotOverrideStatedAgreement)—— 正是 M37 复盘点名的
|
||||
// 「一次性事件被写成 30~80 字第三人称陈述」那种形态。
|
||||
func temporalOneOffCorpus() []string {
|
||||
return []string{
|
||||
"桌面上的文件整理到一半被别的事打断了",
|
||||
"傍晚出门走十分钟的散步这一回没走成",
|
||||
"那条链路断线是主人自己排查到根因的",
|
||||
}
|
||||
}
|
||||
|
||||
// readTemporalColumn 直读一行的 `temporal` 列(该列与 expires_at 一样是**只写不读**
|
||||
// 的列:四条列表读路径的 SELECT 不带它,见 store.go 的 Temporal 注释)。
|
||||
func readTemporalColumn(t *testing.T, store *Store, id string) TemporalKind {
|
||||
t.Helper()
|
||||
var raw string
|
||||
if err := store.db.QueryRowContext(context.Background(),
|
||||
`SELECT temporal FROM memory_items WHERE id = ?`, id).Scan(&raw); err != nil {
|
||||
t.Fatalf("read temporal column %s: %v", id, err)
|
||||
}
|
||||
return TemporalKind(raw)
|
||||
}
|
||||
|
||||
// TestTemporalOneOffLandsTempWithExpiry 钉住验收点①(也是变异点⑤的靶子):
|
||||
// 时间性位 `one_off` ⇒ 全链落库 `(temp, 非空 expires_at)`,且这一位真的写进了
|
||||
// `temporal` 列(判定与留痕不分叉)。
|
||||
//
|
||||
// 「全链」= 与生产同一条写路径:`Store.Add`(唯一 INSERT 路径)→ resolveRetention
|
||||
// → planIngest → 一笔 INSERT。测试**不绕开** Add,也不用任何测试专用的旁路。
|
||||
func TestTemporalOneOffLandsTempWithExpiry(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
for _, content := range temporalOneOffCorpus() {
|
||||
content := content
|
||||
t.Run(content, func(t *testing.T) {
|
||||
// 先证明这段正文**不含** N1~N7 的命中:否则本用例就分不清是规则还是
|
||||
// 时间性位判的 temp(M37 的缺口就是分不清)。
|
||||
bare, err := resolveRetention(habitItem(content, tidyTestTime(t), 0.9))
|
||||
if err != nil {
|
||||
t.Fatalf("resolveRetention(bare): %v", err)
|
||||
}
|
||||
if bare.Tier != RetentionLong {
|
||||
t.Fatalf("夹具语料 %q 在旧规则下就判成 %q(want long)—— 这条夹具证明不了时间性位的作用,换一条",
|
||||
content, bare.Tier)
|
||||
}
|
||||
|
||||
item := habitItem(content, tidyTestTime(t), 0.9)
|
||||
item.Temporal = TemporalOneOff
|
||||
got, err := store.Add(ctx, item)
|
||||
if err != nil {
|
||||
t.Fatalf("Add(one_off): %v", err)
|
||||
}
|
||||
tier, expires := readRetentionColumns(t, store, got.ID)
|
||||
if tier != RetentionTemp {
|
||||
t.Fatalf("%q with temporal=one_off 落库档位 = %q, want %q", content, tier, RetentionTemp)
|
||||
}
|
||||
if expires == "" {
|
||||
t.Fatalf("%q 判成 temp 却没写 expires_at(两列分叉)", content)
|
||||
}
|
||||
if got.ExpiresAt.IsZero() {
|
||||
t.Fatalf("%q 的 Add 返回值没回填 ExpiresAt(调用方看不见「到期到哪」)", content)
|
||||
}
|
||||
if nat := readTemporalColumn(t, store, got.ID); nat != TemporalOneOff {
|
||||
t.Fatalf("%q 的 temporal 列 = %q, want %q(判定与留痕不许分叉)", content, nat, TemporalOneOff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemporalOneOffDefersThroughGate 钉住「判定接线」:闸开着时,`one_off` 判出的
|
||||
// temp 会走**同一条**第五态 `defer` 落库(留痕 `t0_low_value`),而不是另开一条路径。
|
||||
//
|
||||
// 本用例同时是「不要造第二个判定入口」的观测点:`judgeIngest` 只读档位,因此这里
|
||||
// 除了档位列以外不额外断言任何规则重判的痕迹 —— 重判会让
|
||||
// TestRetentionJudgementHasSingleRuleCallSite 先红。
|
||||
func TestTemporalOneOffDefersThroughGate(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := newTestStore(t)
|
||||
base := tidyTestTime(t)
|
||||
ttl := time.Hour
|
||||
r := newGateReviewer(t, store, ReviewConfig{TempTTL: ttl}, nil)
|
||||
store.AttachIngestReviewer(r)
|
||||
|
||||
item := habitItem(temporalOneOffCorpus()[0], base, 0.9)
|
||||
item.Temporal = TemporalOneOff
|
||||
got, err := store.Add(context.Background(), item)
|
||||
if err != nil {
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
rev := gateRecordFor(t, store, got.ID)
|
||||
if rev.Decision != IngestDefer || rev.Reason != retentionLowValueReason {
|
||||
t.Fatalf("one_off 条目的留痕 = %+v, want defer + %q", rev, retentionLowValueReason)
|
||||
}
|
||||
if rev.RuleVer != IngestRuleVersion {
|
||||
t.Fatalf("留痕规则版本 = %q, want %q", rev.RuleVer, IngestRuleVersion)
|
||||
}
|
||||
tier, expires := readRetentionColumns(t, store, got.ID)
|
||||
if tier != RetentionTemp {
|
||||
t.Fatalf("one_off 条目档位 = %q, want %q", tier, RetentionTemp)
|
||||
}
|
||||
// 到期时刻来自同一个判定与同一个时钟(闸注入的 now + 面板 TTL)。
|
||||
if want := expiryText(base.Add(ttl)); expires != want {
|
||||
t.Fatalf("expires_at = %q, want %q", expires, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemporalDurableAndRecurringStayLong 钉住验收点②:`durable` / `recurring` ⇒
|
||||
// `(long, 空)`,**并且覆盖规则闸的 temp 结论**。
|
||||
//
|
||||
// 语料故意取一条旧规则会判 temp 的正文(N6 指示性时点):「模型已经答了这不是
|
||||
// 一次性事件」时,规则不许把它改判成临时层 —— 方向是 fail-safe(多留一条),
|
||||
// 与「规则闸拦下的不许被 T1 推翻」并不矛盾:那一条防的是**放宽**(模型把规则判成
|
||||
// 临时的改成长期),方向相反。
|
||||
func TestTemporalDurableAndRecurringStayLong(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
const content = "用户昨天发来一卷验收纸条"
|
||||
// 前提:这条正文在旧规则下确实判 temp(否则本用例证明不了「覆盖」)。
|
||||
bare, err := resolveRetention(habitItem(content, tidyTestTime(t), 0.9))
|
||||
if err != nil {
|
||||
t.Fatalf("resolveRetention(bare): %v", err)
|
||||
}
|
||||
if bare.Tier != RetentionTemp || bare.Rule != retentionTimepoint {
|
||||
t.Fatalf("前提不成立:%q 旧规则判 %q/%q,want temp/%q", content, bare.Tier, bare.Rule, retentionTimepoint)
|
||||
}
|
||||
for _, temporal := range []TemporalKind{TemporalDurable, TemporalRecurring} {
|
||||
temporal := temporal
|
||||
t.Run(string(temporal), func(t *testing.T) {
|
||||
item := habitItem(content, tidyTestTime(t), 0.9)
|
||||
item.Temporal = temporal
|
||||
got, err := store.Add(ctx, item)
|
||||
if err != nil {
|
||||
t.Fatalf("Add(%q): %v", temporal, err)
|
||||
}
|
||||
tier, expires := readRetentionColumns(t, store, got.ID)
|
||||
if tier != RetentionLong {
|
||||
t.Fatalf("temporal=%q 落库档位 = %q, want %q", temporal, tier, RetentionLong)
|
||||
}
|
||||
if expires != "" {
|
||||
t.Fatalf("temporal=%q 写了 expires_at = %q(非 temp 档必须为空)", temporal, expires)
|
||||
}
|
||||
if nat := readTemporalColumn(t, store, got.ID); nat != temporal {
|
||||
t.Fatalf("temporal 列 = %q, want %q", nat, temporal)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemporalUnknownFallsThroughToRules 钉住验收点③,也是最容易做偏的一条:
|
||||
// `unknown`(以及未分型的空串)**不许自己判 temp** —— 它必须原样交给 N1~N7,
|
||||
// 判据只有旧规则那一份。
|
||||
//
|
||||
// 三条断言方向不同,缺一条都测不出「方向反了」:
|
||||
//
|
||||
// 1. `unknown` + 「今天下雨了」⇒ 旧规则 N6 判 temp。这是**旧规则**的结论:命中的
|
||||
// 规则码必须是 `n6_timepoint`,**绝不能**是时间性位的 `temporal_one_off` ——
|
||||
// 后者出现就说明有人把「看起来像今日事」变成了新的判据;
|
||||
// 2. `unknown` + 旧规则非命中的正文 ⇒ long(不许因为看着像一次性就判 temp);
|
||||
// 3. 未分型(空串)与 `unknown` 走同一条路(历史行的行为逐字不变)。
|
||||
func TestTemporalUnknownFallsThroughToRules(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("unknown_旧规则命中", func(t *testing.T) {
|
||||
item := habitItem("今天下雨了", tidyTestTime(t), 0.9)
|
||||
item.Temporal = TemporalUnknown
|
||||
verdict, err := resolveRetention(item)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveRetention: %v", err)
|
||||
}
|
||||
if verdict.Tier != RetentionTemp {
|
||||
t.Fatalf("unknown + 「今天下雨了」= %q, want %q(旧规则 N6 的结论)", verdict.Tier, RetentionTemp)
|
||||
}
|
||||
if verdict.Rule != retentionTimepoint {
|
||||
t.Fatalf("命中的规则 = %q, want %q —— 若出现 %q 说明 unknown 被当成了「一次性」的新判据",
|
||||
verdict.Rule, retentionTimepoint, retentionTemporalTempRule)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown_旧规则非命中", func(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
item := habitItem(temporalOneOffCorpus()[2], tidyTestTime(t), 0.9)
|
||||
item.Temporal = TemporalUnknown
|
||||
got, err := store.Add(ctx, item)
|
||||
if err != nil {
|
||||
t.Fatalf("Add(unknown): %v", err)
|
||||
}
|
||||
tier, expires := readRetentionColumns(t, store, got.ID)
|
||||
if tier != RetentionLong || expires != "" {
|
||||
t.Fatalf("unknown 且旧规则不命中时落库 = (%q, %q), want (%q, 空) —— 「看着像今日事」不许判 temp",
|
||||
tier, expires, RetentionLong)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("未分型_与unknown同路", func(t *testing.T) {
|
||||
for _, temporal := range []TemporalKind{TemporalUnset, TemporalUnknown} {
|
||||
item := habitItem("谢谢啦", tidyTestTime(t), 0.9)
|
||||
item.Temporal = temporal
|
||||
verdict, err := resolveRetention(item)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveRetention(%q): %v", temporal, err)
|
||||
}
|
||||
if verdict.Tier != RetentionTemp || verdict.Rule != retentionChatter {
|
||||
t.Fatalf("temporal=%q 时 %q 判成 %q/%q, want temp/%q(历史行的行为必须逐字不变)",
|
||||
temporal, item.Content, verdict.Tier, verdict.Rule, retentionChatter)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestTemporalOneOffDoesNotOverrideExplicitRemember 钉住交叉规则:显式「记住」的
|
||||
// 一票否决(extract_retention.go 护栏①)对时间性位**同样有效**。
|
||||
//
|
||||
// 没有这条,模型把「记住我明天三点开会」标成 one_off 就会让主人在 7 天后丢掉自己
|
||||
// 明确要求记的东西 —— 那是 fail-safe 方向的反面,且不可逆。
|
||||
func TestTemporalOneOffDoesNotOverrideExplicitRemember(t *testing.T) {
|
||||
t.Parallel()
|
||||
item := habitItem("记住:明天下午三点开会,别迟到", tidyTestTime(t), 0.9)
|
||||
item.Temporal = TemporalOneOff
|
||||
verdict, err := resolveRetention(item)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveRetention: %v", err)
|
||||
}
|
||||
if verdict.Tier != RetentionLong {
|
||||
t.Fatalf("显式「记住」+ one_off = %q, want %q(护栏① 一票否决对元数据位同样有效)",
|
||||
verdict.Tier, RetentionLong)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemporalOneOffDoesNotTouchRestrictedSource 钉住与受限来源的优先级:群聊 /
|
||||
// 陌生人的行**不分型**(空串),因此时间性位不许把它们判成 temp —— 那会造出
|
||||
// 「对读面隐藏、却永远不会被后台清扫删除」的行(expire.go 跳过非 owner 来源)。
|
||||
func TestTemporalOneOffDoesNotTouchRestrictedSource(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := newTestStore(t)
|
||||
item := habitItem(temporalOneOffCorpus()[2], tidyTestTime(t), 0.9)
|
||||
item.Temporal = TemporalOneOff
|
||||
item.SourceConversation = FormatSourceConversation("sess-group-1", "msg-1")
|
||||
got, err := store.Add(context.Background(), item)
|
||||
if err != nil {
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
tier, expires := readRetentionColumns(t, store, got.ID)
|
||||
if tier != RetentionTierUnset || expires != "" {
|
||||
t.Fatalf("受限来源 + one_off 落库 = (%q, %q), want (未分型, 空)", tier, expires)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemporalOneOffDoesNotOverrideStatedAgreement 钉住 `one_off` 支路的第二条
|
||||
// 例外,也是本片最要紧的一条 fail-safe:**正文在陈述一份约定时,`one_off` 不许把它
|
||||
// 推进临时层**。
|
||||
//
|
||||
// 用例内容逐字取自 M37 复审的量化证据(把 N6 时点窗口放宽到 24 字会多命中 11 条,
|
||||
// 逐条都是「同一条长期约定的重复陈述」):
|
||||
//
|
||||
// 「一起晒太阳」暂时搁置 ×5 → 今天是「约定 + 暂缓」
|
||||
// 「傍晚出门走十分钟…今天没走成明天补上」×6 → 今天是「约定 + 某一次没走成」
|
||||
//
|
||||
// 这两种句子**确实在描述某一次**,因此模型很容易标成 one_off;若代码照单全收,
|
||||
// 7 天后被删掉的就是约定本身 —— M37 的原话:「放宽阈值会在 7 天后把约定本身删掉,
|
||||
// 违背 fail-safe」。
|
||||
func TestTemporalOneOffDoesNotOverrideStatedAgreement(t *testing.T) {
|
||||
t.Parallel()
|
||||
agreements := []string{
|
||||
"两人之间有一个「晒太阳」的约定,目前暂缓,等主人想起来再提",
|
||||
"和主人有「晚饭后出门走十分钟」的约定;今天没走成,算欠着,明天补上",
|
||||
"和叶酱约好每天出门走十分钟;今天太阳先落山没走成,这十分钟欠着",
|
||||
}
|
||||
for _, content := range agreements {
|
||||
content := content
|
||||
t.Run(content, func(t *testing.T) {
|
||||
item := habitItem(content, tidyTestTime(t), 0.9)
|
||||
item.Temporal = TemporalOneOff
|
||||
verdict, err := resolveRetention(item)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveRetention: %v", err)
|
||||
}
|
||||
if verdict.Tier == RetentionTemp {
|
||||
t.Fatalf("%q 因 temporal=one_off 被判成 temp —— 这是长期约定,7 天后会被删掉(fail-safe 反了)", content)
|
||||
}
|
||||
if !retentionStatesAgreement(content) {
|
||||
t.Fatalf("retentionStatesAgreement(%q) = false —— 例外没认出这条约定", content)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemporalOneOffDoesNotOverrideDurableShape 钉住 `one_off` 支路的语义安全阀
|
||||
// (temporalSanityVeto 的后四条):正文自己在说「习惯 / 长期事实 / 自此刻起永久 /
|
||||
// 状态已改变」时,模型标的一次性**不给结论**,落回旧规则。
|
||||
//
|
||||
// 语料全部取自 2026-10-27 对生产库 active 集合的离线重放里**真实出现过**的形态
|
||||
// (它们是「上界候选」的主要来源:被时点词捎带的状态事实)。判成 temp 的后果是
|
||||
// 7 天后被删,因此这些形态必须留住。
|
||||
func TestTemporalOneOffDoesNotOverrideDurableShape(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct{ content, why string }{
|
||||
{"主人有每天吃早餐的习惯,几乎不会省略早餐。", "习惯词(反复发生,与一次性互斥)"},
|
||||
{"用户近期住宿舍(昨天住宿舍,可能今晚还在),需要关心住宿情况。", "硬性长期事实词(居住)"},
|
||||
{"茂密最近换了装:蝴蝶结从棕色变成深色,脖子上多了个小铃铛。", "状态改变(新状态会持续)"},
|
||||
{"主人昨天说以后都叫我小叶", "永久化标记(自此刻起一直成立)"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.content, func(t *testing.T) {
|
||||
item := habitItem(tc.content, tidyTestTime(t), 0.9)
|
||||
item.Temporal = TemporalOneOff
|
||||
if !temporalSanityVeto(tc.content) {
|
||||
t.Fatalf("temporalSanityVeto(%q) = false(%s)", tc.content, tc.why)
|
||||
}
|
||||
verdict, err := resolveRetention(item)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveRetention: %v", err)
|
||||
}
|
||||
if verdict.Tier == RetentionTemp {
|
||||
t.Fatalf("%q 因 temporal=one_off 被判成 temp —— %s,删掉是误弃", tc.content, tc.why)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeTemporal 钉住验收点④的归一化口径:**只认四个字面值**,其余
|
||||
// (空 / 中文 / 自造 / 拼写变体)一律 unknown。
|
||||
//
|
||||
// 不做同义词映射是刻意的:猜同义词的代价是单向的(猜成 one_off 就删记忆),
|
||||
// 而「拿不准一律 long」才是 fail-safe。
|
||||
func TestNormalizeTemporal(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
raw string
|
||||
want TemporalKind
|
||||
}{
|
||||
{"one_off", TemporalOneOff},
|
||||
{"ONE_OFF", TemporalOneOff},
|
||||
{" durable \n", TemporalDurable},
|
||||
{"recurring", TemporalRecurring},
|
||||
{"unknown", TemporalUnknown},
|
||||
{"", TemporalUnknown},
|
||||
{" ", TemporalUnknown},
|
||||
{"一次性", TemporalUnknown},
|
||||
{"one-off", TemporalUnknown},
|
||||
{"oneoff", TemporalUnknown},
|
||||
{"forever", TemporalUnknown},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := normalizeTemporal(tc.raw); got != tc.want {
|
||||
t.Fatalf("normalizeTemporal(%q) = %q, want %q", tc.raw, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemporalKindClosedSet 钉住闭集本身:四个业务值 + 未分型都合法,其他都不合法
|
||||
// (Add 的 validate 会拒掉它们 —— 那是程序错误,不是模型输出)。
|
||||
func TestTemporalKindClosedSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, ok := range []TemporalKind{TemporalUnset, TemporalOneOff, TemporalRecurring, TemporalDurable, TemporalUnknown} {
|
||||
if !ok.valid() {
|
||||
t.Fatalf("%q 应在闭集内", ok)
|
||||
}
|
||||
}
|
||||
for _, bad := range []TemporalKind{"one-off", "sometimes", "ONE_OFF", "float"} {
|
||||
if bad.valid() {
|
||||
t.Fatalf("%q 不应在闭集内", bad)
|
||||
}
|
||||
}
|
||||
if TemporalUnknown.assigned() != true || TemporalUnset.assigned() != false {
|
||||
t.Fatal("assigned() 只应对非空串为真(「未分型」的唯一判据)")
|
||||
}
|
||||
// 非法值必须被写入路径拒掉(不是静默落库成脏列值)。
|
||||
item := habitItem("一条正文", tidyTestTime(t), 0.9)
|
||||
item.Temporal = TemporalKind("sometimes")
|
||||
if err := item.normalize(); err == nil {
|
||||
t.Fatal("validate 接受了非法的时间性位(应返回 ErrInvalidTemporal)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLLMExtractorReadsTemporal 钉住验收点④的另一半:提取器**真的**在读模型给的
|
||||
// 这一位,且非法 / 缺失值不产生错误、只归 unknown(不拖累同批其他候选)。
|
||||
func TestLLMExtractorReadsTemporal(t *testing.T) {
|
||||
t.Parallel()
|
||||
adapter := &mockAdapter{fn: func(_ context.Context, _ model.Request, _ model.Options) (model.Response, error) {
|
||||
return model.Response{Content: `[
|
||||
{"content":"候选一","temporal":"one_off"},
|
||||
{"content":"候选二","temporal":"durable"},
|
||||
{"content":"候选三","temporal":"Recurring"},
|
||||
{"content":"候选四","temporal":"forever"},
|
||||
{"content":"候选五"}
|
||||
]`}, nil
|
||||
}}
|
||||
ex := NewLLMExtractor(adapter, "persona.a", "role.1")
|
||||
items, err := ex.Extract(context.Background(), []model.Message{
|
||||
model.TextMessage(model.RoleUser, "随便聊两句"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Extract: %v(非法 temporal 绝不能让整批提取失败)", err)
|
||||
}
|
||||
want := map[string]TemporalKind{
|
||||
"候选一": TemporalOneOff,
|
||||
"候选二": TemporalDurable,
|
||||
"候选三": TemporalRecurring,
|
||||
"候选四": TemporalUnknown,
|
||||
"候选五": TemporalUnknown,
|
||||
}
|
||||
if len(items) != len(want) {
|
||||
t.Fatalf("候选数 = %d, want %d", len(items), len(want))
|
||||
}
|
||||
for _, it := range items {
|
||||
if it.Temporal != want[it.Content] {
|
||||
t.Fatalf("%q 的 temporal = %q, want %q", it.Content, it.Temporal, want[it.Content])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractionPromptCarriesTemporalEnum 钉住「提示词与闭集不漂移」:模型能看到的
|
||||
// 取值必须与 normalizeTemporal 认的四个值逐字一致(提示词只是辅助,但取值口径漂了
|
||||
// 就会让每条候选都被归成 unknown,这一位等于白加)。
|
||||
func TestExtractionPromptCarriesTemporalEnum(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, want := range []string{"temporal", "one_off", "recurring", "durable", "unknown"} {
|
||||
if !strings.Contains(extractSystemPrompt, want) {
|
||||
t.Fatalf("提取提示词里缺 %q —— 模型无从给出这一位", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user