- 04.4.2/1.9 读侧:Retrieve 候选集并集 = 本人 persona 域 ∪ user 共享域 ∪ project 共享域(他角色 persona 域仍排除),群聊/陌生人取受限收窄
- 写侧:群聊/陌生人整段跳过记忆提取(可查不可写),persona 域仅属主可写
- 04.4.3:source_conversation 不再由 LLM 自由生成(prompt 不索要、候选结构不含该键),落库时无条件盖真实 {session_id,message_id},编解码集中在 memory/source.go
- 新增跨角色种子用例(原用例即使关掉并集也会通过)+ 成对用例「同群聊上下文:读得到共享域、写不进 store」
- 同步 store.go/retrieve.go 文档注释,与 design/03 §3/§4 口径一致
241 lines
8.7 KiB
Go
241 lines
8.7 KiB
Go
package memory
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
"sync"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||
)
|
||
|
||
// extractSystemPrompt tells the model.Adapter how to produce candidate memories:
|
||
// a JSON array of objects, one per memory worth persisting. Format is locked so
|
||
// parseCandidates can unmarshal it deterministically. 溯源(source_conversation)
|
||
// 刻意不在其中:它由持久化层盖真实 session/message id,绝不由模型生成
|
||
// (裁决 04.4.3,见 source.go)。
|
||
const extractSystemPrompt = `你是记忆提取器。从对话中提取值得长期记住的记忆项(用户偏好、习惯、约定、重要事件)。
|
||
只输出一个 JSON 数组,不要任何其他文字。每个元素形如:
|
||
{"content":"记忆内容","scope":"user|persona|project","kind":"relationship|world_event","tags":["标签"],"confidence":0.9,"project_id":"可选项目id"}
|
||
去噪去重:只保留值得长期记住的,忽略寒暄与临时信息。`
|
||
|
||
// llmCandidate is the wire shape the extractor asks the LLM to emit: one JSON
|
||
// object per candidate memory. Scope/Kind default to the extractor's configured
|
||
// values when the model does not provide a valid one. 不含 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 implements Extractor by asking a model.Adapter to derive candidate
|
||
// memory items from a conversation (design §1 LLM 异步自动提取). It runs the LLM
|
||
// call synchronously in Extract and on a background goroutine in ExtractAsync.
|
||
//
|
||
// It is idempotent and safe for concurrent use: candidate content is normalized
|
||
// and deduplicated against a mutex-guarded seen set, so re-extracting the same
|
||
// memory never yields a second copy, and the same content appearing twice inside
|
||
// one conversation collapses to a single item.
|
||
type LLMExtractor struct {
|
||
adapter model.Adapter
|
||
personaID string
|
||
roleID string
|
||
scope Scope
|
||
kind Kind
|
||
|
||
mu sync.Mutex
|
||
seen map[string]struct{}
|
||
}
|
||
|
||
// NewLLMExtractor builds an LLMExtractor whose extracted items default to
|
||
// ScopePersona + KindRelationship when the model does not specify otherwise, and
|
||
// carry personaID/roleID as their author. The persona is a *default*, not the
|
||
// authority: a multi-role caller (the runtime loop, design/22 §4.2) builds the
|
||
// extractor with a blank persona and stamps each item with the role that produced
|
||
// the turn, so pass "" for a role-agnostic extractor. A nil adapter is rejected at
|
||
// use time via ErrNilAdapter, matching the lazy construction pattern of the rest
|
||
// of the runtime; callers may pass nil to build the struct and wire the adapter
|
||
// later.
|
||
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 returns the candidate memory items the adapter derives from
|
||
// conversation. A nil adapter returns ErrNilAdapter; a canceled context aborts
|
||
// before the LLM call. The response is parsed as a JSON array and then
|
||
// deduplicated, so items already extracted by this extractor are dropped.
|
||
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 is the outcome of an ExtractAsync call: either candidate items or
|
||
// the error that aborted extraction.
|
||
type AsyncResult struct {
|
||
Items []MemoryItem
|
||
Err error
|
||
}
|
||
|
||
// ExtractAsync submits the conversation to a background goroutine and returns a
|
||
// channel that delivers exactly one AsyncResult when extraction finishes, closed
|
||
// by the goroutine. A nil adapter returns ErrNilAdapter immediately. If ctx is
|
||
// canceled before the goroutine runs, the result carries the cancellation error.
|
||
// Because Extract is idempotent, repeated async extraction of the same memory is
|
||
// safe and never double-records.
|
||
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 turns the conversation into the model request messages:
|
||
// the system instruction plus one user message carrying the transcript.
|
||
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 decodes the adapter's text response into candidates. A fenced
|
||
// ```json block is unwrapped so the model may wrap its array in markdown. Blank
|
||
// or non-array content yields no candidates; malformed JSON is an error so the
|
||
// caller can decide whether to retry or fall back.
|
||
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 converts valid candidates to MemoryItems, dropping blank content
|
||
// and any content already emitted by this extractor (idempotent dedup). It
|
||
// requires e.mu; only valid items mark their dedup key so a malformed candidate
|
||
// does not suppress a later valid one with the same text.
|
||
//
|
||
// The persona is NOT required here even though MemoryItem.validate demands one: a
|
||
// role-agnostic extractor (personaID == "") is legitimate — the caller that knows
|
||
// the turn's role (the loop) stamps the persona when it persists the item — and
|
||
// rejecting the item here would silently drop every memory of a multi-role
|
||
// process. Content/scope/kind, which only this package can judge, are still
|
||
// enforced. 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 normalizes content for idempotent dedup: case-folded and whitespace-
|
||
// collapsed, so "记住 A" and "记住 a" collide into one memory.
|
||
func dedupeKey(content string) string {
|
||
return strings.Join(strings.Fields(strings.ToLower(content)), " ")
|
||
}
|
||
|
||
// normalizeScope returns s when it names a defined scope, otherwise fallback.
|
||
func normalizeScope(s, fallback string) string {
|
||
if Scope(s).valid() {
|
||
return s
|
||
}
|
||
return fallback
|
||
}
|
||
|
||
// normalizeKind returns s when it names a defined kind, otherwise fallback.
|
||
func normalizeKind(s, fallback string) string {
|
||
if Kind(s).valid() {
|
||
return s
|
||
}
|
||
return fallback
|
||
}
|