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