mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 18:08:04 +00:00
背景:此前媒体是靠「生成的描述文本」将就进记忆的——写 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 等)。
279 lines
7.5 KiB
Go
279 lines
7.5 KiB
Go
package media
|
||
|
||
import (
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// newTestStore 建一个临时媒体存储。
|
||
// 参数保留只为兼容旧调用点;媒体不再有容量上限(生命周期由记忆块决定)。
|
||
func newTestStore(t *testing.T, _ ...int64) *Store {
|
||
t.Helper()
|
||
s, err := New(t.TempDir())
|
||
if err != nil {
|
||
t.Fatalf("New: %v", err)
|
||
}
|
||
t.Cleanup(func() { s.Close() })
|
||
return s
|
||
}
|
||
|
||
func TestPut_ContentAddressedDedup(t *testing.T) {
|
||
s := newTestStore(t, 0)
|
||
data := []byte("fake-png-bytes")
|
||
|
||
d1, err := s.Put(data, Item{MIME: "image/png", OriginPath: "/tmp/a.png"})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
d2, err := s.Put(data, Item{MIME: "image/png", OriginPath: "/tmp/b.png"})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if d1 != d2 {
|
||
t.Fatalf("同一内容应得同一 digest:%s vs %s", d1, d2)
|
||
}
|
||
|
||
// 去重的意义:同一张图反复注入(连问几轮同一截图、see_video 相邻帧)
|
||
// 只占一份磁盘。
|
||
st := s.Stats()
|
||
if st["count"].(int) != 1 {
|
||
t.Fatalf("同一内容应只有 1 条记录,实际 %v", st["count"])
|
||
}
|
||
}
|
||
|
||
func TestPut_KindInferredFromMIME(t *testing.T) {
|
||
s := newTestStore(t, 0)
|
||
cases := map[string]Kind{
|
||
"image/png": KindImage,
|
||
"image/jpeg": KindImage,
|
||
"audio/wav": KindAudio,
|
||
"video/mp4": KindVideo,
|
||
"text/plain": KindOther,
|
||
}
|
||
for mime, want := range cases {
|
||
d, err := s.Put([]byte("payload-"+mime), Item{MIME: mime})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
it, err := s.Stat(d)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if it.Kind != want {
|
||
t.Fatalf("%s 应归为 %s,实际 %s", mime, want, it.Kind)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestGet_DetectsCorruption(t *testing.T) {
|
||
// CAS 的全部保证建立在「文件名 == 内容摘要」上。外部误改或位翻转必须
|
||
// 被发现——把损坏的图喂给模型只会得到无从追溯的幻觉。
|
||
s := newTestStore(t, 0)
|
||
d, err := s.Put([]byte("original-content"), Item{MIME: "image/png"})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := s.Get(d); err != nil {
|
||
t.Fatalf("正常读取应成功: %v", err)
|
||
}
|
||
|
||
if err := os.WriteFile(s.blobPath(d), []byte("tampered!"), 0644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
_, err = s.Get(d)
|
||
if err == nil {
|
||
t.Fatal("内容被改后应报 digest 不匹配,却读成功了")
|
||
}
|
||
if !strings.Contains(err.Error(), "digest mismatch") {
|
||
t.Fatalf("错误应指明 digest 不匹配,得到: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestPut_NoPartialBlobOnDisk(t *testing.T) {
|
||
// 先写 .tmp 再 rename:确认落地后目录里不留临时文件。
|
||
s := newTestStore(t, 0)
|
||
d, err := s.Put([]byte("some-bytes"), Item{MIME: "image/png"})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
dir := filepath.Dir(s.blobPath(d))
|
||
entries, err := os.ReadDir(dir)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for _, e := range entries {
|
||
if strings.HasSuffix(e.Name(), ".tmp") {
|
||
t.Fatalf("落地后不该留临时文件: %s", e.Name())
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestDelete_RemovesContentAndMetadata(t *testing.T) {
|
||
// 删除块即删除内容:Delete 同时清掉 blob 与元数据。
|
||
// 这不是 GC,也不看引用计数——调用方是记忆系统本身。
|
||
s := newTestStore(t)
|
||
d, _ := s.Put([]byte("held"), Item{MIME: "image/png"})
|
||
other, _ := s.Put([]byte("orphaned"), Item{MIME: "image/png"})
|
||
|
||
if err := s.Delete(other); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := s.Stat(other); err == nil {
|
||
t.Fatal("删除后元数据应已移除")
|
||
}
|
||
if _, err := s.Get(other); err == nil {
|
||
t.Fatal("删除后内容应已移除")
|
||
}
|
||
// 未被删除的项不受影响
|
||
if _, err := s.Get(d); err != nil {
|
||
t.Fatalf("未删除的内容不该受影响: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestDelete_UnknownDigestIsNoop(t *testing.T) {
|
||
s := newTestStore(t)
|
||
if err := s.Delete(""); err != nil {
|
||
t.Fatalf("空 digest 应为无操作: %v", err)
|
||
}
|
||
if err := s.Delete("ffffffffffffffff"); err != nil {
|
||
t.Fatalf("不存在的 digest 应为无操作: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestDescribe_Removed(t *testing.T) {
|
||
// 媒体不再有文字描述:描述式索引是废弃的就机制。
|
||
// 这里只保留一个编译期断言,确保 API 不会静默回归。
|
||
s := newTestStore(t)
|
||
if _, err := s.Put([]byte("img"), Item{MIME: "image/png"}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
func TestPut_BackfillsMissingDimensions(t *testing.T) {
|
||
// 后来者可能带着前一次没有的信息(尺寸、工具名)
|
||
s := newTestStore(t, 0)
|
||
data := []byte("img")
|
||
d, _ := s.Put(data, Item{MIME: "image/png"})
|
||
|
||
if _, err := s.Put(data, Item{MIME: "image/png", Width: 640, Height: 480, Tool: "multimodal_see_picture"}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
it, _ := s.Stat(d)
|
||
if it.Width != 640 || it.Height != 480 {
|
||
t.Fatalf("尺寸应被补写: %dx%d", it.Width, it.Height)
|
||
}
|
||
if it.Tool != "multimodal_see_picture" {
|
||
t.Fatalf("工具名应被补写: %q", it.Tool)
|
||
}
|
||
}
|
||
|
||
func TestStats_CountsByKind(t *testing.T) {
|
||
s := newTestStore(t)
|
||
s.Put([]byte("i1"), Item{MIME: "image/png"})
|
||
s.Put([]byte("i2"), Item{MIME: "image/jpeg"})
|
||
s.Put([]byte("a1"), Item{MIME: "audio/wav"})
|
||
|
||
st := s.Stats()
|
||
if st["count"].(int) != 3 {
|
||
t.Fatalf("count 应为 3,实际 %v", st["count"])
|
||
}
|
||
if _, ok := st["described"]; ok {
|
||
t.Fatal("媒体已不再有描述计数")
|
||
}
|
||
byKind := st["by_kind"].(map[string]int)
|
||
if byKind["image"] != 2 || byKind["audio"] != 1 {
|
||
t.Fatalf("by_kind 不对: %v", byKind)
|
||
}
|
||
}
|
||
|
||
func TestParseDataURL(t *testing.T) {
|
||
// 与 agent/api 的 parseAudioDataURL 不同:这里要解码后的字节。
|
||
raw := []byte{0x89, 'P', 'N', 'G'}
|
||
url := DataURL("image/png", raw)
|
||
|
||
mime, data, ok := ParseDataURL(url)
|
||
if !ok {
|
||
t.Fatal("应解析成功")
|
||
}
|
||
if mime != "image/png" {
|
||
t.Fatalf("MIME 应为 image/png,得到 %q", mime)
|
||
}
|
||
if string(data) != string(raw) {
|
||
t.Fatalf("字节应还原,得到 %v", data)
|
||
}
|
||
|
||
for _, bad := range []string{
|
||
"http://example.com/x.png", // 不是 data URL
|
||
"data:image/png,notbase64", // 缺 ;base64
|
||
"data:;base64,", // 空 MIME 与空载荷
|
||
"data:image/png;base64,!!!", // 非法 base64
|
||
} {
|
||
if _, _, ok := ParseDataURL(bad); ok {
|
||
t.Fatalf("%q 应解析失败", bad)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestPut_RejectsEmpty(t *testing.T) {
|
||
s := newTestStore(t)
|
||
if _, err := s.Put(nil, Item{MIME: "image/png"}); err == nil {
|
||
t.Fatal("空内容应报错")
|
||
}
|
||
}
|
||
|
||
func TestReopen_PersistsAcrossRestart(t *testing.T) {
|
||
// 记忆的意义就在于跨重启还在。
|
||
dir := t.TempDir()
|
||
s1, err := New(dir)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
d, _ := s1.Put([]byte("persistent-img"), Item{MIME: "image/png", OriginPath: "/tmp/x.png"})
|
||
s1.SetVec(d, []float64{0.1, 0.2}, "test-space")
|
||
s1.Close()
|
||
|
||
s2, err := New(dir)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer s2.Close()
|
||
|
||
it, err := s2.Stat(d)
|
||
if err != nil {
|
||
t.Fatalf("重开后应能查到: %v", err)
|
||
}
|
||
if it.OriginPath != "/tmp/x.png" || len(it.Vec) != 2 || it.VecModel != "test-space" {
|
||
t.Fatalf("元数据与向量应持久化: %+v", it)
|
||
}
|
||
data, err := s2.Get(d)
|
||
if err != nil || string(data) != "persistent-img" {
|
||
t.Fatalf("内容应持久化: %v / %q", err, data)
|
||
}
|
||
}
|
||
|
||
func TestStaleVecDigests_TracksModelSwitch(t *testing.T) {
|
||
// 模型切换后旧向量必须被重算:StaleVecDigests 是启动迁移的入口。
|
||
s := newTestStore(t)
|
||
d1, _ := s.Put([]byte("a"), Item{MIME: "image/png"})
|
||
d2, _ := s.Put([]byte("b"), Item{MIME: "image/png"})
|
||
s.SetVec(d1, []float64{0.1}, "space-a")
|
||
|
||
stale, err := s.StaleVecDigestsAll("space-a")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(stale) != 1 || stale[0] != d2 {
|
||
t.Fatalf("只有未嵌入的 d2 需重算,实际 %v", stale)
|
||
}
|
||
|
||
stale, err = s.StaleVecDigestsAll("space-b")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(stale) != 2 {
|
||
t.Fatalf("换空间后两条都需重算,实际 %v", stale)
|
||
}
|
||
}
|