Files
Cyrene_v2/internal/runtime/memory/llmextractor_test.go
T
AskaEth 61cf80d5ee
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(comments): 注释中文化 · internal/runtime/memory(31 文件,纯替换)
- 只翻注释/文档字符串:820 增 / 820 删 严格 1:1;剥掉整行注释与行尾注释尾巴后,
  逐文件与 HEAD 比对「代码部分差异 = 0」;裁决/design 编号计数逐项与 HEAD 一致
- 保留可执行 SQL 样例、SQLite 报错原文、pgvector/HNSW/ANN/BLOB 等技术词原文
- 复核:go build 绿 + memory/projectindex/vector 三包测试绿;gofmt 仅剩 2 个既有未格式化文件(改动前即如此,未顺手修)
2026-10-04 13:32:54 +08:00

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