Files
Cyrene_v2/internal/runtime/memory/llmextractor.go
T
AskaEth 21b906ffb0 feat(memory): D3 第五态 defer(入库审查)+ 提取留存规则闸 + 临时 TTL 配置面
主人原话:「收窄提取器,确实要给他一个主动式的记忆留存规则,不能想到什么都
往记忆里写」「.env 只做兜底用 …这些键优先从面板配置上取」「临时记忆到期删除」。
按 design/23 §18(D3 第五态 defer)、§19(到期处置)、design/03 §9.6
(tempTtlSec 默认 7 天 + 坏值口径)、§10(提取留存规则)落地。

- IngestDecision 闭集加第五态 defer:照常 active 落库 + 写短 expires_at 落
  临时层(不删行、不改正文,回滚 = 清空该列);IngestRuleVersion d3.2 → d3.3。
- 落点:ingestGatePlan 增 ExpiresAt + expiresAt()(走 expiryText/formatTime 的
  定宽 30 字节 UTC,与读侧时效闸同一套文本口径),由 finishIngest 在 defer 一态
  补写 expires_at(INSERT 与补写之间的短暂窗口如实登记,方向 = 宁可多留)。
  非 defer 三态一笔都不多写。
- T0 提取留存规则闸(extract_retention.go):design/03 §10.5 的 N1~N5 纯规则,
  接进 judgeIngest 的候选比对之前,命中即 defer + reason=t0_low_value,不进 T1;
  两道护栏(显式「记住」/ 持久信号)一票放行,fail-safe 方向 = 宁可多留。
- T1 闭集扩为 merge/modify/keep/defer(同一个 prompt、同一次调用,绝不新增
  第二次模型调用):只有关系判为真新时才问价值;defer 要求 target=0,否则判
  非法回落 create(reason=t1_reply_invalid)。
- 提取器提示词加固(design/03 §10.3 B):正向清单 + 反例清单 + 「允许返回 []」。
- 临时记忆 TTL:代码默认 tempTtlSec = 604800(7 天);面板
  $DATA_DIR/config/memory-tidy.json 的 tempTtlSec 优先(ApplyReviewTuning 逐键
  解析,坏值回落默认 + WARN,绝不写进 cfg)。该键在 .env 里没有出处且不新增
  (.env 已冻结)⇒ 实际生效链是「面板 > 代码默认」。启动播种同步该键(*int +
  omitempty,nil 省略、绝不写 null),保证播种文件与生效值逐字一致。
- preinsert_store.go 的 listReviewCandidatesSQL 原位注释:这条内部召回 SQL
  刻意不过时效闸的裁定与理由(design/03 §11.2 第 3 条)。
- 测试:defer 落库带短 expires_at(闸 + T1 两条路径)、写入口径与时效闸同源、
  TTL 坏值与回落、规则版本 d3.3、整理候选幂等键不含 IngestRuleVersion、
  提取规则正例 11 条 / 反例 11 条。

验收:go build ./... + go vet ./... + go test ./... 全绿;windows/darwin 交叉
编译通过(GOOS=darwin GOARCH=arm64 / GOOS=windows GOARCH=amd64)。
2026-10-13 20:22:48 +08:00

274 lines
10 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 []」)—— 不写这一句,模型会为了「有产出」而硬凑。
const extractSystemPrompt = `你是记忆提取器。从对话中提取**值得长期记住**的记忆项。
只保留这些(正向清单):
- 长期事实:身份、住址、职业、家庭成员、健康约束(过敏、忌口、长期用药)。
- 稳定偏好:口味、作息、喜欢什么、讨厌什么。
- 关系里程碑:约定、承诺、共同经历、纪念日、称呼习惯。
- 项目约定:编号规则、暗号、路径、命名约定。
- 能力边界:主人明确说过的「不要做 X」。
下面这些**不要提取**(反例清单):
- 寒暄与致谢:「谢谢」「早上好」「辛苦啦」「晚安」。
- 情绪波动:「今天好累」「心情不错」。
- 一次性琐事与任务指令:「帮我订个 7 点的闹钟」「把这个文件转成 pdf」。
- 时效性数据:当前时间、天气、「现在几点了」。
- 助手自己说过的话 —— 只提取主人这边的长期事实与偏好。
- 不确定的推断(「他可能喜欢 X」):拿不准就不要提取。
**允许返回空数组 []**:一轮对话里没有值得长期记住的内容时,正确做法就是回
[],不要为了凑数而提取。
只输出一个 JSON 数组,不要任何其他文字。每个元素形如:
{"content":"记忆内容","scope":"user|persona|project","kind":"relationship|world_event","tags":["标签"],"confidence":0.9,"project_id":"可选项目id"}
去噪去重:只保留值得长期记住的,忽略寒暄与临时信息。`
// llmCandidate 是提取器要求 LLM 输出的线上形状:每条候选
// 记忆一个 JSON 对象。模型未给出合法值时,Scope/Kind 取提取器配置的
// 默认值。不含 source_conversation:
// 模型即使回填该键也会被忽略(结构化溯源只来自持久化层)。
type llmCandidate struct {
Content string `json:"content"`
Scope string `json:"scope"`
Kind string `json:"kind"`
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,
}
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
}