200 lines
7.1 KiB
Go
200 lines
7.1 KiB
Go
package memory
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/projectindex"
|
|
)
|
|
|
|
// newProjectStoreForTest 打开一个隔离的项目索引库并返回其 store。
|
|
func newProjectStoreForTest(t *testing.T) *projectindex.Store {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
projDB, err := db.Open(ctx, ":memory:", testLogger())
|
|
if err != nil {
|
|
t.Fatalf("open project db: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = projDB.Close() })
|
|
ps, err := projectindex.New(ctx, projDB, testLogger())
|
|
if err != nil {
|
|
t.Fatalf("projectindex.New: %v", err)
|
|
}
|
|
return ps
|
|
}
|
|
|
|
// projectForLogged 报告缓冲里是否存在一条 source=memory/retriever 且 msg 含 want
|
|
// 的 JSON 记录。用结构化字段断言,避免依赖文本格式。
|
|
func projectForLogged(t *testing.T, buf *bytes.Buffer, want string) bool {
|
|
t.Helper()
|
|
for _, line := range strings.Split(strings.TrimSpace(buf.String()), "\n") {
|
|
if strings.TrimSpace(line) == "" {
|
|
continue
|
|
}
|
|
rec := map[string]any{}
|
|
if err := json.Unmarshal([]byte(line), &rec); err != nil {
|
|
continue
|
|
}
|
|
if rec["source"] != "memory/retriever" {
|
|
continue
|
|
}
|
|
if msg, _ := rec["msg"].(string); strings.Contains(msg, want) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// TestRetriever_ProjectFor_LogsLookupFailure 钉住审计 2026-10-05 §7 的静默吞点:
|
|
// 项目链接加载失败(项目行不存在 / 存储故障)必须留下带来源的日志,绝不静默按
|
|
// 「无项目」返回。这里构造一条 ProjectID 悬空的项目作用域记忆,断言检索器记下一条
|
|
// source=memory/retriever 的记录,且命中仍照常返回(加载失败不影响检索)。
|
|
func TestRetriever_ProjectFor_LogsLookupFailure(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
ps := newProjectStoreForTest(t)
|
|
|
|
ms := newTestStore(t)
|
|
addItem(t, ms, MemoryItem{
|
|
PersonaID: "persona.a", Content: "Alpha 项目事实",
|
|
Scope: ScopeProject, ProjectID: "project.missing", Kind: KindWorldEvent,
|
|
})
|
|
|
|
buf := &bytes.Buffer{}
|
|
log := slog.New(slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
|
r := NewRetriever(ms, WithProjectIndex(ps), WithRetrieverLogger(log))
|
|
|
|
results, err := r.Retrieve(ctx, "persona.a", "Alpha", 10)
|
|
if err != nil {
|
|
t.Fatalf("Retrieve: %v", err)
|
|
}
|
|
if len(results) == 0 {
|
|
t.Fatal("expected a result (a lookup failure must not drop the item)")
|
|
}
|
|
if results[0].Project != nil {
|
|
t.Fatalf("Result.Project = %+v, want nil for a dangling project_id", results[0].Project)
|
|
}
|
|
if !projectForLogged(t, buf, "project link not found") {
|
|
t.Fatalf("缺带来源(source=memory/retriever)的「项目链接加载失败」日志:%q", buf.String())
|
|
}
|
|
}
|
|
|
|
// TestRetriever_ProjectWriteback_MaterializesIndexEntry 钉住审计 2026-10-05 §4 的
|
|
// 「project 域记忆 project_id 悬空 / project 表数据为零」:接入项目索引后,写入一条
|
|
// 带 ProjectID 的项目作用域记忆会确保项目索引条目存在,使 projectFor 能解析链接
|
|
// (模型因此够得着「项目索引 → 项目描述」层级),且只补不覆盖既有 title/desc。
|
|
func TestRetriever_ProjectWriteback_MaterializesIndexEntry(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
ps := newProjectStoreForTest(t)
|
|
if err := ps.PutProject(ctx, projectindex.Project{ID: "project.beta", Title: "真实标题", Desc: "真实描述"}); err != nil {
|
|
t.Fatalf("PutProject: %v", err)
|
|
}
|
|
|
|
ms := newTestStore(t)
|
|
_ = NewRetriever(ms, WithProjectIndex(ps)) // 把项目索引接到写入路径
|
|
|
|
addItem(t, ms, MemoryItem{
|
|
PersonaID: "persona.a", Content: "Alpha 项目事实",
|
|
Scope: ScopeProject, ProjectID: "project.alpha", Kind: KindWorldEvent,
|
|
})
|
|
addItem(t, ms, MemoryItem{
|
|
PersonaID: "persona.a", Content: "Beta 项目事实",
|
|
Scope: ScopeProject, ProjectID: "project.beta", Kind: KindWorldEvent,
|
|
})
|
|
|
|
p, err := ps.GetProject(ctx, "project.alpha")
|
|
if err != nil {
|
|
t.Fatalf("project index entry not materialized from memory write: %v", err)
|
|
}
|
|
if p.ID != "project.alpha" {
|
|
t.Fatalf("materialized project id = %q, want project.alpha", p.ID)
|
|
}
|
|
// 只补不覆盖:既有项目的中文标题必须原样保留。
|
|
kept, err := ps.GetProject(ctx, "project.beta")
|
|
if err != nil {
|
|
t.Fatalf("GetProject(beta): %v", err)
|
|
}
|
|
if kept.Title != "真实标题" || kept.Desc != "真实描述" {
|
|
t.Fatalf("EnsureProject clobbered an existing entry: %+v", kept)
|
|
}
|
|
|
|
// 端到端:检索命中带出链接的项目。
|
|
r := NewRetriever(ms, WithProjectIndex(ps))
|
|
results, err := r.Retrieve(ctx, "persona.a", "Alpha", 10)
|
|
if err != nil {
|
|
t.Fatalf("Retrieve: %v", err)
|
|
}
|
|
if len(results) == 0 || results[0].Project == nil || results[0].Project.ID != "project.alpha" {
|
|
t.Fatalf("Result.Project = %+v, want the materialized project.alpha", results[0].Project)
|
|
}
|
|
}
|
|
|
|
// TestRetriever_Retrieve_MaterializesDecayWeight 钉住审计 2026-10-05 §7 / design/03
|
|
// §2 裁决 Q3:检索命中时把衰减值物化回写 decay_weight(实库曾 388/388 全 1.0)。
|
|
func TestRetriever_Retrieve_MaterializesDecayWeight(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
s := newTestStore(t)
|
|
|
|
old := time.Now().UTC().Add(-60 * 24 * time.Hour)
|
|
stored := addItem(t, s, MemoryItem{
|
|
PersonaID: "persona.a", Content: "coffee preference", UpdatedAt: old,
|
|
Scope: ScopePersona, Kind: KindRelationship,
|
|
})
|
|
|
|
r := NewRetriever(s)
|
|
if _, err := r.Retrieve(ctx, "persona.a", "coffee", 10); err != nil {
|
|
t.Fatalf("Retrieve: %v", err)
|
|
}
|
|
|
|
var dw float64
|
|
var updated string
|
|
if err := s.db.QueryRowContext(ctx,
|
|
"SELECT decay_weight, updated_at FROM memory_items WHERE id = ?", stored.ID).Scan(&dw, &updated); err != nil {
|
|
t.Fatalf("read decay_weight: %v", err)
|
|
}
|
|
if dw >= defaultDecayWeight {
|
|
t.Fatalf("decay_weight = %v, want < %v(命中物化的衰减值)", dw, defaultDecayWeight)
|
|
}
|
|
// 命中即使用:updated_at 被推进到本轮,避免下次读把已物化的值再乘一遍。
|
|
if updated == formatTime(old) {
|
|
t.Fatalf("updated_at 未随命中推进:%q", updated)
|
|
}
|
|
}
|
|
|
|
// TestRetriever_Retrieve_ReadOnlyContext_NoDecayWrite 钉住只读隔离(design/03 §5
|
|
// 「能查不能写」):受限(群聊 / 陌生人)检索经 WithoutDecayWriteback 调用时,
|
|
// 记忆库零写入。
|
|
func TestRetriever_Retrieve_ReadOnlyContext_NoDecayWrite(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
s := newTestStore(t)
|
|
|
|
old := time.Now().UTC().Add(-60 * 24 * time.Hour)
|
|
stored := addItem(t, s, MemoryItem{
|
|
PersonaID: "persona.a", Content: "coffee preference", UpdatedAt: old,
|
|
Scope: ScopePersona, Kind: KindRelationship,
|
|
})
|
|
|
|
r := NewRetriever(s)
|
|
if _, err := r.Retrieve(WithoutDecayWriteback(ctx), "persona.a", "coffee", 10); err != nil {
|
|
t.Fatalf("Retrieve: %v", err)
|
|
}
|
|
|
|
var dw float64
|
|
if err := s.db.QueryRowContext(ctx,
|
|
"SELECT decay_weight FROM memory_items WHERE id = ?", stored.ID).Scan(&dw); err != nil {
|
|
t.Fatalf("read decay_weight: %v", err)
|
|
}
|
|
if dw != defaultDecayWeight {
|
|
t.Fatalf("只读检索写入了 decay_weight = %v, want 保持不变 %v", dw, defaultDecayWeight)
|
|
}
|
|
}
|