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 等)。
177 lines
5.1 KiB
Go
177 lines
5.1 KiB
Go
package media
|
||
|
||
import (
|
||
"math"
|
||
"testing"
|
||
)
|
||
|
||
func TestQueryMedia_BasicSimilarity(t *testing.T) {
|
||
s := newTestStore(t, 0)
|
||
defer s.Close()
|
||
|
||
// 入库三张带向量的媒体:两张图、一段音频
|
||
d1, _ := s.Put([]byte("img1"), Item{MIME: "image/png"})
|
||
d2, _ := s.Put([]byte("img2"), Item{MIME: "image/jpeg"})
|
||
d3, _ := s.Put([]byte("aud1"), Item{MIME: "audio/wav"})
|
||
|
||
// 模拟视觉嵌入:img1 和 img2 向量接近,aud1 远离
|
||
vec1 := []float64{0.9, 0.1, 0.0, 0.0}
|
||
vec2 := []float64{0.8, 0.2, 0.0, 0.0} // 与 vec1 相似
|
||
vec3 := []float64{0.0, 0.0, 0.9, 0.1} // 与前两个完全不同
|
||
|
||
s.SetVec(d1, vec1, "test-clip")
|
||
s.SetVec(d2, vec2, "test-clip")
|
||
s.SetVec(d3, vec3, "test-clip")
|
||
|
||
// 用 vec1 作为查询:vec2 最相似,vec3 与 vec1 正交(相似度 0,被阈值过滤)
|
||
results, err := s.QueryMedia(vec1, "test-clip", 10)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// vec3 与 vec1 正交(余弦相似度 0),被 0.05 阈值正确剔除 → 只召回 2 个
|
||
if len(results) != 2 {
|
||
t.Fatalf("expected 2 results (正交的 aud1 被阈值过滤), got %d", len(results))
|
||
}
|
||
// 第一个应该是 img2(0.9 vs d1 的 1.0?不,这里算清楚)
|
||
// vec1·vec2 与 vec1·vec1 比较:
|
||
// sim(vec1,vec1) = 1.0(img1 与自身),sim(vec1,vec2) = 0.9*0.8+0.1*0.2 = 0.74
|
||
// 所以 img1(自相似 1.0)排第一,img2 排第二
|
||
if results[0].Digest != d1 {
|
||
t.Errorf("expected d1 (自相似 1.0) as first, got %s", results[0].Digest)
|
||
}
|
||
if results[1].Digest != d2 {
|
||
t.Errorf("expected d2 as second, got %s", results[1].Digest)
|
||
}
|
||
|
||
// 验证分数:img1 与自身是 1.0
|
||
selfScore := cosineSimilaritySlice(vec1, vec1)
|
||
if math.Abs(selfScore-1.0) > 1e-10 {
|
||
t.Errorf("self-similarity should be 1.0, got %f", selfScore)
|
||
}
|
||
|
||
// img1 与 aud1 的相似度应该很低
|
||
crossScore := cosineSimilaritySlice(vec1, vec3)
|
||
if crossScore > 0.1 {
|
||
t.Errorf("cross-modality similarity should be low, got %f", crossScore)
|
||
}
|
||
}
|
||
|
||
func TestQueryMedia_EmptyVecSkipped(t *testing.T) {
|
||
s := newTestStore(t, 0)
|
||
defer s.Close()
|
||
|
||
d1, _ := s.Put([]byte("img1"), Item{MIME: "image/png"})
|
||
_, _ = s.Put([]byte("img2"), Item{MIME: "image/png"})
|
||
|
||
// d1 有向量,d2 没有
|
||
s.SetVec(d1, []float64{0.5, 0.5}, "test")
|
||
// d2 留空
|
||
|
||
results, err := s.QueryMedia([]float64{0.5, 0.5}, "test", 10)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(results) != 1 {
|
||
t.Fatalf("expected 1 result (d2 has no vec), got %d", len(results))
|
||
}
|
||
if results[0].Digest != d1 {
|
||
t.Errorf("expected d1, got %s", results[0].Digest)
|
||
}
|
||
}
|
||
|
||
func TestQueryMedia_DimensionMismatchSkipped(t *testing.T) {
|
||
s := newTestStore(t, 0)
|
||
defer s.Close()
|
||
|
||
d1, _ := s.Put([]byte("img1"), Item{MIME: "image/png"})
|
||
s.SetVec(d1, []float64{0.5, 0.5}, "model-A") // 2 维
|
||
|
||
// 查询用 3 维向量:维度不匹配,应该返回空
|
||
results, err := s.QueryMedia([]float64{0.3, 0.3, 0.3}, "model-A", 10)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(results) != 0 {
|
||
t.Fatalf("expected 0 results (dim mismatch), got %d", len(results))
|
||
}
|
||
}
|
||
|
||
func TestQueryMedia_EmptyQueryReturnsNil(t *testing.T) {
|
||
s := newTestStore(t, 0)
|
||
defer s.Close()
|
||
|
||
results, err := s.QueryMedia(nil, "", 10)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if results != nil {
|
||
t.Fatalf("expected nil, got %d results", len(results))
|
||
}
|
||
}
|
||
|
||
func TestSetVec_PersistsCorrectly(t *testing.T) {
|
||
s := newTestStore(t, 0)
|
||
defer s.Close()
|
||
|
||
d, _ := s.Put([]byte("hello"), Item{MIME: "image/png"})
|
||
vec := []float64{0.1, 0.2, 0.3, 0.4}
|
||
s.SetVec(d, vec, "clip-vit-b32")
|
||
|
||
it, err := s.Stat(d)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if it.VecModel != "clip-vit-b32" {
|
||
t.Errorf("VecModel = %q, want clip-vit-b32", it.VecModel)
|
||
}
|
||
if len(it.Vec) != 4 {
|
||
t.Fatalf("Vec len = %d, want 4", len(it.Vec))
|
||
}
|
||
for i, v := range vec {
|
||
if math.Abs(it.Vec[i]-v) > 1e-10 {
|
||
t.Errorf("Vec[%d] = %f, want %f", i, it.Vec[i], v)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestStaleVecDigests(t *testing.T) {
|
||
s := newTestStore(t, 0)
|
||
defer s.Close()
|
||
|
||
// 有向量且 vec_model 匹配 → 非 stale
|
||
d1, _ := s.Put([]byte("img1"), Item{MIME: "image/png"})
|
||
s.SetVec(d1, []float64{0.1}, "space-a")
|
||
|
||
// 有向量但 vec_model 旧 → stale
|
||
d2, _ := s.Put([]byte("img2"), Item{MIME: "image/png"})
|
||
s.SetVec(d2, []float64{0.2}, "space-old")
|
||
|
||
// 从未嵌入(vec_model 空)→ stale
|
||
d3, _ := s.Put([]byte("img3"), Item{MIME: "image/png"})
|
||
|
||
// 与向量/描述无关的图片同样应被迁移:图片独立参与向量空间
|
||
d4, _ := s.Put([]byte("img4"), Item{MIME: "image/png"})
|
||
|
||
// 音频不参与图片迁移(StaleVecDigests 只查 kind='image')
|
||
s.Put([]byte("aud1"), Item{MIME: "audio/wav"})
|
||
|
||
stale, err := s.StaleVecDigests("space-a")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// d1 匹配模型 → 非 stale;d2 旧模型 + d3 未嵌入 + d4 无描述图片 = 3 stale;aud1 不算
|
||
if len(stale) != 3 {
|
||
t.Fatalf("expected 3 stale digests (d2 旧模型 + d3 未嵌入 + d4 无描述), got %d: %v", len(stale), stale)
|
||
}
|
||
got := map[string]bool{}
|
||
for _, d := range stale {
|
||
got[d] = true
|
||
}
|
||
if !got[d2] || !got[d3] || !got[d4] {
|
||
t.Errorf("expected d2, d3, d4 stale, got %v", stale)
|
||
}
|
||
if got[d1] {
|
||
t.Errorf("d1 (匹配模型) 不应 stale")
|
||
}
|
||
}
|