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:
1 parent
af61b8ccfe
commit
91101dedfb
15 files changed
+1130
-37
No files matched your search
@@ -29,6 +29,14 @@ import (
|
||||
//
|
||||
// 「允许返回 `[]`」是 Mem0 官方口径(design/23 §2:官方原话「Include examples
|
||||
// that should produce []」)—— 不写这一句,模型会为了「有产出」而硬凑。
|
||||
//
|
||||
// 🆕 2026-10-27(切片 M45):prompt 里追加**时间性位** `temporal`(四个枚举值)。
|
||||
// 它**不是**「又一段靠自然语言描述判据」的提示词加固:模型只负责**标注**一个值为
|
||||
// 四个枚举之一,**判定**(one_off ⇒ temp / durable ⇒ long / unknown ⇒ 走旧规则)
|
||||
// 是 temporal.go 里的一段 Go 闭集 switch,落库是 `temporal` 列(迁移 0027)——
|
||||
// 因此 AGENTS §9「判据靠元数据,不靠提示词」在这里是**结构性成立**的:即便模型
|
||||
// 完全不听这段说明,最坏结果也只是它一律答 unknown(= 回到本片之前的旧规则),
|
||||
// 不会产生任何新的误判路径。
|
||||
const extractSystemPrompt = `你是记忆提取器。从对话中提取**值得长期记住**的记忆项。
|
||||
|
||||
只保留这些(正向清单):
|
||||
@@ -49,18 +57,33 @@ const extractSystemPrompt = `你是记忆提取器。从对话中提取**值得
|
||||
**允许返回空数组 []**:一轮对话里没有值得长期记住的内容时,正确做法就是回
|
||||
[],不要为了凑数而提取。
|
||||
|
||||
每条还要标一个**时间性位** "temporal",只能取下面四个英文值之一:
|
||||
- "one_off":这条说的是**某一次**发生的事(今天 / 昨天 / 这次 / 某个具体日子),
|
||||
即使你已经把它写成「用户……」这样的陈述句 —— 一次性事件标成 durable 会让它
|
||||
永远留着,标成 one_off 会让它进临时层、到期自动消失,所以只有确实只发生过
|
||||
一次时才标它;
|
||||
- "recurring":说的是**反复发生**的规律(每天 / 每周 / 每次都…);
|
||||
- "durable":说的是**长期成立**的事实 / 偏好 / 约定 / 称呼 / 身份;
|
||||
- "unknown":拿不准,或者上面三个都不贴切。
|
||||
|
||||
temporal 只许填这四个英文值之一(不要换成中文、不要自造新值);拿不准就填
|
||||
"unknown" —— 标错方向的代价不对称:标成 one_off 会让本该留着的记忆被提前删掉。
|
||||
|
||||
只输出一个 JSON 数组,不要任何其他文字。每个元素形如:
|
||||
{"content":"记忆内容","scope":"user|persona|project","kind":"relationship|world_event","tags":["标签"],"confidence":0.9,"project_id":"可选项目id"}
|
||||
去噪去重:只保留值得长期记住的,忽略寒暄与临时信息。`
|
||||
{"content":"记忆内容","scope":"user|persona|project","kind":"relationship|world_event","temporal":"one_off|recurring|durable|unknown","tags":["标签"],"confidence":0.9,"project_id":"可选项目id"}
|
||||
去噪去重:只保留值得长期记住的,忽略寒暄与临时信息;确实发生过的**一次性事件**
|
||||
如果值得记,也照实提取,但必须把 temporal 标成 one_off(它会进临时层)。`
|
||||
|
||||
// llmCandidate 是提取器要求 LLM 输出的线上形状:每条候选
|
||||
// 记忆一个 JSON 对象。模型未给出合法值时,Scope/Kind 取提取器配置的
|
||||
// 默认值。不含 source_conversation:
|
||||
// 默认值;Temporal 则**一律归 unknown**(temporal.go 的 normalizeTemporal,
|
||||
// 绝不猜:猜成 one_off 的方向是把该留的记忆删掉)。不含 source_conversation:
|
||||
// 模型即使回填该键也会被忽略(结构化溯源只来自持久化层)。
|
||||
type llmCandidate struct {
|
||||
Content string `json:"content"`
|
||||
Scope string `json:"scope"`
|
||||
Kind string `json:"kind"`
|
||||
Temporal string `json:"temporal"`
|
||||
Tags []string `json:"tags"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
ProjectID string `json:"project_id"`
|
||||
@@ -240,6 +263,10 @@ func (e *LLMExtractor) dedupeAndBuild(cands []llmCandidate) []MemoryItem {
|
||||
Content: content,
|
||||
Tags: c.Tags,
|
||||
Confidence: c.Confidence,
|
||||
// 时间性位(迁移 0027)在**产出侧**归一:模型只能选四个值之一,
|
||||
// 其余(空 / 非法 / 自造)一律归 unknown —— 解析失败绝不报错,
|
||||
// 也不拖累同批的其他候选(见 temporal.go 的 normalizeTemporal)。
|
||||
Temporal: normalizeTemporal(c.Temporal),
|
||||
}
|
||||
if !it.Scope.valid() || !it.Kind.valid() {
|
||||
continue
|
||||
|
||||
Reference in new issue
Block a user