mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 09:28:14 +00:00
媒体/文本在 L3 不再是正文标记反解出的代理实体,而是带原生 modality/digest/MIME/size/vector/fingerprint 的 memory_blocks 节点; contains/depicts/derived_from 等语义边落在 memory_block_edges, 端点必须是真实图节点(block/entity/sentence),不复用 owner 字符串。 本步只建立存储与查询能力,不接入 media_refs,也不改变 L0/L2 路径。
526 lines
13 KiB
Go
526 lines
13 KiB
Go
package memory
|
||
|
||
import (
|
||
"database/sql"
|
||
"os"
|
||
"path/filepath"
|
||
"testing"
|
||
)
|
||
|
||
func newTestGraph(t *testing.T) *GraphDB {
|
||
t.Helper()
|
||
f, err := os.CreateTemp("", "graph_test_*.db")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
f.Close()
|
||
os.Remove(f.Name())
|
||
|
||
g, err := NewGraphDB(f.Name())
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return g
|
||
}
|
||
|
||
func TestNewGraphDB(t *testing.T) {
|
||
g := newTestGraph(t)
|
||
defer os.Remove(g.dbPath)
|
||
defer g.Close()
|
||
|
||
stats, err := g.Introspect()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if stats["entity_count"].(int) != 0 {
|
||
t.Errorf("expected 0 entities, got %d", stats["entity_count"])
|
||
}
|
||
if stats["relation_count"].(int) != 0 {
|
||
t.Errorf("expected 0 relations, got %d", stats["relation_count"])
|
||
}
|
||
}
|
||
|
||
func TestCommitTriples(t *testing.T) {
|
||
g := newTestGraph(t)
|
||
defer os.Remove(g.dbPath)
|
||
defer g.Close()
|
||
|
||
triples := []Triple{
|
||
{Subject: "张三", Relation: "喜欢", Object: "编程"},
|
||
{Subject: "张三", Relation: "居住", Object: "北京"},
|
||
}
|
||
|
||
ec, rc, err := g.Commit(triples, "test_session", 1)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if ec != 4 {
|
||
t.Errorf("expected 4 entity ops (张三×2, 编程, 北京), got %d", ec)
|
||
}
|
||
if rc != 2 {
|
||
t.Errorf("expected 2 relations, got %d", rc)
|
||
}
|
||
}
|
||
|
||
func TestCommitDedupSameSession(t *testing.T) {
|
||
g := newTestGraph(t)
|
||
defer os.Remove(g.dbPath)
|
||
defer g.Close()
|
||
|
||
triple := []Triple{{Subject: "李四", Relation: "喜欢", Object: "篮球"}}
|
||
|
||
ec, rc, err := g.Commit(triple, "session_dup", 1)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if ec != 2 || rc != 1 {
|
||
t.Fatalf("first commit: want 2/1, got %d/%d", ec, rc)
|
||
}
|
||
|
||
// 同一会话重复 commit 同一三元组:关系不再新增
|
||
_, rc, err = g.Commit(triple, "session_dup", 2)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if rc != 0 {
|
||
t.Errorf("duplicate commit should not create relations again, got %d", rc)
|
||
}
|
||
|
||
var cnt int
|
||
if err := g.db.QueryRow(`SELECT COUNT(*) FROM relations`).Scan(&cnt); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if cnt != 1 {
|
||
t.Errorf("expected exactly 1 relation after duplicate commit, got %d", cnt)
|
||
}
|
||
}
|
||
|
||
func TestCommitDedupDifferentSession(t *testing.T) {
|
||
g := newTestGraph(t)
|
||
defer os.Remove(g.dbPath)
|
||
defer g.Close()
|
||
|
||
triple := []Triple{{Subject: "王五", Relation: "喜欢", Object: "足球"}}
|
||
|
||
for _, sess := range []string{"s1", "s2"} {
|
||
if _, _, err := g.Commit(triple, sess, 0); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
var cnt int
|
||
if err := g.db.QueryRow(`SELECT COUNT(*) FROM relations`).Scan(&cnt); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if cnt != 2 {
|
||
t.Errorf("different sessions may repeat a triple, expected 2 relations, got %d", cnt)
|
||
}
|
||
}
|
||
|
||
func TestMigrateRelationUniqueDedupsOldTable(t *testing.T) {
|
||
dir := t.TempDir()
|
||
dbPath := filepath.Join(dir, "legacy.db")
|
||
|
||
// 构造旧版 schema:relations 无复合唯一约束,且塞入重复行
|
||
db, err := sql.Open("sqlite3", dbPath)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
setup := []string{
|
||
`CREATE TABLE entities (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
name TEXT UNIQUE NOT NULL,
|
||
type TEXT DEFAULT 'Concept',
|
||
mention_count INTEGER DEFAULT 1,
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||
)`,
|
||
`CREATE TABLE sentences (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
text TEXT UNIQUE NOT NULL,
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||
)`,
|
||
`CREATE TABLE relations (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
source_id INTEGER NOT NULL,
|
||
target_id INTEGER NOT NULL,
|
||
relation_type TEXT NOT NULL,
|
||
confidence REAL DEFAULT 1.0,
|
||
status TEXT DEFAULT 'active',
|
||
session_id TEXT,
|
||
turn_id INTEGER DEFAULT 0,
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
date_bucket TEXT,
|
||
sentence_id INTEGER DEFAULT 0,
|
||
sentence_ref TEXT DEFAULT '',
|
||
FOREIGN KEY (source_id) REFERENCES entities(id),
|
||
FOREIGN KEY (target_id) REFERENCES entities(id)
|
||
)`,
|
||
`INSERT INTO entities (id, name) VALUES (1, '张三'), (2, '编程')`,
|
||
`INSERT INTO relations (source_id, target_id, relation_type, session_id) VALUES (1, 2, '喜欢', 's'), (1, 2, '喜欢', 's')`,
|
||
}
|
||
for _, s := range setup {
|
||
if _, err := db.Exec(s); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
db.Close()
|
||
|
||
// 用 NewGraphDB 打开,应触发 migrateRelationUnique:重建带约束表并去重
|
||
g, err := NewGraphDB(dbPath)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer g.Close()
|
||
|
||
var cnt int
|
||
if err := g.db.QueryRow(`SELECT COUNT(*) FROM relations`).Scan(&cnt); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if cnt != 1 {
|
||
t.Errorf("expected 1 relation after migration dedup, got %d", cnt)
|
||
}
|
||
|
||
// 再次提交重复三元组不应再新增
|
||
_, rc, err := g.Commit([]Triple{{Subject: "张三", Relation: "喜欢", Object: "编程"}}, "s", 1)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if rc != 0 {
|
||
t.Errorf("after migration, duplicate commit should add 0 relations, got %d", rc)
|
||
}
|
||
}
|
||
|
||
func TestCommitEmptyTriples(t *testing.T) {
|
||
g := newTestGraph(t)
|
||
defer os.Remove(g.dbPath)
|
||
defer g.Close()
|
||
|
||
ec, rc, err := g.Commit(nil, "test", 0)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if ec != 0 || rc != 0 {
|
||
t.Errorf("expected 0,0 for nil triples, got %d,%d", ec, rc)
|
||
}
|
||
}
|
||
|
||
func TestRecallByKeywords(t *testing.T) {
|
||
g := newTestGraph(t)
|
||
defer os.Remove(g.dbPath)
|
||
defer g.Close()
|
||
|
||
g.Commit([]Triple{
|
||
{Subject: "咖啡", Relation: "属于", Object: "饮品"},
|
||
{Subject: "咖啡", Relation: "含有", Object: "咖啡因"},
|
||
}, "session1", 0)
|
||
|
||
result, err := g.Recall([]string{"咖啡"}, nil, 1, "")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(result.Entities) == 0 {
|
||
t.Error("expected entities for keyword '咖啡'")
|
||
}
|
||
}
|
||
|
||
func TestRecallBySeedEntity(t *testing.T) {
|
||
g := newTestGraph(t)
|
||
defer os.Remove(g.dbPath)
|
||
defer g.Close()
|
||
|
||
g.Commit([]Triple{
|
||
{Subject: "Go", Relation: "是", Object: "编程语言"},
|
||
{Subject: "Go", Relation: "用于", Object: "后端开发"},
|
||
}, "session2", 0)
|
||
|
||
result, err := g.Recall(nil, []string{"Go"}, 1, "")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(result.Entities) == 0 {
|
||
t.Error("expected entities for seed 'Go'")
|
||
}
|
||
}
|
||
|
||
func TestRecallWithDepth(t *testing.T) {
|
||
g := newTestGraph(t)
|
||
defer os.Remove(g.dbPath)
|
||
defer g.Close()
|
||
|
||
g.Commit([]Triple{
|
||
{Subject: "小明", Relation: "认识", Object: "小红"},
|
||
{Subject: "小红", Relation: "认识", Object: "小刚"},
|
||
}, "session3", 0)
|
||
|
||
result, err := g.Recall(nil, []string{"小明"}, 2, "")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(result.Relations) == 0 {
|
||
t.Error("expected relations with depth search")
|
||
}
|
||
}
|
||
|
||
func TestPurgeHard(t *testing.T) {
|
||
g := newTestGraph(t)
|
||
defer os.Remove(g.dbPath)
|
||
defer g.Close()
|
||
|
||
g.Commit([]Triple{
|
||
{Subject: "临时", Relation: "用于", Object: "测试"},
|
||
}, "session4", 0)
|
||
|
||
n, err := g.Purge(map[string]string{"subject_contains": "临时"}, "hard")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if n != 1 {
|
||
t.Errorf("expected 1 purged relation, got %d", n)
|
||
}
|
||
|
||
stats, _ := g.Introspect()
|
||
if stats["relation_count"].(int) != 0 {
|
||
t.Errorf("expected 0 relations after purge, got %d", stats["relation_count"])
|
||
}
|
||
}
|
||
|
||
func TestPurgeSoft(t *testing.T) {
|
||
g := newTestGraph(t)
|
||
defer os.Remove(g.dbPath)
|
||
defer g.Close()
|
||
|
||
g.Commit([]Triple{
|
||
{Subject: "可删除", Relation: "属于", Object: "测试"},
|
||
}, "session5", 0)
|
||
|
||
n, err := g.Purge(map[string]string{"subject_contains": "可删除"}, "soft")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if n != 1 {
|
||
t.Errorf("expected 1 soft-deleted relation, got %d", n)
|
||
}
|
||
|
||
stats, _ := g.Introspect()
|
||
if stats["relation_count"].(int) != 0 {
|
||
t.Errorf("expected 0 active relations after soft-delete, got %d", stats["relation_count"])
|
||
}
|
||
}
|
||
|
||
func TestArchive(t *testing.T) {
|
||
g := newTestGraph(t)
|
||
defer os.Remove(g.dbPath)
|
||
defer g.Close()
|
||
|
||
// 直接插入一条旧记录
|
||
g.db.Exec(`INSERT INTO entities (id, name, type) VALUES (1, '旧数据', 'Concept')`)
|
||
g.db.Exec(`INSERT INTO relations (source_id, target_id, relation_type, created_at)
|
||
VALUES (1, 1, '包含', datetime('now', '-1 day'))`)
|
||
|
||
n, err := g.Archive(0)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if n != 1 {
|
||
t.Errorf("expected 1 archived relation, got %d", n)
|
||
}
|
||
}
|
||
|
||
func TestIntrospectHotspots(t *testing.T) {
|
||
g := newTestGraph(t)
|
||
defer os.Remove(g.dbPath)
|
||
defer g.Close()
|
||
|
||
g.Commit([]Triple{
|
||
{Subject: "热门话题", Relation: "关于", Object: "AI"},
|
||
{Subject: "热门话题", Relation: "关于", Object: "机器学习"},
|
||
{Subject: "冷门话题", Relation: "关于", Object: "旧技术"},
|
||
}, "session7", 0)
|
||
|
||
stats, _ := g.Introspect()
|
||
hotspots := stats["memory_hotspots"].([]map[string]interface{})
|
||
if len(hotspots) == 0 {
|
||
t.Error("expected hotspots")
|
||
}
|
||
}
|
||
|
||
func TestMergeEntities(t *testing.T) {
|
||
g := newTestGraph(t)
|
||
defer os.Remove(g.dbPath)
|
||
defer g.Close()
|
||
|
||
g.Commit([]Triple{
|
||
{Subject: "张三", Relation: "喜欢", Object: "编程"},
|
||
{Subject: "张三", Relation: "居住", Object: "北京"},
|
||
}, "session", 0)
|
||
|
||
g.Commit([]Triple{
|
||
{Subject: "张先生", Relation: "工作", Object: "字节跳动"},
|
||
}, "session", 0)
|
||
|
||
// 合并前:两个实体各有关联
|
||
stats, _ := g.Introspect()
|
||
if stats["entity_count"].(int) != 5 {
|
||
t.Fatalf("expected 5 entities (张三, 编程, 北京, 张先生, 字节跳动), got %d", stats["entity_count"])
|
||
}
|
||
|
||
// 先增加张先生的 mention_count
|
||
g.Commit([]Triple{
|
||
{Subject: "张先生", Relation: "喜欢", Object: "Go"},
|
||
}, "session", 0)
|
||
|
||
n, err := g.MergeEntities("张先生", "张三")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if n < 2 {
|
||
t.Errorf("expected at least 2 redirected relations, got %d", n)
|
||
}
|
||
|
||
// source 应被改名
|
||
result, err := g.Recall(nil, []string{"张先生"}, 1, "")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(result.Entities) > 0 {
|
||
t.Error("张先生 should be merged and hidden")
|
||
}
|
||
|
||
// target 的 mention_count 应合并
|
||
// 验证 target 还存在(seedEntities 精确查找)
|
||
result2, err := g.Recall([]string{"张三"}, nil, 1, "")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
found := false
|
||
for _, e := range result2.Entities {
|
||
if e.Name == "张三" {
|
||
found = true
|
||
if e.MentionCount < 2 {
|
||
t.Errorf("expected 张三 mention_count >= 2 after merge, got %d", e.MentionCount)
|
||
}
|
||
break
|
||
}
|
||
}
|
||
if !found {
|
||
t.Error("张三 should still exist after merge")
|
||
}
|
||
}
|
||
|
||
func TestMergeEntitiesSelf(t *testing.T) {
|
||
g := newTestGraph(t)
|
||
defer os.Remove(g.dbPath)
|
||
defer g.Close()
|
||
|
||
g.Commit([]Triple{
|
||
{Subject: "张三", Relation: "喜欢", Object: "编程"},
|
||
}, "session", 0)
|
||
|
||
_, err := g.MergeEntities("张三", "张三")
|
||
if err == nil {
|
||
t.Error("expected error when merging entity with itself")
|
||
}
|
||
}
|
||
|
||
func TestMergeEntitiesNonexistent(t *testing.T) {
|
||
g := newTestGraph(t)
|
||
defer os.Remove(g.dbPath)
|
||
defer g.Close()
|
||
|
||
_, err := g.MergeEntities("不存在", "张三")
|
||
if err == nil {
|
||
t.Error("expected error for nonexistent source")
|
||
}
|
||
}
|
||
|
||
func TestMemoryBlocksAreFirstClassGraphNodes(t *testing.T) {
|
||
g := newTestGraph(t)
|
||
defer os.Remove(g.dbPath)
|
||
defer g.Close()
|
||
|
||
image := MemoryBlock{
|
||
ID: "block_image_1", Modality: BlockImage,
|
||
PayloadDigest: "0123456789abcdef", MIME: "image/png", Size: 1234,
|
||
Width: 768, Height: 512, Vector: []float64{0.1, 0.2, 0.3},
|
||
Fingerprint: "qwen:test", Source: "qq", Tool: "upload",
|
||
}
|
||
text := MemoryBlock{ID: "block_text_1", Modality: BlockText, Text: "用户上传了一张架构图"}
|
||
if err := g.PutMemoryBlocks([]MemoryBlock{image, text}); err != nil {
|
||
t.Fatalf("PutMemoryBlocks: %v", err)
|
||
}
|
||
if err := g.AddMemoryBlockEdge("block", text.ID, "block", image.ID, "contains"); err != nil {
|
||
t.Fatalf("AddMemoryBlockEdge: %v", err)
|
||
}
|
||
|
||
blocks, err := g.MemoryBlocks()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(blocks) != 2 {
|
||
t.Fatalf("memory blocks=%d, want 2", len(blocks))
|
||
}
|
||
var gotImage *MemoryBlock
|
||
for i := range blocks {
|
||
if blocks[i].ID == image.ID {
|
||
gotImage = &blocks[i]
|
||
}
|
||
}
|
||
if gotImage == nil || gotImage.Modality != BlockImage || gotImage.PayloadDigest != image.PayloadDigest || gotImage.Fingerprint != image.Fingerprint || len(gotImage.Vector) != 3 {
|
||
t.Fatalf("image block not round-tripped: %+v", gotImage)
|
||
}
|
||
|
||
edges, err := g.MemoryBlockEdges()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(edges) != 1 || edges[0].Type != "contains" || edges[0].SourceID != text.ID || edges[0].TargetID != image.ID {
|
||
t.Fatalf("memory block edges=%+v", edges)
|
||
}
|
||
|
||
graph, err := g.GraphData()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
graphBlocks, ok := graph["memory_blocks"].([]MemoryBlock)
|
||
if !ok || len(graphBlocks) != 2 {
|
||
t.Fatalf("GraphData memory_blocks=%T %+v", graph["memory_blocks"], graph["memory_blocks"])
|
||
}
|
||
graphEdges, ok := graph["memory_block_edges"].([]MemoryBlockEdge)
|
||
if !ok || len(graphEdges) != 1 {
|
||
t.Fatalf("GraphData memory_block_edges=%T %+v", graph["memory_block_edges"], graph["memory_block_edges"])
|
||
}
|
||
}
|
||
|
||
func TestMemoryBlockEdgeRejectsMissingEndpoint(t *testing.T) {
|
||
g := newTestGraph(t)
|
||
defer os.Remove(g.dbPath)
|
||
defer g.Close()
|
||
|
||
if err := g.PutMemoryBlocks([]MemoryBlock{{ID: "known", Modality: BlockText, Text: "known"}}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := g.AddMemoryBlockEdge("block", "known", "block", "missing", "derived_from"); err == nil {
|
||
t.Fatal("edge to missing node must fail")
|
||
}
|
||
edges, err := g.MemoryBlockEdges()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(edges) != 0 {
|
||
t.Fatalf("failed transaction left edges: %+v", edges)
|
||
}
|
||
}
|
||
|
||
func TestPlaceholders(t *testing.T) {
|
||
if placeholders(0) != "NULL" {
|
||
t.Errorf("expected NULL for n=0, got %s", placeholders(0))
|
||
}
|
||
if placeholders(1) != "?" {
|
||
t.Errorf("expected '?' for n=1, got %s", placeholders(1))
|
||
}
|
||
if placeholders(3) != "?,?,?" {
|
||
t.Errorf("expected '?,?,?' for n=3, got %s", placeholders(3))
|
||
}
|
||
}
|