feat(memory): retention_tier 档位列 + defer 与档位合并为同一判定(M37)
主人 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)。
This commit is contained in:
1 parent
240b59708d
commit
cc6904b1b4
23 files changed
+1592
-181
No files matched your search
@@ -113,11 +113,23 @@ type MemoryItem struct {
|
||||
// 零值 = 不设过期,落库为空串,四条列表读路径恒可见;非零值落库为定宽 UTC
|
||||
// 文本,到期后该行即从读路径消失,直到后台清扫物理删除(design/23 §19)。
|
||||
//
|
||||
// 它与 RetentionTier 是**同一根轴的两种表达**(会到期 ⇔ temp),因此两者由
|
||||
// `Store.Add` 里的同一个判定与同一处换算落库,绝不会分叉:显式给非零值而
|
||||
// 档位不是 temp 会被拒(ErrRetentionConflict),给 temp 而不给则按临时 TTL 算。
|
||||
//
|
||||
// ⚠️ **本字段目前只写不读**:Add 会把它写进 expires_at 列,但读路径一律不
|
||||
// SELECT 该列(闸是纯 SQL,见 timegate.go),故 Get/List* 回来的条目
|
||||
// ExpiresAt 恒为零值。要读回时效需同步 13 列 SELECT 与 scanItem,并连带
|
||||
// ExpiresAt 恒为零值。要读回时效需同步 14 列 SELECT 与 scanItem,并连带
|
||||
// tidy/preinsert 的内部召回 SQL —— 那是改扫描口径,不在本切片授权范围。
|
||||
ExpiresAt time.Time
|
||||
|
||||
// RetentionTier 是「留多久 / 干什么用」这根轴(迁移 0026、retention.go)。
|
||||
// 它与 Kind(关于什么)、Scope(谁的)**正交,互不推导**。
|
||||
//
|
||||
// 写入侧语义:空串 = 由 `Store.Add` 的单一判定填(见 resolveRetention);
|
||||
// 非空 = 调用方的显式断言,判据不许推翻它。读回来时:
|
||||
// **空串 = 尚未分型**(0026 之前的历史行 / 受限来源新行),绝不是某一档。
|
||||
RetentionTier RetentionTier
|
||||
}
|
||||
|
||||
// 区分 memory 包各类失败的哨兵错误。每个都在
|
||||
@@ -135,6 +147,15 @@ var (
|
||||
// ErrInvalidKind 在条目的 Kind 不是已定义 kind 时返回。
|
||||
ErrInvalidKind = errors.New("memory: invalid kind")
|
||||
|
||||
// ErrInvalidRetentionTier 在条目的 RetentionTier(或列表读路径的档位过滤)
|
||||
// 不是闭集内的值时返回。空串**不在**此列:它是「尚未分型」这一合法状态。
|
||||
ErrInvalidRetentionTier = errors.New("memory: invalid retention tier")
|
||||
|
||||
// ErrRetentionConflict 在条目同时给出「非 temp 的档位」与「非零的 expires_at」
|
||||
// 时返回 —— 那是自相矛盾的输入(一行标着长期却会到期消失),既不猜也不静默
|
||||
// 丢弃其中一半。
|
||||
ErrRetentionConflict = errors.New("memory: retention tier conflicts with expires_at")
|
||||
|
||||
// ErrNotFound 在条目 id 不存在时返回。
|
||||
ErrNotFound = errors.New("memory: item not found")
|
||||
|
||||
@@ -215,6 +236,11 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error)
|
||||
if err := ensureIngestReviewSchema(ctx, db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 「留多久 / 干什么用」轴(迁移 0026):既有库补列 + 建读侧索引。它排在
|
||||
// 建表与整理列之后,因为索引依赖的列必须先存在(见 retention_schema.go)。
|
||||
if err := ensureRetentionSchema(ctx, db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := normalizeMemoryTimestamps(ctx, db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -246,6 +272,13 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error)
|
||||
// `expires_at` 列:Add 是全仓唯一 INSERT 路径(design/23 §15.1 D3-1),
|
||||
// 因此时效列的真生产者**只能**在这里 —— 读侧时效闸(timegate.go)比对的
|
||||
// 就是这一列,若写入侧不写出它,闸就退化成恒真的空壳。
|
||||
//
|
||||
// 🔴 **档位判定与有效期换算都在这一处**(迁移 0026、retention.go):
|
||||
// `resolveRetention` 是全仓唯一的档位判据(调用方显式档位 > 显式有效期 >
|
||||
// 受限来源不分型 > 留存规则 → temp > 默认 long),`plan.tempDeadline` 是唯一的
|
||||
// 「档位 → expires_at」换算。两列因此**同一个判定、同一笔 INSERT**落下,
|
||||
// 不存在「判了 temp 却没写有效期」的中间态 —— M2 时代登记过的「INSERT 与补写
|
||||
// expires_at 之间的短窗口」在本片被结构性关闭(不再有第二笔 UPDATE)。
|
||||
func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) {
|
||||
if err := item.normalize(); err != nil {
|
||||
return MemoryItem{}, err
|
||||
@@ -254,21 +287,35 @@ func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) {
|
||||
if item, err = applyTimestamps(item); err != nil {
|
||||
return MemoryItem{}, err
|
||||
}
|
||||
// 🔴 档位判定(唯一入口):把结果写回条目,闸与异步审查因此**只读**这一位,
|
||||
// 不再各自跑一遍留存规则(见 preinsert_judge.go 的 defer 分支)。
|
||||
verdict, err := resolveRetention(item)
|
||||
if err != nil {
|
||||
return MemoryItem{}, err
|
||||
}
|
||||
item.RetentionTier = verdict.Tier
|
||||
if verdict.Rule != retentionNone {
|
||||
// 具体命中的反例规则码只进日志(进留痕列的是闭集里的 t0_low_value):
|
||||
// 「n1~n7 谁命中过」是 M27 体检缺的那一条观测线。
|
||||
s.log.Debug("memory: retention judged temporary",
|
||||
"id", item.ID, "scope", string(item.Scope), "rule", string(verdict.Rule))
|
||||
}
|
||||
tagsJSON, err := json.Marshal(item.Tags)
|
||||
if err != nil {
|
||||
return MemoryItem{}, fmt.Errorf("memory: encode tags: %w", err)
|
||||
}
|
||||
|
||||
plan := s.planIngest(ctx, item)
|
||||
item.ExpiresAt = plan.ExpiresAt
|
||||
_, err = s.db.ExecContext(ctx, `INSERT INTO memory_items
|
||||
(id, persona_id, role_id, project_id, scope, kind, content, tags, confidence,
|
||||
source_conversation, created_at, updated_at, decay_weight, status, merged_into,
|
||||
expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
expires_at, retention_tier)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
item.ID, item.PersonaID, item.RoleID, item.ProjectID, string(item.Scope), string(item.Kind),
|
||||
item.Content, string(tagsJSON), item.Confidence, item.SourceConversation,
|
||||
formatTime(item.CreatedAt), formatTime(item.UpdatedAt), item.DecayWeight,
|
||||
plan.insertStatus(), plan.absorbedID(), expiryText(item.ExpiresAt))
|
||||
plan.insertStatus(), plan.absorbedID(), plan.expiresAt(), plan.retention())
|
||||
if err != nil {
|
||||
return MemoryItem{}, fmt.Errorf("memory: insert item: %w", err)
|
||||
}
|
||||
@@ -290,88 +337,6 @@ func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) {
|
||||
return item, nil
|
||||
}
|
||||
|
||||
// Get 返回给定 id 的条目。id 为空或
|
||||
// 不存在时返回 ErrNotFound。
|
||||
//
|
||||
// 🔴 **刻意不过任何读侧闸**(status 与时效都不过;design/03 §11.2 第 3 条):
|
||||
// 撤销软合并必须先读得到被吸收的行,人工核查 / 清算也要先读得到已过期与
|
||||
// 已失效的行 —— 加闸等于把审计与撤销的抓手一并抹掉。要「面向用户的可见集」
|
||||
// 请走 ListByPersona / ListScope / ListProject / List 或 Retriever(那些都过闸)。
|
||||
func (s *Store) Get(ctx context.Context, id string) (MemoryItem, error) {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return MemoryItem{}, ErrNotFound
|
||||
}
|
||||
row := s.db.QueryRowContext(ctx, `SELECT id, persona_id, role_id, project_id, scope, kind,
|
||||
content, tags, confidence, source_conversation, created_at, updated_at,
|
||||
decay_weight FROM memory_items WHERE id = ?`, id)
|
||||
item, err := scanItem(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return MemoryItem{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return MemoryItem{}, fmt.Errorf("memory: get item: %w", err)
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
// ListByPersona 返回 personaID 的全部条目,最新的在前。rowid 打破
|
||||
// created_at 并列,使同一时钟刻内写入的条目保持稳定的
|
||||
// 最新优先顺序。它是角色隔离边界:属于
|
||||
// 其他角色的条目绝不返回,因此角色之间从不共享各自的角色日常
|
||||
// 命名空间(design §4)。它只是检索候选集的*自有*
|
||||
// 那一半 —— 检索器在排名前把它与共享的用户/项目作用域
|
||||
// 取并集(design §3/§4,见 Retriever.visibleCandidates)。
|
||||
//
|
||||
// 只返回**在册**(status='active')条目:整理与入库前审查都只做软动作,
|
||||
// 被合并/被归档的行内容原样留在表里,因此「能看到什么记忆」的每条读路径
|
||||
// 都必须过 status 闸,否则清理过的行照样会被检索到、照样占满面板。
|
||||
//
|
||||
// 同时过**时效闸**(timeGateClause):已过期(expires_at ≤ now)与已失效
|
||||
// (valid_to ≤ now)的行一并不再返回 —— 判据细节与「为什么必须下沉到这里」
|
||||
// 见 timegate.go 与 design/03 §9.6/§11.2。
|
||||
func (s *Store) ListByPersona(ctx context.Context, personaID string) ([]MemoryItem, error) {
|
||||
if strings.TrimSpace(personaID) == "" {
|
||||
return nil, ErrMissingPersona
|
||||
}
|
||||
// 闸的左右操作数同源:两边都由 formatTime 产出定宽 UTC 文本。
|
||||
now := timeGateNow()
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, persona_id, role_id, project_id, scope, kind,
|
||||
content, tags, confidence, source_conversation, created_at, updated_at,
|
||||
decay_weight FROM memory_items WHERE persona_id = ? AND status = ?`+timeGateClause+`
|
||||
ORDER BY created_at DESC, rowid DESC`,
|
||||
personaID, string(tidyStatusActive), now, now)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("memory: list persona items: %w", err)
|
||||
}
|
||||
return scanItems(rows, "persona item")
|
||||
}
|
||||
|
||||
// List 返回跨所有角色的每条**在册**记忆条目,最新的在前。rowid
|
||||
// 打破 created_at 并列,使同一时钟刻内写入的条目保持
|
||||
// 稳定的最新优先顺序。它是聚合/管理视图,不受角色隔离边界限制;必须
|
||||
// 遵守隔离的调用方应改用 ListByPersona 或 ListScope。返回的
|
||||
// 切片非 nil。
|
||||
//
|
||||
// 「在册」= status='active':被软合并('merged')与被归档('archived')
|
||||
// 的行**内容与行都还在**,但不再参与检索、不再进上下文、也不再出现在
|
||||
// 管理面板的记忆列表里(design/23 §8/§10 的软动作口径)。要看它们走
|
||||
// 审查留痕 `GET /admin/memories/reviews` 或按 id 用 Get —— 后者刻意
|
||||
// 不过 status 闸,因为撤销软合并必须先读得到被吸收的行。
|
||||
//
|
||||
// 同样过**时效闸**:临时记忆到期(expires_at ≤ now)与事实失效
|
||||
// (valid_to ≤ now)的行一并从这里消失(timegate.go、design/03 §9.6)。
|
||||
func (s *Store) List(ctx context.Context) ([]MemoryItem, error) {
|
||||
now := timeGateNow()
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, persona_id, role_id, project_id, scope, kind,
|
||||
content, tags, confidence, source_conversation, created_at, updated_at,
|
||||
decay_weight FROM memory_items WHERE status = ?`+timeGateClause+`
|
||||
ORDER BY created_at DESC, rowid DESC`, string(tidyStatusActive), now, now)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("memory: list items: %w", err)
|
||||
}
|
||||
return scanItems(rows, "item")
|
||||
}
|
||||
|
||||
// Delete 删除给定 id 的条目,id 为空或
|
||||
// 没有删除任何行时返回 ErrNotFound。调用方只应传入经
|
||||
// 角色作用域读取(ListByPersona/Get)得到的 id,使删除始终留在
|
||||
@@ -414,6 +379,10 @@ func (s *Store) RememberManual(ctx context.Context, personaID, roleID, text stri
|
||||
// normalize 填充默认的 Scope/Kind/Confidence/DecayWeight,然后校验。
|
||||
// 它由 Add 调用,因此调用方可以省略取值,同时仍能
|
||||
// 捕获显式非法值。
|
||||
//
|
||||
// RetentionTier **不在这里填默认值**:空串是「还没分型」这一合法状态,它由 Add 的
|
||||
// 单一判定(resolveRetention)落定 —— 在 normalize 里塞一个默认档会把「判定没跑」
|
||||
// 与「判定结论是 long」变成同一件事。
|
||||
func (it *MemoryItem) normalize() error {
|
||||
if it.Scope == "" {
|
||||
it.Scope = ScopePersona
|
||||
@@ -445,6 +414,9 @@ func (it *MemoryItem) validate() error {
|
||||
if !it.Kind.valid() {
|
||||
return ErrInvalidKind
|
||||
}
|
||||
if !it.RetentionTier.valid() {
|
||||
return fmt.Errorf("%w: %q", ErrInvalidRetentionTier, it.RetentionTier)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user