diff --git a/go.mod b/go.mod index dfeb2bbd..931baa0c 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,7 @@ require ( github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 golang.org/x/sys v0.38.0 - golang.org/x/text v0.3.8 + golang.org/x/text v0.23.0 ) require ( @@ -41,6 +41,7 @@ require ( github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/image v0.25.0 // indirect ) replace github.com/JianFeeeee/homeagentsdk => ./third_party/homeagent-sdk diff --git a/go.sum b/go.sum index 3fcd858e..280b470e 100644 --- a/go.sum +++ b/go.sum @@ -56,12 +56,16 @@ github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= +golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/agent/core/medialoop.go b/internal/agent/core/medialoop.go index 8a9c457b..07d63119 100644 --- a/internal/agent/core/medialoop.go +++ b/internal/agent/core/medialoop.go @@ -1,12 +1,14 @@ package core import ( + "errors" "fmt" "log" "sync" "sync/atomic" "github.com/JianFeeeee/HologramAgent/internal/memory" + "github.com/JianFeeeee/HologramAgent/internal/memory/vector" safeclose "github.com/JianFeeeee/HologramAgent/internal/safeclose" ) @@ -67,19 +69,41 @@ func (a *Agent) reembedStaleMedia() { var jobsOnce sync.Once jobs := make(chan memory.MemoryBlock, workers*2) - var done, failed int64 + var done, failed, unsupported int64 var wg sync.WaitGroup + // 失败原因按错误文本聚合 —— 不逐条打日志(上千张图会刷屏), + // 但**必须留下"为什么失败"**。 + // + // ★ 2026-10-23 实测教训:原先这里只 `failed++` 然后 continue,于是 + // 「向量迁移完成: 成功=0 失败=4」成了唯一的线索 —— 没有任何日志说明 + // 这 4 个是 webp(当时 chineseclip 未注册 webp 解码器)。失败不可观测 + // 直接导致这次排查要从数据库里反查 mime 才找到根因。 + var failMu sync.Mutex + failReasons := map[string]int{} + for i := 0; i < workers; i++ { wg.Add(1) go func() { defer wg.Done() for b := range jobs { - if err := a.reembedOneBlock(b, fp); err != nil { - atomic.AddInt64(&failed, 1) + err := a.reembedOneBlock(b, fp) + if err == nil { + atomic.AddInt64(&done, 1) continue } - atomic.AddInt64(&done, 1) + // 模态不受支持是**永久**状态(不是本次网络/服务抖动): + // 这类块永远不会有向量,语义检索也永远搜不到它。 + // 单独计数,避免它混在"失败"里让人以为重试就能好。 + if errors.Is(err, vector.ErrModalityUnsupported) { + atomic.AddInt64(&unsupported, 1) + } else { + atomic.AddInt64(&failed, 1) + } + key := b.MIME + " | " + err.Error() + failMu.Lock() + failReasons[key]++ + failMu.Unlock() } }() } @@ -93,8 +117,17 @@ func (a *Agent) reembedStaleMedia() { } safeclose.Safe(&jobsOnce, jobs) wg.Wait() - log.Printf("[media] 向量迁移完成: 成功=%d 失败=%d 总计=%d fp=%s", - done, failed, len(targets), shortFP(fp)) + log.Printf("[media] 向量迁移完成: 成功=%d 失败=%d 不支持=%d 总计=%d fp=%s", + done, failed, unsupported, len(targets), shortFP(fp)) + // 逐类打印失败原因(聚合后通常只有 1~2 类)。 + // 没有这段,运维只能看到"失败=N"而不知为何 —— 本次 webp 问题的排查成本 + // 全花在这里。 + if len(failReasons) > 0 { + log.Printf("[media] 失败原因分类(按 mime | error 聚合):") + for reason, n := range failReasons { + log.Printf("[media] %d 个: %s", n, reason) + } + } } // reembedOneBlock 为一个图像块重算向量并写回。负载就在块里,无需外部取字节。 diff --git a/internal/agent/core/medialoop_test.go b/internal/agent/core/medialoop_test.go index 3e0f7c41..87a581b5 100644 --- a/internal/agent/core/medialoop_test.go +++ b/internal/agent/core/medialoop_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "path/filepath" + "regexp" "strings" "testing" @@ -102,3 +103,100 @@ func TestReembedStaleMedia_NilSpaceNoop(t *testing.T) { 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() {} diff --git a/providers/chineseclip/image.go b/providers/chineseclip/image.go index 4030d59a..fd5a85aa 100644 --- a/providers/chineseclip/image.go +++ b/providers/chineseclip/image.go @@ -6,6 +6,17 @@ import ( "image" // 契约要求 provider 自行解码 Data,所以这里注册常见图像格式。 + // + // ★ webp 必须注册(2026-10-23 实测):线上 551 个图像块里有 4 个是 + // `image/webp`(由 see_image 抓取的第三方图片),它们**永远嵌不进去** —— + // 启动时向量迁移报「失败=4」,但错误被 continue 吞掉、没有任何日志, + // 只看到数字对不上。根因就是这里少了 webp 注册: + // + // image.Decode(...) → "image: unknown format" + // + // Go 标准库不含 webp 解码器(那是 x/image 的子包),所以这是新增依赖的 + // 唯一理由。x/image 是纯 Go、无 cgo,不改变二进制形态。 + _ "golang.org/x/image/webp" _ "image/gif" _ "image/jpeg" _ "image/png" diff --git a/providers/chineseclip/webp_test.go b/providers/chineseclip/webp_test.go new file mode 100644 index 00000000..a5972e9d --- /dev/null +++ b/providers/chineseclip/webp_test.go @@ -0,0 +1,97 @@ +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) + } +}