主人 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)。
80 lines
2.6 KiB
Go
80 lines
2.6 KiB
Go
package memory
|
||
|
||
import (
|
||
"database/sql"
|
||
"encoding/json"
|
||
"fmt"
|
||
)
|
||
|
||
// 本文件是 memory_items 的**行映射**(scanItem / scanItems 与它们的公共接口
|
||
// rowScanner),从 store.go 拆出来只为守 AGENTS §10 的文件行数上限(≤500 行)
|
||
// 与「模块按职责拆源文件」那条规范 —— 口径逐字未变。
|
||
//
|
||
// 放在独立的源文件里还有一层好处:读路径的每一处「哪些列被读出来」都集中在此,
|
||
// 与写路径(store.go 的 Add 与 preinsert_gate.go 的闸)对照时不必在 500 行里翻。
|
||
|
||
// rowScanner 同时被 *sql.Row 与 *sql.Rows 满足,因此单个 scanItem
|
||
// 辅助函数即可服务 Get 与 ListByPersona。
|
||
type rowScanner interface {
|
||
Scan(dest ...any) error
|
||
}
|
||
|
||
// scanItems 把每一行读入 MemoryItem 切片,关闭 rows 并用
|
||
// 操作名包裹 scan/遍历失败。成功时总是返回
|
||
// 非 nil、可能为空的切片。
|
||
func scanItems(rows *sql.Rows, op string) ([]MemoryItem, error) {
|
||
defer func() { _ = rows.Close() }()
|
||
|
||
items := make([]MemoryItem, 0)
|
||
for rows.Next() {
|
||
item, err := scanItem(rows)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("memory: scan %s: %w", op, err)
|
||
}
|
||
items = append(items, item)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, fmt.Errorf("memory: iterate %s: %w", op, err)
|
||
}
|
||
return items, nil
|
||
}
|
||
|
||
// scanItem 读取一行 memory_items。tags 以 JSON 数组字符串存储;
|
||
// 时间戳以定宽 UTC 字符串(timeLayout)存储,因此文本
|
||
// 比较即可按时间排序。
|
||
//
|
||
// retention_tier 是**第 14 列**(迁移 0026):它与 `kind` 是两根独立的轴,
|
||
// 因此这里只做「读出来」,不参与任何推导。空串原样成为
|
||
// RetentionTierUnset(= 尚未分型),绝不被折成某一档。
|
||
func scanItem(sc rowScanner) (MemoryItem, error) {
|
||
var (
|
||
it MemoryItem
|
||
scope, kind, tier string
|
||
tagsJSON string
|
||
createdAt, updatedAt string
|
||
)
|
||
if err := sc.Scan(&it.ID, &it.PersonaID, &it.RoleID, &it.ProjectID, &scope, &kind,
|
||
&it.Content, &tagsJSON, &it.Confidence, &it.SourceConversation,
|
||
&createdAt, &updatedAt, &it.DecayWeight, &tier); err != nil {
|
||
return MemoryItem{}, err
|
||
}
|
||
it.Scope = Scope(scope)
|
||
it.Kind = Kind(kind)
|
||
it.RetentionTier = RetentionTier(tier)
|
||
if err := json.Unmarshal([]byte(tagsJSON), &it.Tags); err != nil {
|
||
return MemoryItem{}, fmt.Errorf("memory: decode tags: %w", err)
|
||
}
|
||
if it.Tags == nil {
|
||
it.Tags = []string{}
|
||
}
|
||
|
||
var err error
|
||
if it.CreatedAt, err = parseTime(createdAt); err != nil {
|
||
return MemoryItem{}, err
|
||
}
|
||
if it.UpdatedAt, err = parseTime(updatedAt); err != nil {
|
||
return MemoryItem{}, err
|
||
}
|
||
return it, nil
|
||
}
|