Files
HomeAgent/internal/memory/indexer_test.go
JianFeeeee b850feaef2 feat: 段页式记忆流转、coldcontext 重命名与安全治理
记忆层:
- MemoryBlock 承载 FrameTS/PageSeq/PageKind/BlockRole/CallID
- coldcontext 归档与回填按完整页传递,工具调用参数和配对键不丢失
- 召回先估算总 token,超预算时按全局页相似度标记 inactive
- inactive 仅是 context 渲染期状态,不落盘、不进入 coldcontext,Prune 后重置
- Peek/确认/Remove 两阶段召回,失败保留冷上下文副本
- stablemem 记录 last_recalled_at,删块连带清理边

命名:
- internal/memory/document → internal/memory/coldcontext
- Doc/DocEvent/DocHit → ColdDoc/ColdEvent/ColdHit
- ContextToColdContext/RecentColdContexts/FindColdContexts
- 保留既有 JSON 标签、doc_ 文件前缀与 doc_count 状态键,兼容存量数据

安全治理:
- 工具策略门禁、安全配置、事件总线发布者身份与入站脱敏
- 声明式 SecurityAPI 与 QQ owner/guest profile
- 日志保留策略与相关 e2e/回归判据

验证:go build ./...、go vet ./...、go test ./...(62 包);
生产副本 e2e:7013 块迁移后 ID/向量/指纹 0 变化、0 缺失。
2026-10-29 23:53:48 +08:00

307 lines
7.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package memory
import (
"path/filepath"
"testing"
"time"
)
func TestNewIndexer(t *testing.T) {
idx := NewIndexer(nil)
if idx == nil {
t.Fatal("expected non-nil indexer")
}
}
func TestIndexerSyncWithNilDB(t *testing.T) {
idx := NewIndexer(nil)
if err := idx.Sync(); err != nil {
t.Errorf("expected no error with nil db, got %v", err)
}
}
func TestIndexerSync(t *testing.T) {
db, err := NewGraphDB(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
// 写入一些实体
ec, _, err := db.Commit([]Triple{
{Subject: "张三", Relation: "喜欢", Object: "篮球"},
{Subject: "李四", Relation: "喜欢", Object: "足球"},
}, "test", 0)
if err != nil {
t.Fatal(err)
}
idx := NewIndexer(db)
if err := idx.Sync(); err != nil {
t.Fatal(err)
}
if !idx.trained {
t.Error("expected indexer to be trained after sync")
}
// ★ 期望值改用**块数**(2026-10-04)
//
// 原来用 Commit 返回的 ec(旧表实体计数)——旧表双写已停,
// ec 恒为 0,于是判据说「应有 0 个向量」而索引器建了 4 个。
//
// ★★ 这是**判据与实现都错**的组合:
// 判据错在用了一个已失效的信号;实现没错(索引器照块建向量)。
blocks, err := db.MemoryBlocks()
if err != nil {
t.Fatal(err)
}
if idx.vec.Size() != len(blocks) {
t.Errorf("expected %d vectors (one per block), got %d",
len(blocks), idx.vec.Size())
}
_ = ec
}
// TestSyncIfStaleBaseline 钉住增量同步的基线口径:
// - 实体数未变 → 不重训
// - 实体数变了但不足 retrainInterval → 先不重训(避免密集写入时写放大)
// - 实体数变了且间隔已过 → 重训,并把基线追到新值
func TestSyncIfStaleBaseline(t *testing.T) {
db, err := NewGraphDB(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, _, err := db.Commit([]Triple{
{Subject: "张三", Relation: "喜欢", Object: "篮球"},
}, "test", 0); err != nil {
t.Fatal(err)
}
idx := NewIndexer(db)
if !idx.syncIfStale() {
t.Fatal("首次应建立索引")
}
if idx.syncIfStale() {
t.Error("实体数未变不该重训")
}
if _, _, err := db.Commit([]Triple{
{Subject: "李四", Relation: "喜欢", Object: "足球"},
}, "test", 0); err != nil {
t.Fatal(err)
}
if idx.syncIfStale() {
t.Error("retrainInterval 内不该重训(避免写放大)")
}
// 把上次同步时刻推老,计数变化才该触发重训
idx.mu.Lock()
idx.lastSyncAt = time.Now().Add(-2 * retrainInterval)
idx.mu.Unlock()
if !idx.syncIfStale() {
t.Error("计数变化且间隔已过应重训")
}
if idx.syncIfStale() {
t.Error("重训后基线应追上,不该再重训")
}
}
func TestIndexerBuildContext(t *testing.T) {
db, err := NewGraphDB(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
db.Commit([]Triple{
{Subject: "张三", Relation: "喜欢", Object: "篮球"},
{Subject: "张三", Relation: "职业", Object: "程序员"},
}, "test", 0)
idx := NewIndexer(db)
idx.Sync()
ctx := idx.BuildContext("张三")
if ctx == nil {
t.Fatal("expected non-nil context")
}
if len(ctx.Entities) == 0 {
t.Error("expected at least one entity in context")
}
}
func TestIndexerBuildContextEmpty(t *testing.T) {
idx := NewIndexer(nil)
ctx := idx.BuildContext("anything")
if ctx == nil {
t.Fatal("expected non-nil context even with nil db")
}
if ctx.Summary != "" {
t.Errorf("expected empty summary with nil db, got %q", ctx.Summary)
}
}
func TestIndexerMarkRecalled(t *testing.T) {
idx := NewIndexer(nil)
idx.MarkRecalled("张三", "李四")
idx.mu.RLock()
_, ok1 := idx.recalled["张三"]
_, ok2 := idx.recalled["李四"]
_, ok3 := idx.recalled["王五"]
idx.mu.RUnlock()
if !ok1 {
t.Error("expected '张三' to be marked recalled")
}
if !ok2 {
t.Error("expected '李四' to be marked recalled")
}
if ok3 {
t.Error("expected '王五' NOT to be marked recalled")
}
}
func TestIndexerFilterRecalled(t *testing.T) {
db, err := NewGraphDB(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
db.Commit([]Triple{
{Subject: "张三", Relation: "喜欢", Object: "篮球"},
{Subject: "李四", Relation: "喜欢", Object: "足球"},
}, "test", 0)
idx := NewIndexer(db)
idx.Sync()
idx.MarkRecalled("张三")
ctx := idx.BuildContext("张三")
if ctx == nil {
t.Fatal("expected non-nil context")
}
for _, e := range ctx.Entities {
if e.Name == "张三" {
t.Error("expected '张三' to be filtered out (marked recalled)")
}
}
}
func TestFormatContext(t *testing.T) {
idx := NewIndexer(nil)
result := idx.FormatContext(nil)
if result != "" {
t.Errorf("expected empty string for nil context, got %q", result)
}
ctx := &InjectedContext{
Entities: []Entity{
{ID: 1, Name: "张三", Type: "Person"},
{ID: 2, Name: "篮球", Type: "Concept"},
},
Summary: "关联 2 个记忆实体,高频:张三、篮球",
}
result = idx.FormatContext(ctx)
if result == "" {
t.Error("expected non-empty formatted context")
}
if !contains(result, "张三") || !contains(result, "篮球") {
t.Errorf("expected context to contain entity names, got %q", result)
}
}
func TestBuildIndexSummary(t *testing.T) {
entities := []Entity{
{Name: "张三", Type: "Person", MentionCount: 10},
{Name: "李四", Type: "Person", MentionCount: 5},
{Name: "篮球", Type: "Concept", MentionCount: 3},
{Name: "北京", Type: "Location", MentionCount: 2},
}
s := buildIndexSummary(entities)
if !contains(s, "张三") || !contains(s, "李四") {
t.Errorf("expected summary to contain top entities, got %q", s)
}
}
func TestBuildIndexSummaryEmpty(t *testing.T) {
s := buildIndexSummary(nil)
if s != "" {
t.Errorf("expected empty summary for nil, got %q", s)
}
}
func TestExtractKeywords(t *testing.T) {
tests := []struct {
input string
min int
}{
{"今天天气怎么样", 1},
{"张三的朋友李四", 1},
{"hello world", 1},
{"的了的", 0}, // all stop words
}
for _, tt := range tests {
kw := ExtractKeywords(tt.input)
if len(kw) < tt.min {
t.Errorf("ExtractKeywords(%q) = %v, want at least %d keywords", tt.input, kw, tt.min)
}
}
}
func TestGetToolDefinitions(t *testing.T) {
idx := NewIndexer(nil)
defs := idx.GetToolDefinitions()
if len(defs) < 3 {
t.Errorf("expected at least 3 tool defs, got %d", len(defs))
}
}
func TestBuildToolPrompt(t *testing.T) {
idx := NewIndexer(nil)
prompt := idx.BuildToolPrompt()
if !contains(prompt, "stablemem_recall") {
t.Errorf("expected prompt to mention stablemem_recall, got %q", prompt)
}
}
func TestVectorSearchEntities(t *testing.T) {
db, err := NewGraphDB(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
db.Commit([]Triple{
{Subject: "张三", Relation: "喜欢", Object: "篮球"},
}, "test", 0)
idx := NewIndexer(db)
idx.Sync()
// After sync, we have entities. Vector search should find relevant ones.
entities := idx.vectorSearchEntities("张三")
if len(entities) == 0 {
// This might be empty due to TF-IDF matching — vector search on single
// entity names is approximate. Just check it doesn't crash.
t.Log("vector search returned 0 results (acceptable for short queries)")
}
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsStr(s, substr))
}
func containsStr(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}