From 0ce498754a68c7483954453215f344092059bb9e Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 4 Oct 2026 12:07:44 +0800 Subject: [PATCH] =?UTF-8?q?fix(memory,loop):=20=E8=AE=B0=E5=BF=86=E4=BD=9C?= =?UTF-8?q?=E7=94=A8=E5=9F=9F=E8=AF=BB=E5=86=99=E6=88=90=E5=AF=B9=20+=20so?= =?UTF-8?q?urce=5Fconversation=20=E7=BB=93=E6=9E=84=E5=8C=96=EF=BC=88?= =?UTF-8?q?=E8=A3=81=E5=86=B3=201.9/04.4.2/04.4.3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 口径一致 --- internal/runtime/loop/memory.go | 41 ++++++--- internal/runtime/loop/memory_async_test.go | 6 ++ internal/runtime/loop/memory_gating_test.go | 89 +++++++++++++++----- internal/runtime/loop/memory_source_test.go | 67 +++++++++++++++ internal/runtime/memory/llmextractor.go | 30 ++++--- internal/runtime/memory/llmextractor_test.go | 17 +++- internal/runtime/memory/retrieve.go | 65 +++++++++++--- internal/runtime/memory/retrieve_test.go | 50 +++++++++++ internal/runtime/memory/source.go | 60 +++++++++++++ internal/runtime/memory/source_test.go | 56 ++++++++++++ internal/runtime/memory/store.go | 7 +- 11 files changed, 428 insertions(+), 60 deletions(-) create mode 100644 internal/runtime/loop/memory_source_test.go create mode 100644 internal/runtime/memory/source.go create mode 100644 internal/runtime/memory/source_test.go diff --git a/internal/runtime/loop/memory.go b/internal/runtime/loop/memory.go index 8bf564d..d86b33e 100644 --- a/internal/runtime/loop/memory.go +++ b/internal/runtime/loop/memory.go @@ -31,10 +31,11 @@ type asyncExtractor interface { // 它原样返回输入消息。 // // ctx 上的会话环境为检索设门:owner 全权限环境保留完整 -// 召回(行为不变);受限环境(群聊 / 陌生人, -// design/02 §3, design/11 §2)是只读隔离 —— 只注入 -// 非主人私有的项目记忆,使主人敏感记忆不泄露给群聊或 -// 陌生人。 +// 召回(行为不变;候选集已并入 user/project 共享域, +// design/03 §4);受限环境(群聊 / 陌生人, +// design/02 §3, design/11 §2, design/03 §5)是只读隔离 —— +// 只注入非敏感的共享记忆(用户习惯 + 项目),主人私有的 +// 角色日常记忆绝不泄露给群聊或陌生人。 func (l *Loop) injectMemory(ctx context.Context, messages []model.Message) []model.Message { block := l.memoryBlock(ctx, messages) if block == "" { @@ -66,10 +67,14 @@ func (l *Loop) memoryBlock(ctx context.Context, messages []model.Message) string } // retrieveMemoryBlock 返回 env 与该 turn 角色的记忆注入块。 -// owner 全权限环境保持既有的 Inject 行为。受限 -// 环境(群聊 / 陌生人)只注入项目作用域记忆(人人可读, -// 非主人私有),并把该块标记为群聊隔离,使调用方能区分 -// 隔离读取与主人的完整读取。 +// owner 全权限环境保持既有的 Inject 行为(候选集已并入 +// user/project 共享域)。受限环境(群聊 / 陌生人)只注入 +// 非敏感的共享域记忆 —— 用户习惯(ScopeUser)与项目记忆 +// (ScopeProject,人人可读)—— 并把该块标记为群聊隔离, +// 使调用方能区分隔离读取与主人的完整读取;主人私有的 +// 角色日常记忆(ScopePersona)属敏感项,绝不注入 +// (design/03 §5「不泄露主人敏感信息」+ 主人 2026-10-03 +// 裁决「用户习惯记忆与不敏感数据:可查、不可写」)。 func (l *Loop) retrieveMemoryBlock(ctx context.Context, env SessionEnv, personaID, query string) (string, error) { if !env.Restricted() { return l.retriever.Inject(ctx, personaID, query, 0) @@ -80,7 +85,7 @@ func (l *Loop) retrieveMemoryBlock(ctx context.Context, env SessionEnv, personaI } shared := make([]memory.Result, 0, len(results)) for _, r := range results { - if r.Item.Scope == memory.ScopeProject { + if sharedScope(r.Item.Scope) { shared = append(shared, r) } } @@ -90,6 +95,14 @@ func (l *Loop) retrieveMemoryBlock(ctx context.Context, env SessionEnv, personaI return formatMemoryBlock(shared, "群聊隔离"), nil } +// sharedScope 报告 scope 是否为群聊/陌生人可查的非敏感共享域: +// 用户习惯(user,全局共享)与项目记忆(project,人人可读)。 +// 角色日常(persona)是主人私有的敏感项,不在其列 +// (design/03 §3/§5 的敏感度划分即作用域本身)。 +func sharedScope(s memory.Scope) bool { + return s == memory.ScopeUser || s == memory.ScopeProject +} + // formatMemoryBlock 由检索结果构建前导系统块。非空 // marker 作为该块的前缀,使隔离的群聊读取可与 // 主人的完整读取区分开。 @@ -257,14 +270,22 @@ func (l *Loop) extractMemoryAsync(ctx context.Context, messages []model.Message, // extractor 刻意不感知角色(它不再持有一个在构造期 // 内嵌的人格,cmd/cyrene/extractor.go),否则多角色 // 进程会把每条记忆都归到一个角色下。角色为空(完全未配置 -// persona)时条目保持 extractor 产出时的样子。store +// persona)时条目保持 extractor 产出时的样子。 +// +// 溯源同样在此盖真实 id(裁决 04.4.3,design/03 §2): +// source_conversation 由本持久化点从运行上下文取 Run 的 +// session id 与 gateway 挂载的 turn id 结构化编码 +// (memory.FormatSourceConversation),无条件覆盖 extractor +// 产出值 —— 模型自由文本绝无机会落库为来源。store // 失败会被记录日志,绝不致命,因此一条被拒的条目无法中止一个 turn。 func (l *Loop) persistMemories(ctx context.Context, items []memory.MemoryItem, step int) { personaID := l.personaFor(ctx) + source := memory.FormatSourceConversation(loopSession(ctx), loopTurn(ctx)) for _, it := range items { if personaID != "" { it.PersonaID = personaID } + it.SourceConversation = source if _, err := l.memory.Add(ctx, it); err != nil { l.log.Warn("memory store", "step", step, "error", err) } diff --git a/internal/runtime/loop/memory_async_test.go b/internal/runtime/loop/memory_async_test.go index c854dd2..2652ab8 100644 --- a/internal/runtime/loop/memory_async_test.go +++ b/internal/runtime/loop/memory_async_test.go @@ -100,6 +100,12 @@ func TestExtractMemoryAsync_LLMExtractorThroughLoop(t *testing.T) { if items[0].PersonaID != "persona-a" { t.Fatalf("item persona = %q, want persona-a", items[0].PersonaID) } + // 裁决 04.4.3:真实 LLM 提取路径的溯源由持久化层盖 —— Run 的 + // 会话 id 结构化落库(该测试未挂 turn id,故 message_id 为空)。 + ref, ok := memory.ParseSourceConversation(items[0].SourceConversation) + if !ok || ref.SessionID != "sess" || ref.MessageID != "" { + t.Fatalf("source = %q → %+v(ok=%v), want structured session sess / empty message", items[0].SourceConversation, ref, ok) + } extractCalls := 0 for _, req := range ad.reqs { diff --git a/internal/runtime/loop/memory_gating_test.go b/internal/runtime/loop/memory_gating_test.go index 8bd24aa..573bac3 100644 --- a/internal/runtime/loop/memory_gating_test.go +++ b/internal/runtime/loop/memory_gating_test.go @@ -27,6 +27,25 @@ func seedMemories(t *testing.T, store *memory.Store) { } } +// seedCrossRoleMemories 写入跨角色的共享域与主人私有记忆各一批: +// 另一个角色(persona-b)写用户习惯 + 项目记忆(共享域,design/03 §3/§4 +// 对所有角色可读),persona-a 写自己的角色日常(敏感项,仅属主可读)。 +// 用于验证读取域的并集真的含他角色写入的共享项,且敏感项不外泄 +// (裁决 1.9/04.4.2)。 +func seedCrossRoleMemories(t *testing.T, store *memory.Store) { + t.Helper() + seeds := []memory.MemoryItem{ + {PersonaID: "persona-b", Content: "用户习惯:喜欢冰美式", Scope: memory.ScopeUser, Kind: memory.KindRelationship}, + {PersonaID: "persona-b", Content: "项目记忆:Cyrene 用 Go 编写", Scope: memory.ScopeProject, Kind: memory.KindWorldEvent}, + {PersonaID: "persona-a", Content: "角色日常:周末喜欢爬山", Scope: memory.ScopePersona, Kind: memory.KindRelationship}, + } + for _, it := range seeds { + if _, err := store.Add(context.Background(), it); err != nil { + t.Fatalf("seed memory: %v", err) + } + } +} + // TestInjectMemory_OwnerEnv_InjectsAllScopes 证明 owner 全权限环境保持 // 既有的完整召回:用户、角色与项目记忆都被注入。 func TestInjectMemory_OwnerEnv_InjectsAllScopes(t *testing.T) { @@ -49,41 +68,73 @@ func TestInjectMemory_OwnerEnv_InjectsAllScopes(t *testing.T) { } } -// TestInjectMemory_RestrictedEnv_OnlyProject 证明群聊/陌生人环境是 -// 只读隔离:只注入非主人私有的项目记忆, -// 主人的用户/角色记忆被排除,且该块被标记为 群聊隔离。 -func TestInjectMemory_RestrictedEnv_OnlyProject(t *testing.T) { +// TestInjectMemory_RestrictedEnv_NonSensitiveOnly 证明群聊/陌生人环境是 +// 只读的非敏感召回(裁决 1.9/04.4.2,design/03 §3/§4/§5):共享域由 +// **别的角色**(persona-b)写入,本角色(persona-a)的群聊上下文依然 +// 能查到用户习惯与项目记忆 —— 这正是读取域并集生效之处;主人私有的 +// 角色日常记忆(ScopePersona,敏感项)被排除,且该块被标记为 群聊隔离。 +func TestInjectMemory_RestrictedEnv_NonSensitiveOnly(t *testing.T) { t.Parallel() store := newTestMemoryStore(t) - seedMemories(t, store) + seedCrossRoleMemories(t, store) l := New(Options{Retriever: memory.NewRetriever(store), PersonaID: "persona-a"}) msgs := []model.Message{model.TextMessage(model.RoleUser, "go")} ctx := WithSessionEnv(context.Background(), SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false}) view := l.assembleContext(ctx, msgs, 1) if len(view) != 2 { - t.Fatalf("restricted view = %d messages, want 2 (project memory injected)", len(view)) + t.Fatalf("restricted view = %d messages, want 2 (non-sensitive memory injected)", len(view)) } block := messageText(view[0]) - if !strings.Contains(block, "群聊隔离") || !strings.Contains(block, "Cyrene 用 Go 编写") { - t.Fatalf("restricted block missing project memory / marker:\n%s", block) + // 非敏感项:他角色写的用户习惯 + 项目记忆 —— 可查(design/03 §3/§5)。 + if !strings.Contains(block, "群聊隔离") || !strings.Contains(block, "喜欢冰美式") || !strings.Contains(block, "Cyrene 用 Go 编写") { + t.Fatalf("restricted block missing non-sensitive memory / marker:\n%s", block) } - for _, banned := range []string{"喜欢冰美式", "喜欢爬山"} { - if strings.Contains(block, banned) { - t.Fatalf("restricted block leaked owner memory %q:\n%s", banned, block) - } + // 敏感项:本角色私有的角色日常 —— 绝不注入。 + if strings.Contains(block, "喜欢爬山") { + t.Fatalf("restricted block leaked owner-sensitive memory %q:\n%s", "喜欢爬山", block) } } -// TestInjectMemory_Restricted_NoProjectMemory_NoInject 证明当群聊 -// 或陌生人上下文没有非主人私有(项目)记忆时,什么也不 -// 注入 —— 该对话不会用主人的上下文来播种。 -func TestInjectMemory_Restricted_NoProjectMemory_NoInject(t *testing.T) { +// TestGroupChat_SharedReadable_WriteRejected 证明裁决 1.9/04.4.2 的 +// 「可查不可写」在同一群聊上下文里成对生效:共享域记忆被注入(可查), +// 而同一次提取的写入被拒(store 一行都不新增)。 +func TestGroupChat_SharedReadable_WriteRejected(t *testing.T) { t.Parallel() store := newTestMemoryStore(t) - // 只存在主人私有(用户/角色)记忆。 + seedCrossRoleMemories(t, store) + l := New(Options{ + Retriever: memory.NewRetriever(store), + Memory: store, + Extractor: memory.NewManualExtractor("persona-a", "owner"), + PersonaID: "persona-a", + }) + + ctx := WithSessionEnv(context.Background(), SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false}) + block := l.memoryBlock(ctx, []model.Message{model.TextMessage(model.RoleUser, "go")}) + if !strings.Contains(block, "群聊隔离") || !strings.Contains(block, "喜欢冰美式") { + t.Fatalf("group chat must read the shared domain:\n%s", block) + } + + l.extractMemory(ctx, []model.Message{model.TextMessage(model.RoleUser, "记住 我真的爱喝冰美式")}, 1) + items, err := store.List(context.Background()) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(items) != 3 { + t.Fatalf("group chat wrote memory: %d rows, want 3 (writes must be rejected)", len(items)) + } +} + +// TestInjectMemory_Restricted_OnlySensitiveMemory_NoInject 证明当群聊 +// 或陌生人上下文只有主人私有(角色日常)记忆时,什么也不 +// 注入 —— 该对话不会用主人的敏感上下文来播种。 +func TestInjectMemory_Restricted_OnlySensitiveMemory_NoInject(t *testing.T) { + t.Parallel() + store := newTestMemoryStore(t) + // 只存在主人私有(角色日常)记忆。 for _, it := range []memory.MemoryItem{ - {PersonaID: "persona-a", Content: "用户习惯:喜欢冰美式", Scope: memory.ScopeUser, Kind: memory.KindRelationship}, + {PersonaID: "persona-a", Content: "角色日常:周末喜欢爬山", Scope: memory.ScopePersona, Kind: memory.KindRelationship}, } { if _, err := store.Add(context.Background(), it); err != nil { t.Fatalf("seed memory: %v", err) @@ -95,7 +146,7 @@ func TestInjectMemory_Restricted_NoProjectMemory_NoInject(t *testing.T) { ctx := WithSessionEnv(context.Background(), SessionEnv{ActorEnv: restrict.ActorStranger, MemWritable: false}) view := l.assembleContext(ctx, msgs, 1) if len(view) != 1 { - t.Fatalf("restricted (no project) view = %d messages, want 1 (no injected memory)", len(view)) + t.Fatalf("restricted (only sensitive) view = %d messages, want 1 (no injected memory)", len(view)) } } diff --git a/internal/runtime/loop/memory_source_test.go b/internal/runtime/loop/memory_source_test.go new file mode 100644 index 0000000..affe404 --- /dev/null +++ b/internal/runtime/loop/memory_source_test.go @@ -0,0 +1,67 @@ +package loop + +import ( + "context" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" +) + +// TestPersistMemories_StampsRealSource 证明裁决 04.4.3 的落库值: +// 提取调用点(persistMemories)从运行上下文盖真实 session/message id, +// extractor 产出的自由文本来源被无条件覆盖。 +func TestPersistMemories_StampsRealSource(t *testing.T) { + t.Parallel() + store := newTestMemoryStore(t) + l := New(Options{Memory: store, Extractor: memory.NewManualExtractor("persona-a", "owner"), PersonaID: "persona-a"}) + + // extractor 刻意夹带一条伪造来源,证明持久化层会盖掉它。 + bogus := []memory.MemoryItem{{ + PersonaID: "persona-a", Scope: memory.ScopeUser, Kind: memory.KindRelationship, + Content: "用户习惯:爱喝冰美式", SourceConversation: "模型瞎编的来源", + }} + ctx := withLoopSession(WithTurnID(WithSessionEnv(context.Background(), OwnerEnv()), "turn-1"), "web:abc") + l.persistMemories(ctx, bogus, 1) + + items, err := store.ListByPersona(context.Background(), "persona-a") + if err != nil { + t.Fatalf("ListByPersona: %v", err) + } + if len(items) != 1 { + t.Fatalf("stored %d items, want 1", len(items)) + } + ref, ok := memory.ParseSourceConversation(items[0].SourceConversation) + if !ok { + t.Fatalf("SourceConversation %q is not structured, want real ids", items[0].SourceConversation) + } + if ref.SessionID != "web:abc" || ref.MessageID != "turn-1" { + t.Fatalf("source ref = %+v, want session web:abc / message turn-1", ref) + } +} + +// TestPersistMemories_SourceSessionOnly 证明未挂 turn id 的调用方 +// (渠道路径现状)溯源降级为仅会话粒度 —— 真实、可解析,不伪造。 +func TestPersistMemories_SourceSessionOnly(t *testing.T) { + t.Parallel() + store := newTestMemoryStore(t) + l := New(Options{Memory: store, Extractor: memory.NewManualExtractor("persona-a", "owner"), PersonaID: "persona-a"}) + + ctx := withLoopSession(WithSessionEnv(context.Background(), OwnerEnv()), "qq-obv11:group:42") + l.extractMemory(ctx, []model.Message{model.TextMessage(model.RoleUser, "记住 我爱喝冰美式")}, 1) + + items, err := store.ListByPersona(context.Background(), "persona-a") + if err != nil { + t.Fatalf("ListByPersona: %v", err) + } + if len(items) != 1 { + t.Fatalf("stored %d items, want 1", len(items)) + } + ref, ok := memory.ParseSourceConversation(items[0].SourceConversation) + if !ok { + t.Fatalf("SourceConversation %q is not structured", items[0].SourceConversation) + } + if ref.SessionID != "qq-obv11:group:42" || ref.MessageID != "" { + t.Fatalf("source ref = %+v, want session-only provenance", ref) + } +} diff --git a/internal/runtime/memory/llmextractor.go b/internal/runtime/memory/llmextractor.go index e482d71..0542fc5 100644 --- a/internal/runtime/memory/llmextractor.go +++ b/internal/runtime/memory/llmextractor.go @@ -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 diff --git a/internal/runtime/memory/llmextractor_test.go b/internal/runtime/memory/llmextractor_test.go index c7bfab1..7eff43c 100644 --- a/internal/runtime/memory/llmextractor_test.go +++ b/internal/runtime/memory/llmextractor_test.go @@ -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 diff --git a/internal/runtime/memory/retrieve.go b/internal/runtime/memory/retrieve.go index bd7ddae..97c0381 100644 --- a/internal/runtime/memory/retrieve.go +++ b/internal/runtime/memory/retrieve.go @@ -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 diff --git a/internal/runtime/memory/retrieve_test.go b/internal/runtime/memory/retrieve_test.go index 4332a22..4a8b225 100644 --- a/internal/runtime/memory/retrieve_test.go +++ b/internal/runtime/memory/retrieve_test.go @@ -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() diff --git a/internal/runtime/memory/source.go b/internal/runtime/memory/source.go new file mode 100644 index 0000000..2c730bf --- /dev/null +++ b/internal/runtime/memory/source.go @@ -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 +} diff --git a/internal/runtime/memory/source_test.go b/internal/runtime/memory/source_test.go new file mode 100644 index 0000000..72d4fea --- /dev/null +++ b/internal/runtime/memory/source_test.go @@ -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) + } + } +} diff --git a/internal/runtime/memory/store.go b/internal/runtime/memory/store.go index b2e29b1..2809050 100644 --- a/internal/runtime/memory/store.go +++ b/internal/runtime/memory/store.go @@ -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