feat(memory): M45 提取器时间性元数据位 temporal 驱动留存判定

背景(M37 复审的原话):「真正卡住上游的是提取器的改写:一次性事件被写成
30~80 字的第三人称长期陈述……这不是阈值问题,是判定链缺信息位。」本片补的
就是那一位,不动 N1~N7 的任何阈值。

产出侧
- temporal.go:TemporalKind 闭集(one_off / recurring / durable / unknown,
  空串 = 未分型),normalizeTemporal 只认四个字面值,其余一律归 unknown(绝不猜
  同义词);提取提示词追加该枚举(模型只负责标注,判定在 Go 闭集 switch 里)。
- llmextractor:候选解析后归一并随 MemoryItem 带出;非法/缺失值不报错、不拖累
  同批其他候选。

落库
- 迁移 0027_memory_temporal(up: ADD COLUMN temporal TEXT NOT NULL DEFAULT '';
  down: DROP COLUMN,只撤结构、不改写任何行数据)+ NewStore 的自举前滚
  (duplicate column 是预期路径);Store.Add 的同一笔 INSERT 写该列,判定与
  留痕不分叉。

判定(仍是 resolveRetention 一处收口,judgeIngest 一字未改)
- one_off ⇒ temp(+ expires_at),但先过 temporalSanityVeto 六条语义安全阀:
  显式「记住」/ 在陈述约定 / 习惯词 / 硬性长期事实词 / 永久化 / 状态改变 ——
  命中即不给结论、落回 N1~N7。依据是离线重放的实测:去掉「记住+约定」两条时
  21 条长期约定会被判 temp(4.80%),全开后在最近 500 条窗口内翻转条数为 0。
- durable / recurring ⇒ long 且不再跑规则;unknown / 未分型 ⇒ 落回 N1~N7
  (不许因为「正文看着像今天的事」就判 temp)。
- IngestRuleVersion d3.4 → d3.5(判定结论会变,留痕必须可分)。

验证
- go build ./... + go vet ./... 全绿;go test -count=1 ./internal/runtime/memory/...
  ./internal/db/... 全绿;memory -race 全绿。
- 新增 temporal_test.go(验收点 ①~⑤ + 两条交叉护栏 + 闭集/归一化)与
  temporal_migration_test.go(0027 up/down/同形/真实启动顺序)。变异实测:
  摘掉 one_off 支路 ⇒ ① 全红;关掉 temporalSanityVeto ⇒ 三条护栏用例全红。
- 迁移测试的「回滚 N 步」改为从 schema_migrations 现算(migrateDownToBefore),
  新加一条迁移不再打红 0025/0026 的用例。
This commit is contained in:
AskaEth committed 2026-10-26 18:57:38 +08:00
1 parent af61b8ccfe
commit 91101dedfb
15 files changed
+1130 -37

No files matched your search

+35 -3
View File
@@ -135,6 +135,17 @@ type MemoryItem struct {
// 非空 = 调用方的显式断言,判据不许推翻它。读回来时:
// **空串 = 尚未分型**(0026 之前的历史行 / 受限来源新行),绝不是某一档。
RetentionTier RetentionTier
// Temporal 是**提取器给出的时间性元数据位**(迁移 0027、temporal.go):
// one_off / recurring / durable / unknown,空串 = 未分型(历史行与
// 非提取器写入路径)。它只由提取器在**产出侧**填,判定侧
// (resolveRetention)读它一位,不让任何规则去猜「这句话说的是不是今天的事」。
//
// ⚠️ **与 ExpiresAt 同样是「只写不读」的列**:`Add` 把它写进 `temporal` 列,
// 但四条列表读路径的 SELECT 与 scanItem 都不读它 —— 改扫描口径要动全部读点
// (rules.go / store_list.go / tidy_store.go / preinsert_store.go,共 14→15 列),
// 那是另一件事,不在本片授权范围。要按时间性位巡检/回填时直读该列即可。
Temporal TemporalKind
}
// 区分 memory 包各类失败的哨兵错误。每个都在
@@ -161,6 +172,10 @@ var (
// 丢弃其中一半。
ErrRetentionConflict = errors.New("memory: retention tier conflicts with expires_at")
// ErrInvalidTemporal 在条目的 Temporal(时间性元数据位,迁移 0027)不是闭集内
// 的值时返回。空串**不在**此列:它是「尚未分型」这一合法状态。
ErrInvalidTemporal = errors.New("memory: invalid temporal kind")
// ErrNotFound 在条目 id 不存在时返回。
ErrNotFound = errors.New("memory: item not found")
@@ -246,6 +261,10 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error)
if err := ensureRetentionSchema(ctx, db); err != nil {
return nil, err
}
// 时间性元数据位(迁移 0027):只补列、不建索引、不回填(见 temporal_schema.go)。
if err := ensureTemporalSchema(ctx, db); err != nil {
return nil, err
}
if err := normalizeMemoryTimestamps(ctx, db); err != nil {
return nil, err
}
@@ -284,6 +303,11 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error)
// 「档位 → expires_at」换算。两列因此**同一个判定、同一笔 INSERT**落下,
// 不存在「判了 temp 却没写有效期」的中间态 —— M2 时代登记过的「INSERT 与补写
// expires_at 之间的短窗口」在本片被结构性关闭(不再有第二笔 UPDATE)。
//
// 🆕 切片 M45:`item.Temporal`(时间性元数据位,迁移 0027)也随**这一笔** INSERT
// 落下(`temporal` 列)。它的判定作用已经折算进上面的档位(temporal.go 的
// temporalVerdict 是 resolveRetention 的第 4 步),列值本身是**留痕**:记录提取器
// 当时标了什么,供事后巡检「模型答的是不是 one_off」—— 判定与留痕因此不会分叉。
func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) {
if err := item.normalize(); err != nil {
return MemoryItem{}, err
@@ -315,12 +339,13 @@ func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) {
_, 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, retention_tier)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
expires_at, retention_tier, temporal)
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(), plan.expiresAt(), plan.retention())
plan.insertStatus(), plan.absorbedID(), plan.expiresAt(), plan.retention(),
string(item.Temporal))
if err != nil {
return MemoryItem{}, fmt.Errorf("memory: insert item: %w", err)
}
@@ -428,6 +453,13 @@ func (it *MemoryItem) validate() error {
if !it.RetentionTier.valid() {
return fmt.Errorf("%w: %q", ErrInvalidRetentionTier, it.RetentionTier)
}
// 时间性位(迁移 0027)同样只接受闭集内的值:它是**枚举元数据**,一个自造的
// 值("sometimes")既判不出档位、又不该静默落库 —— 提取器已经把模型输出归一
// 到 unknown(temporal.go 的 normalizeTemporal),走到这里还是非法值只能是
// 程序错误,直接拒。
if !it.Temporal.valid() {
return fmt.Errorf("%w: %q", ErrInvalidTemporal, it.Temporal)
}
return nil
}