Files
Cyrene_v2/internal/runtime/memory/retention.go
T
AskaEth 91101dedfb 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 的用例。
2026-10-26 18:57:38 +08:00

201 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package memory
import (
"fmt"
"strings"
"time"
)
// 本文件是 `memory_items` 的第三根轴「留多久 / 干什么用」:档位闭集、**唯一**的
// 判定入口、以及「档位 → 有效期」的**唯一**换算(迁移 0026、审计
// `DevDocs/audit/记忆存量体检-2026-10-26.md` 卷四)。
//
// 🔴 正交性(本片最容易写错的一条):`kind` 是「关于什么」
// (relationship / world_event),`scope` 是「谁的」(user / persona / project),
// `retention_tier` 是「留多久 / 干什么用」。三根轴**互不推导**:一条关系记忆可以
// 是临时,也可以是长期;一条世界事件也可以是知识库条目。任何「由 kind 推 tier」
// (或反过来)的写法都是把两根轴压回一根,那正是本片要避免的设计回归。
//
// 🔴 空串不是第六档,是「**尚未分型**」。它只出现在两类行上:
//
// ① 0026 迁移之前的**历史行**(列默认值为空串,本片刻意不做回填判定);
// ② **受限来源**(群聊 / 陌生人)的新行 —— 见 resolveRetention:它们的留存由
// 来源面决定,后台到期清扫也会跳过它们(expire.go 的 `nonOwnerSource` 谓词),
// 判成 temp 就是造一条「对读面隐藏、却永远不会被删」的行。
//
// 因此读侧**必须**能区分「未分型」与「已分型」:三条列表读路径缺省不过滤(历史行照常
// 返回),显式传 `RetentionTierUnset` 时**只**返回未分型的行。
// RetentionTier 是「留多久 / 干什么用」轴的档位。零值 = 尚未分型。
type RetentionTier string
const (
// RetentionTierUnset 是「尚未分型」:历史行与受限来源行。**它不是一档业务档位**
// —— 读侧绝不许把它当成 temp / long 之类的某一档(那会让「未分型」这个事实
// 当场丢失,而它正是将来回填分型的抓手)。
RetentionTierUnset RetentionTier = ""
// RetentionTemp 是临时层:到期即从读面消失(读侧时效闸,timegate.go),随后被
// 后台清扫物理删除(design/23 §19)。它是**唯一带时效**的档位,
// 且 `retention_tier='temp'` ⇔ `expires_at` 非空 —— 两者由同一个判定、同一处换算
// 落库(见 resolveRetention / tempDeadline),**不许分叉**。
RetentionTemp RetentionTier = "temp"
// RetentionDaily 是日常层:一天之内的生活琐事 / 节奏。它与 `scope=persona` 的
// 「角色日常」**不是一回事** —— 后者回答「谁的记忆」,这一档回答「留多久 / 干什么用」。
RetentionDaily RetentionTier = "daily"
// RetentionWork 是工作层:任务推进、工单、项目过程。
RetentionWork RetentionTier = "work"
// RetentionLong 是长期层:稳定的偏好 / 关系 / 事实。它也是入库判定的**默认落点**
// (判据没命中「显然不值得长期留」时,落到长期层 —— 后果只是多留一条,方向是
// fail-safe 的「宁可多留,不可误弃」,design/23 §18.3)。
RetentionLong RetentionTier = "long"
// RetentionKnowledge 是知识库层:外部资料 / 规格 / 文档类条目。它与
// `internal/runtime/knowledge` 的独立存储无关(那一层不归记忆管),这里只说
// 「这条**记忆行**是知识库性质的」。
RetentionKnowledge RetentionTier = "knowledge"
)
// valid 报告 t 是否落在闭集内(**含**未分型的空串:它是合法的列值,不是一个错误)。
func (t RetentionTier) valid() bool {
switch t {
case RetentionTierUnset, RetentionTemp, RetentionDaily, RetentionWork, RetentionLong, RetentionKnowledge:
return true
default:
return false
}
}
// assigned 报告 t 是否**已分型**(即非空串)。它是「未分型」的唯一判据,
// 调用方不许用 `tier == RetentionLong` 之类的比较去替代它。
func (t RetentionTier) assigned() bool { return t != RetentionTierUnset }
// retentionVerdict 是「留多久」轴的判定结果:档位 + 命中的反例规则码。
//
// 规则码只进日志、不进 `memory_ingest_reviews.reason`(那一列的闭集是
// `{t0_low_value, t1_defer, …}`,见 extract_retention.go 的口径)—— 但它必须有
// 一个落点,否则「n1~n7 到底谁命中过」只能靠猜(M27 体检就是这么发现闸形同虚设的)。
type retentionVerdict struct {
Tier RetentionTier
Rule retentionRule
}
// resolveRetention 决定一条待落库条目最终落哪一档 —— **全仓唯一的档位判定入口**。
//
// 判定顺序(每一档都有不可走样的理由):
//
// 1. **调用方显式给了档位** ⇒ 以调用方为准,规则/模型一律不许推翻它。这条对齐
// AGENTS §9「判据靠元数据,不靠提示词」:写记忆的调用方(手动「记住」、
// 知识库导入、整理侧回填)比一条词项规则更知道「这条该留多久」。
// 显式非 temp 档 + 显式有效期是**自相矛盾**的输入(会造出一行「标着长期却会
// 到期消失」的记忆),直接报错而不是替它挑一个。
// 2. **显式给了有效期**(`item.ExpiresAt` 非零)⇒ 这一行会到期 ⇒ 它就是临时记忆。
// 两者是同一根轴的两种表达,因此这里不是「推导」,而是同一个事实的补全。
// 3. **受限来源**(群聊 / 陌生人)⇒ 不分型(空串)。见文件头注 ②。
// 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 的
// 实测缺口正是「两处各判一遍、上游几乎不触发」,合并之后只有一个判据。
func resolveRetention(item MemoryItem) (retentionVerdict, error) {
if tier := item.RetentionTier; tier.assigned() {
if tier != RetentionTemp && !item.ExpiresAt.IsZero() {
return retentionVerdict{}, fmt.Errorf(
"memory: retention_tier=%q with a non-empty expires_at: %w", tier, ErrRetentionConflict)
}
return retentionVerdict{Tier: tier}, nil
}
if !item.ExpiresAt.IsZero() {
return retentionVerdict{Tier: RetentionTemp}, nil
}
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
}
return retentionVerdict{Tier: RetentionLong}, nil
}
// tempDeadline 给出落库时要写进 `expires_at` 的时刻,是「档位 → 有效期」的
// **唯一**换算点:temp ⇒ 一个时刻,其余档位 ⇒ 零值(落库为空串 ⇒ 永不过期)。
//
// 因此「temp ⇔ expires_at 非空」不是一条靠自觉维持的约定,而是这条换算的结构性结果;
// 任何新写路径只要拿这一行去 INSERT,就不可能写出「temp 却没有有效期」的行。
//
// 时刻的三种来源(优先级从高到低):
//
// 1. 调用方显式给的 `item.ExpiresAt`(回填 / 测试夹具需要「已经过期」的行);
// 2. 接了入库审查服务时用**它的**可注入时钟与面板 TTL(`deferDeadline`)——
// 闸、异步审查与写入侧因此用的是同一个算式;
// 3. 没接审查服务时用墙钟 + 代码默认 `tempTtlSec`。
func (s *Store) tempDeadline(item MemoryItem) time.Time {
if item.RetentionTier != RetentionTemp {
return time.Time{}
}
if !item.ExpiresAt.IsZero() {
return item.ExpiresAt
}
if r := s.ingestReviewer(); r != nil {
return r.deferDeadline(r.now().UTC())
}
return time.Now().UTC().Add(time.Duration(tempTtlSec) * time.Second)
}
// retentionTierClause 把可选的档位过滤渲染成参数化 WHERE 片段(SQL 全参数化)。
//
// 缺省(未传任何档位)= **不过滤**:既有调用方零改动,历史行(空串)与其他档位一样
// 照常返回。传了档位就只返回这些档位;显式传 `RetentionTierUnset`(空串)是
// 「只看未分型的行」—— 这是回填分型的入口,必须与「不过滤」可区分。
func retentionTierClause(tiers []RetentionTier) (string, []any, error) {
if len(tiers) == 0 {
return "", nil, nil
}
seen := make(map[RetentionTier]bool, len(tiers))
args := make([]any, 0, len(tiers))
marks := make([]string, 0, len(tiers))
for _, tier := range tiers {
if !tier.valid() {
return "", nil, fmt.Errorf("%w: %q", ErrInvalidRetentionTier, tier)
}
if seen[tier] {
continue
}
seen[tier] = true
marks = append(marks, "?")
args = append(args, string(tier))
}
return " AND retention_tier IN (" + strings.Join(marks, ", ") + ")", args, nil
}
// retentionTierList 是三条列表读路径共用的拼装:把可选的档位过滤接到既有 WHERE
// 之后、时效闸与 ORDER BY 之前,并按**子句出现顺序**排出参数。
//
// 参数顺序是纯文本比较之外的唯一坑:SQLite 的 `?` 按出现位置绑定,档位片段的
// 占位符夹在 status 与时效闸之间,因此档位参数也必须夹在那两组之间 —— 拼错不会
// 报错,只会**静默按错误的档位过滤**(timegate 的历史事故同型)。
func retentionTierList(where string, args []any, tiers []RetentionTier) (string, []any, error) {
clause, tierArgs, err := retentionTierClause(tiers)
if err != nil {
return "", nil, err
}
return where + clause, append(args, tierArgs...), nil
}