背景(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 的用例。
301 lines
13 KiB
Go
301 lines
13 KiB
Go
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
|
||
}
|