feat(memory): Graph 原生一等记忆块节点与结构边(§13.12)

媒体/文本在 L3 不再是正文标记反解出的代理实体,而是带原生
modality/digest/MIME/size/vector/fingerprint 的 memory_blocks 节点;
contains/depicts/derived_from 等语义边落在 memory_block_edges,
端点必须是真实图节点(block/entity/sentence),不复用 owner 字符串。

本步只建立存储与查询能力,不接入 media_refs,也不改变 L0/L2 路径。
This commit is contained in:
JianFeeeee
2026-09-11 10:08:59 +08:00
parent 96c1d7baae
commit e44164f5bd
3 changed files with 380 additions and 3 deletions

View File

@ -2,8 +2,8 @@ package memory
import (
"database/sql"
"path/filepath"
"os"
"path/filepath"
"testing"
)
@ -434,6 +434,84 @@ func TestMergeEntitiesNonexistent(t *testing.T) {
}
}
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))