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 }