Files
Cyrene_v2/internal/runtime/memory/retrieve_test.go
T
AskaEth 0ce498754a 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 口径一致
2026-10-04 12:07:44 +08:00

268 lines
7.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package memory
import (
"context"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
)
// addItem is a convenience to persist an item and fail the test on error.
func addItem(t *testing.T, s *Store, item MemoryItem) MemoryItem {
t.Helper()
ctx := context.Background()
stored, err := s.Add(ctx, item)
if err != nil {
t.Fatalf("Add item: %v", err)
}
return stored
}
func TestRetriever_Retrieve_Relevance_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
s := newTestStore(t)
addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "user loves coffee", Tags: []string{"coffee"},
Scope: ScopePersona, Kind: KindWorldEvent,
})
addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "user plays guitar", Tags: []string{"music"},
Scope: ScopePersona, Kind: KindWorldEvent,
})
r := NewRetriever(s)
results, err := r.Retrieve(ctx, "persona.a", "coffee", 10)
if err != nil {
t.Fatalf("Retrieve: %v", err)
}
if len(results) == 0 {
t.Fatal("expected at least one result")
}
if got := results[0].Item.Content; got != "user loves coffee" {
t.Fatalf("top result = %q, want the coffee item", got)
}
if results[0].Score <= 0 {
t.Fatalf("top result score = %v, want > 0", results[0].Score)
}
}
func TestRetriever_Retrieve_Recency_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
s := newTestStore(t)
old := time.Now().UTC().Add(-48 * time.Hour)
newer := time.Now().UTC().Add(-2 * time.Hour)
addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "old memory", UpdatedAt: old,
Scope: ScopePersona, Kind: KindWorldEvent,
})
addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "new memory", UpdatedAt: newer,
Scope: ScopePersona, Kind: KindWorldEvent,
})
r := NewRetriever(s)
// A blank query matches nothing, so ordering falls back to recency.
results, err := r.Retrieve(ctx, "persona.a", "", 10)
if err != nil {
t.Fatalf("Retrieve: %v", err)
}
if len(results) != 2 {
t.Fatalf("got %d results, want 2", len(results))
}
if got := results[0].Item.Content; got != "new memory" {
t.Fatalf("top result = %q, want the newer memory", got)
}
// The older memory must rank below.
if got := results[1].Item.Content; got != "old memory" {
t.Fatalf("second result = %q, want the older memory", got)
}
}
func TestRetriever_Retrieve_TopK_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
s := newTestStore(t)
for i := 0; i < 5; i++ {
addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "shared term item",
Tags: []string{"term"}, Scope: ScopePersona, Kind: KindWorldEvent,
})
}
r := NewRetriever(s)
results, err := r.Retrieve(ctx, "persona.a", "term", 3)
if err != nil {
t.Fatalf("Retrieve: %v", err)
}
if len(results) != 3 {
t.Fatalf("got %d results, want topK=3", len(results))
}
}
func TestRetriever_Retrieve_Isolation_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
s := newTestStore(t)
addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "coffee for a", Tags: []string{"coffee"},
Scope: ScopePersona, Kind: KindRelationship,
})
addItem(t, s, MemoryItem{
PersonaID: "persona.b", Content: "coffee for b", Tags: []string{"coffee"},
Scope: ScopePersona, Kind: KindRelationship,
})
r := NewRetriever(s)
results, err := r.Retrieve(ctx, "persona.a", "coffee", 10)
if err != nil {
t.Fatalf("Retrieve: %v", err)
}
for _, res := range results {
if res.Item.PersonaID != "persona.a" {
t.Fatalf("retrieved item belongs to persona %q, want persona.a", res.Item.PersonaID)
}
}
if len(results) != 1 {
t.Fatalf("got %d results for persona.a, want 1", len(results))
}
}
func TestRetriever_Retrieve_WithEmbedder_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
s := newTestStore(t)
addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "she is a coffee lover", Tags: []string{"drink"},
Scope: ScopePersona, Kind: KindRelationship,
})
addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "she plays the guitar", Tags: []string{"music"},
Scope: ScopePersona, Kind: KindRelationship,
})
emb := embedding.NewNGram(64)
r := NewRetriever(s, WithEmbedder(emb))
results, err := r.Retrieve(ctx, "persona.a", "coffee", 10)
if err != nil {
t.Fatalf("Retrieve with embedder: %v", err)
}
if len(results) == 0 {
t.Fatal("expected results when embedding is enabled")
}
if got := results[0].Item.Content; got != "she is a coffee lover" {
t.Fatalf("top result = %q, want the coffee item", got)
}
}
func TestRetriever_Retrieve_EmptyPersona_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
s := newTestStore(t)
r := NewRetriever(s)
results, err := r.Retrieve(ctx, "ghost", "anything", 10)
if err != nil {
t.Fatalf("Retrieve: %v", err)
}
if len(results) != 0 {
t.Fatalf("got %d results for empty persona, want 0", len(results))
}
}
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()
s := newTestStore(t)
addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "她喜欢冰美式", Tags: []string{"coffee"},
Scope: ScopePersona, Kind: KindRelationship,
})
r := NewRetriever(s)
block, err := r.Inject(ctx, "persona.a", "冰美式", 10)
if err != nil {
t.Fatalf("Inject: %v", err)
}
if !strings.Contains(block, "相关记忆") {
t.Fatalf("inject block missing header: %q", block)
}
if !strings.Contains(block, "她喜欢冰美式") {
t.Fatalf("inject block missing item content: %q", block)
}
}
func TestRetriever_Inject_NoResults_正常(t *testing.T) {
t.Parallel()
ctx := context.Background()
s := newTestStore(t)
r := NewRetriever(s)
block, err := r.Inject(ctx, "ghost", "anything", 10)
if err != nil {
t.Fatalf("Inject: %v", err)
}
if block != "" {
t.Fatalf("Inject on empty persona = %q, want empty string", block)
}
}