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

@@ -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)
}
}