Files
Cyrene_v2/internal/runtime/memory/rules.go
T
AskaEth cc6904b1b4 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)。
2026-10-26 13:09:30 +08:00

198 lines
8.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package memory
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
)
// 作用域访问控制哨兵错误。当角色试图执行 design §3 规则
// 不允许的写入时返回。
var (
// ErrReadOnly 在角色试图写入或删除
// 不属于它的角色作用域记忆条目时返回。按 design §3,其他角色
// 对角色日常记忆只读。
ErrReadOnly = errors.New("memory: read-only: persona does not own this item")
// ErrMissingProjectID 在项目作用域查询的 id 为空时返回。
ErrMissingProjectID = errors.New("memory: project_id is required")
)
// canWrite 报告 actorID 按 design §3 作用域规则是否可以写入 item:
//
// - ScopeUser:任何角色都可写(用户习惯全局共享)。
// - ScopePersona:只有归属角色可写(其他角色只读)。
// - ScopeProject:任何角色都可写(项目记忆人人可写)。
//
// 调用方必须已校验 item.Scope;非法作用域对任何人
// 都不可写。
func canWrite(actorID string, it MemoryItem) bool {
switch it.Scope {
case ScopeUser, ScopeProject:
return true
case ScopePersona:
return it.PersonaID == actorID
default:
return false
}
}
// AddScoped 以 actorID 的权限持久化 item,按 persona_id + scope 施加
// design §3 写规则。actorID 为空返回 ErrMissingPersona。
// item.PersonaID 为空时用 actorID 填充它,因此角色总是
// 记录自身的来源;归属其他角色的角色作用域条目
// 返回 ErrReadOnly。用户与项目作用域的条目任何角色都可写。
func (s *Store) AddScoped(ctx context.Context, actorID string, item MemoryItem) (MemoryItem, error) {
if strings.TrimSpace(actorID) == "" {
return MemoryItem{}, ErrMissingPersona
}
if item.PersonaID == "" {
item.PersonaID = actorID
}
if !item.Scope.valid() {
return MemoryItem{}, ErrInvalidScope
}
if !canWrite(actorID, item) {
return MemoryItem{}, ErrReadOnly
}
return s.Add(ctx, item)
}
// ListScope 返回 personaID 可见的该作用域全部条目,最新的在前,
// 施加 design §3 读规则(rowid 打破 created_at 并列,使
// 同一时钟刻内写入的条目保持稳定的最新优先顺序):
//
// - ScopeUser:全部用户作用域条目(跨角色全局共享)。
// - ScopePersona:全部角色作用域条目。其他角色只读但
// 仍可读,因此角色在这里可以读到另一个角色的角色日常记忆。
// - ScopeProject:全部项目作用域条目(人人可读)。
//
// personaID 指明读取方角色,且必填,以便调用方即使在共享
// 作用域上也始终声明身份边界。是否允许受限上下文(群聊 / 陌生人,design §5)
// 接收这些内容仍由更高层策略决定;本方法只施加
// 基础读规则。
//
// 与 ListByPersona/List 同口径:只返回 status='active' 且过**时效闸**
// (expires_at / valid_to 均未到期)的行 —— 闸只有这里与 ListByPersona /
// ListProject / List 四处,检索路径经 Retriever.visibleCandidates 组合调用
// 本方法,因此自动获得同一判据(timegate.go 头注、design/03 §11.2)。
//
// `tiers` 的档位过滤见同族的 ListScopeTiers(签名不变的理由见
// store_list.go 的 ListByPersona 注释:网关接口钉着这批精确签名)。
func (s *Store) ListScope(ctx context.Context, scope Scope, personaID string) ([]MemoryItem, error) {
return s.listScope(ctx, scope, personaID, nil)
}
// ListScopeTiers 是 ListScope 的**可选档位过滤**版本(空 tiers = 不过滤;
// `RetentionTierUnset` = 只看尚未分型的行)。
func (s *Store) ListScopeTiers(ctx context.Context, scope Scope, personaID string, tiers ...RetentionTier) ([]MemoryItem, error) {
return s.listScope(ctx, scope, personaID, tiers)
}
// listScope 是 ListScope / ListScopeTiers 的共同实现(tiers 为空不过滤)。
func (s *Store) listScope(ctx context.Context, scope Scope, personaID string, tiers []RetentionTier) ([]MemoryItem, error) {
if strings.TrimSpace(personaID) == "" {
return nil, ErrMissingPersona
}
if !scope.valid() {
return nil, ErrInvalidScope
}
now := timeGateNow()
query := `SELECT id, persona_id, role_id, project_id, scope, kind,
content, tags, confidence, source_conversation, created_at, updated_at,
decay_weight, retention_tier FROM memory_items WHERE scope = ? AND status = ?`
args := []any{string(scope), string(tidyStatusActive)}
query, args, err := retentionTierList(query, args, tiers)
if err != nil {
return nil, err
}
rows, err := s.db.QueryContext(ctx, query+timeGateClause+`
ORDER BY created_at DESC, rowid DESC`, append(args, now, now)...)
if err != nil {
return nil, fmt.Errorf("memory: list scope items: %w", err)
}
return scanItems(rows, "scope item")
}
// ListProject 返回链接到 projectID 的全部记忆条目,最新的在前,
// rowid 打破 created_at 并列,使同一刻的写入保持稳定顺序。项目
// 作用域记忆携带一个 ProjectID,链到 projectindex
// 层级(design §3);这是深检索入口 —— 调用方把它与
// projectindex 存储结合,沿 项目索引 → 项目描述 → 章节内容 遍历。项目
// 记忆所有角色可读,因此不施加角色过滤。projectID 为空
// 返回 ErrMissingProjectID。与其余三条列表读路径同口径:只返回在册且过
// **时效闸**的行(timegate.go、design/03 §9.6/§11.2)。
//
// `tiers` 的档位过滤见同族的 ListProjectTiers —— 本方法虽然是深检索入口,但
// 「留多久」这根轴对项目记忆同样成立,因此一并支持。
func (s *Store) ListProject(ctx context.Context, projectID string) ([]MemoryItem, error) {
return s.listProject(ctx, projectID, nil)
}
// ListProjectTiers 是 ListProject 的**可选档位过滤**版本(空 tiers = 不过滤)。
func (s *Store) ListProjectTiers(ctx context.Context, projectID string, tiers ...RetentionTier) ([]MemoryItem, error) {
return s.listProject(ctx, projectID, tiers)
}
// listProject 是 ListProject / ListProjectTiers 的共同实现(tiers 为空不过滤)。
func (s *Store) listProject(ctx context.Context, projectID string, tiers []RetentionTier) ([]MemoryItem, error) {
if strings.TrimSpace(projectID) == "" {
return nil, ErrMissingProjectID
}
now := timeGateNow()
query := `SELECT id, persona_id, role_id, project_id, scope, kind,
content, tags, confidence, source_conversation, created_at, updated_at,
decay_weight, retention_tier FROM memory_items WHERE project_id = ? AND status = ?`
args := []any{projectID, string(tidyStatusActive)}
query, args, err := retentionTierList(query, args, tiers)
if err != nil {
return nil, err
}
rows, err := s.db.QueryContext(ctx, query+timeGateClause+`
ORDER BY created_at DESC, rowid DESC`, append(args, now, now)...)
if err != nil {
return nil, fmt.Errorf("memory: list project items: %w", err)
}
return scanItems(rows, "project item")
}
// DeleteScoped 删除给定 id 的条目,但仅当按 design §3 规则 actorID
// 可以写它。这在删除上施加角色只读边界:
// 角色可以删自己的角色作用域条目,以及任何用户或
// 项目作用域条目,但不能删另一个角色的角色日常记忆。id 为空返回
// ErrNotFound;改动他人的角色作用域条目返回 ErrReadOnly。
func (s *Store) DeleteScoped(ctx context.Context, actorID, id string) error {
if strings.TrimSpace(actorID) == "" {
return ErrMissingPersona
}
item, err := s.Get(ctx, id)
if err != nil {
return fmt.Errorf("memory: delete scoped load: %w", err)
}
if !canWrite(actorID, item) {
return ErrReadOnly
}
return s.Delete(ctx, id)
}
// ensureProjectIDColumn 在 memory_items 表创建于 M4 项目索引
// (design §3)存在之前时把它前滚:缺失时补加 project_id 列,
// 已存在时忽略重复列错误。它是
// SQLite 特有的(记忆存储基于 SQLite)且幂等,因此 NewStore
// 可在全新 schema 或既有 schema 上调用。
func ensureProjectIDColumn(ctx context.Context, db *sql.DB) error {
_, err := db.ExecContext(ctx, `ALTER TABLE memory_items
ADD COLUMN project_id TEXT NOT NULL DEFAULT ''`)
if err == nil {
return nil
}
// 当该列已存在(例如由 NewStore 中的 CREATE TABLE 创建,
// 或由上一次运行创建)时,SQLite 报 "duplicate column name: project_id"。
if strings.Contains(err.Error(), "duplicate column name") {
return nil
}
return fmt.Errorf("memory: roll forward project_id column: %w", err)
}