主人 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)。
74 lines
4.9 KiB
Go
74 lines
4.9 KiB
Go
package memory
|
||
|
||
import "time"
|
||
|
||
// 本文件是**读侧时效闸**的唯一实现(design/03 §9.6 / §11.2),也是「临时记忆到期
|
||
// 即从读面消失」链路里的第 ① 步(隐藏):判据就是列值本身,零写入、即时生效。
|
||
// 第 ② 步(后台物理删除)与第 ③ 步(删除前审计)是另一条显式、有界、可停的
|
||
// 后台路径,**绝不混进读路径**(design/23 §19.1)。
|
||
//
|
||
// 🔴 为什么必须下沉到共享底层、不许写在调用点(design/03 §11.2 死角分析):
|
||
// 记忆读入口有 8 个(对话注入 owner / 群聊陌生人、`knowledge` 工具、capacity L4
|
||
// 习惯召回、子代理以 B 身份、admin 面板列表 / 搜索 / RAG),但它们**只走同一批
|
||
// 底层** —— Store.ListByPersona / ListScope / ListProject / List(面板与直接调用方)
|
||
// 以及 Retriever.visibleCandidates(它组合调用前两条,因此所有复用检索器的入口
|
||
// 自动获得同一判据)。闸一旦写在某个调用点上,立刻出现「对话看不见、`knowledge`
|
||
// 工具看得见」的死角。**因此判据只有这一处,四条列表读路径共用。**
|
||
//
|
||
// 🔴 同时登记**两处刻意不过闸**(design/03 §11.2 第 3 条;与 design/23 §10.1 ①
|
||
// 已立的先例同构):
|
||
//
|
||
// 1. 按 id 的 Store.Get(store_list.go)—— 撤销软合并 / 查看已软删行必须先读得到,
|
||
// 加闸等于把撤销与审计的抓手抹掉。过期行同理:清算与人工核查要读得到。
|
||
// 2. 整理与审查的**内部**召回 SQL —— listTidyPartitionItemsSQL(tidy_store.go)
|
||
// 与 listReviewCandidatesSQL(preinsert_store.go)刻意**只过 status 闸**:
|
||
// 它们比对的是正文,与「这条对外可不可见」无关;过期行随后由后台清扫删除,
|
||
// 不会复活(design/03 §11.2「主会话已裁定:保持不过时效闸」)。两处都在
|
||
// SQL 旁落了原位注释:前者在 tidy_store.go(切片 M1),后者的原位注释由
|
||
// 切片 M2 落在 preinsert_store.go —— 此处登记,免得分头实现时漏掉。
|
||
// 3. 审查留痕读侧(`GET /admin/memories/reviews`)—— 它读的是留痕表
|
||
// memory_ingest_reviews(preinsert_schema.go:29),那张表**没有**
|
||
// status / expires_at / valid_to 任何一列(结构与可见性无关,只记判定结果),
|
||
// 因此这里根本没有可加的闸;它是软删/被吸收行的**查看入口**,加闸等于审计自我抹除。
|
||
//
|
||
// **新增 / 变更任何记忆读路径时,先问这一句**:它要不要看见已过期(临时层)与
|
||
// 已软删行?答案写进注释(design/03 §11.2)。
|
||
|
||
// timeGateClause 是时效闸的 WHERE 片段,拼在四条列表读路径既有的 status 闸之后、
|
||
// ORDER BY 之前。判据与 design/03 §9.6 逐字一致:
|
||
//
|
||
// (expires_at = '' OR expires_at > ?) AND (valid_to = '' OR valid_to > ?)
|
||
//
|
||
// 空串是「从未设过期 / 从未失效」(长期记忆与日常记忆的默认值,见
|
||
// store_schema.go:27-29 的 DEFAULT 空串),因此空串**必须可见**;只有非空才比较。
|
||
// 两个 ? 都绑定 timeGateNow() 的返回值。
|
||
const timeGateClause = ` AND (expires_at = '' OR expires_at > ?) AND (valid_to = '' OR valid_to > ?)`
|
||
|
||
// timeGateNow 返回时效闸比较用的「当下」。
|
||
//
|
||
// 🔴 口径必须与**写入格式同源**(本仓历史事故,DevDocs/开发习惯与约定.md §7):
|
||
// 列里存的是 formatTime 产出的定宽 30 字节 UTC 文本(timefmt.go:17-21),所以右边
|
||
// 的「当下」也必须由同一个 formatTime 产出**同形文本**,两边才落在同一个字母表里、
|
||
// 文本序才等于时间序。反例是拿 SQLite 的 datetime('now')("YYYY-MM-DD HH:MM:SS",
|
||
// 空格分隔、无小数秒)去比这些列值:'T'(0x54) 恒大于 ' '(0x20),于是**任何当日行
|
||
// 都「大于当下」**,过期行反而被判为未过期 —— 时间判断静默错,不报错、不 panic。
|
||
// 同理不许改写成 datetime(expires_at) 之类的函数比较:那是另一套口径,
|
||
// 一旦写入格式变化就再次静默分叉。timegate_test.go 里有这条反证的实证。
|
||
func timeGateNow() string {
|
||
return formatTime(time.Now())
|
||
}
|
||
|
||
// expiryText 把 MemoryItem.ExpiresAt 编码为要落库的 expires_at 文本,是
|
||
// 「读侧口径与写入格式同源」在**写入侧**的那一半:零值 = 不设过期
|
||
// (长期 / 日常记忆),写空串(与 DDL 的默认值一致);非零值走 formatTime。
|
||
//
|
||
// 它不做任何判据(过去值不在这里被拒、也不被改写):写入侧不决定可见性,
|
||
// 一条「出生即过期」的行照常落库,四条列表读路径立刻看不见它,直到后台清扫
|
||
// 把它删掉;Get 仍读得到(见 store_list.go 的 Get 注释)。
|
||
func expiryText(t time.Time) string {
|
||
if t.IsZero() {
|
||
return ""
|
||
}
|
||
return formatTime(t)
|
||
}
|