Files
HomeAgent/internal/memory/media/smoke_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

278 lines
8.6 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 media
import (
"bytes"
"compress/zlib"
"encoding/binary"
"fmt"
"hash/crc32"
"testing"
)
// 冒烟测试:走真实数据路径的端到端场景,而非孤立的 API 单测。
//
// 媒体存储现在只做内容寻址CAS字节 + 元数据 + 向量。
// “哪些字节还活着”由三层记忆持有的一等记忆块决定,调用方把该集合传给
// GC/检索,本层不维护 media_refs/ref_count 这类平行账本。
// makePNG 生成一张 w×h 的条带 PNG用真 PNG 而不是随机字节,
// 让入库/回读/digest 走的是与生产一致的数据形态。
func makePNG(w, h, variant int) []byte {
palette := [][3]byte{
{255, 0, 0}, {0, 192, 0}, {0, 0, 255}, {255, 220, 0}, {160, 0, 200},
}
var raw bytes.Buffer
for y := 0; y < h; y++ {
raw.WriteByte(0) // 每行的滤波器字节
c := palette[(variant+y*3/h)%len(palette)]
for x := 0; x < w; x++ {
raw.Write(c[:])
}
}
// 把 variant 写进首行头几个像素,确保不同 variant 字节必然不同。
b := raw.Bytes()
if len(b) > 8 {
b[1] = byte(variant)
b[2] = byte(variant >> 8)
}
var z bytes.Buffer
zw := zlib.NewWriter(&z)
zw.Write(b)
zw.Close()
chunk := func(typ string, data []byte) []byte {
var out bytes.Buffer
binary.Write(&out, binary.BigEndian, uint32(len(data)))
out.WriteString(typ)
out.Write(data)
binary.Write(&out, binary.BigEndian, crc32.ChecksumIEEE(append([]byte(typ), data...)))
return out.Bytes()
}
var ihdr bytes.Buffer
binary.Write(&ihdr, binary.BigEndian, uint32(w))
binary.Write(&ihdr, binary.BigEndian, uint32(h))
ihdr.Write([]byte{8, 2, 0, 0, 0}) // 8bit 深度、truecolor
var out bytes.Buffer
out.Write([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'})
out.Write(chunk("IHDR", ihdr.Bytes()))
out.Write(chunk("IDAT", z.Bytes()))
out.Write(chunk("IEND", nil))
return out.Bytes()
}
func TestSmoke_SamePictureAcrossTurns(t *testing.T) {
// 场景用户连问几轮同一张截图。multimodal 每轮都会重新注入,
// 内容寻址天然去重,磁盘上只应有一份。
s := newTestStore(t, 50*1024*1024)
png := makePNG(400, 400, 0)
var d0 string
for turn := 1; turn <= 5; turn++ {
// 走 data URL这是 SetToolBlocks 实际给出的形态
url := DataURL("image/png", png)
mime, data, ok := ParseDataURL(url)
if !ok {
t.Fatalf("第 %d 轮 data URL 解析失败", turn)
}
d, err := s.Put(data, Item{
MIME: mime, Width: 400, Height: 400,
OriginPath: fmt.Sprintf("/tmp/probe_%d.png", turn),
Tool: "multimodal_see_picture",
})
if err != nil {
t.Fatalf("第 %d 轮 Put: %v", turn, err)
}
if d0 == "" {
d0 = d
} else if d != d0 {
t.Fatalf("同一张图第 %d 轮 digest 变了", turn)
}
}
st := s.Stats()
if st["count"].(int) != 1 {
t.Fatalf("5 轮同图应只存 1 份,实际 %v 条", st["count"])
}
if total := st["total_bytes"].(int64); total != int64(len(png)) {
t.Fatalf("字节数应等于单张原图 %d实际 %d", len(png), total)
}
// 三层记忆持有它;内容应仍可读
if _, err := s.Get(d0); err != nil {
t.Fatalf("内容应仍可读: %v", err)
}
}
func TestSmoke_VideoFramesDistinct(t *testing.T) {
// 场景see_video 抽 6 帧,帧间内容不同,应各存一份。
s := newTestStore(t, 50*1024*1024)
keep := map[string]bool{}
for i := 0; i < 6; i++ {
d, err := s.Put(makePNG(320, 240, i), Item{
MIME: "image/jpeg", Width: 320, Height: 240, Tool: "multimodal_see_video",
})
if err != nil {
t.Fatalf("第 %d 帧: %v", i, err)
}
keep[d] = true
}
if st := s.Stats(); st["count"].(int) != 6 {
t.Fatalf("6 帧应各存一份,实际 %v 条", st["count"])
}
}
func TestSmoke_NearestNeighborVectorRetrieve(t *testing.T) {
// 场景 C图片只按自己的原生向量被检索。
// 没有描述文本参与——描述式索引是废弃的就机制。
s := newTestStore(t)
pic, _ := s.Put(makePNG(400, 400, 0), Item{MIME: "image/png", Tool: "multimodal_see_picture"})
s.SetVec(pic, []float64{1, 0, 0, 0}, "space")
var frames []string
for i := 0; i < 6; i++ {
d, _ := s.Put(makePNG(320, 240, i), Item{MIME: "image/jpeg", Tool: "multimodal_see_video"})
s.SetVec(d, []float64{1, 1, float64(i) / 10, 0}, "space")
frames = append(frames, d)
}
hits, err := s.QueryMediaScored([]float64{1, 0, 0, 0}, "space", 10)
if err != nil {
t.Fatal(err)
}
if len(hits) != 7 {
t.Fatalf("7 份媒体都有同空间向量,应全部可召,实际 %d", len(hits))
}
if hits[0].Item.Digest != pic {
t.Fatalf("与查询同向的应是第一命中,实际 %s", shortDigest(hits[0].Item.Digest))
}
// 不同向量空间/模型的条目不得参与:坐标系不同,余弦无意义。
foreign := frames[0]
if err := s.SetVec(foreign, []float64{1, 0, 0, 0}, "other-space"); err != nil {
t.Fatal(err)
}
hits, err = s.QueryMediaScored([]float64{1, 0, 0, 0}, "space", 10)
if err != nil {
t.Fatal(err)
}
for _, h := range hits {
if h.Item.Digest == foreign {
t.Fatal("另一套空间other-space的向量不该被 space 查询召回")
}
}
}
func TestSmoke_ContentSurvivesLayerMigration(t *testing.T) {
// 场景:同一份媒体随记忆块从 Context 迁移到 Document 再到 Graph。
// 迁移的是块本身digest 不变,因此内容在整条链路上始终可读。
s := newTestStore(t, 50*1024*1024)
png := makePNG(400, 400, 0)
d, _ := s.Put(png, Item{MIME: "image/png", Tool: "multimodal_see_picture"})
// 迁移过程中该 digest 始终可读
for _, layer := range []string{"context", "document", "graph"} {
if got, err := s.Get(d); err != nil || !bytes.Equal(got, png) {
t.Fatalf("迁移到 %s 时内容应完好: %v", layer, err)
}
}
}
func TestSmoke_DeleteRemovesOnlyThatContent(t *testing.T) {
// 场景:某个工具产出的一次性图片所在的记忆块被删除时,
// 只有它自己的内容被删;其他块的内容一个都不能少。
s := newTestStore(t)
held, _ := s.Put(makePNG(400, 400, 0), Item{MIME: "image/png"})
var frames []string
for i := 0; i < 6; i++ {
d, _ := s.Put(makePNG(320, 240, i), Item{MIME: "image/jpeg"})
frames = append(frames, d)
}
var ephemeral []string
for i := 0; i < 20; i++ {
d, _ := s.Put(makePNG(100, 100, 1000+i), Item{MIME: "image/png", Tool: "cmd_run"})
ephemeral = append(ephemeral, d)
}
before := s.Stats()["count"].(int)
for _, d := range ephemeral {
if err := s.Delete(d); err != nil {
t.Fatal(err)
}
}
after := s.Stats()["count"].(int)
if after != before-20 {
t.Fatalf("条目数应从 %d 降到 %d实际 %d", before, before-20, after)
}
if _, err := s.Get(held); err != nil {
t.Fatalf("被保留的内容被误删: %v", err)
}
for i, f := range frames {
if _, err := s.Get(f); err != nil {
t.Fatalf("第 %d 帧被误删: %v", i, err)
}
}
}
func TestSmoke_FullLifecycleAcrossRestart(t *testing.T) {
// 端到端:入库 → 嵌入 → 删除一些内容 → 重启 → 向量检索,
// 并确认磁盘与元数据不出现双向孤儿。记忆的意义就在于跨重启还在。
dir := t.TempDir()
s, err := New(dir)
if err != nil {
t.Fatal(err)
}
png := makePNG(400, 400, 0)
pic, _ := s.Put(png, Item{MIME: "image/png", Width: 400, Height: 400, Tool: "multimodal_see_picture"})
s.SetVec(pic, []float64{1, 0, 0}, "space")
for i := 0; i < 6; i++ {
d, _ := s.Put(makePNG(320, 240, i), Item{MIME: "image/jpeg", Tool: "multimodal_see_video"})
s.SetVec(d, []float64{0, 1, float64(i)}, "space")
}
for i := 0; i < 10; i++ {
d, _ := s.Put(makePNG(64, 64, 2000+i), Item{MIME: "image/png", Tool: "cmd_run"})
if err := s.Delete(d); err != nil {
t.Fatal(err)
}
}
beforeCount := s.Stats()["count"].(int)
s.Close()
s2, err := New(dir)
if err != nil {
t.Fatalf("重开失败: %v", err)
}
defer s2.Close()
if got := s2.Stats()["count"].(int); got != beforeCount {
t.Fatalf("重开后条目数变了: %d → %d", beforeCount, got)
}
it, err := s2.Stat(pic)
if err != nil {
t.Fatalf("重开后查不到: %v", err)
}
if len(it.Vec) != 3 || it.VecModel != "space" {
t.Fatalf("向量未持久化: %+v", it)
}
data, err := s2.Get(pic)
if err != nil || !bytes.Equal(data, png) {
t.Fatalf("重开后内容不一致: %v", err)
}
hits, err := s2.QueryMediaScored([]float64{1, 0, 0}, "space", 10)
if err != nil {
t.Fatal(err)
}
if len(hits) == 0 || hits[0].Item.Digest != pic {
t.Fatal("重开后向量检索应仍能命中")
}
// 磁盘文件数 == 元数据条数:无「元数据在文件没了」也无「文件在元数据没了」
if n := blobFileCount(t, s2); n != beforeCount {
t.Fatalf("磁盘 blob=%d 与元数据=%d 不一致", n, beforeCount)
}
t.Logf("跨重启:%d 条目、向量与内容全部完好", beforeCount)
}