Files
Cyrene_v2/internal/runtime/memory/retention.go
T
AskaEth cc6904b1b4 feat(memory): retention_tier 档位列 + defer 与档位合并为同一判定(M37)
主人 2026-10-26 对「记忆分型」裁定「两列」:kind(关于什么)保留,新增
retention_tier(留多久 / 干什么用)。本片把这条轴从列、判定、写入、读侧过滤
一路打通,并把「判第五态 defer」与「判 retention_tier=temp」合并成一个判定。

结构(迁移 0026 + 自举前滚):
- memory_items 新增 retention_tier TEXT NOT NULL DEFAULT ''(空串 = 尚未分型,
  历史行与受限来源行),索引 idx_memory_items_retention_tier;up/down 均实跑过
  (在隔离的生产库副本上 migrate → 建 Store → down → 再 up,5073 行零丢失)。
- NewStore 幂等前滚该列 + 建索引(ensureRetentionSchema),与迁移同形由测试钉住。

判定(retention.go,全仓唯一入口):
- RetentionTier 闭集 temp/daily/work/long/knowledge + 空串未分型;与 kind 正交。
- resolveRetention 的优先级:调用方显式档位 > 显式有效期(⇒ temp)> 受限来源
  不分型 > 留存规则 N1~N7 ⇒ temp > 默认 long。判据靠元数据,规则不许推翻显式档位。
- tempDeadline 是「档位 → expires_at」的唯一换算:temp ⇔ 有效期非空,两列同一笔
  INSERT 落下(M2 登记过的「INSERT 与补写之间的短窗口」就此关闭)。

合并(defer ⇔ temp):
- judgeIngest 不再跑留存规则,只读 Add 落定的 item.RetentionTier;规则调用点因此
  全仓唯一(源码级断言钉住)。
- 判定不再依赖同步闸是否开启:闸未接/关着时档位与有效期照样落库。
- 异步层对已是 temp 的行不重复改写(避免 TTL 随审查延迟漂移)。
- IngestRuleVersion d3.3 → d3.4(判据作用范围变了,按既有硬规矩升版)。

读侧:
- 四条列表读路径的 SELECT 与 scanItem 读出 retention_tier(14 列);Get 同样读回。
- 新增 ListByPersonaTiers / ListScopeTiers / ListTiers / ListProjectTiers(可选档位
  过滤,缺省不过滤 ⇒ 历史行照常返回;显式传空串只取未分型;非法档位报错)。
  原方法签名逐字不变 —— 网关 MemoryStore 接口钉着它们,可变参数会编译不过。
- store.go 的读路径拆到 store_list.go,守 500 行上限。

回归:go build ./... + go vet ./... + go test ./... 全绿(内存包另有 -race)。
2026-10-26 13:09:30 +08:00

188 lines
9.5 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. **留存规则命中**(design/03 §10.5 的 N1~N7)⇒ temp,并带回规则码。
// 5. 其余 ⇒ long(默认落点,fail-safe)。
//
// 🔴 「判第五态 `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 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
}