fix(memory,loop): 记忆作用域读写成对 + source_conversation 结构化(裁决 1.9/04.4.2/04.4.3)

- 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 口径一致
This commit is contained in:
AskaEth committed 2026-10-04 12:07:44 +08:00
1 parent af8591b78a
commit 0ce498754a
11 files changed
+428 -60

No files matched your search

+16 -14
View File
@@ -12,15 +12,18 @@ import (
// 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.
// 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","source_conversation":"溯源"}
{"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.
// values when the model does not provide a valid one. 不含 source_conversation:
// 模型即使回填该键也会被忽略(结构化溯源只来自持久化层)。
type llmCandidate struct {
Content string `json:"content"`
Scope string `json:"scope"`
@@ -28,7 +31,6 @@ type llmCandidate struct {
Tags []string `json:"tags"`
Confidence float64 `json:"confidence"`
ProjectID string `json:"project_id"`
Source string `json:"source_conversation"`
}
// LLMExtractor implements Extractor by asking a model.Adapter to derive candidate
@@ -180,7 +182,8 @@ func parseCandidates(s string) ([]llmCandidate, error) {
// 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.
// enforced. SourceConversation 同理刻意留空:由提取调用点(持久化层)
// 盖真实 session/message id(裁决 04.4.3,见 source.go)。
func (e *LLMExtractor) dedupeAndBuild(cands []llmCandidate) []MemoryItem {
e.mu.Lock()
defer e.mu.Unlock()
@@ -196,15 +199,14 @@ func (e *LLMExtractor) dedupeAndBuild(cands []llmCandidate) []MemoryItem {
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,
SourceConversation: strings.TrimSpace(c.Source),
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
+15 -2
View File
@@ -3,6 +3,7 @@ package memory
import (
"context"
"errors"
"strings"
"testing"
"time"
@@ -51,8 +52,10 @@ func TestLLMExtractor_Extract_Parse_正常(t *testing.T) {
if items[0].Content != "用户喜欢冰美式" || items[0].Scope != ScopeUser || items[0].Confidence != 0.9 {
t.Errorf("items[0] = %+v, want parsed user item", items[0])
}
if items[0].SourceConversation != "conv.1" {
t.Errorf("items[0].SourceConversation = %q, want conv.1", items[0].SourceConversation)
// 裁决 04.4.3:模型回填的 source_conversation 被忽略 —— 提取器
// 落空溯源,真实 id 由持久化层(loop.persistMemories)盖。
if items[0].SourceConversation != "" {
t.Errorf("items[0].SourceConversation = %q, want empty (source is stamped by the persistence layer, never the model)", items[0].SourceConversation)
}
if items[1].ProjectID != "project.alpha" || items[1].Scope != ScopeProject {
t.Errorf("items[1] = %+v, want project-scoped item", items[1])
@@ -84,6 +87,16 @@ func TestLLMExtractor_Extract_InvalidScopeKind_Fallback_正常(t *testing.T) {
}
}
// TestLLMExtractor_Prompt_ExcludesSource 锁死提取契约(裁决 04.4.3):
// 提取 prompt 不得向模型索要 source_conversation —— 溯源只由持久化层
// 盖真实 session/message id,模型自由文本不是来源。
func TestLLMExtractor_Prompt_ExcludesSource(t *testing.T) {
t.Parallel()
if strings.Contains(extractSystemPrompt, "source_conversation") {
t.Fatal("extract prompt asks the model for source_conversation: provenance must come from the persistence layer, never the model")
}
}
func TestLLMExtractor_Extract_Dedup_正常(t *testing.T) {
t.Parallel()
// The model emits the same memory three times, differing only in case and
+53 -12
View File
@@ -42,8 +42,10 @@ type Result struct {
Item MemoryItem
Score float64
// SourceConversation is the conversation the item was extracted from, for
// 溯源 (design §2). It mirrors Item.SourceConversation so callers can trace a
// hit without reaching into Item.
// 溯源 (design §2). 裁决 04.4.3 起为结构化 JSON(session_id/message_id,
// 见 ParseSourceConversation),由持久化层盖真实 id。It mirrors
// Item.SourceConversation so callers can trace a hit without reaching into
// Item.
SourceConversation string
// Project is the linked project-index entry (设计 §3 项目分层), populated only
// when the retriever is wired with a project-index store and the item is a
@@ -52,11 +54,13 @@ type Result struct {
Project *projectindex.Project
}
// Retriever ranks a persona's long-term memories by relevance (keyword and,
// when an embedder or vector index is configured, semantic vectors) weighted by
// decay (decay_weight × time decay), then returns the top K. It is stateless and
// safe for concurrent use; the ranking is recomputed from the store on every
// call, so results always reflect persisted state.
// Retriever ranks the long-term memories visible to a persona — its own items
// plus the shared user/project scopes (design §3/§4, see visibleCandidates) —
// by relevance (keyword and, when an embedder or vector index is configured,
// semantic vectors) weighted by decay (decay_weight × time decay), then returns
// the top K. It is stateless and safe for concurrent use; the ranking is
// recomputed from the store on every call, so results always reflect persisted
// state.
type Retriever struct {
store *Store
embedder embedding.Embedder
@@ -149,16 +153,20 @@ func NewRetriever(store *Store, opts ...RetrieverOption) *Retriever {
// Retrieve returns up to topK items for personaID that best match query, ranked
// by relevance (keyword + optional semantic) weighted by decay (decay_weight ×
// time decay). Query and candidate contents are both read under ctx; a canceled
// context aborts the work. A blank query returns the most recently used items
// (decay order only).
// time decay). 候选集是 design/03 §3/§4 的可见域并集:该角色自己的条目
// (ListByPersona)∪ 全局共享的用户习惯域(ScopeUser)∪ 人人可读的
// 项目域(ScopeProject),按 id 去重。他角色的角色日常记忆(ScopePersona)
// 绝不进入候选集 —— 跨角色读只走「把 B 当子代理去查 B」的派发机制
// (design/22 §9),直接检索不越权。Query and candidate contents are both
// read under ctx; a canceled context aborts the work. A blank query returns the
// most recently used items (decay order only).
func (r *Retriever) Retrieve(ctx context.Context, personaID, query string, topK int) ([]Result, error) {
if topK <= 0 {
topK = defaultTopK
}
items, err := r.store.ListByPersona(ctx, personaID)
items, err := r.visibleCandidates(ctx, personaID)
if err != nil {
return nil, fmt.Errorf("memory: retrieve list: %w", err)
return nil, err
}
if len(items) == 0 {
return []Result{}, nil
@@ -214,6 +222,39 @@ func (r *Retriever) Retrieve(ctx context.Context, personaID, query string, topK
return results, nil
}
// visibleCandidates 载入 personaID 可见的候选记忆并集(design/03
// §3/§4):本人条目(ListByPersona)∪ ScopeUser 全局共享域 ∪
// ScopeProject 人人可读域,按 id 去重。本人条目先入集,因此去重
// 保留的是同一行的唯一实例;他角色的 ScopePersona 条目(角色
// 日常)不在其列 —— 跨角色读走子代理派发(design/22 §9),
// 直接检索不得越权。任一载入失败都整体报错,绝不静默丢弃
// 某个共享域。
func (r *Retriever) visibleCandidates(ctx context.Context, personaID string) ([]MemoryItem, error) {
own, err := r.store.ListByPersona(ctx, personaID)
if err != nil {
return nil, fmt.Errorf("memory: retrieve list: %w", err)
}
seen := make(map[string]struct{}, len(own))
for _, it := range own {
seen[it.ID] = struct{}{}
}
items := own
for _, scope := range []Scope{ScopeUser, ScopeProject} {
shared, err := r.store.ListScope(ctx, scope, personaID)
if err != nil {
return nil, fmt.Errorf("memory: retrieve list %s scope: %w", scope, err)
}
for _, it := range shared {
if _, dup := seen[it.ID]; dup {
continue
}
seen[it.ID] = struct{}{}
items = append(items, it)
}
}
return items, nil
}
// keywordScores scores each item against query with an in-memory TF-IDF inverted
// index (embedding.Index), reusing the embedding package's tokenizer. Scores are
// normalized to [0,1] by the maximum so they are comparable with the semantic
+50
View File
@@ -178,6 +178,56 @@ func TestRetriever_Retrieve_EmptyPersona_正常(t *testing.T) {
}
}
func TestRetriever_Retrieve_SharedScopesUnion_共享域可读(t *testing.T) {
t.Parallel()
ctx := context.Background()
s := newTestStore(t)
// persona.a 自己的条目 + persona.b 写入三个作用域各一条。
addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "a 自己的咖啡记忆", Tags: []string{"咖啡"},
Scope: ScopePersona, Kind: KindRelationship,
})
addItem(t, s, MemoryItem{
PersonaID: "persona.b", Content: "用户习惯:喜欢手冲咖啡", Tags: []string{"咖啡"},
Scope: ScopeUser, Kind: KindRelationship,
})
addItem(t, s, MemoryItem{
PersonaID: "persona.b", Content: "项目记忆:咖啡项目用 Go 编写", Tags: []string{"咖啡"},
Scope: ScopeProject, Kind: KindWorldEvent,
})
addItem(t, s, MemoryItem{
PersonaID: "persona.b", Content: "b 的角色日常:周末爬山", Tags: []string{"咖啡"},
Scope: ScopePersona, Kind: KindRelationship,
})
r := NewRetriever(s)
results, err := r.Retrieve(ctx, "persona.a", "咖啡", 10)
if err != nil {
t.Fatalf("Retrieve: %v", err)
}
seen := map[string]bool{}
for _, res := range results {
seen[res.Item.PersonaID+"/"+string(res.Item.Scope)] = true
}
// 可见:自己的条目 + user/project 共享域(对所有角色可读,design/03 §3/§4)。
for _, want := range []string{"persona.a/persona", "persona.b/user", "persona.b/project"} {
if !seen[want] {
t.Fatalf("candidate set missing %s (shared scopes must be unioned in): %+v", want, seen)
}
}
// 不可见:他角色的角色日常 —— 直接检索不越权,跨角色读只走
// 子代理派发(design/03 §4,design/22 §9)。
if seen["persona.b/persona"] {
t.Fatal("candidate set includes persona.b's persona-scope memory: cross-role daily memory must not be directly retrievable")
}
// 并集不得重复计入同一行。
if len(results) != 3 {
t.Fatalf("got %d results, want 3 (own + shared user + shared project, no dupes)", len(results))
}
}
func TestRetriever_Inject_Format_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
+60
View File
@@ -0,0 +1,60 @@
package memory
import (
"encoding/json"
"strings"
)
// SourceRef 是 source_conversation 落库值的结构化形状(裁决
// 04.4.3,design/03 §2 溯源):一条记忆由哪次会话的哪条消息
// 提取而来。两个 id 都由持久化层(提取调用点)从运行上下文
// 盖真实值,绝不由模型生成,因此「可回溯到哪次对话」是
// 机器可验证的,而不是 LLM 自由文本。
type SourceRef struct {
// SessionID 是提取发生时的会话 id(Run 的 sessionID:Web
// 主/次级会话或渠道会话)。
SessionID string `json:"session_id"`
// MessageID 是触发提取的消息标识:gateway 在 Run 前挂载的
// turn id;调用方未挂载时为空,此时溯源只到会话粒度。
MessageID string `json:"message_id"`
}
// FormatSourceConversation 把真实 session/message id 编码为
// source_conversation 的落库值:固定键序的紧凑 JSON,可被
// ParseSourceConversation 解回。两个 id 都为空时返回 ""(无
// 溯源)。纯字符串字段结构的 Marshal 不会失败;防御性兜底
// 返回 ""。
func FormatSourceConversation(sessionID, messageID string) string {
ref := SourceRef{
SessionID: strings.TrimSpace(sessionID),
MessageID: strings.TrimSpace(messageID),
}
if ref.SessionID == "" && ref.MessageID == "" {
return ""
}
b, err := json.Marshal(ref)
if err != nil {
return ""
}
return string(b)
}
// ParseSourceConversation 解码落库的 source_conversation。ok 为
// false 表示该值不是结构化溯源 —— 裁决前由 LLM 自由文本填写
// 的遗留行会落在这里 —— 调用方据此区分可机器回溯的行与
// 遗留行,而不是把自由文本误当真实 id。解出的两个字段都为
// 空同样返回 false:没有真实 id 的行不构成溯源。
func ParseSourceConversation(s string) (SourceRef, bool) {
s = strings.TrimSpace(s)
if !strings.HasPrefix(s, "{") {
return SourceRef{}, false
}
var ref SourceRef
if err := json.Unmarshal([]byte(s), &ref); err != nil {
return SourceRef{}, false
}
if ref.SessionID == "" && ref.MessageID == "" {
return SourceRef{}, false
}
return ref, true
}
+56
View File
@@ -0,0 +1,56 @@
package memory
import "testing"
// TestSourceConversation_Roundtrip 证明溯源编码是机器可回溯的:
// Format 后 Parse 得回同一对真实 id(裁决 04.4.3,design/03 §2)。
func TestSourceConversation_Roundtrip(t *testing.T) {
t.Parallel()
raw := FormatSourceConversation("web:abc", "turn-1")
ref, ok := ParseSourceConversation(raw)
if !ok {
t.Fatalf("ParseSourceConversation(%q) ok = false, want structured", raw)
}
if ref.SessionID != "web:abc" || ref.MessageID != "turn-1" {
t.Fatalf("parsed ref = %+v, want session web:abc / message turn-1", ref)
}
}
// TestSourceConversation_BothBlank 证明两个 id 都缺时不落伪溯源
// (空串而不是空 JSON 对象)。
func TestSourceConversation_BothBlank(t *testing.T) {
t.Parallel()
if got := FormatSourceConversation("", " "); got != "" {
t.Fatalf("FormatSourceConversation(blank) = %q, want empty", got)
}
if _, ok := ParseSourceConversation(""); ok {
t.Fatal("ParseSourceConversation(\"\") ok = true, want false")
}
}
// TestSourceConversation_SessionOnly 证明只知会话粒度时溯源降级为
// 仅 session_id(渠道路径未挂 turn id 时的真实形态)。
func TestSourceConversation_SessionOnly(t *testing.T) {
t.Parallel()
raw := FormatSourceConversation("qq-obv11:group:123", "")
ref, ok := ParseSourceConversation(raw)
if !ok {
t.Fatalf("ParseSourceConversation(%q) ok = false, want structured", raw)
}
if ref.SessionID != "qq-obv11:group:123" || ref.MessageID != "" {
t.Fatalf("parsed ref = %+v, want session-only provenance", ref)
}
}
// TestSourceConversation_LegacyFreeTextNotStructured 证明裁决前的
// LLM 自由文本来源解不回结构化形状 —— 调用方靠 ok=false 区分
// 遗留行与真实溯源,而不是把自由文本误当 id。
func TestSourceConversation_LegacyFreeTextNotStructured(t *testing.T) {
t.Parallel()
for _, legacy := range []string{"conv.1", "昨天聊咖啡时提到的", `["session"]`, `{"other":1}`} {
if _, ok := ParseSourceConversation(legacy); ok {
t.Fatalf("ParseSourceConversation(%q) ok = true, want false for legacy free text", legacy)
}
}
}
+4 -3
View File
@@ -234,9 +234,10 @@ func (s *Store) Get(ctx context.Context, id string) (MemoryItem, error) {
// ListByPersona returns every item for personaID, newest first. rowid breaks
// created_at ties so items written within the same clock tick keep a stable
// newest-first order. It is the persona-isolation boundary: items belonging to
// other personas are never returned, so personas never share memory (design
// §4). Use ListScope when a caller needs to see shared user/project scopes or
// another persona's read-only items.
// other personas are never returned, so personas never share their own 角色日常
// namespace (design §4). It is only the *own* half of the retrieval candidate
// set — the retriever unions it with the shared user/project scopes before
// ranking (design §3/§4, see Retriever.visibleCandidates).
func (s *Store) ListByPersona(ctx context.Context, personaID string) ([]MemoryItem, error) {
if strings.TrimSpace(personaID) == "" {
return nil, ErrMissingPersona