Files
HomeAgent/internal/sdk/memory_impl_test.go
JianFeeeee 5836c2ce5c refactor(memory): 拆除描述式媒体索引,媒体成为一等块并按原生向量融合
背景:此前媒体是靠「生成的描述文本」将就进记忆的——写 marker 进正文、
再由正则反解成 media_refs 与图库里的 type=Media 实体。这条链路有三个
致命缺陷:描述由异步模型生成(未生成前媒体等于不存在)、语义检索实质上
只搜描述文字、图库里的「媒体节点」是描述文本的投影而不是媒体本身。

本提交把这条链路整体拆除,媒体改为按自己的原生向量参与记忆:

一、描述链彻底删除(无残留、无兼容分支)
- media.Item 去掉 Description/DescribedBy 与对应列;
- 删除 Store.Describe / Store.Search / Store.Pending;
- 删除 Agent.mediaDescribeLoop / describePendingMedia 与配置项
  core.memory.media.describe_on_ingest;
- SDK 侧 MediaAttachment 去掉 Description(见 SDK 仓独立提交)。

二、marker 机制删除,媒体归属改为结构化块边
- 删除 mediaMarkerLine/parseMediaMarkers/mediaEntityName/mediaTriplesFromText/
  extractMediaDigests/sentenceWithMediaMarkers/docMediaContext;
- memory.Triple 新增 MediaDigests 结构化字段;句子文本保持原样,
  不再被 marker 污染;
- 块以 sentence --contains--> block / document --contains--> block 结构边
  挂到承载节点(新增 documents 表与 document 节点种类);
- 模型未给原句时用「主谓宾。」拼一句自然语言作落点,不造 marker 文本。

三、旧数据迁移(幂等)
- 新增 GraphDB.MigrateLegacyMediaEntities:把 type=Media 的旧实体按短 digest
  还原成原生块、挂回原句子、删除旧实体与描述关系;Agent 启动时执行;
- CleanupOrphanedSentences 同时看关系引用与块边,避免把只靠块存活的句子
  连同块边一起删掉。

四、向量融合:媒体按图本身被召回
- 新增 vector.FuseVectors(逐维求和 + L2 归一化);
- Doc.DenseVec = 文本向量 ⊕ 文档块的媒体向量(同 fingerprint 才融合),
  新增 Doc.DenseFP,指纹变化触发重算;
- ContextEvent.DenseVec 同理融合事件块;事件新增 DenseFP,Prune 只在
  同一统一空间内比稠密余弦;
- 跨模态视觉路只召回「仍被某层记忆块持有」的媒体,CAS 全库字节不再
  直接充当记忆检索结果。

五、同时纳入本分支既有的嵌入基础改造(此前工作区未提交,缺它 HEAD 不可构建)
- internal/tfidf 懒回退包、千问三段式多模态 ONNX 空间的 Go 侧
  (qwen/embedder.go、image.go、model_input.go)、CLIP 移除、
  sdk.NewStore 分词器签名与调用点、embed 侧车 systemd 单元。

验证:go build ./... 、go vet ./...(含 -tags medialive)均通过;
在 HEAD 的独立 worktree 上重放本次暂存集后 go test -short ./internal/...
全部通过(端口冲突类用例在隔离环境中亦通过)。未提交工作区中与本改造
无关的改动(HarmonyOS、waiter、devicebridge、plan.md 等)。
2026-09-11 11:45:24 +08:00

413 lines
13 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 sdk
import (
"path/filepath"
"strconv"
"strings"
"testing"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
doc "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
)
// 插件边界的媒体透传测试。
//
// 断言的都是不变量,而不是"函数被调过"
// 1. 插件填的字段一个都不许丢(旧实现静默裁掉 Confidence/类型/SentenceText
// 2. 插件不必知道媒体标记格式,内核负责补;
// 3. 媒体引用挂到正确的 owner 上,删除时释放;
// 4. mediaStore 为 nil 时整条链路退化成纯文本,不 panic 不报错。
func newTestStores(t *testing.T) (*memory.GraphDB, *doc.Store, *text.Memory, *media.Store) {
t.Helper()
dir := t.TempDir()
g, err := memory.NewGraphDB(filepath.Join(dir, "graph.db"))
if err != nil {
t.Fatalf("NewGraphDB: %v", err)
}
t.Cleanup(func() { g.Close() })
ds := doc.NewStore(filepath.Join(dir, "documents"), memory.TokenizeWords)
if err := ds.Start(); err != nil {
t.Fatalf("doc store start: %v", err)
}
t.Cleanup(func() { ds.Stop() })
tm := text.New(filepath.Join(dir, "text"))
if err := tm.Start(); err != nil {
t.Fatalf("text memory start: %v", err)
}
t.Cleanup(func() { tm.Stop() })
ms, err := media.New(filepath.Join(dir, "media"))
if err != nil {
t.Fatalf("media.New: %v", err)
}
t.Cleanup(func() { ms.Close() })
return g, ds, tm, ms
}
// putMedia 存一份媒体,返回完整 digest。
func putMedia(t *testing.T, ms *media.Store, payload string) string {
t.Helper()
d, err := ms.Put([]byte(payload), media.Item{MIME: "image/png"})
if err != nil {
t.Fatalf("media.Put: %v", err)
}
return d
}
// ---------- 图记忆 ----------
// 旧实现只搬 Subject/Relation/Object其余字段静默丢弃
// 插件标注的类型全部落成默认 Concept置信度全成 1.0SentenceText 直接消失。
func TestGraphCommit_CarriesAllFields(t *testing.T) {
g, _, _, ms := newTestStores(t)
m := NewGraphMemoryWithMedia("tester", g, ms)
err := m.Commit([]Triple{{
Subject: "张三",
Relation: "养",
Object: "橘猫",
Confidence: 0.75,
SubjectType: "Person",
ObjectType: "Animal",
SentenceText: "张三养了一只橘猫。",
}})
if err != nil {
t.Fatalf("Commit: %v", err)
}
res, err := g.Recall([]string{"张三"}, nil, 2, "")
if err != nil {
t.Fatalf("Recall: %v", err)
}
if len(res.Relations) == 0 {
t.Fatal("召回不到刚提交的关系")
}
r := res.Relations[0]
if r.Confidence != 0.75 {
t.Errorf("Confidence = %v期望 0.75(插件标注的置信度被丢弃)", r.Confidence)
}
if r.SentenceText != "张三养了一只橘猫。" {
t.Errorf("SentenceText = %q期望原句丢了它媒体就没有落点", r.SentenceText)
}
var subjType, objType string
for _, e := range res.Entities {
switch e.Name {
case "张三":
subjType = e.Type
case "橘猫":
objType = e.Type
}
}
if subjType != "Person" || objType != "Animal" {
t.Errorf("实体类型 = (%q,%q),期望 (Person,Animal)", subjType, objType)
}
}
// 插件只给 digest句子由内核合成块必须挂到该句子sentence --contains--> block
func TestGraphCommit_BindsMediaFromDigests(t *testing.T) {
g, _, _, ms := newTestStores(t)
digest := putMedia(t, ms, "png-bytes")
m := NewGraphMemoryWithMedia("tester", g, ms)
if err := m.Commit([]Triple{{
Subject: "配色图",
Relation: "包含",
Object: "三色带",
SentenceText: "这张图是紫蓝红三色带。",
MediaDigests: []string{digest[:12]}, // 插件手里通常只有短 digest
}}); err != nil {
t.Fatalf("Commit: %v", err)
}
res, err := g.Recall([]string{"配色图"}, nil, 2, "")
if err != nil {
t.Fatalf("Recall: %v", err)
}
if len(res.Relations) == 0 || res.Relations[0].SentenceID == 0 {
t.Fatal("没有句子落点 —— 媒体块无从挂接")
}
sid := res.Relations[0].SentenceID
// 句子文本保持原样:不再往正文里贴媒体标记。
if strings.Contains(res.Relations[0].SentenceText, digest[:12]) {
t.Errorf("句子文本不该被媒体标记污染: %q", res.Relations[0].SentenceText)
}
blocks, err := g.BlocksForNode("sentence", strconv.FormatInt(sid, 10))
if err != nil {
t.Fatalf("BlocksForNode: %v", err)
}
if len(blocks) != 1 || blocks[0].PayloadDigest != digest {
t.Errorf("句子 #%d 的媒体块 = %+v期望 [%s]", sid, blocks, digest)
}
}
// 同一个 digest 在同一三元组里重复出现(短/完整混写)时只能建一个块。
func TestGraphCommit_DedupesRepeatedDigest(t *testing.T) {
g, _, _, ms := newTestStores(t)
digest := putMedia(t, ms, "dup-bytes")
m := NewGraphMemoryWithMedia("tester", g, ms)
if err := m.Commit([]Triple{{
Subject: "重复图",
Relation: "标记",
Object: "一次",
SentenceText: "同一张图说了两遍。",
MediaDigests: []string{digest[:12], digest},
}}); err != nil {
t.Fatalf("Commit: %v", err)
}
res, _ := g.Recall([]string{"重复图"}, nil, 2, "")
if len(res.Relations) == 0 {
t.Fatal("召回不到关系")
}
blocks, err := g.BlocksForNode("sentence", strconv.FormatInt(res.Relations[0].SentenceID, 10))
if err != nil {
t.Fatal(err)
}
if len(blocks) != 1 {
t.Fatalf("同一 digest 应只产生 1 个块,实际 %d: %+v", len(blocks), blocks)
}
}
// mediaStore 为 nil 时仍要能提交(媒体是增强,不是必需品);只是不会建块。
func TestGraphCommit_NilMediaStoreDegrades(t *testing.T) {
g, _, _, _ := newTestStores(t)
m := NewGraphMemory(g)
if err := m.Commit([]Triple{{
Subject: "无存储",
Relation: "仍可",
Object: "提交",
SentenceText: "无媒体存储时的句子。",
MediaDigests: []string{"aabbccddeeff"},
}}); err != nil {
t.Fatalf("Commit 在无媒体存储时不该失败: %v", err)
}
res, _ := g.Recall([]string{"无存储"}, nil, 2, "")
if len(res.Relations) == 0 {
t.Fatal("召回不到关系")
}
blocks, err := g.BlocksForNode("sentence", strconv.FormatInt(res.Relations[0].SentenceID, 10))
if err != nil {
t.Fatal(err)
}
if len(blocks) != 0 {
t.Fatalf("无媒体存储时不该建块,实际 %+v", blocks)
}
}
// Recall 必须把置信度带回插件:拿不到它,插件只能把所有召回结果等同看待。
func TestGraphRecall_CarriesConfidence(t *testing.T) {
g, _, _, ms := newTestStores(t)
m := NewGraphMemoryWithMedia("tester", g, ms)
// 实体名至少两个字符validEntityName 会静默跳过单字实体,
// 那样 Commit 返回 nil 但什么都没写,测试会退化成假阳性。
if err := m.Commit([]Triple{{
Subject: "甲方", Relation: "疑似", Object: "乙方", Confidence: 0.3,
}}); err != nil {
t.Fatalf("Commit: %v", err)
}
_, rels, err := m.Recall([]string{"甲方"}, 2)
if err != nil {
t.Fatalf("Recall: %v", err)
}
if len(rels) == 0 {
t.Fatal("召回为空")
}
if rels[0].Confidence != 0.3 {
t.Errorf("Confidence = %v期望 0.3", rels[0].Confidence)
}
}
// ---------- 文档记忆(知识库) ----------
// 附件带 Data → 落进 CAS 并成为文档直接持有的一等块。
func TestDocInsertWithMedia_StoresAndBinds(t *testing.T) {
_, ds, _, ms := newTestStores(t)
dm := NewDocMemoryWithMedia("tester", ds, ms)
d := &Doc{Title: "带图笔记", Content: "这是正文。"}
err := dm.InsertWithMedia(d, []MediaAttachment{{
MIME: "image/png",
Data: []byte("attachment-bytes"),
Name: "chart.png",
}})
if err != nil {
t.Fatalf("InsertWithMedia: %v", err)
}
if d.ID == "" {
t.Fatal("ID 未回填 —— 插件拿不到刚写入文档的 id")
}
// 正文保持原样:不再往 Content 里拼任何媒体标记。
if strings.Contains(d.Content, "image/png") {
t.Errorf("正文不该被媒体标记污染: %q", d.Content)
}
blocks := ds.Blocks()
if len(blocks) != 1 || blocks[0].PayloadDigest == "" {
t.Fatalf("文档记忆块 = %+v期望 1 条", blocks)
}
// 内容可读,说明真的落盘了而不只是记了个 digest。
got, err := ms.Get(blocks[0].PayloadDigest)
if err != nil || string(got) != "attachment-bytes" {
t.Errorf("媒体内容读回失败: %v / %q", err, got)
}
}
// 只给 Digest 的附件是「引用已有内容」,不该报错也不该重复落盘。
func TestDocInsertWithMedia_DigestOnlyReference(t *testing.T) {
_, ds, _, ms := newTestStores(t)
digest := putMedia(t, ms, "existing")
before := ms.Stats()["count"]
dm := NewDocMemoryWithMedia("tester", ds, ms)
d := &Doc{Title: "引用已有", Content: "正文"}
if err := dm.InsertWithMedia(d, []MediaAttachment{{Digest: digest[:10]}}); err != nil {
t.Fatalf("InsertWithMedia: %v", err)
}
if after := ms.Stats()["count"]; after != before {
t.Errorf("媒体条数从 %v 变成 %v —— 引用已有内容不该新增", before, after)
}
blocks := ds.Blocks()
if len(blocks) != 1 || blocks[0].PayloadDigest != digest {
t.Errorf("引用 = %+v期望 [%s]", blocks, digest)
}
}
// Query 必须回媒体元数据但**不回字节**:一次检索可能命中几十份媒体,
// 全塞回插件会把跨进程消息撑爆。
func TestDocQuery_FillsMediaMetadataWithoutBytes(t *testing.T) {
_, ds, _, ms := newTestStores(t)
dm := NewDocMemoryWithMedia("tester", ds, ms)
d := &Doc{Title: "紫蓝红三色带", Content: "配色说明"}
if err := dm.InsertWithMedia(d, []MediaAttachment{{
MIME: "image/png", Data: []byte("query-bytes"),
}}); err != nil {
t.Fatalf("InsertWithMedia: %v", err)
}
got := dm.Query("紫蓝红三色带 配色说明", 3)
if len(got) == 0 {
t.Fatal("检索不到刚写入的文档")
}
var hit *Doc
for _, g := range got {
if g.ID == d.ID {
hit = g
}
}
if hit == nil {
t.Fatalf("检索结果里没有目标文档: %+v", got)
}
if len(hit.MediaDigests) != 1 {
t.Errorf("MediaDigests = %v期望 1 条", hit.MediaDigests)
}
if len(hit.Attachments) != 1 {
t.Fatalf("Attachments = %v期望 1 条", hit.Attachments)
}
att := hit.Attachments[0]
if att.MIME != "image/png" || att.Digest != hit.MediaDigests[0] {
t.Errorf("附件元数据 = %+v期望 mime=image/png 且 digest 与 MediaDigests 一致", att)
}
if len(att.Data) != 0 {
t.Errorf("Attachments 不该带字节(%d 字节)—— 需要时按 digest 单取", len(att.Data))
}
}
// 文档被删除时它持有的一等记忆块随之消失,媒体不再被任何记忆块持有。
func TestDocRemove_DropsBlocks(t *testing.T) {
_, ds, _, ms := newTestStores(t)
dm := NewDocMemoryWithMedia("tester", ds, ms)
d := &Doc{Title: "待删除", Content: "正文"}
if err := dm.InsertWithMedia(d, []MediaAttachment{{
MIME: "image/png", Data: []byte("to-be-freed"),
}}); err != nil {
t.Fatalf("InsertWithMedia: %v", err)
}
if blocks := ds.Blocks(); len(blocks) != 1 {
t.Fatalf("前置条件不成立,块 = %+v", blocks)
}
dm.Remove(d.ID)
if blocks := ds.Blocks(); len(blocks) != 0 {
t.Errorf("删除文档后仍持有 %+v —— 媒体仍被记忆引用", blocks)
}
}
// mediaStore 为 nil 时 Insert/Query/Remove 必须与本特性上线前完全一致。
func TestDocMemory_NilMediaStoreDegrades(t *testing.T) {
_, ds, _, _ := newTestStores(t)
dm := NewDocMemory(ds)
d := &Doc{Title: "无媒体存储", Content: "正文照常写入"}
if err := dm.InsertWithMedia(d, []MediaAttachment{{
MIME: "image/png", Data: []byte("ignored"),
}}); err != nil {
t.Fatalf("无媒体存储时写入不该失败: %v", err)
}
if d.ID == "" {
t.Error("ID 仍应回填")
}
got := dm.Query("无媒体存储 正文照常写入", 3)
if len(got) == 0 {
t.Fatal("检索不到文档")
}
if len(got[0].Attachments) != 0 {
t.Errorf("无媒体存储时不该有附件: %+v", got[0].Attachments)
}
dm.Remove(d.ID) // 不该 panic
}
// ---------- 文本记忆 ----------
// 文本记忆是追加写 JSONL 的字符串日志,没有块容器。
// 它不会愄造文本标记来承载媒体:附件被明确忽略并记录日志,
// 需要保存媒体请用文档/图记忆。
func TestTextMemory_AttachmentsIgnoredNotFaked(t *testing.T) {
_, _, tm, ms := newTestStores(t)
m := NewTextMemoryWithMedia("tester", tm, ms)
if err := m.Append(TextEvent{
Role: "user",
Content: "看这张图",
Attachments: []MediaAttachment{{
MIME: "image/png", Data: []byte("text-mem-bytes"),
}},
}); err != nil {
t.Fatalf("Append: %v", err)
}
got, err := m.RecentEvents(5)
if err != nil {
t.Fatalf("RecentEvents: %v", err)
}
if len(got) == 0 {
t.Fatal("读不到刚追加的事件")
}
last := got[len(got)-1]
if last.Content != "看这张图" {
t.Errorf("正文应保持原样,实际 %q", last.Content)
}
if len(last.Attachments) != 0 {
t.Errorf("文本层不该凭空造出附件(没有块存储可挂): %+v", last.Attachments)
}
}