Files
Cyrene_v2/internal/runtime/memory/temporal_schema.go
T
AskaEth 91101dedfb 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 的用例。
2026-10-26 18:57:38 +08:00

46 lines
2.2 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"
"fmt"
"strings"
)
// 本文件是「时间性元数据位」轴(迁移 0027)的 schema 前滚:给**既有**的
// memory_items 补 `temporal` 列。
//
// 与 retention_schema.go 同一手法、同一原因:`createMemoryItemsTable` 是
// `CREATE TABLE IF NOT EXISTS`,只对**新库**生效 —— 既有部署的表早就建好了,
// 新列不会自己长出来。记忆存储必须自包含、能在任意 *sql.DB 上使用,因此这条
// 前滚不能只靠 `internal/db/migrations`。
//
// 🔴 与迁移的分工(两份真源,必须同形):权威机制是
// `internal/db/migrations/0027_memory_temporal`;这里是「任意 *sql.DB」的前滚。
// 启动顺序是「先 MigrateUp、后 NewStore」(cmd/cyrene/wire.go),因此生产库里
// 0027 先落地、这里走到 `duplicate column name` 是**预期路径**。
//
// 本列**不建索引**:目前没有任何按时间性位的读路径(它是写入侧的判定输入 +
// 事后巡检用的留痕列),提前建索引只是给每笔写入加一份没人用的维护成本。
// 将来整理侧真要按它筛(例如「把 one_off 的行捞出来复核」)再加索引不迟,
// 那时两条路径(迁移 + 本文件)一起加。
// ensureTemporalSchema 幂等地给 memory_items 补上时间性位列(列缺失时 ALTER,
// 已存在时忽略 SQLite 的 "duplicate column name" —— 那是预期路径,不是错误)。
//
// 默认值 = 空串 = 尚未分型:既有行因此**行为逐字不变**(resolveRetention 对
// 未分型的处理与 0027 之前完全一样,只跑 N1~N7)。本前滚刻意**不做**任何回填
// 判定:历史行没有对应的提取器输出,拿正文反推「它当时是一次性事件吗」正是
// 本片要消灭的那种猜测。
func ensureTemporalSchema(ctx context.Context, db *sql.DB) error {
_, err := db.ExecContext(ctx, `ALTER TABLE memory_items
ADD COLUMN temporal TEXT NOT NULL DEFAULT ''`)
if err == nil {
return nil
}
if strings.Contains(err.Error(), "duplicate column name") {
return nil
}
return fmt.Errorf("memory: roll forward temporal column: %w", err)
}