Files
HomeAgent/internal/plugins/seemedia/plugin_test.go
JianFeeeee 16309c3307 feat(seemedia): 多模态直视插件——让模型一手看图/看视频/听音频 (#6)
* feat(seemedia): 新增多模态直视插件,让模型一手看图/看视频/听音频

## 动机

现有 multimodal(describe_image / transcribe_audio / ocr_image)走的是**二手**路:
插件自己起一次 VLM 子调用,把结果**文字**喂回主模型。省 token、能入记忆正文、
主模型非视觉也能用,但模型看到的是「别人转述的描述」,细节丢失不可逆。

缺的是**一手**路:让模型主动把原始像素/帧/音频投给**自己**去判读。本插件补上。

## 工具(三模态,用户决策 image+video+audio)

- `see_image`:path/digest/url → image_url 块,经 SetToolBlocks 直投主模型
- `see_video`:ffmpeg 抽帧(默认 6 帧,上限 10,对齐内核 modalfallback 约束)
  → 多张 image_url 块一起投;探时长成功走均匀定位抽帧,失败退单遍 thumbnail
- `hear_audio`:path/digest/url → audio_url 块直投

三者都 `ParallelSafe:false`:SetToolBlocks 写的是「当前 call」的块,并发会串台。

## 与 multimodal 并存(用户决策 coexist)

职责分工,不互相取代:
- 要省 token / 要把结论写进记忆 / 主模型不支持该模态 → describe_image(二手文字)
- 要模型亲自判读细节(看清截图、逐帧看操作) → see_*(一手原始媒体)

## 媒体来源(用户决策 path+digest+url 全支持)

- path:设备/工具回传的落盘文件(local_screensee、browser_screenshot、mc 截图…)
- digest:用户上传已入 CAS 的媒体,sdk.Media().Get 取回,不在内核↔插件间传 base64
- url:http(s) 直链;图/音频原样透传(不下载、不入 CAS),视频交 ffmpeg 原生读 http

## 直接进记忆(记忆载体已块化,天然打通)

seemedia 经 SetToolBlocks 注入的块,内核 task.go captureBlockMedia 处**无条件**
落进 CAS 并转成一等记忆块挂到本轮 ContextEvent(「无论直视还是回退,媒体本体都该
进记忆」)。所以一手看到的图/帧**同时**进了记忆,之后能按多模态向量被
doc_query / memory_recall 召回。本插件不写任何入库代码,复用既有块化链路。

★ 边界(刻意取舍):captureBlockMedia 只对 data URL 入库,http(s) URL 被跳过
(不下载、不入记忆、不算向量,规避 SSRF/超时)。即 path/digest 来源可召回,
url 来源只是模型当场看到但不进记忆。要让 http 媒体长期可召回,先落盘再传 path。

## 形态:内置插件(用户决策 builtin)

与 multimodal 并排 internal/plugins/seemedia,经 all.go init() 注册。代价是延续
「内核进程内读任意本地文件」这个 a02d8b00 本想消除的张力——但 multimodal 已是
内置且同样读 path,保持一致比单独走子进程沙箱更不易让人困惑;日后收紧应两者同搬。

## 判据(可杀变异,实测)

plugin_test.go:
- url 透传且拒 file://(去掉 isHTTPURL 校验 → FAIL)
- path 优先于 digest、url 优先于 path,且 path 命中时不碰 CAS
- 无来源报错且信息可操作(指明 path/digest/url)
- 文件错误带路径、空文件明说「空」
- clampFrames 把帧数夹到 [1,10]、非正回默认(超限不夹 → FAIL)
- 三工具不得声明 ParallelSafe:true(防 SetToolBlocks 串台)

9/9 单测通过;本机 ffmpeg/ffprobe 已就位;全插件 + homed(-tags onnxruntime)
构建通过;internal/plugins 装配冒烟通过。

* test(seemedia): 补集成/压力/变异测试(16 判据,5 变异全杀)

上一个提交只带 9 个纯逻辑单测(来源解析、clamp、MIME)。抽帧是本插件唯一
真正驱动外部进程、且会并发的部分,必须真跑 ffmpeg 压一压:

- 真视频 → 请求 N 帧就拿到 N 帧,每帧校验 PNG 魔数(1/3/6/10 四档)
- ffprobe 置空 → 走单遍兜底仍能拿到帧
- 损坏/非视频文件 → 如实报错,不 panic、不返半成品
- 已取消的 ctx → ffmpeg 必须立刻失败(取消传播真生效)
- 并发 16 路抽帧 → 不串台、不泄漏临时目录(前后计数比对)
- digest 视频 → 真 CAS 取字节落临时文件,cleanup 后必须删掉
- 静态判据:所有子进程必须 exec.CommandContext,不得裸 exec.Command

为什么超时判据从「计时」改成「已取消 ctx」:合成视频抽帧本来就快(~0.1s),
「1ns 超时」和「忽略超时」在计时上分不出——实测这种变异杀不掉,是判据设计
缺陷。改用已取消 ctx 的行为判据(CommandContext 在 ctx 已取消时 Start 即失败)
后,忽略 ctx 的变异被杀。

变异实测(5/5 全 FAIL,还原后全绿):
  A clampFrames 超限不夹        → TestClampFrames FAIL
  B url 分支去掉 isHTTPURL      → TestResolveMediaURL_URLPassthrough FAIL
  C cleanup 不删临时文件         → TestResolveVideoInput_DigestCleanup FAIL
  D grabFrameAt 忽略 ctx         → TestFrameGrabHonorsCanceledContext FAIL
  E 抽帧数写死 1                 → TestExtractFrames_RealVideoExactCount FAIL

16/16 通过;`-tags onnxruntime` 全量 homed 构建通过,三工具在册。

---------

Co-authored-by: JianFeeeee <JianFeeeee@users.noreply.gitcode.com>
2026-10-11 13:06:23 +08:00

172 lines
5.7 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 seemedia
import (
"os"
"path/filepath"
"strings"
"testing"
)
// seemedia 让模型**一手**看图/看视频/听音频(SetToolBlocks 直投主模型),
// 与 multimodal(二手文字描述)并存。
//
// 这些判据钉住的是**不变量**,不是某次实现:
//
// ① 来源解析三条路(url 透传 / path 读盘转 data URL / digest 取 CAS)与优先级
// ② 没有任何来源时报错,且错误**可操作**(说清传什么)
// ③ 抽帧数永远被 clamp 到 [1, maxVideoFrames]——对齐内核 modalfallback 的约束
// ④ 直投工具必须声明 ParallelSafe:false(SetToolBlocks 写的是"当前 call"的块,
// 并发多工具会串台)
// ---- ① / ② 来源解析 ----
func TestResolveMediaURL_URLPassthrough(t *testing.T) {
p := &Plugin{}
got, err := p.resolveMediaURL(map[string]interface{}{"url": "https://x/y.png"}, "image")
if err != nil {
t.Fatalf("http 直链应原样透传: %v", err)
}
if got != "https://x/y.png" {
t.Errorf("url 应原样返回(不下载、不转 data URL),实际: %q", got)
}
// 非 http 的 url 要被拒(避免 file:// 之类绕过 path 分支的本地读)
if _, err := p.resolveMediaURL(map[string]interface{}{"url": "file:///etc/passwd"}, "image"); err == nil {
t.Error("非 http(s) 的 url 必须被拒绝")
}
}
func TestResolveMediaURL_PathWinsOverDigest(t *testing.T) {
p := &Plugin{}
dir := t.TempDir()
fp := filepath.Join(dir, "shot.jpg")
if err := os.WriteFile(fp, []byte{0xFF, 0xD8, 0xFF, 1, 2}, 0o644); err != nil {
t.Fatal(err)
}
// path 与 digest 同时给:path 优先,且**不碰 CAS**(p.sdk 为 nil 也不该 panic)
got, err := p.resolveMediaURL(map[string]interface{}{"path": fp, "digest": "deadbeef"}, "image")
if err != nil {
t.Fatalf("path 优先时应成功: %v", err)
}
if !strings.HasPrefix(got, "data:image/jpeg;base64,") {
t.Errorf("应按扩展名给 image/jpeg data URL,实际前缀: %.40s", got)
}
// url 又比 path 优先
got2, err := p.resolveMediaURL(map[string]interface{}{"url": "http://a/b", "path": fp}, "image")
if err != nil || got2 != "http://a/b" {
t.Errorf("url 应比 path 优先,实际: %q err=%v", got2, err)
}
}
func TestResolveMediaURL_NoSourceIsActionable(t *testing.T) {
p := &Plugin{}
_, err := p.resolveMediaURL(map[string]interface{}{}, "image")
if err == nil {
t.Fatal("无任何来源时必须报错")
}
msg := err.Error()
for _, want := range []string{"path", "digest", "url"} {
if !strings.Contains(msg, want) {
t.Errorf("错误信息要指明可传 %s,实际: %v", want, err)
}
}
}
func TestResolveMediaURL_FileErrorsSpecific(t *testing.T) {
p := &Plugin{}
if _, err := p.resolveMediaURL(map[string]interface{}{"path": "/nope/x.jpg"}, "image"); err == nil ||
!strings.Contains(err.Error(), "/nope/x.jpg") {
t.Errorf("读取失败应带路径,实际: %v", err)
}
dir := t.TempDir()
empty := filepath.Join(dir, "e.png")
if err := os.WriteFile(empty, nil, 0o644); err != nil {
t.Fatal(err)
}
if _, err := p.resolveMediaURL(map[string]interface{}{"path": empty}, "image"); err == nil ||
!strings.Contains(err.Error(), "空") {
t.Errorf("空文件应明说「空」,实际: %v", err)
}
}
func TestFallbackMIME(t *testing.T) {
if fallbackMIME("audio") != "audio/wav" {
t.Error("audio 兜底应为 audio/wav")
}
if fallbackMIME("image") != "image/png" {
t.Error("image 兜底应为 image/png")
}
// 未知 kind 落到 image(图是最常见的一手媒体)
if fallbackMIME("??") != "image/png" {
t.Error("未知 kind 应兜底 image/png")
}
}
// ---- ③ 抽帧数 clamp ----
func TestClampFrames(t *testing.T) {
cases := map[int]int{
-5: defaultVideoFrames, // 非正数回默认,而不是 0 帧(0 帧=白调一次)
0: defaultVideoFrames,
1: 1,
6: 6,
10: 10,
11: maxVideoFrames, // 超限夹到上限,不放任上游超体积
999: maxVideoFrames,
}
for in, want := range cases {
if got := clampFrames(in); got != want {
t.Errorf("clampFrames(%d)=%d,期望 %d", in, got, want)
}
}
// 不变量:上限必须 ≥ 默认,否则默认值会被自己夹掉
if maxVideoFrames < defaultVideoFrames {
t.Fatal("maxVideoFrames 不能小于 defaultVideoFrames")
}
}
func TestMimeByExt(t *testing.T) {
for p, want := range map[string]string{
"a.jpg": "image/jpeg", "a.JPEG": "image/jpeg", "a.png": "image/png",
"a.webp": "image/webp", "a.gif": "image/gif", "a.bmp": "image/bmp",
"a.wav": "audio/wav", "a.mp3": "audio/mpeg", "a.flac": "audio/flac",
"a.bin": "", "noext": "",
} {
if got := mimeByExt(p); got != want {
t.Errorf("mimeByExt(%q)=%q,期望 %q", p, got, want)
}
}
}
func TestIsHTTPURL(t *testing.T) {
for s, want := range map[string]bool{
"http://a": true,
"https://a": true,
"ftp://a": false,
"file:///x": false,
"/local/x": false,
"data:image": false,
} {
if got := isHTTPURL(s); got != want {
t.Errorf("isHTTPURL(%q)=%v,期望 %v", s, got, want)
}
}
}
// ---- ④ 直投工具的并发契约 ----
//
// SetToolBlocks 写的是"当前 call"的媒体块(IOManager.SetToolBlocks 无 call_id
// 时的兼容入口),并发执行多个直投工具会把彼此的块串到一起。所以三个工具都
// 必须 ParallelSafe:false。这条判据跨实现有效:谁把它改回 true,谁就引入串台。
func TestSeeToolsAreNotParallelSafe(t *testing.T) {
src, err := os.ReadFile("plugin.go")
if err != nil {
t.Fatal(err)
}
s := string(src)
// 不得出现 ParallelSafe:true(本插件所有工具都直投,必须串行)
if strings.Contains(strings.ReplaceAll(s, " ", ""), "ParallelSafe:true") {
t.Error("seemedia 的直投工具不得声明 ParallelSafe:true —— " +
"SetToolBlocks 写的是当前 call 的块,并发会串台")
}
}