Files
HomeAgent/internal/memory/media/media_test.go
JianFeeeee 74a24f93d7 feat(memory): 媒体 GC 与描述生成两条后台循环
补齐媒体记忆的最后两块:容量上限真正生效,描述文本成为持久语义记忆。

## mediaGCLoop:让容量上限不再形同虚设

CAS 的 GC 只在被显式调用时执行,Put 路径不触发它。此前配置项
core.memory.media.max_mb 注册了却没有任何调用方——一次 see_video 抽 10 帧,
帧本身在工具结果被 Prune 后就没人引用了,若无人清理会一直堆在磁盘上。

现在按 gc_interval(默认 6h)周期调 GC(gc_min_age)。两个不变量:
  - 有引用的内容永不删除,即使超容量(宁可超限也不断引用)
  - gc_min_age(默认 1h)保护刚 Put 还没来得及 AddRef 的项——它们
    refcount 也是 0

## mediaDescribeLoop:描述才是能活过 GC 的那部分

blob 会被容量 GC 淘汰,而描述留在 media 表里,并经 mediaSummaryForEvent
写进 L0 事件、随归档进 L2 文档、经蒸馏进 L3 图库。于是「那张紫蓝红三色
带图」在原始字节早已被清掉之后仍然可被检索到。

复用既有的视觉回退链(resolveModalFallback + chatModalFallbackBatch),
不新造一套模型调用。

三个刻意的决定:

  - **走后台而非入库时同步**:视觉模型一次调用生产实测 9.6s。放在对话
    路径上会让每张图都给回复加十几秒,而描述的价值是几个月后还能检索到,
    不是这一轮——这一轮模型本来就直接看着图。
  - **逐条而非批量**:批量拿回来是一整段文字,无法可靠切分回各自的
    digest(模型未必按序号输出,也可能把两张图合并成一句)。宁可多几次
    往返也要保证「描述 ↔ digest」的对应关系确定。
  - **默认关闭**(describe_on_ingest=false):它消耗视觉模型配额。开启后
    每 30s 最多处理 4 条,不跟对话抢额度。

失败处理分三类:
  - 网络抖动/配额 → 不标记,下轮重试
  - 空回复 → 视作失败(上游剥离媒体时通常回空,与 modalfallback 同理)
  - 不可描述(kind=other、blob 已丢失)→ 标记 described_by=unsupported/
    content-missing,退出队列

## 顺带修掉 Pending 的一个真缺陷

测试写出来才发现:Pending 原先只看 `description = ''`,于是被标记为
described_by=unsupported 但 description 仍空的项**每轮都会被重新取出来
重试**,永久占着 LIMIT 的名额,真正需要描述的新项永远轮不到。
改为同时要求 described_by 也为空。

这是「先写断言再看它是否成立」抓到的——原本我以为标记一下就够了。

## 测试

medialoop_test.go 7 例:两条循环在禁用时立即返回(nil store / 零间隔 /
describe 关闭三种形态,不留空转 goroutine)、GC 清孤儿保留有引用项、
minAge 保护新项、无可用源时不误标记、不可描述大类被标记后退出队列。
media_test.go 补 1 例专测 Pending 的排除逻辑。

全仓 go build / go vet / go test 通过,SDK 冻结 diff = 0。
2026-09-04 22:00:03 +08:00

514 lines
14 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 (
"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)
}
}
func TestPending_ExcludesAttemptedButUndescribable(t *testing.T) {
// 「已尝试但无法描述」的项必须退出待描述队列。
//
// 这些项被标记为 described_by=unsupported/content-missing 而 description
// 仍为空。若 Pending 只看 description它们每轮都会被取出来重试、
// 永久占着 LIMIT 的名额,真正需要描述的新项永远轮不到。
s := newTestStore(t, 0)
fresh, _ := s.Put([]byte("needs-describe"), Item{MIME: "image/png"})
unsupported, _ := s.Put([]byte("cannot-describe"), Item{MIME: "application/octet-stream"})
described, _ := s.Put([]byte("已描述"), Item{MIME: "image/png"})
// 标记「尝试过但不支持」description 空described_by 非空
if err := s.Describe(unsupported, "", "unsupported"); err != nil {
t.Fatal(err)
}
if err := s.Describe(described, "一张图", "visionllm"); err != nil {
t.Fatal(err)
}
pending, err := s.Pending(10)
if err != nil {
t.Fatal(err)
}
if len(pending) != 1 {
var names []string
for _, p := range pending {
names = append(names, shortDigest(p.Digest))
}
t.Fatalf("应只剩 1 条待描述,实际 %d 条: %v", len(pending), names)
}
if pending[0].Digest != fresh {
t.Fatalf("待描述的应是未处理项,实际 %s", shortDigest(pending[0].Digest))
}
}