Files
HomeAgent/providers/chineseclip/webp_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

98 lines
3.5 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 chineseclip
// webp 解码支持与「失败必须可观测」的判据。
//
// ## 为什么这两条要放在这里
//
// 2026-10-23 线上排查:启动日志只有一行
//
// [media] 向量迁移完成: 成功=0 失败=4 总计=4
//
// 没有任何信息说明为什么失败。反查数据库才发现那 4 个块的 mime 是
// `image/webp`,而本 provider 只注册了 gif/jpeg/png 三个解码器 ⇒
// `image.Decode` 报 "unknown format" ⇒ 它们**永远**拿不到向量,
// 语义检索也永远搜不到它们。
//
// 两个独立缺陷:
// 1. 功能缺口:webp 不可解码
// 2. 可观测性缺口:失败被 `continue` 吞掉,只有计数没有原因
//
// 判据分开钉住,避免以后只修其中一个。
import (
"bytes"
"image"
"image/color"
"image/png"
"testing"
)
// webpBytes 是一个最小的合法 webp 文件(1×1 白色,RIFF/WEBP/VP8L)。
//
// 手写而不依赖测试夹具:夹具文件容易被误删,而"能解码 webp"这件事
// 必须一直成立 —— 线上确实存在 webp 记忆块。
var webpBytes = []byte{
// 由 ffmpeg 生成的真实 2×2 白色 webp(44 字节,VP8 有损编码)。
// 手写字节序列容易构造出不合法容器 —— 必须用真实编码器产物。
0x52, 0x49, 0x46, 0x46, 0x24, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50,
0x56, 0x50, 0x38, 0x20, 0x18, 0x00, 0x00, 0x00, 0x30, 0x01, 0x00, 0x9d,
0x01, 0x2a, 0x02, 0x00, 0x02, 0x00, 0x02, 0x00, 0x34, 0x25, 0xa4, 0x00,
0x03, 0x70, 0x00, 0xfe, 0xfb, 0x94, 0x00, 0x00,
}
// ① webp 必须能被解码(否则 memory_blocks 里的 webp 块永远无向量)。
//
// 变异:注释掉 image.go 里的 `_ "golang.org/x/image/webp"` ⇒ 红。
func TestWebpIsDecodable(t *testing.T) {
_, format, err := image.Decode(bytes.NewReader(webpBytes))
if err != nil {
t.Fatalf("webp 解码失败: %v —— image.go 里缺少 _ \"golang.org/x/image/webp\""+
"(线上 4 个 webp 记忆块因此永远拿不到向量)", err)
}
if format != "webp" {
t.Fatalf("格式识别错误: got %q want \"webp\"", format)
}
}
// ② webp 必须能走完整的预处理链路(不只是解码,还要变成 NCHW 张量)。
//
// 只验解码不够:预处理里有缩放/归一化,任何一步对 webp 不适用都算没支持。
func TestWebpPreprocessProducesTensor(t *testing.T) {
const size = 32
mean := []float64{0.48145466, 0.4578275, 0.40821073}
std := []float64{0.26862954, 0.26130258, 0.27577711}
tensor, err := preprocessImage(webpBytes, size, mean, std)
if err != nil {
t.Fatalf("webp 预处理失败: %v", err)
}
if want := 3 * size * size; len(tensor) != want {
t.Fatalf("张量长度 %d,期望 %d(3×%d×%d)", len(tensor), want, size, size)
}
// 1×1 白色图放大后应接近 (1-mean)/std 的白点值;只验非全零,
// 具体像素值由 jpeg/png 的测试覆盖(同一套代码路径)。
nonZero := false
for _, v := range tensor {
if v != 0 {
nonZero = true
break
}
}
if !nonZero {
t.Fatal("张量全为零 —— 预处理没有真正读到像素")
}
}
// ③ png 仍必须可用(防止注册表改动把其它格式弄坏)。
func TestPngStillDecodable(t *testing.T) {
img := image.NewRGBA(image.Rect(0, 0, 2, 2))
img.Set(0, 0, color.RGBA{255, 255, 255, 255})
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("构造 png 失败: %v", err)
}
if _, format, err := image.Decode(&buf); err != nil || format != "png" {
t.Fatalf("png 解码异常: format=%q err=%v", format, err)
}
}