- 只翻注释/文档字符串:820 增 / 820 删 严格 1:1;剥掉整行注释与行尾注释尾巴后, 逐文件与 HEAD 比对「代码部分差异 = 0」;裁决/design 编号计数逐项与 HEAD 一致 - 保留可执行 SQL 样例、SQLite 报错原文、pgvector/HNSW/ANN/BLOB 等技术词原文 - 复核:go build 绿 + memory/projectindex/vector 三包测试绿;gofmt 仅剩 2 个既有未格式化文件(改动前即如此,未顺手修)
254 lines
8.7 KiB
Go
254 lines
8.7 KiB
Go
package memory
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
|
)
|
|
|
|
// mockAdapter 是测试用的可脚本化 model.Adapter。未设置 fn 时它返回
|
|
// 空候选数组,因此只需要一次成功调用的测试可以直接
|
|
// 用它。
|
|
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, onEvent func(model.StreamEvent)) (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])
|
|
}
|
|
// 裁决 04.4.3:模型回填的 source_conversation 被忽略 —— 提取器
|
|
// 落空溯源,真实 id 由持久化层(loop.persistMemories)盖。
|
|
if items[0].SourceConversation != "" {
|
|
t.Errorf("items[0].SourceConversation = %q, want empty (source is stamped by the persistence layer, never the model)", 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))
|
|
}
|
|
// 非法 scope/kind 回退到提取器配置的默认值。
|
|
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)
|
|
}
|
|
}
|
|
|
|
// TestLLMExtractor_Prompt_ExcludesSource 锁死提取契约(裁决 04.4.3):
|
|
// 提取 prompt 不得向模型索要 source_conversation —— 溯源只由持久化层
|
|
// 盖真实 session/message id,模型自由文本不是来源。
|
|
func TestLLMExtractor_Prompt_ExcludesSource(t *testing.T) {
|
|
t.Parallel()
|
|
if strings.Contains(extractSystemPrompt, "source_conversation") {
|
|
t.Fatal("extract prompt asks the model for source_conversation: provenance must come from the persistence layer, never the model")
|
|
}
|
|
}
|
|
|
|
func TestLLMExtractor_Extract_Dedup_正常(t *testing.T) {
|
|
t.Parallel()
|
|
// 模型三次输出同一条记忆,只有大小写与
|
|
// 周边空白不同;它们必须归并为一条条目。
|
|
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)
|
|
}
|
|
// adapter 仍阻塞在 release 上时 ExtractAsync 就返回了,
|
|
// 证明提取跑在后台 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)
|
|
}
|
|
|
|
// 对同一条记忆的第二次异步提取去重后为零条。
|
|
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)
|
|
}
|
|
}
|