按 r1 复审裁决 D:这一位先只做观测,`resolveRetention` 不许读它。理由(实测):
无条件 one_off ⇒ temp 会当天删 21 条长期约定;只放开 N6 位置判据能翻的 13 条
逐条是约定、而复审点名的三条目标行 0/3 救得了。在「模型真实标签分布」这份证据
缺位时不该做 7 天后物理删的不可逆决定。
撤接线(结论集逐字回到 M45 之前):
- retention.go:resolveRetention 不再读 item.Temporal;与之对比 dev base
(af61b8c)本文件**只有注释差异**,规则与判定入口逐字未变。
- extract_retention.go:judgeExtractionRetention 签名回退单参;删除
judgeRetentionTimepointWide 与 n6_timepoint_wide 规则码。与 dev base 对比
**同样只有注释差异** ⇒ 「结论集 = 不含 temporal 的那个集合」是结构性的,
不是靠调用点记得别传。
- 连带取消初版的 durable / recurring ⇒ long(零影响必须是双向的)。
保留观测面(一律未动):迁移 0027 的列、自举前滚、提取器的 temporal 产出、
prompt 枚举、闭集与 normalizeTemporal、store_temporal.go 的校验/编码。
另外把 prompt 里「标成 one_off 会让它进临时层、到期消失」这句**撤接线后已是假话**
改成中性标注说明 —— 否则会诱导模型避开 one_off,观测数据直接被污染。
测试:
- TestTemporalBitDoesNotAffectRetention —— 4 类语料 × 5 个取值,(档位,规则码)
与未分型逐字相同(核心正断言);
- TestTemporalOneOffDoesNotMakeAnythingTemporary / ...DoesNotRescueRuleHitRows
—— 零影响的双向点名;
- TestTemporalIsStillRecorded —— one_off 的约定行仍落 temporal='one_off' 而档位
long、expires 空(观测在、判定不动);
- TestTemporalDoesNotTouchExplicitTierOrRestrictedSource —— 人工入口与受限来源不变。
变异实测:把 `if item.Temporal == TemporalOneOff { return temp }` 插回
resolveRetention ⇒ 上述四条用例全红(FAIL 输出已贴给塔),恢复后全绿。
IngestRuleVersion 保持 d3.5,注释改准为「记录 temporal 信号,但不参与判定」
(留痕面变了:库里多了「模型当时标了什么」这一维,回退会让新旧留痕混在一起)。
P2 三条保留:store.go 500 行(拆 store_temporal.go)、temporal.go 头注('' 的口径
+ 两次被否的接线方案 + 接线前必须看的那两条统计 SQL)、报告语境段。
回归:go build ./... + go vet ./... 全绿;memory/db 包测试全绿;memory -race
38.2s 全绿。
196 lines
10 KiB
Go
196 lines
10 KiB
Go
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)。
|
||
//
|
||
// 🔴 **`temporal`(迁移 0027 的时间性元数据位)不在这个列表里**(r1 复审裁决 D,
|
||
// 2026-10-27):这一位目前**只落库、只观测,不参与任何判定**。它被接进判定的两个
|
||
// 候选设计都已被生产重放否掉 —— ①无条件 `one_off ⇒ temp` 会当天删掉 21 条长期约定;
|
||
// ②只放开 N6 位置判据,最近 500 条里能翻的 13 条**逐条都是约定**、而复审点名的
|
||
// 三条目标行 0/3 救得了(详见 temporal.go 头注)。因此判定的结论集与「没有这一位」
|
||
// 逐字相同;temporal_test.go 有一条正断言钉住「喂 one_off 不影响任何结论」,
|
||
// 另有一条变异靶子(把这一位重新接进本函数 ⇒ 那条正断言必红)。
|
||
//
|
||
// 🔴 「判第五态 `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
|
||
}
|