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

+31 -10
View File
@@ -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)
}
@@ -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 {
+70 -19
View File
@@ -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))
}
}
@@ -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)
}
}