背景(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 的用例。
198 lines
11 KiB
Go
198 lines
11 KiB
Go
package memory
|
||
|
||
import "strings"
|
||
|
||
// 本文件是**提取器产出侧的时间性元数据位**(`temporal`,迁移 0027):它回答
|
||
// 「这条记忆说的是**一次发生的事**,还是**反复 / 长期成立的事**」。
|
||
//
|
||
// 为什么需要这一位(M37 复审的结论,逐字):「真正卡住上游的是**提取器的改写**:
|
||
// 一次性事件被写成 30~80 字的第三人称长期陈述(同时带『近期/目前』和持久词),
|
||
// 规则闸看不到『这是今天的一次性事件』这一位。这不是阈值问题,是**判定链缺信息位**。」
|
||
//
|
||
// 词项规则(extract_retention.go 的 N1~N7)只能看**落库后的正文**,而正文是提取器
|
||
// 改写过的陈述:M37 实测里多出来的 11 条命中,逐条都是「同一条一次性安排的重复
|
||
// 陈述」(「一起晒太阳」×5、「傍晚出门走十分钟…今天没走成明天补上」×6)—— 正文里
|
||
// 连时点词都没有,把时点窗口从 12 放宽到 24 字只会**把约定本身判成临时并删掉**。
|
||
// 因此补的不是阈值,而是这一位。
|
||
//
|
||
// 🔴 **它是枚举元数据,不是提示词描述**(AGENTS §9「判据靠元数据,不靠提示词」):
|
||
// 模型只能在下面四个值里选一个,落库是**列值**(TEXT),判定走 Go 闭集 switch ——
|
||
// 不是让规则去理解一句自然语言。模型给不出合法值时**一律归 `unknown`**(绝不猜成
|
||
// `one_off`:猜错的方向是「本该留着的被提前删掉」,不可逆)。
|
||
//
|
||
// 闭集(与迁移 0027 的列值口径逐字一致):
|
||
//
|
||
// one_off 一次性事件(今天 / 这次 / 某一次发生的事)⇒ temp + expires_at
|
||
// recurring 反复发生的规律(每天 / 每周 / 每次都…)⇒ long
|
||
// durable 长期成立的事实 / 偏好 / 约定 / 称呼 / 身份 ⇒ long
|
||
// unknown 模型拿不准,或没给出合法值 ⇒ **没有结论**,交给 N1~N7 旧规则
|
||
// '' 尚未分型:0027 之前的历史行,以及**非提取器**的写入路径(调用方显式
|
||
// 给了档位的那些)—— 它不是一个业务取值,只是列的默认值
|
||
//
|
||
// 🔴 `unknown` 与 `”` 在判定上**走同一条路**(都落到旧规则),但在口径上是两件事:
|
||
// `unknown` 是「问过模型了,它拿不准」;`”` 是「没问过」。把两者折成一个值会让
|
||
// 「模型到底答了什么」这个观测面当场消失,而那正是本片要验证的东西。
|
||
type TemporalKind string
|
||
|
||
const (
|
||
// TemporalUnset 是「尚未分型」:历史行与非提取器写入路径。**它不是业务取值**,
|
||
// 判定上与 TemporalUnknown 同路(落到旧规则),但含义不同(见文件头注)。
|
||
TemporalUnset TemporalKind = ""
|
||
|
||
// TemporalOneOff 是一次性事件。它是**唯一**会把记忆推进临时层的时间性位:
|
||
// 命中 ⇒ `retention_tier=temp` + 非空 `expires_at`(7 天后从读面消失、随后被
|
||
// 后台清扫物理删除)。因此这个值只许模型在**明确知道**「这是某一次发生的事」
|
||
// 时给出 —— 拿不准必须给 unknown。
|
||
TemporalOneOff TemporalKind = "one_off"
|
||
|
||
// TemporalRecurring 是反复发生的规律(「每天散步十分钟」「每周三开会」)。
|
||
// 「反复」= 将来还会再发生,因此不许进临时层。
|
||
TemporalRecurring TemporalKind = "recurring"
|
||
|
||
// TemporalDurable 是长期成立的事实 / 偏好 / 约定 / 称呼 / 身份。
|
||
TemporalDurable TemporalKind = "durable"
|
||
|
||
// TemporalUnknown 是「问过模型了,它拿不准」。**它绝不是「一次性」的同义词**:
|
||
// 判定上它与未分型一样落到 N1~N7,不许因为「正文看着像今天的事」就判 temp ——
|
||
// 那等于把本片要消灭的猜测重新引回判定链(fail-safe 方向:宁可多留)。
|
||
TemporalUnknown TemporalKind = "unknown"
|
||
)
|
||
|
||
// valid 报告 t 是否落在闭集内(**含**未分型的空串:它是合法的列值,不是错误)。
|
||
func (t TemporalKind) valid() bool {
|
||
switch t {
|
||
case TemporalUnset, TemporalOneOff, TemporalRecurring, TemporalDurable, TemporalUnknown:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// assigned 报告 t 是否**已由提取器分型**(即非空串)。它是「未分型」的唯一判据,
|
||
// 与 RetentionTier.assigned 同族,调用方不许用 `t == TemporalUnknown` 之类的
|
||
// 比较去替代它(那是「问过但拿不准」,不是「没问过」)。
|
||
func (t TemporalKind) assigned() bool { return t != TemporalUnset }
|
||
|
||
// normalizeTemporal 把模型输出的自由文本归一到上面四个值。
|
||
//
|
||
// 🔴 **只做大小写折叠与首尾空白裁剪,绝不做同义词猜测**(「一次性」「once」→ 都不认):
|
||
// 认不出来的答案就是解析失败,一律落 TemporalUnknown —— 这正是任务书
|
||
// 「模型只能选那四个值,解析失败一律归 unknown(绝不猜)」的字面实现。
|
||
// 猜同义词的代价是单向的:猜成 one_off 就删记忆,猜成别的只是多留一条。
|
||
func normalizeTemporal(s string) TemporalKind {
|
||
switch TemporalKind(strings.ToLower(strings.TrimSpace(s))) {
|
||
case TemporalOneOff:
|
||
return TemporalOneOff
|
||
case TemporalRecurring:
|
||
return TemporalRecurring
|
||
case TemporalDurable:
|
||
return TemporalDurable
|
||
default:
|
||
// 空值、非法值、拼写错误、模型自造的新值:全部归 unknown(**不报错**)。
|
||
// 一条候选记忆的价值不取决于它的时间性位是否写对,因此解析失败绝不
|
||
// 让整批提取失败(那会静默丢掉同批的其他候选)。
|
||
return TemporalUnknown
|
||
}
|
||
}
|
||
|
||
// temporalRetentionTempRule 是「时间性位把条目推进临时层」时进日志的规则码。
|
||
//
|
||
// 它**刻意不编号成 n8**:N1~N7 是**词项**规则(看正文),这一条看的是**元数据位**,
|
||
// 两者混在一套编号里会让「谁命中过」这条观测线读不出「是词判的还是模型标的」。
|
||
// 与 retentionLowValueReason 的分工不变:规则码只进日志,留痕列仍是闭集里的
|
||
// `t0_low_value`(extract_retention.go 的口径)。
|
||
const retentionTemporalTempRule retentionRule = "temporal_one_off"
|
||
|
||
// temporalVerdict 把时间性位换算成档位结论(**唯一**一处换算,只由
|
||
// `resolveRetention` 调用,见 retention.go 的判定顺序第 4 步)。
|
||
//
|
||
// - `one_off` ⇒ temp —— 但先过 temporalSanityVeto(语义安全阀):正文自己还在说
|
||
// 「长期成立 / 反复发生 / 一份约定 / 主人明确要求记住」时,这一位**不给结论**,
|
||
// 落回 N1~N7。它是本片唯一会**收紧**判定的方向,因此阀门全开在 fail-safe 一侧。
|
||
// - `durable` / `recurring` ⇒ long,并且**不再跑规则**:模型已经回答了「这不是
|
||
// 一次性事件」,此时规则再判 temp 就是把「判定链缺的那一位」又丢掉。方向
|
||
// 仍是 fail-safe(多留)。
|
||
// - `unknown` / 未分型 ⇒ 不给结论,交给 N1~N7(任务书验收点③:不许因为
|
||
// 「看着像今天的事」就判 temp)。
|
||
func temporalVerdict(item MemoryItem) (retentionVerdict, bool) {
|
||
switch item.Temporal {
|
||
case TemporalOneOff:
|
||
if temporalSanityVeto(item.Content) {
|
||
return retentionVerdict{}, false
|
||
}
|
||
return retentionVerdict{Tier: RetentionTemp, Rule: retentionTemporalTempRule}, true
|
||
case TemporalDurable, TemporalRecurring:
|
||
return retentionVerdict{Tier: RetentionLong}, true
|
||
default:
|
||
return retentionVerdict{}, false
|
||
}
|
||
}
|
||
|
||
// temporalSanityVeto 是 `one_off` 支路的**语义安全阀**:正文里出现「这条说的是
|
||
// 长期成立的东西」的任何一类信号时,元数据位**不给结论**(落回 N1~N7)。
|
||
//
|
||
// 它回答的是一个具体的分工问题:这一位补的是**位置判据**那一块(N6 要求时点词出现在
|
||
// 句首 12 字内,而提取器改写出的第三人称长句根本不满足),**不是**把 N6 的语义安全阀
|
||
// 也一起换掉。判「这不是一次性事件」的词项证据在任何一遍判定里都同样有效,因此这里
|
||
// **原样复用同一批词表**,只让「这一次到底是不是一次性」由模型来答。
|
||
//
|
||
// 六条各自的依据(前两条是既有红线,后四条是 2026-10-27 对生产库 active 集合的离线
|
||
// 重放实测):
|
||
//
|
||
// 1. **显式「记住 / 记下 / 别忘了」** —— extract_retention.go 护栏①,主人的直接
|
||
// 指令不许被任何判据改成临时记忆;
|
||
// 2. **在陈述约定 / 承诺** —— M37 量化:放宽时点窗口多命中的 11 条逐条都是「同一
|
||
// 长期约定的重复陈述」,删掉的就是约定本身;
|
||
// 3. **习惯词**(每天 / 通常 / 喜欢…)—— 说的是「反复发生」,与一次性互斥;
|
||
// 4. **硬性长期事实词**(住 / 宿舍 / 工作 / 职业 / 称呼 / 项目 / 生日 / 过敏…)——
|
||
// 加这条之前,最近 500 条里的 18 条「上界候选」有 12 条是「近期住宿舍(昨天住
|
||
// 宿舍)」「项目已接近收尾」这类**被时点词捎带的状态事实**;加上之后窗口内的
|
||
// 上界从 4.2% 降到 0%(实测见 M45 的量报告),而全量 active 的上界从 5.4% 降到
|
||
// 2.8% —— 这一条是「宁可多留」方向上最便宜的一刀;
|
||
// 5. **永久化标记**(以后 / 往后 / 从此…);
|
||
// 6. **状态改变标记**(换成 / 变成 / 改成…:新状态会持续)。
|
||
//
|
||
// ⚠️ **刻意不收「具体约束」这一条**(与 N6 的第三/四条不同):数值与频次并不排他 ——
|
||
// 「帮我订个 7 点的闹钟」恰恰是最典型的一次性事件,把数值当否决会让这一位对真事件
|
||
// 彻底失效。
|
||
func temporalSanityVeto(content string) bool {
|
||
text := normalizeRetentionText(content)
|
||
if retentionExplicitRemember(text) || retentionStatesAgreement(text) {
|
||
return true
|
||
}
|
||
if containsAny(text, tidyHabitMarkers()) || containsAny(text, retentionHardDurableMarkers()) {
|
||
return true
|
||
}
|
||
if containsAny(text, retentionPermanenceMarkers()) || containsAny(text, retentionStateChangeMarkers()) {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// retentionStatesAgreement 报告正文是否在**陈述一项约定 / 承诺**(而不是在记录
|
||
// 一次发生的事)。
|
||
//
|
||
// 它是 `one_off` 支路的第二条例外,语料依据逐条可查(2026-10-27 对生产库最近
|
||
// 500 条 active 的离线重放 + M37 复审的量化):被判成一次性事件的高危句式不是
|
||
// 「今天下雨了」那种流水账,而是
|
||
//
|
||
// 「和主人有『晚饭后出门走十分钟』的约定;今天没走成,算欠着,明天补上」
|
||
// 「两人之间有一个『晒太阳』的约定,目前暂缓,等主人想起来再提」
|
||
//
|
||
// —— 句子的**主语是一份长期约定**,时点只修饰其中一次执行。M37 已经量过:把 N6
|
||
// 的时点窗口放宽到 24 字,这 11 条会一起命中,而它们要删的正是约定本身。因此这条
|
||
// 例外不是「又一条词规则」,而是把 M37 那条实测结论固化成交付物。
|
||
//
|
||
// 词表刻意与 retentionDurableMarkers 的「关系里程碑 / 约定」族同源,但**独立列出**
|
||
// (+「约好」):那一族还会被别的判定用到,两者的职责不同 —— 这里只回答
|
||
// 「这句话是不是在说一份约定」。
|
||
func retentionStatesAgreement(content string) bool {
|
||
text := normalizeRetentionText(content)
|
||
for _, marker := range []string{"约定", "承诺", "答应", "说好", "约好"} {
|
||
if strings.Contains(text, marker) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|