Files
Cyrene_v2/internal/runtime/memory/retrieve_p2_test.go
T
AskaEth 509dc412eb
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(memory): 记忆 P2(项目索引物化 / decay 回写 / 作用域写规则 / 静默吞点 + loop 级只读红线测试)
2026-10-05 11:47:39 +08:00

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