mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-23 10:28:06 +00:00
此前四层记忆全是纯文本载体,没有任何一层能存二进制:
L0 ContextEvent — Input/Response/ToolResults[].Output 全 string
L1 text.Event — 同上
L2 document.Doc — Summary/Content/Tags 全 string
L3 图库 — sentences.text TEXT UNIQUE,节点身份就是那串文本
于是 multimodal 插件注入的图只在本轮对话内可见(走 message 数组,不经
记忆),下一轮起只剩 ToolResultItem.Output 里那句
"[已将图片注入后续对话] /tmp/x.png"——一条路径字符串。那个文件被删或
被覆盖之后连线索都断了。
## 为何内容寻址而不是存路径
- 路径会失效。/tmp 下的探针图、下载缓存、别的进程的临时产物,记忆里
留个路径等于留个悬空指针。
- 同一内容常被反复注入(连问几轮同一张截图、see_video 相邻帧高度相似),
按 sha256 寻址天然去重。
- 内容即身份,与 L3 图库 sentences.text UNIQUE 思路一致:文本节点用文本
本身做身份,媒体节点用内容摘要做身份。
## 结构
元数据(SQLite media.db)与内容(磁盘 blobs/ 两级前缀分桶)分离,不把
blob 塞进库:单张图动辄几 MB,塞进去让每次 VACUUM/备份都拖着几百 MB 走,
WAL 也会迅速膨胀。
media(digest PK, kind, mime, size, width, height, origin_path, tool,
description, described_by, ref_count, first_seen, last_seen)
media_refs(digest, owner_kind, owner_id, created_at, PK 三列)
digest 既是主键也是文件名,所以没有 Path 字段——路径由 digest 推导,
不落库(落了就又是个会失效的引用)。origin_path 仅供人类溯源,注释里
明确标注不可用于读取。owner_kind 预留 context/document/graph_sentence。
## 几处刻意的决定
- Get 强制校验 digest:CAS 的全部保证建立在「文件名 == 内容摘要」上,
位翻转或外部误改必须被发现——把损坏的图喂给模型只会得到无从追溯的幻觉。
- 先写 .tmp 再 rename:中途崩溃不留半个 blob 被当成完整内容读走。
- AddRef 幂等:只有真插进 media_refs 才递增,否则计数虚高会让 GC 永远
不敢清。DropRef 用 MAX(0,...) 兜底防负数。
- Put 的空描述不冲掉已有描述(先到的可能来自更强的模型),但尺寸/工具名
这类前一次缺失的信息会被补写。Describe 是显式操作,允许覆盖。
- GC 两段 + minAge 保护:刚 Put 还没 AddRef 的项 refcount 也是 0,minAge
防「落地后还没挂上就被清掉」。有引用的项永不删除,即使超容量——宁可
超限也不断引用。
## 测试
21 例,覆盖去重 / MIME 归类 / 损坏检测 / 无残留临时文件 / AddRef 幂等 /
计数不为负 / DropOwner / GC 保留有引用项 / minAge 保护 / 容量淘汰 /
描述覆盖与补写策略 / Search 按描述与 kind 过滤 / Pending / data URL
往返 / Stats / 跨重启持久化。
本 commit 只加存储层,尚未接入 L0/L2/L3 与描述生成。
478 lines
13 KiB
Go
478 lines
13 KiB
Go
package media
|
||
|
||
import (
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
func newTestStore(t *testing.T, maxBytes int64) *Store {
|
||
t.Helper()
|
||
s, err := New(t.TempDir(), maxBytes)
|
||
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 TestRefCount_AddIsIdempotent(t *testing.T) {
|
||
s := newTestStore(t, 0)
|
||
d, _ := s.Put([]byte("img"), Item{MIME: "image/png"})
|
||
|
||
for i := 0; i < 3; i++ {
|
||
if err := s.AddRef(d, "context", "evt-1"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
it, _ := s.Stat(d)
|
||
// 重复 AddRef 若都递增,计数会虚高,GC 永远不敢清。
|
||
if it.RefCount != 1 {
|
||
t.Fatalf("同一 owner 重复 AddRef 应只计 1,实际 %d", it.RefCount)
|
||
}
|
||
|
||
if err := s.AddRef(d, "document", "doc-9"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
it, _ = s.Stat(d)
|
||
if it.RefCount != 2 {
|
||
t.Fatalf("不同 owner 应各计一次,实际 %d", it.RefCount)
|
||
}
|
||
}
|
||
|
||
func TestRefCount_DropAndNeverNegative(t *testing.T) {
|
||
s := newTestStore(t, 0)
|
||
d, _ := s.Put([]byte("img"), Item{MIME: "image/png"})
|
||
s.AddRef(d, "context", "e1")
|
||
|
||
if err := s.DropRef(d, "context", "e1"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
it, _ := s.Stat(d)
|
||
if it.RefCount != 0 {
|
||
t.Fatalf("应归零,实际 %d", it.RefCount)
|
||
}
|
||
|
||
// 多余的 DropRef 不该把计数压成负数(负数会让容量 GC 的排序失去意义)
|
||
for i := 0; i < 3; i++ {
|
||
s.DropRef(d, "context", "e1")
|
||
}
|
||
it, _ = s.Stat(d)
|
||
if it.RefCount != 0 {
|
||
t.Fatalf("重复 DropRef 后仍应为 0,实际 %d", it.RefCount)
|
||
}
|
||
}
|
||
|
||
func TestDropOwner_RemovesAllItsRefs(t *testing.T) {
|
||
s := newTestStore(t, 0)
|
||
d1, _ := s.Put([]byte("frame1"), Item{MIME: "image/jpeg"})
|
||
d2, _ := s.Put([]byte("frame2"), Item{MIME: "image/jpeg"})
|
||
s.AddRef(d1, "context", "evt-x")
|
||
s.AddRef(d2, "context", "evt-x")
|
||
s.AddRef(d1, "document", "doc-y") // 别的 owner 也引了 d1
|
||
|
||
n, err := s.DropOwner("context", "evt-x")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if n != 2 {
|
||
t.Fatalf("应注销 2 条引用,实际 %d", n)
|
||
}
|
||
|
||
it1, _ := s.Stat(d1)
|
||
it2, _ := s.Stat(d2)
|
||
if it1.RefCount != 1 {
|
||
t.Fatalf("d1 仍被 document 引用,应剩 1,实际 %d", it1.RefCount)
|
||
}
|
||
if it2.RefCount != 0 {
|
||
t.Fatalf("d2 应归零,实际 %d", it2.RefCount)
|
||
}
|
||
}
|
||
|
||
func TestRefs_ListsOwnerDigests(t *testing.T) {
|
||
s := newTestStore(t, 0)
|
||
d1, _ := s.Put([]byte("a"), Item{MIME: "image/png"})
|
||
d2, _ := s.Put([]byte("b"), Item{MIME: "image/png"})
|
||
s.AddRef(d1, "context", "e1")
|
||
s.AddRef(d2, "context", "e1")
|
||
|
||
got, err := s.Refs("context", "e1")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(got) != 2 {
|
||
t.Fatalf("应返回 2 个 digest,实际 %d", len(got))
|
||
}
|
||
}
|
||
|
||
func TestGC_KeepsReferencedContent(t *testing.T) {
|
||
// 有引用的项永不删除——那会让记忆里的 digest 变成悬空指针,
|
||
// 正是本包要避免的。
|
||
s := newTestStore(t, 0)
|
||
kept, _ := s.Put([]byte("referenced"), Item{MIME: "image/png"})
|
||
orphan, _ := s.Put([]byte("orphaned"), Item{MIME: "image/png"})
|
||
s.AddRef(kept, "context", "e1")
|
||
|
||
// minAge=0 让刚 Put 的都算超龄
|
||
removed, _, err := s.GC(0)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if removed != 1 {
|
||
t.Fatalf("应只清 1 条无引用项,实际 %d", removed)
|
||
}
|
||
if _, err := s.Get(kept); err != nil {
|
||
t.Fatalf("被引用的内容不该被清: %v", err)
|
||
}
|
||
if _, err := s.Stat(orphan); err == nil {
|
||
t.Fatal("无引用项的元数据应已删除")
|
||
}
|
||
}
|
||
|
||
func TestGC_MinAgeProtectsFreshUnreferenced(t *testing.T) {
|
||
// 刚 Put 还没来得及 AddRef 的项 refcount 也是 0;
|
||
// minAge 必须保护它们,否则「Put 完还没挂上就被 GC 清掉」。
|
||
s := newTestStore(t, 0)
|
||
d, _ := s.Put([]byte("just-arrived"), Item{MIME: "image/png"})
|
||
|
||
removed, _, err := s.GC(time.Hour)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if removed != 0 {
|
||
t.Fatalf("新入库项应被 minAge 保护,却清掉了 %d 条", removed)
|
||
}
|
||
if _, err := s.Get(d); err != nil {
|
||
t.Fatalf("内容应还在: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestGC_EnforcesCapacity(t *testing.T) {
|
||
// 容量上限:清完超龄项后仍超限,继续按 last_seen 从旧到新淘汰无引用项。
|
||
blob := make([]byte, 1024)
|
||
s := newTestStore(t, 2048) // 只容 2KB
|
||
|
||
var digests []string
|
||
for i := 0; i < 4; i++ {
|
||
b := append([]byte{byte(i)}, blob...) // 内容各异,避免去重
|
||
d, err := s.Put(b, Item{MIME: "image/png"})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
digests = append(digests, d)
|
||
time.Sleep(2 * time.Millisecond) // 拉开 last_seen
|
||
}
|
||
|
||
// 保护最后一个,确认容量 GC 也不碰有引用的
|
||
s.AddRef(digests[3], "context", "e1")
|
||
|
||
removed, freed, err := s.GC(0)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if removed == 0 {
|
||
t.Fatal("超限应触发淘汰")
|
||
}
|
||
if _, err := s.Get(digests[3]); err != nil {
|
||
t.Fatalf("有引用项即使超限也不该删: %v", err)
|
||
}
|
||
t.Logf("removed=%d freed=%d", removed, freed)
|
||
|
||
st := s.Stats()
|
||
if total := st["total_bytes"].(int64); total > 2048 {
|
||
// 有引用项可能让总量降不到线下,这是刻意的(宁可超限也不断引用)
|
||
t.Logf("总量 %d 仍超 2048,因有引用项不可删(预期行为)", total)
|
||
}
|
||
}
|
||
|
||
func TestDescribe_OverwritesExplicitly(t *testing.T) {
|
||
s := newTestStore(t, 0)
|
||
d, _ := s.Put([]byte("img"), Item{MIME: "image/png"})
|
||
|
||
if err := s.Describe(d, "一只橘猫", "vis-a"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
it, _ := s.Stat(d)
|
||
if it.Description != "一只橘猫" || it.DescribedBy != "vis-a" {
|
||
t.Fatalf("描述未写入: %+v", it)
|
||
}
|
||
|
||
// Describe 是显式操作,允许覆盖(换更强模型重描述)
|
||
if err := s.Describe(d, "一只橘色虎斑猫坐在窗台", "vis-b"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
it, _ = s.Stat(d)
|
||
if !strings.Contains(it.Description, "虎斑") || it.DescribedBy != "vis-b" {
|
||
t.Fatalf("Describe 应覆盖旧描述: %+v", it)
|
||
}
|
||
}
|
||
|
||
func TestDescribe_UnknownDigestErrors(t *testing.T) {
|
||
s := newTestStore(t, 0)
|
||
err := s.Describe("deadbeef", "x", "y")
|
||
if err == nil {
|
||
t.Fatal("未知 digest 应报错而非静默成功")
|
||
}
|
||
}
|
||
|
||
func TestPut_DoesNotClobberExistingDescription(t *testing.T) {
|
||
// 先到的描述可能来自更强的模型;后到的空值不该把它冲掉。
|
||
s := newTestStore(t, 0)
|
||
data := []byte("img")
|
||
d, _ := s.Put(data, Item{MIME: "image/png", Description: "详细描述", DescribedBy: "strong-model"})
|
||
|
||
// 第二次 Put 同内容但不带描述
|
||
if _, err := s.Put(data, Item{MIME: "image/png"}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
it, _ := s.Stat(d)
|
||
if it.Description != "详细描述" || it.DescribedBy != "strong-model" {
|
||
t.Fatalf("重复 Put 的空描述不该冲掉已有描述: %+v", it)
|
||
}
|
||
}
|
||
|
||
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 TestSearch_FiltersByDescriptionAndKind(t *testing.T) {
|
||
s := newTestStore(t, 0)
|
||
di, _ := s.Put([]byte("chart-img"), Item{MIME: "image/png"})
|
||
da, _ := s.Put([]byte("speech-aud"), Item{MIME: "audio/wav"})
|
||
dn, _ := s.Put([]byte("no-desc"), Item{MIME: "image/png"})
|
||
s.Describe(di, "一张蓝色的柱状图表", "vis")
|
||
s.Describe(da, "一段关于图表的讲解录音", "aud")
|
||
|
||
all, err := s.Search("图表", "", 10)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(all) != 2 {
|
||
t.Fatalf("两条描述都含「图表」,应返回 2,实际 %d", len(all))
|
||
}
|
||
|
||
imgs, _ := s.Search("图表", KindImage, 10)
|
||
if len(imgs) != 1 || imgs[0].Digest != di {
|
||
t.Fatalf("按 image 过滤应只剩图片,实际 %d 条", len(imgs))
|
||
}
|
||
|
||
// 无描述的项不该出现在语义检索结果里
|
||
for _, it := range all {
|
||
if it.Digest == dn {
|
||
t.Fatal("无描述的项不该被 Search 返回")
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestPending_ReturnsUndescribed(t *testing.T) {
|
||
s := newTestStore(t, 0)
|
||
described, _ := s.Put([]byte("has-desc"), Item{MIME: "image/png"})
|
||
undescribed, _ := s.Put([]byte("needs-desc"), Item{MIME: "image/png"})
|
||
s.Describe(described, "已有描述", "vis")
|
||
|
||
pending, err := s.Pending(10)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(pending) != 1 || pending[0].Digest != undescribed {
|
||
t.Fatalf("应只返回未描述项,实际 %d 条", len(pending))
|
||
}
|
||
}
|
||
|
||
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 TestStats_CountsByKindAndDescription(t *testing.T) {
|
||
s := newTestStore(t, 4096)
|
||
d1, _ := s.Put([]byte("i1"), Item{MIME: "image/png"})
|
||
s.Put([]byte("i2"), Item{MIME: "image/jpeg"})
|
||
s.Put([]byte("a1"), Item{MIME: "audio/wav"})
|
||
s.Describe(d1, "描述", "vis")
|
||
s.AddRef(d1, "context", "e1")
|
||
|
||
st := s.Stats()
|
||
if st["count"].(int) != 3 {
|
||
t.Fatalf("count 应为 3,实际 %v", st["count"])
|
||
}
|
||
if st["described"].(int) != 1 {
|
||
t.Fatalf("described 应为 1,实际 %v", st["described"])
|
||
}
|
||
if st["unreferenced"].(int) != 2 {
|
||
t.Fatalf("unreferenced 应为 2,实际 %v", st["unreferenced"])
|
||
}
|
||
byKind := st["by_kind"].(map[string]int)
|
||
if byKind["image"] != 2 || byKind["audio"] != 1 {
|
||
t.Fatalf("by_kind 不对: %v", byKind)
|
||
}
|
||
}
|
||
|
||
func TestPut_RejectsEmpty(t *testing.T) {
|
||
s := newTestStore(t, 0)
|
||
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, 0)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
d, _ := s1.Put([]byte("persistent-img"), Item{MIME: "image/png", OriginPath: "/tmp/x.png"})
|
||
s1.Describe(d, "跨重启的描述", "vis")
|
||
s1.AddRef(d, "context", "e1")
|
||
s1.Close()
|
||
|
||
s2, err := New(dir, 0)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer s2.Close()
|
||
|
||
it, err := s2.Stat(d)
|
||
if err != nil {
|
||
t.Fatalf("重开后应能查到: %v", err)
|
||
}
|
||
if it.Description != "跨重启的描述" || it.RefCount != 1 {
|
||
t.Fatalf("元数据应持久化: %+v", it)
|
||
}
|
||
data, err := s2.Get(d)
|
||
if err != nil || string(data) != "persistent-img" {
|
||
t.Fatalf("内容应持久化: %v / %q", err, data)
|
||
}
|
||
}
|