Files
HomeAgent/internal/plugins/seemedia/integration_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

242 lines
7.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 seemedia
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/JianFeeeee/HomeAgent/internal/memory/media"
sdk "github.com/JianFeeeee/HomeAgent/internal/sdk"
)
// 真跑 ffmpeg 的集成/压力/变异测试;无 ffmpeg 时自动跳过。
//
// 单测(plugin_test.go)钉纯逻辑(来源解析、clamp、MIME);这里钉真实行为:
// - 抽帧真能从一段真视频拿到正确数量的合法 PNG
// - 探不到时长时走单遍兜底仍能拿到帧
// - 损坏输入如实报错、不 panic
// - 已取消的 ctx 必须让 ffmpeg 立刻失败(取消传播真生效)
// - 并发 16 路抽帧不串台、不泄漏临时目录
// - digest 视频走真 CAS → 临时文件 → cleanup 删文件
// sdkWithVideo 用真 CAS(临时目录)把一段视频字节 Put 进去,返回带媒体面的
// PluginSDK 与**真实 digest**(真实 CAS 的 key 是内容哈希,直接用它取回)。
func sdkWithVideo(t *testing.T, raw []byte) (*sdk.PluginSDK, string) {
t.Helper()
dir, err := os.MkdirTemp("", "seemedia-cas-")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(dir) })
store, err := media.New(dir)
if err != nil {
t.Fatal(err)
}
digest, err := store.Put(raw, media.Item{MIME: "video/mp4", Tool: "test"})
if err != nil {
t.Fatal(err)
}
return sdk.New("seemedia", sdk.SDKConfig{Media: sdk.NewMedia(store)}), digest
}
func mkTestVideo(t *testing.T, dur int) string {
t.Helper()
ff, err := exec.LookPath("ffmpeg")
if err != nil {
t.Skip("无 ffmpeg,跳过集成测试")
}
out := filepath.Join(t.TempDir(), "clip.mp4")
cmd := exec.Command(ff, "-nostdin", "-loglevel", "error",
"-f", "lavfi", "-i", fmt.Sprintf("testsrc=duration=%d:size=320x240:rate=10", dur),
"-pix_fmt", "yuv420p", out, "-y")
if err := cmd.Run(); err != nil {
t.Skipf("ffmpeg 造测试视频失败(环境问题,跳过): %v", err)
}
return out
}
func newProbedPlugin(t *testing.T) *Plugin {
t.Helper()
p := &Plugin{videoFrames: defaultVideoFrames, timeout: 60 * time.Second}
p.ffmpeg, _ = exec.LookPath("ffmpeg")
p.ffprobe, _ = exec.LookPath("ffprobe")
if p.ffmpeg == "" {
t.Skip("无 ffmpeg,跳过集成测试")
}
return p
}
func isPNG(b []byte) bool {
return len(b) >= 8 &&
b[0] == 0x89 && b[1] == 'P' && b[2] == 'N' && b[3] == 'G' &&
b[4] == 0x0D && b[5] == 0x0A && b[6] == 0x1A && b[7] == 0x0A
}
func countSeemediaTemp() int {
frames, _ := filepath.Glob(filepath.Join(os.TempDir(), "seemedia-frames-*"))
vids, _ := filepath.Glob(filepath.Join(os.TempDir(), "seemedia-*.video"))
return len(frames) + len(vids)
}
// ① 真视频 → 请求 N 帧就拿到 N 帧,且每帧都是合法 PNG。
func TestExtractFrames_RealVideoExactCount(t *testing.T) {
p := newProbedPlugin(t)
video := mkTestVideo(t, 10)
for _, n := range []int{1, 3, 6, 10} {
frames, err := p.extractFrames(video, n)
if err != nil {
t.Fatalf("n=%d 抽帧失败: %v", n, err)
}
if len(frames) != n {
t.Errorf("请求 %d 帧,实得 %d 帧", n, len(frames))
}
for i, f := range frames {
if !isPNG(f) {
t.Errorf("n=%d 第 %d 帧不是合法 PNG(%d 字节)", n, i, len(f))
}
}
}
}
// ② 探不到时长(ffprobe 置空)时走单遍兜底,仍能拿到帧。
func TestExtractFrames_FallbackPathWorks(t *testing.T) {
p := newProbedPlugin(t)
video := mkTestVideo(t, 6)
p.ffprobe = "" // 强制走兜底:模拟探时长失败
frames, err := p.extractFrames(video, 4)
if err != nil {
t.Fatalf("兜底路径抽帧失败: %v", err)
}
if len(frames) == 0 {
t.Fatal("兜底路径一帧都没抽到")
}
for i, f := range frames {
if !isPNG(f) {
t.Errorf("兜底第 %d 帧不是 PNG", i)
}
}
}
// ③ 损坏/非视频文件:如实报错,不 panic、不返回半成品。
func TestExtractFrames_CorruptInput(t *testing.T) {
p := newProbedPlugin(t)
bad := filepath.Join(t.TempDir(), "notvideo.mp4")
if err := os.WriteFile(bad, []byte("this is definitely not a video"), 0o644); err != nil {
t.Fatal(err)
}
frames, err := p.extractFrames(bad, 3)
if err == nil && len(frames) > 0 {
t.Errorf("损坏输入不应抽出帧,却得到 %d 帧", len(frames))
}
}
// ④ 取消传播真生效:已取消的 ctx 必须让 ffmpeg 立刻失败,而不是照跑。
//
// 为什么不计时测 p.timeout:合成视频抽帧本来就快(~0.1s),「1ns 超时」和
// 「忽略超时」在计时上分不出(此前实测这种变异未被杀,正是判据设计缺陷)。
// 真正要防的是「完全不用 ctx → 无限挂」,用已取消 ctx 的行为判据来钉:
// exec.CommandContext 在 ctx 已取消时 Start 即失败;换成 exec.Command 则照跑。
func TestFrameGrabHonorsCanceledContext(t *testing.T) {
p := newProbedPlugin(t)
video := mkTestVideo(t, 4)
ctx, cancel := context.WithCancel(context.Background())
cancel() // 立即取消
b, err := p.grabFrameAt(ctx, video, 1.0)
if err == nil {
t.Fatalf("已取消的 ctx 必须让抽帧失败,却成功返回 %d 字节(ffmpeg 未被 ctx 约束)", len(b))
}
}
// ⑤ 压力:并发 16 路抽帧,各自独立临时目录,不串帧、不泄漏临时目录。
func TestExtractFrames_ConcurrentNoLeak(t *testing.T) {
p := newProbedPlugin(t)
video := mkTestVideo(t, 8)
tmpBefore := countSeemediaTemp()
const workers = 16
var wg sync.WaitGroup
errs := make(chan error, workers)
for i := 0; i < workers; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
n := (id % 10) + 1
frames, err := p.extractFrames(video, n)
if err != nil {
errs <- fmt.Errorf("worker %d: %w", id, err)
return
}
if len(frames) == 0 {
errs <- fmt.Errorf("worker %d: 0 帧", id)
}
}(i)
}
wg.Wait()
close(errs)
for err := range errs {
t.Error(err)
}
time.Sleep(200 * time.Millisecond)
tmpAfter := countSeemediaTemp()
if tmpAfter > tmpBefore {
t.Errorf("并发抽帧泄漏临时目录: before=%d after=%d", tmpBefore, tmpAfter)
}
}
// ⑥ digest 视频来源:真 CAS 取字节 → 临时文件 → 抽帧 → cleanup 删文件。
func TestResolveVideoInput_DigestCleanup(t *testing.T) {
p := newProbedPlugin(t)
video := mkTestVideo(t, 4)
raw, err := os.ReadFile(video)
if err != nil {
t.Fatal(err)
}
s, digest := sdkWithVideo(t, raw)
p.sdk = s
input, cleanup, err := p.resolveVideoInput(map[string]interface{}{"digest": digest})
if err != nil {
t.Fatalf("digest 解析失败: %v", err)
}
if _, serr := os.Stat(input); serr != nil {
t.Fatalf("临时视频文件应存在: %v", serr)
}
frames, err := p.extractFrames(input, 2)
if err != nil || len(frames) == 0 {
t.Errorf("从 digest 临时文件抽帧失败: err=%v frames=%d", err, len(frames))
}
cleanup()
if _, serr := os.Stat(input); !os.IsNotExist(serr) {
t.Errorf("cleanup 后临时文件应被删除,stat=%v", serr)
}
}
// ⑦ 静态判据:所有 ffmpeg/ffprobe 子进程必须用 exec.CommandContext。
//
// CommandContext 是「任何情况下都能被 ctx 中断」的契约;换成 exec.Command
// 就失去取消能力。这条跨实现有效:谁把 CommandContext 改回 Command,谁就引入
// 「网络流/坏文件上无限挂」的风险。
func TestFrameCmdsUseCommandContext(t *testing.T) {
src, err := os.ReadFile("plugin.go")
if err != nil {
t.Fatal(err)
}
for i, line := range strings.Split(string(src), "\n") {
if strings.Contains(line, "exec.Command(") && !strings.Contains(line, "exec.CommandContext(") {
t.Errorf("plugin.go:%d 用裸 exec.Command 跑子进程 —— 必须 exec.CommandContext(ctx 取消才杀得掉): %s", i+1, strings.TrimSpace(line))
}
}
}