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:
1 parent
af8591b78a
commit
0ce498754a
11 files changed
+428
-60
No files matched your search
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user