feat(memory): M4 记忆完善(三类作用域访问规则+项目索引分层+LLM异步提取+衰减溯源+向量接入,兼容既有57用例)
This commit is contained in:
1 parent
5b37f5a551
commit
d947ec41a9
10 files changed
+1349
-53
No files matched your search
@@ -0,0 +1,240 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// mockAdapter is a scriptable model.Adapter for tests. With no fn it returns an
|
||||
// empty candidate array, so a test that only needs a successful call can use it
|
||||
// directly.
|
||||
type mockAdapter struct {
|
||||
fn func(ctx context.Context, req model.Request, opts model.Options) (model.Response, error)
|
||||
}
|
||||
|
||||
func (m *mockAdapter) Name() string { return "mock" }
|
||||
|
||||
func (m *mockAdapter) Chat(ctx context.Context, req model.Request, opts model.Options) (model.Response, error) {
|
||||
if m.fn != nil {
|
||||
return m.fn(ctx, req, opts)
|
||||
}
|
||||
return model.Response{Content: "[]"}, nil
|
||||
}
|
||||
|
||||
func (m *mockAdapter) ChatStream(ctx context.Context, req model.Request, onChunk func(delta string)) (model.Response, error) {
|
||||
return model.Response{}, model.ErrStream
|
||||
}
|
||||
|
||||
func TestLLMExtractor_Extract_Parse_正常(t *testing.T) {
|
||||
t.Parallel()
|
||||
adapter := &mockAdapter{fn: func(_ context.Context, _ model.Request, _ model.Options) (model.Response, error) {
|
||||
return model.Response{Content: `[
|
||||
{"content":"用户喜欢冰美式","scope":"user","kind":"relationship","tags":["咖啡"],"confidence":0.9,"source_conversation":"conv.1"},
|
||||
{"content":"项目Alpha周三开会","scope":"project","project_id":"project.alpha","source_conversation":"conv.1"}
|
||||
]`}, nil
|
||||
}}
|
||||
ex := NewLLMExtractor(adapter, "persona.a", "role.1")
|
||||
|
||||
items, err := ex.Extract(context.Background(), []model.Message{
|
||||
model.TextMessage(model.RoleUser, "你好"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Extract: %v", err)
|
||||
}
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("got %d items, want 2", len(items))
|
||||
}
|
||||
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)
|
||||
}
|
||||
if items[1].ProjectID != "project.alpha" || items[1].Scope != ScopeProject {
|
||||
t.Errorf("items[1] = %+v, want project-scoped item", items[1])
|
||||
}
|
||||
if items[0].PersonaID != "persona.a" || items[0].RoleID != "role.1" {
|
||||
t.Errorf("items[0] author = %q/%q, want persona.a/role.1", items[0].PersonaID, items[0].RoleID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMExtractor_Extract_InvalidScopeKind_Fallback_正常(t *testing.T) {
|
||||
t.Parallel()
|
||||
adapter := &mockAdapter{fn: func(_ context.Context, _ model.Request, _ model.Options) (model.Response, error) {
|
||||
return model.Response{Content: `[{"content":"一个事实","scope":"bogus","kind":"bogus"}]`}, nil
|
||||
}}
|
||||
ex := NewLLMExtractor(adapter, "persona.a", "role.1")
|
||||
|
||||
items, err := ex.Extract(context.Background(), []model.Message{
|
||||
model.TextMessage(model.RoleUser, "hi"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Extract: %v", err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("got %d items, want 1", len(items))
|
||||
}
|
||||
// Bogus scope/kind fall back to the extractor's configured defaults.
|
||||
if items[0].Scope != ScopePersona || items[0].Kind != KindRelationship {
|
||||
t.Errorf("fallback scope/kind = %q/%q, want persona/relationship", items[0].Scope, items[0].Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMExtractor_Extract_Dedup_正常(t *testing.T) {
|
||||
t.Parallel()
|
||||
// The model emits the same memory three times, differing only in case and
|
||||
// surrounding whitespace; they must collapse into one item.
|
||||
adapter := &mockAdapter{fn: func(_ context.Context, _ model.Request, _ model.Options) (model.Response, error) {
|
||||
return model.Response{Content: `[
|
||||
{"content":"记得喝咖啡"},
|
||||
{"content":" 记得喝咖啡 "},
|
||||
{"content":"记得喝咖啡"}
|
||||
]`}, nil
|
||||
}}
|
||||
ex := NewLLMExtractor(adapter, "persona.a", "role.1")
|
||||
|
||||
items, err := ex.Extract(context.Background(), []model.Message{
|
||||
model.TextMessage(model.RoleUser, "hi"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Extract: %v", err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("got %d items, want 1 after dedup", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMExtractor_Extract_Empty_正常(t *testing.T) {
|
||||
t.Parallel()
|
||||
ex := NewLLMExtractor(&mockAdapter{}, "persona.a", "role.1")
|
||||
|
||||
items, err := ex.Extract(context.Background(), []model.Message{
|
||||
model.TextMessage(model.RoleUser, "hi"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Extract: %v", err)
|
||||
}
|
||||
if len(items) != 0 {
|
||||
t.Fatalf("got %d items, want 0 for empty candidate response", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMExtractor_Extract_InvalidJSON_异常(t *testing.T) {
|
||||
t.Parallel()
|
||||
adapter := &mockAdapter{fn: func(_ context.Context, _ model.Request, _ model.Options) (model.Response, error) {
|
||||
return model.Response{Content: "{not-json}"}, nil
|
||||
}}
|
||||
ex := NewLLMExtractor(adapter, "persona.a", "role.1")
|
||||
|
||||
if _, err := ex.Extract(context.Background(), []model.Message{
|
||||
model.TextMessage(model.RoleUser, "hi"),
|
||||
}); err == nil {
|
||||
t.Fatal("Extract on malformed JSON: expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMExtractor_Extract_NilAdapter_异常(t *testing.T) {
|
||||
t.Parallel()
|
||||
ex := NewLLMExtractor(nil, "persona.a", "role.1")
|
||||
|
||||
if _, err := ex.Extract(context.Background(), []model.Message{
|
||||
model.TextMessage(model.RoleUser, "hi"),
|
||||
}); !errors.Is(err, ErrNilAdapter) {
|
||||
t.Fatalf("Extract with nil adapter err = %v, want ErrNilAdapter", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMExtractor_Extract_Canceled_异常(t *testing.T) {
|
||||
t.Parallel()
|
||||
adapter := &mockAdapter{fn: func(ctx context.Context, _ model.Request, _ model.Options) (model.Response, error) {
|
||||
<-ctx.Done()
|
||||
return model.Response{}, ctx.Err()
|
||||
}}
|
||||
ex := NewLLMExtractor(adapter, "persona.a", "role.1")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
if _, err := ex.Extract(ctx, []model.Message{
|
||||
model.TextMessage(model.RoleUser, "hi"),
|
||||
}); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Extract with canceled ctx err = %v, want context.Canceled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMExtractor_ExtractAsync_异步_正常(t *testing.T) {
|
||||
t.Parallel()
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
adapter := &mockAdapter{fn: func(_ context.Context, _ model.Request, _ model.Options) (model.Response, error) {
|
||||
close(started)
|
||||
<-release
|
||||
return model.Response{Content: `[{"content":"异步提取的记忆"}]`}, nil
|
||||
}}
|
||||
ex := NewLLMExtractor(adapter, "persona.a", "role.1")
|
||||
|
||||
ch, err := ex.ExtractAsync(context.Background(), []model.Message{
|
||||
model.TextMessage(model.RoleUser, "hi"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ExtractAsync: %v", err)
|
||||
}
|
||||
// ExtractAsync returned while the adapter is still blocked on release,
|
||||
// proving the extraction runs on a background goroutine.
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("extraction goroutine did not start in time")
|
||||
}
|
||||
close(release)
|
||||
|
||||
res := <-ch
|
||||
if res.Err != nil {
|
||||
t.Fatalf("async result err = %v", res.Err)
|
||||
}
|
||||
if len(res.Items) != 1 || res.Items[0].Content != "异步提取的记忆" {
|
||||
t.Fatalf("async items = %+v, want one extracted item", res.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMExtractor_ExtractAsync_Idempotent_正常(t *testing.T) {
|
||||
t.Parallel()
|
||||
adapter := &mockAdapter{fn: func(_ context.Context, _ model.Request, _ model.Options) (model.Response, error) {
|
||||
return model.Response{Content: `[{"content":"习惯:早起"}]`}, nil
|
||||
}}
|
||||
ex := NewLLMExtractor(adapter, "persona.a", "role.1")
|
||||
|
||||
conv := []model.Message{model.TextMessage(model.RoleUser, "hi")}
|
||||
|
||||
first, err := ex.ExtractAsync(context.Background(), conv)
|
||||
if err != nil {
|
||||
t.Fatalf("ExtractAsync first: %v", err)
|
||||
}
|
||||
if res := <-first; res.Err != nil || len(res.Items) != 1 {
|
||||
t.Fatalf("first async result = %+v, want 1 item", res)
|
||||
}
|
||||
|
||||
// A second async extraction of the same memory deduplicates to zero.
|
||||
second, err := ex.ExtractAsync(context.Background(), conv)
|
||||
if err != nil {
|
||||
t.Fatalf("ExtractAsync second: %v", err)
|
||||
}
|
||||
if res := <-second; res.Err != nil || len(res.Items) != 0 {
|
||||
t.Fatalf("second async result = %+v, want 0 items (idempotent)", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMExtractor_ExtractAsync_NilAdapter_异常(t *testing.T) {
|
||||
t.Parallel()
|
||||
ex := NewLLMExtractor(nil, "persona.a", "role.1")
|
||||
|
||||
if _, err := ex.ExtractAsync(context.Background(), []model.Message{
|
||||
model.TextMessage(model.RoleUser, "hi"),
|
||||
}); !errors.Is(err, ErrNilAdapter) {
|
||||
t.Fatalf("ExtractAsync with nil adapter err = %v, want ErrNilAdapter", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user