Files
HomeAgent/internal/agent/core/medialoop_test.go
JianFeeeee 6327578693 fix(media): 支持 webp 解码 + 向量迁移失败必须留下原因
## 生产现状(2026-10-23 排查)

启动日志只有一行,没有任何细节:

    [media] 启动向量迁移: 4 个图像块 → fp=cd2a495cf990 dim=512 workers=4
    [media] 向量迁移完成: 成功=0 失败=4 总计=4

反查 `memory/graph.db` 才找到根因:

| mime | 块数 | fingerprint |
|---|---|---|
| image/jpeg | 153 | 当前 fp |
| image/png | 394 | 当前 fp |
| **image/webp** | **4** | **空**(从未嵌入成功) |

而 `providers/chineseclip/image.go` 只注册了 gif/jpeg/png:

    _ "image/gif"
    _ "image/jpeg"
    _ "image/png"

⇒ `image.Decode` 对 webp 返回 `image: unknown format` ⇒ 这 4 个块
**永远**拿不到向量 ⇒ 语义检索永远搜不到它们,且没有任何报错。

## 两个独立缺陷

### ① 功能缺口:webp 不可解码

加 `_ "golang.org/x/image/webp"`。这是纯 Go 实现、无 cgo,不改变二进制形态。

代价:`x/image v0.25.0` 硬性要求 `x/text >= v0.23.0`,因此 `x/text` 从
`v0.3.8` 升到 `v0.23.0`。已实测 `providers/chineseclip` 全绿(分词器用
`x/text/unicode/norm` 做 NFC 归一化,升级未改变行为)。

### ② 可观测性缺口:失败被 `continue` 吞掉

原实现:

    if err := a.reembedOneBlock(b, fp); err != nil {
        atomic.AddInt64(&failed, 1)
        continue          ← 原因就此消失
    }

现在按 `mime | error` 聚合后打印(不逐条打,上千张图会刷屏):

    [media] 向量迁移完成: 成功=0 失败=0 不支持=4 总计=4 fp=cd2a495cf990
    [media] 失败原因分类(按 mime | error 聚合):
    [media]   4 个: image/webp | chineseclip: 解码图像: image: unknown format

同时区分 `ErrModalityUnsupported`(**永久**无向量,重试无意义)与临时失败,
单独计为 `不支持=N` —— 混在"失败"里会让人以为重试就能恢复。

## 判据(4 条,全部变异自证)

| 判据 | 覆盖 | 变异 |
|---|---|---|
| `TestWebpIsDecodable` | webp 能被 `image.Decode` 识别 | 注释掉 webp 注册 ⇒ 红并指名「image.go 里缺少 ...」 |
| `TestWebpPreprocessProducesTensor` | webp 能走完整预处理(缩放+归一化),不只解码 | 同上 ⇒ 红 |
| `TestPngStillDecodable` | 注册表改动没弄坏 png | — |
| `TestReembedStaleMedia_LogsFailureReasons` | 失败原因(mime + 错误原文 + 计数)必须进日志 | 删掉打印 ⇒ 红 |
| `TestReembedStaleMedia_SeparatesUnsupportedFromFailed` | 不支持模态单独计数 | — |

webp 夹具用 ffmpeg 生成的真实 2×2 webp(44 字节)。手写字节序列构造不出
合法容器——第一版手写就失败了,这是实测教训。

## 验证

- `providers/chineseclip` / `internal/agent/core` / `internal/memory/vector` 全绿
- `go build ./...` / `go vet ./...` 干净
- 全量回归:见提交后 CI
2026-10-23 15:50:59 +08:00

203 lines
7.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 core
import (
"context"
"fmt"
"path/filepath"
"regexp"
"strings"
"testing"
"github.com/JianFeeeee/HologramAgent/internal/memory"
"github.com/JianFeeeee/HologramAgent/internal/memory/vector"
)
// 媒体与记忆块的生命周期测试(块 union 化之后)。
//
// 媒体没有独立生命周期管理(没有 GC、没有引用计数):字节是记忆块的内容,
// 块的创建/迁移/删除由记忆系统决定。块被删即字节被删——没有独立层要清理。
//
// 需要特殊对待的只有一项:向量空间更换(换模型/换维度)时,
// 历史图像块的向量要迁移到新空间,否则永远无法被召回。
func newMediaLoopAgent(t *testing.T) *Agent {
t.Helper()
dir := t.TempDir()
g, err := memory.NewGraphDB(filepath.Join(dir, "graph.db"))
if err != nil {
t.Fatalf("NewGraphDB: %v", err)
}
t.Cleanup(func() { g.Close() })
a := &Agent{memory: g}
a.ctx, a.cancel = context.WithCancel(context.Background())
t.Cleanup(a.cancel)
return a
}
// fakeSpace 是一个只覆盖图像的假统一空间,用来验证「不在本空间」与
// 「本次失败」必须被区分对待。
type fakeSpace struct{}
func (fakeSpace) VectorizeDense(string) ([]float64, error) { return []float64{1, 0}, nil }
func (fakeSpace) EmbedImageDense(_ []byte, mime string) ([]float64, error) {
if strings.HasPrefix(mime, "audio/") || strings.HasPrefix(mime, "video/") {
return nil, fmt.Errorf("%w: %s", vector.ErrModalityUnsupported, mime)
}
return []float64{1, 0}, nil
}
func (fakeSpace) Fingerprint() string { return "fake-space" }
func (fakeSpace) Dim() int { return 2 }
func (fakeSpace) Loaded() bool { return true }
func (fakeSpace) Close() {}
// TestReembedStaleMedia_SkipsUnsupportedWithoutFaking 验证向量迁移不会:
// - 把音频当失败反复重试;
// - 更不能拿另一个模型的向量顶替音频(那会污染统一空间且静默)。
func TestReembedStaleMedia_SkipsUnsupportedWithoutFaking(t *testing.T) {
a := newMediaLoopAgent(t)
// 图像块与音频块各落一条:字节都进块(union)
img := mediaBlock("image/png", []byte("img-bytes"))
aud := mediaBlock("audio/wav", []byte("aud-bytes"))
if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{img, aud}); err != nil {
t.Fatal(err)
}
a.multimodalSpace = fakeSpace{}
a.reembedStaleMedia()
// 图像块应拿到本空间向量(fp 写回,dim=2)
imgGot, ok, err := a.memory.BlockByIDPrefix(img.ID)
if err != nil || !ok {
t.Fatalf("BlockByIDPrefix(img): %v / %v", ok, err)
}
if len(imgGot.Vector) != 2 || imgGot.Fingerprint != "fake-space" {
t.Fatalf("图像应拿到本空间向量,实际 vec=%v model=%q", imgGot.Vector, imgGot.Fingerprint)
}
// 音频块不得被写入任何向量(不能用别的模型顶替),fp 应保持为空
audGot, ok, err := a.memory.BlockByIDPrefix(aud.ID)
if err != nil || !ok {
t.Fatalf("BlockByIDPrefix(aud): %v / %v", ok, err)
}
if len(audGot.Vector) != 0 || audGot.Fingerprint != "" {
t.Fatalf("音频不得被写入任何向量(不能用别的模型顶替),实际 vec=%v model=%q",
audGot.Vector, audGot.Fingerprint)
}
}
// 无多模态空间可用时迁移路径必须整体跳过,不报错也不写脏向量。
func TestReembedStaleMedia_NilSpaceNoop(t *testing.T) {
a := newMediaLoopAgent(t)
b := mediaBlock("image/png", []byte("x"))
if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
t.Fatal(err)
}
// multimodalSpace 为 nil:整体跳过
a.multimodalSpace = nil
a.reembedStaleMedia()
got, _, _ := a.memory.BlockByIDPrefix(b.ID)
if len(got.Vector) != 0 || got.Fingerprint != "" {
t.Errorf("无空间可用时不该写向量: vec=%v model=%q", got.Vector, got.Fingerprint)
}
}
// failSpace 让指定 mime 的图像嵌入失败,用来验证「失败必须留下原因」。
type failSpace struct {
failMIME string
}
func (failSpace) VectorizeDense(string) ([]float64, error) { return []float64{1, 0}, nil }
func (f failSpace) EmbedImageDense(_ []byte, mime string) ([]float64, error) {
if mime == f.failMIME {
return nil, fmt.Errorf("模拟解码失败: %s", mime)
}
return []float64{1, 0}, nil
}
func (failSpace) Fingerprint() string { return "fail-space" }
func (failSpace) Dim() int { return 2 }
func (failSpace) Loaded() bool { return true }
func (failSpace) Close() {}
// 迁移失败必须留下**可诊断的原因**,而不只是一个计数。
//
// ★ 2026-10-23 生产事故:日志只有
//
// [media] 向量迁移完成: 成功=0 失败=4 总计=4
//
// 没有任何信息说明为什么。反查数据库才发现那 4 个块是 image/webp,
// 而 provider 没注册 webp 解码器。**失败不可观测直接导致排查成本翻倍**。
//
// 本判据钉住:失败原因按 "mime | error" 聚合后必须出现在日志里。
//
// 变异:把 failReasons 的采集与打印删掉 ⇒ 红。
func TestReembedStaleMedia_LogsFailureReasons(t *testing.T) {
a := newMediaLoopAgent(t)
// 两个都会失败(同一 mime)—— 应聚合成一条 "2 个: ..."
for i := 0; i < 2; i++ {
b := mediaBlock("image/webp", []byte("webp-bytes"))
if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
t.Fatal(err)
}
}
logs := captureLog(t, func() {
a.multimodalSpace = failSpace{failMIME: "image/webp"}
a.reembedStaleMedia()
})
if !strings.Contains(logs, "失败原因分类") {
t.Fatalf("迁移失败时必须打印失败原因分类,实际日志:\n%s", logs)
}
if !strings.Contains(logs, "image/webp") {
t.Fatalf("失败原因里必须出现具体 mime(否则无从定位是哪种格式),实际日志:\n%s", logs)
}
if !strings.Contains(logs, "模拟解码失败") {
t.Fatalf("失败原因里必须带上 provider 返回的错误原文,实际日志:\n%s", logs)
}
// 计数必须出现(格式 "N 个: ...")—— 具体数字取决于去重后的块数,
// 这里只要求"有计数",不绑死数字(绑死会让判据随去重策略变化而脆)。
if !regexp.MustCompile(`\d+ 个: image/webp`).MatchString(logs) {
t.Fatalf("失败原因应带聚合计数(形如「N 个: image/webp | ...」),实际日志:\n%s", logs)
}
}
// 不支持模态与临时失败必须**分开计数**:前者永远好不了(重试无意义),
// 混在"失败"里会让人误以为重试即可恢复。
func TestReembedStaleMedia_SeparatesUnsupportedFromFailed(t *testing.T) {
a := newMediaLoopAgent(t)
// 音频走 fakeSpace 的 ErrModalityUnsupported 分支;但 reembedStaleMedia
// 只挑 image 模态,所以用图像 mime + 让 provider 返回 unsupported 来验证分类。
img := mediaBlock("image/tiff", []byte("tiff-bytes"))
if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{img}); err != nil {
t.Fatal(err)
}
logs := captureLog(t, func() {
a.multimodalSpace = unsupportedImageSpace{}
a.reembedStaleMedia()
})
if !strings.Contains(logs, "不支持=1") {
t.Fatalf("不受支持的模态应单独计数(不支持=1),实际日志:\n%s", logs)
}
}
// unsupportedImageSpace 对任何图像都返回 ErrModalityUnsupported。
type unsupportedImageSpace struct{}
func (unsupportedImageSpace) VectorizeDense(string) ([]float64, error) { return []float64{1, 0}, nil }
func (unsupportedImageSpace) EmbedImageDense(_ []byte, mime string) ([]float64, error) {
return nil, fmt.Errorf("%w: %s", vector.ErrModalityUnsupported, mime)
}
func (unsupportedImageSpace) Fingerprint() string { return "unsupported-space" }
func (unsupportedImageSpace) Dim() int { return 2 }
func (unsupportedImageSpace) Loaded() bool { return true }
func (unsupportedImageSpace) Close() {}