Files
Cyrene_v2/internal/runtime/memory/llmextractor.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

301 lines
13 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"
"encoding/json"
"fmt"
"strings"
"sync"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// extractSystemPrompt 告诉 model.Adapter 如何产出候选记忆:
// 一个 JSON 对象数组,每个对象一条值得持久化的记忆。格式固定,
// 以便 parseCandidates 确定性地反序列化。溯源(source_conversation)
// 刻意不在其中:它由持久化层盖真实 session/message id,绝不由模型生成
// (裁决 04.4.3,见 source.go)。
//
// 2026-10-13(design/03 §10.3 的 **B 级:提示词加固**):prompt 里补上
// **正向清单一句话 + 反例清单一句话 + 明确允许返回 `[]`**。回应主人原话
// 「**收窄提取器,确实要给他一个主动式的记忆留存规则,不能想到什么都往记忆里
// 写**」。
//
// 🔴 为什么提示词**只是辅助**(design/03 §10.2):提示词产出是自由文本,**不可
// 验收**(测试只能断言「模型有没有听话」),且判据落在提示词上违反 AGENTS §9
// 「判据靠元数据,不靠提示词」。**真正的防线是 A 级规则闸**(extract_retention.go
// → judgeIngest,纯函数、可单测、在唯一写路径上);这里的两份清单是**同源口径的
// 第二道**,让模型与规则闸朝同一个方向使劲,而不是各写各的。
//
// 「允许返回 `[]`」是 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 = `你是记忆提取器。从对话中提取**值得长期记住**的记忆项。
只保留这些(正向清单):
- 长期事实:身份、住址、职业、家庭成员、健康约束(过敏、忌口、长期用药)。
- 稳定偏好:口味、作息、喜欢什么、讨厌什么。
- 关系里程碑:约定、承诺、共同经历、纪念日、称呼习惯。
- 项目约定:编号规则、暗号、路径、命名约定。
- 能力边界:主人明确说过的「不要做 X」。
下面这些**不要提取**(反例清单):
- 寒暄与致谢:「谢谢」「早上好」「辛苦啦」「晚安」。
- 情绪波动:「今天好累」「心情不错」。
- 一次性琐事与任务指令:「帮我订个 7 点的闹钟」「把这个文件转成 pdf」。
- 时效性数据:当前时间、天气、「现在几点了」。
- 助手自己说过的话 —— 只提取主人这边的长期事实与偏好。
- 不确定的推断(「他可能喜欢 X」):拿不准就不要提取。
**允许返回空数组 []**:一轮对话里没有值得长期记住的内容时,正确做法就是回
[],不要为了凑数而提取。
每条还要标一个**时间性位** "temporal",只能取下面四个英文值之一:
- "one_off":这条说的是**某一次**发生的事(今天 / 昨天 / 这次 / 某个具体日子),
即使你已经把它写成「用户……」这样的陈述句 —— 一次性事件标成 durable 会让它
永远留着,标成 one_off 会让它进临时层、到期自动消失,所以只有确实只发生过
一次时才标它;
- "recurring":说的是**反复发生**的规律(每天 / 每周 / 每次都…);
- "durable":说的是**长期成立**的事实 / 偏好 / 约定 / 称呼 / 身份;
- "unknown":拿不准,或者上面三个都不贴切。
temporal 只许填这四个英文值之一(不要换成中文、不要自造新值);拿不准就填
"unknown" —— 标错方向的代价不对称:标成 one_off 会让本该留着的记忆被提前删掉。
只输出一个 JSON 数组,不要任何其他文字。每个元素形如:
{"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 取提取器配置的
// 默认值;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"`
}
// LLMExtractor 让 model.Adapter 从对话中推导候选记忆条目
// 来实现 Extractor(design §1 LLM 异步自动提取)。它把 LLM
// 调用在 Extract 中同步执行,在 ExtractAsync 中放到后台 goroutine 执行。
//
// 它是幂等且并发安全的:候选内容会规范化,
// 并在互斥锁保护的 seen 集合上去重,因此对同一条
// 记忆重复提取不会产生第二份,同一对话中两次出现的
// 相同内容会合并为一条。
type LLMExtractor struct {
adapter model.Adapter
personaID string
roleID string
scope Scope
kind Kind
mu sync.Mutex
seen map[string]struct{}
}
// NewLLMExtractor 构建 LLMExtractor,当模型未另行指定时,其提取出的条目默认为
// ScopePersona + KindRelationship,并以
// personaID/roleID 为作者。角色是*默认值*,不是
// 权威:多角色调用方(运行时循环,design/22 §4.2)以空
// 角色构建提取器,并在持久化时用产出该轮对话的角色给每条条目
// 盖章,因此要构建与角色无关的提取器就传 ""。nil adapter 在
// 使用期经 ErrNilAdapter 拒绝,与运行时其余部分的
// 惰性构造模式一致;调用方可先传 nil 构建结构体、之后再接上
// adapter。
func NewLLMExtractor(adapter model.Adapter, personaID, roleID string) *LLMExtractor {
return &LLMExtractor{
adapter: adapter,
personaID: personaID,
roleID: roleID,
scope: ScopePersona,
kind: KindRelationship,
seen: make(map[string]struct{}),
}
}
// Extract 返回 adapter 从 conversation 推导出的候选记忆条目。
// nil adapter 返回 ErrNilAdapter;ctx 被取消会在 LLM 调用前
// 中止。响应按 JSON 数组解析,然后
// 去重,因此本提取器已提取过的条目会被丢弃。
func (e *LLMExtractor) Extract(ctx context.Context, conversation []model.Message) ([]MemoryItem, error) {
if e.adapter == nil {
return nil, ErrNilAdapter
}
if err := ctx.Err(); err != nil {
return nil, err
}
resp, err := e.adapter.Chat(ctx, model.Request{
Messages: buildExtractionPrompt(conversation),
Purpose: "memory_extract",
}, model.Options{})
if err != nil {
return nil, fmt.Errorf("memory: llm extract: %w", err)
}
cands, err := parseCandidates(resp.Content)
if err != nil {
return nil, err
}
return e.dedupeAndBuild(cands), nil
}
// AsyncResult 是一次 ExtractAsync 调用的结果:候选条目
// 或中止提取的错误。
type AsyncResult struct {
Items []MemoryItem
Err error
}
// ExtractAsync 把对话提交给后台 goroutine,并返回一个
// channel:提取完成时投递恰好一条 AsyncResult,随后由该 goroutine
// 关闭。nil adapter 立即返回 ErrNilAdapter。若 ctx 在 goroutine 运行前
// 被取消,结果携带取消错误。
// 由于 Extract 幂等,对同一条记忆重复异步提取是
// 安全的,绝不会双写。
func (e *LLMExtractor) ExtractAsync(ctx context.Context, conversation []model.Message) (<-chan AsyncResult, error) {
if e.adapter == nil {
return nil, ErrNilAdapter
}
out := make(chan AsyncResult, 1)
go func() {
defer close(out)
if err := ctx.Err(); err != nil {
out <- AsyncResult{Err: err}
return
}
items, err := e.Extract(ctx, conversation)
out <- AsyncResult{Items: items, Err: err}
}()
return out, nil
}
// buildExtractionPrompt 把对话转成模型请求消息:
// 系统指令,外加一条携带对话记录的用户消息。
func buildExtractionPrompt(conversation []model.Message) []model.Message {
var b strings.Builder
for _, m := range conversation {
b.WriteString(string(m.Role))
b.WriteString(": ")
b.WriteString(messageText(m))
b.WriteByte('\n')
}
return []model.Message{
model.TextMessage(model.RoleSystem, extractSystemPrompt),
model.TextMessage(model.RoleUser, b.String()),
}
}
// parseCandidates 把 adapter 的文本响应解码为候选。被围栏包裹的
// ```json 块会被剥离,模型因此可以用 markdown 包住数组。空
// 或非数组内容不产出候选;JSON 格式错误则报错,
// 由调用方决定重试还是回退。
func parseCandidates(s string) ([]llmCandidate, error) {
s = strings.TrimSpace(s)
if strings.HasPrefix(s, "```") {
s = strings.TrimPrefix(s, "```")
s = strings.TrimSuffix(s, "```")
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "json")
s = strings.TrimSpace(s)
}
if s == "" {
return nil, nil
}
if strings.HasPrefix(s, "{") {
return nil, fmt.Errorf("memory: llm candidates: expected a JSON array")
}
var cands []llmCandidate
if err := json.Unmarshal([]byte(s), &cands); err != nil {
return nil, fmt.Errorf("memory: decode llm candidates: %w", err)
}
if cands == nil {
return nil, nil
}
return cands, nil
}
// dedupeAndBuild 把合法候选转成 MemoryItem,丢弃空内容
// 以及本提取器已产出的内容(幂等去重)。它
// 要求持有 e.mu;只有合法条目才标记其去重键,因此格式错误的候选
// 不会压掉之后内容相同的合法候选。
//
// 这里不要求 persona,尽管 MemoryItem.validate 要求一个:
// 与角色无关的提取器(personaID == "")是合法的 —— 知道
// 该轮角色的调用方(循环)在持久化条目时盖上角色 —— 而
// 在此拒绝条目会静默丢掉多角色
// 进程的每一条记忆。只有本包能判断的
// content/scope/kind 仍会施加。SourceConversation 同理刻意留空:由提取调用点(持久化层)
// 盖真实 session/message id(裁决 04.4.3,见 source.go)。
func (e *LLMExtractor) dedupeAndBuild(cands []llmCandidate) []MemoryItem {
e.mu.Lock()
defer e.mu.Unlock()
out := make([]MemoryItem, 0, len(cands))
for _, c := range cands {
content := strings.TrimSpace(c.Content)
if content == "" {
continue
}
key := dedupeKey(content)
if _, ok := e.seen[key]; ok {
continue
}
it := MemoryItem{
PersonaID: e.personaID,
RoleID: e.roleID,
ProjectID: strings.TrimSpace(c.ProjectID),
Scope: Scope(normalizeScope(c.Scope, string(e.scope))),
Kind: Kind(normalizeKind(c.Kind, string(e.kind))),
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
}
e.seen[key] = struct{}{}
out = append(out, it)
}
return out
}
// dedupeKey 为幂等去重规范化内容:大小写折叠并
// 压缩空白,因此「记住 A」与「记住 a」会归并为同一条记忆。
func dedupeKey(content string) string {
return strings.Join(strings.Fields(strings.ToLower(content)), " ")
}
// normalizeScope 在 s 命名了已定义作用域时返回 s,否则返回 fallback。
func normalizeScope(s, fallback string) string {
if Scope(s).valid() {
return s
}
return fallback
}
// normalizeKind 在 s 命名了已定义 kind 时返回 s,否则返回 fallback。
func normalizeKind(s, fallback string) string {
if Kind(s).valid() {
return s
}
return fallback
}