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.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.go 的 Get 注释)。
|
||
func expiryText(t time.Time) string {
|
||
if t.IsZero() {
|
||
return ""
|
||
}
|
||
return formatTime(t)
|
||
}
|