按 r1 复审裁定(p1-3items / fix-then-merge)重做三处: P1-1/P1-2:删掉自造的六条语义安全阀(temporalSanityVeto)与约定词表 (retentionStatesAgreement)。理由被复审用两条生产行否证:它们既把该救的行 杀了(「…睡得比平时早」被习惯词、「…以后不用再惦记」被永久化标记),又挡不住 同义异形的约定(说定 / 约了)。现在 `one_off` 的全部作用是**放开 N6 的位置判据**: judgeExtractionRetention 新增 temporal 入参 → judgeRetentionTransient → judgeRetentionTimepointWide(位置内命中仍返 n6_timepoint,只有靠放开位置才命中 才返新增的 n6_timepoint_wide,供观测区分)。N1~N5、N7、护栏①②、N6 的 1~7 条 语义安全阀一字不动;durable / recurring 仍直接判 long(fail-safe 方向)。 P1-3:上界口径改为「时点词存在 ∧ 语义安全阀全过 ∧ 当前 long」——不依赖任何 标注,可逐条列出。实测(生产库只读副本,快照 2026-10-26):最近 500 条 active 能翻 13 条、**逐条都是「约定 + 今天没走成」**;全量 3694 条能翻 26 条(13 条 约定 + 13 条状态行)。旧的「句首时点词」口径是同义反复,已删。 已知代价(写进 temporal.go 头注与 TestTemporalOneOffWidenedPositionTouchesAgreements, 不藏在词表后面):位置窗口今天正是那批约定行的唯一保护,放开后只要模型标 one_off 它们就会判 temp;而复审点名的两条目标行本片救不了(被语义阀挡,与位置 无关)。因此这一位在当前语料上**没有可测收益**,是否保留是产品裁决。 P2-1:store.go 503 → 497 行,拆出 store_temporal.go(哨兵错误 + 字段校验 + 列值编码)。 P2-2:temporal.go 头注改准 —— `''` 是「0027 之前的历史行 + 没做时间性判断的 写入路径(ManualExtractor / 网关手写 / 整理回填,它们由调用方显式给档位)」。 P2-3:报告补语境(retention_tier 非空仅 26 行、temp 1 行、temporal 列当时还不 存在 ⇒ 重放结论对新写入没有预测力)。 测试:验收点 ①~⑤ 按新语义重写;新增「位置放开可打红」的变异靶子与「N7 位置 不放宽」「无时点词时这一位不做事」「两条目标行救不了」「约定行会翻(已知风险)」 五条边界断言。变异实测:unlocksTimepointPosition 改恒 false ⇒ 三条用例红。 回归:go build ./... + go vet ./... 全绿;memory/db 包测试与 memory -race 全绿。
203 lines
11 KiB
Go
203 lines
11 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. **提取器的时间性元数据位**(迁移 0027、temporal.go):
|
||
// `durable` / `recurring` ⇒ long 并**不再跑规则**(模型已答「不是一次性事件」,
|
||
// 方向是 fail-safe:多留);`one_off` ⇒ **不作结论**,只把 N6 的位置判据放开
|
||
// (交给下一步的同一份规则跑,语义安全阀照旧);`unknown` / 未分型 ⇒ 规则照旧。
|
||
// 🔴 `one_off` **不是**「无条件 temp」:r1 复审裁定这一位只接管位置判据,
|
||
// 不接管语义判据(详见 temporal.go 头注)。🔴 `unknown` 也绝不等于「一次性」。
|
||
// 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
|
||
}
|
||
// 时间性位只有 `durable` / `recurring` 直接出结论;`one_off` 作为**输入**进规则层
|
||
// (放开 N6 的位置判据),因此下面这一行必须把 `item.Temporal` 传下去。
|
||
if tier, ok := temporalTier(item.Temporal); ok {
|
||
return retentionVerdict{Tier: tier}, nil
|
||
}
|
||
if rule := judgeExtractionRetention(item.Content, item.Temporal); 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
|
||
}
|