背景(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 的用例。
201 lines
10 KiB
Go
201 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. **提取器的时间性元数据位**(迁移 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
|
||
}
|