GPU_GUARD_MONOREPO/docs/superpowers/plans/2026-05-04-vehicle-damage-inspection-skill.md
2026-05-20 21:39:12 +08:00

2154 lines
72 KiB
Markdown
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.

# 汽车环车视频旧伤检测 Skill Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 新增一个生产可用的 `vehicle-damage-inspection` compute-entry Skill从环车视频中抽帧、检测漆面旧伤、定位标注损伤区域并筛选最佳证据帧。
**Architecture:** 后端先补强 `SkillExecutorService`,为 compute-entry 子进程注入 Windows 安装包生产所需的 `dataDir/workspace/uploads` 非密钥运行时环境变量。业务能力作为一个大 Skill 放在 `packages/backend/skills/vehicle-damage-inspection`Agent 通过 `execute_skill` 调一次Skill 内部用 Node CommonJS 脚本按固定流水线执行抽帧、视觉模型检测、bbox 标注和最佳帧筛选。
**Tech Stack:** TypeScript, Midway service, Jest, Node CommonJS, ffmpeg/ffprobe, fluent-ffmpeg, axios, sharp, 火山方舟 OpenAI-compatible Chat Completions, 豆包 `doubao-seed-2-0-pro-260215`
**Spec:** `docs/superpowers/specs/2026-05-04-vehicle-damage-inspection-skill-design.md`
---
## File Map
| 文件 | 职责 | 操作 |
|------|------|------|
| `packages/backend/src/modules/netaclaw/service/skill_runtime_env.ts` | 根据 `pDataPath()` / `pUploadPath()` 构造 compute-entry skill 运行目录 env | 新增 |
| `packages/backend/src/modules/netaclaw/service/skill_env_schema.ts` | 从 `skill.config.yaml env` 同步管理页和 secrets 使用的 env schema | 新增 |
| `packages/backend/src/modules/netaclaw/service/skill_executor.ts` | 注入 runtime env确保 Windows 安装包产物落在 dataDir | 修改 |
| `packages/backend/test/skill_runtime_env.test.ts` | 测试 runtime env 默认值和 skill secrets 覆盖顺序 | 新增 |
| `packages/backend/test/skill_env_schema.test.ts` | 测试 `skill.config.yaml env` 到 DB `envSchema` 的同步规则 | 新增 |
| `packages/backend/skills/vehicle-damage-inspection/SKILL.md` | Agent 可读 Skill 说明 | 新增 |
| `packages/backend/skills/vehicle-damage-inspection/skill.config.yaml` | compute-entry 配置、模型 env、依赖、接口 | 新增 |
| `packages/backend/skills/vehicle-damage-inspection/README.md` | 人工调试和 Windows 安装包注意事项 | 新增 |
| `packages/backend/skills/vehicle-damage-inspection/prompts/damage_detect.md` | 环车旧伤候选检测 prompt | 新增 |
| `packages/backend/skills/vehicle-damage-inspection/prompts/grounding.md` | bbox/grounding prompt | 新增 |
| `packages/backend/skills/vehicle-damage-inspection/prompts/dedup.md` | 损伤去重 prompt | 新增 |
| `packages/backend/skills/vehicle-damage-inspection/prompts/best_frame.md` | 最佳帧筛选 prompt | 新增 |
| `packages/backend/skills/vehicle-damage-inspection/scripts/index.cjs` | stdin/stdout JSON 入口和流水线编排 | 新增 |
| `packages/backend/skills/vehicle-damage-inspection/scripts/make.sh` | `check/run/demo/fix` 本地调试入口 | 新增 |
| `packages/backend/skills/vehicle-damage-inspection/scripts/setup.ps1` | Windows installer setup 入口;当前 installer 用 `powershell -File` 执行 | 新增 |
| `packages/backend/skills/vehicle-damage-inspection/scripts/lib/json_utils.cjs` | JSON、bbox、数字 clamp、sleep、并发限制等通用函数 | 新增 |
| `packages/backend/skills/vehicle-damage-inspection/scripts/lib/workspace.cjs` | task workspace、路径解析、JSON artifact I/O | 新增 |
| `packages/backend/skills/vehicle-damage-inspection/scripts/lib/frame_extractor.cjs` | ffmpeg/ffprobe 抽帧 | 新增 |
| `packages/backend/skills/vehicle-damage-inspection/scripts/lib/vision_client.cjs` | OpenAI-compatible 视觉 API 调用、重试、图片压缩编码 | 新增 |
| `packages/backend/skills/vehicle-damage-inspection/scripts/lib/damage_detector.cjs` | 分批候选旧伤检测 | 新增 |
| `packages/backend/skills/vehicle-damage-inspection/scripts/lib/image_marker.cjs` | sharp 画 bbox 标注框 | 新增 |
| `packages/backend/skills/vehicle-damage-inspection/scripts/lib/damage_grounding.cjs` | 损伤定位标注 | 新增 |
| `packages/backend/skills/vehicle-damage-inspection/scripts/lib/best_frame_selector.cjs` | 损伤去重和最佳帧筛选 | 新增 |
| `packages/backend/skills/vehicle-damage-inspection/tests/*.test.cjs` | Skill 纯函数和 mock 流水线测试 | 新增 |
---
### Task 1: Skill env schema 同步
**Files:**
- Create: `packages/backend/src/modules/netaclaw/service/skill_env_schema.ts`
- Modify: `packages/backend/src/modules/netaclaw/service/skill_loader.ts`
- Test: `packages/backend/test/skill_env_schema.test.ts`
- [ ] **Step 1: 写失败测试,覆盖从 skill.config.yaml env 同步默认模型**
Create `packages/backend/test/skill_env_schema.test.ts`:
```typescript
import { buildSkillEnvSchema } from '../src/modules/netaclaw/service/skill_env_schema.js';
describe('buildSkillEnvSchema', () => {
it('uses skill.config.yaml env as the envSchema source of truth', () => {
const schema = buildSkillEnvSchema({
env: [
{
name: 'ARK_API_KEY',
required: true,
description: '火山方舟/豆包 OpenAI-compatible API Key',
},
{
name: 'DAMAGE_DETECT_MODEL',
required: true,
default: 'doubao-seed-2-0-pro-260215',
description: '用于环车旧伤候选检测的多模态模型 ID',
},
],
});
expect(schema).toEqual([
{
name: 'ARK_API_KEY',
required: true,
description: '火山方舟/豆包 OpenAI-compatible API Key',
default: undefined,
},
{
name: 'DAMAGE_DETECT_MODEL',
required: true,
description: '用于环车旧伤候选检测的多模态模型 ID',
default: 'doubao-seed-2-0-pro-260215',
},
]);
});
it('returns null when the skill has no env declaration', () => {
expect(buildSkillEnvSchema({})).toBeNull();
});
});
```
- [ ] **Step 2: 运行测试确认失败**
Run: `pnpm --filter @neta/backend test -- --runInBand test/skill_env_schema.test.ts`
Expected: FAIL with module not found for `skill_env_schema.js`.
- [ ] **Step 3: 新增 skill_env_schema helper**
Create `packages/backend/src/modules/netaclaw/service/skill_env_schema.ts`:
```typescript
import type { SkillConfig } from './skill_config.js';
export interface SkillEnvSchemaItem {
name: string;
required: boolean;
description?: string;
default?: string;
}
export function buildSkillEnvSchema(config: Pick<SkillConfig, 'env'>): SkillEnvSchemaItem[] | null {
if (!Array.isArray(config.env) || config.env.length === 0) return null;
return config.env.map(item => ({
name: item.name,
required: !!item.required,
description: item.description || undefined,
default: item.default || undefined,
}));
}
```
- [ ] **Step 4: 接入 SkillLoaderService.getSkillMetas**
Modify `packages/backend/src/modules/netaclaw/service/skill_loader.ts`.
Add import near existing service imports:
```typescript
import { buildSkillEnvSchema } from './skill_env_schema.js';
```
Inside `getSkillMetas()`, before `await this.skillRepo.save(...)`, add:
```typescript
const config = this.skillConfig.getConfig(fs.name);
const envSchema = buildSkillEnvSchema(config ?? {});
```
Replace the `envSchema` assignment in the save object:
```typescript
envSchema: Array.isArray((fs.metadata as any)?.env) ? (fs.metadata as any).env.map((e: any) => ({
name: e.name,
required: !!e.required,
description: e.description || undefined,
default: e.default || undefined,
})) : null,
```
with:
```typescript
envSchema,
```
This makes `skill.config.yaml env` the single source of truth for model defaults and secret fields. Do not duplicate env declarations in `SKILL.md metadata.env`.
- [ ] **Step 5: 运行测试确认通过**
Run: `pnpm --filter @neta/backend test -- --runInBand test/skill_env_schema.test.ts`
Expected: PASS.
- [ ] **Step 6: 验证后端编译**
Run: `pnpm --filter @neta/backend build`
Expected: build completes without TypeScript errors.
- [ ] **Step 7: 提交**
```bash
git add packages/backend/src/modules/netaclaw/service/skill_env_schema.ts packages/backend/src/modules/netaclaw/service/skill_loader.ts packages/backend/test/skill_env_schema.test.ts
git commit -m "feat(netaclaw): sync skill config env schema"
```
---
### Task 2: 宿主 runtime env 注入
**Files:**
- Create: `packages/backend/src/modules/netaclaw/service/skill_runtime_env.ts`
- Modify: `packages/backend/src/modules/netaclaw/service/skill_executor.ts`
- Test: `packages/backend/test/skill_runtime_env.test.ts`
- [ ] **Step 1: 写失败测试,覆盖 Windows dataDir env 默认值**
Create `packages/backend/test/skill_runtime_env.test.ts`:
```typescript
import * as path from 'path';
import { buildSkillRuntimeEnv } from '../src/modules/netaclaw/service/skill_runtime_env.js';
describe('buildSkillRuntimeEnv', () => {
it('injects dataDir, workspace root and upload root for vehicle damage inspection', () => {
const dataDir = path.join('C:', 'RZYX_AI_DATA');
const uploadRoot = path.join(dataDir, 'uploads');
const env = buildSkillRuntimeEnv({
skillName: 'vehicle-damage-inspection',
dataDir,
uploadRoot,
});
expect(env.RZYX_AI_DATA_DIR).toBe(path.resolve(dataDir));
expect(env.RZYX_AI_UPLOAD_ROOT).toBe(path.resolve(uploadRoot));
expect(env.RZYX_AI_WORKSPACE_ROOT).toBe(
path.join(path.resolve(dataDir), 'workspace', 'vehicle-damage-inspection'),
);
});
it('uses generic workspace directory for other compute-entry skills', () => {
const dataDir = path.join('C:', 'RZYX_AI_DATA');
const env = buildSkillRuntimeEnv({
skillName: 'ocr-reader',
dataDir,
uploadRoot: path.join(dataDir, 'uploads'),
});
expect(env.RZYX_AI_WORKSPACE_ROOT).toBe(
path.join(path.resolve(dataDir), 'workspace', 'ocr-reader'),
);
});
});
```
- [ ] **Step 2: 运行测试确认失败**
Run: `pnpm --filter @neta/backend test -- --runInBand test/skill_runtime_env.test.ts`
Expected: FAIL with module not found for `skill_runtime_env.js`.
- [ ] **Step 3: 新增 runtime env helper**
Create `packages/backend/src/modules/netaclaw/service/skill_runtime_env.ts`:
```typescript
import * as path from 'path';
export interface BuildSkillRuntimeEnvParams {
skillName: string;
dataDir: string;
uploadRoot: string;
}
export function buildSkillRuntimeEnv(params: BuildSkillRuntimeEnvParams): Record<string, string> {
const dataDir = path.resolve(params.dataDir);
const uploadRoot = path.resolve(params.uploadRoot);
const workspaceRoot = path.join(dataDir, 'workspace', params.skillName);
return {
RZYX_AI_DATA_DIR: dataDir,
RZYX_AI_UPLOAD_ROOT: uploadRoot,
RZYX_AI_WORKSPACE_ROOT: workspaceRoot,
};
}
```
- [ ] **Step 4: 接入 SkillExecutorService**
Modify `packages/backend/src/modules/netaclaw/service/skill_executor.ts`.
Add imports near the existing imports:
```typescript
import { pDataPath, pUploadPath } from '../../../comm/path.js';
import { buildSkillRuntimeEnv } from './skill_runtime_env.js';
```
Replace:
```typescript
const skillEnv = await this.skillSecret.resolveEnv(skillName);
const env = { ...baseEnv, ...skillEnv };
```
with:
```typescript
const runtimeEnv = buildSkillRuntimeEnv({
skillName,
dataDir: pDataPath(),
uploadRoot: pUploadPath(),
});
const skillEnv = await this.skillSecret.resolveEnv(skillName);
const env = { ...baseEnv, ...runtimeEnv, ...skillEnv };
```
This order lets skill secrets override runtime defaults when an administrator explicitly configures a path.
- [ ] **Step 5: 运行测试确认通过**
Run: `pnpm --filter @neta/backend test -- --runInBand test/skill_runtime_env.test.ts`
Expected: PASS.
- [ ] **Step 6: 验证后端编译**
Run: `pnpm --filter @neta/backend build`
Expected: build completes without TypeScript errors.
- [ ] **Step 7: 提交**
```bash
git add packages/backend/src/modules/netaclaw/service/skill_runtime_env.ts packages/backend/src/modules/netaclaw/service/skill_executor.ts packages/backend/test/skill_runtime_env.test.ts
git commit -m "feat(netaclaw): inject runtime data paths into compute skills"
```
---
### Task 3: Skill scaffold and metadata
**Files:**
- Create: `packages/backend/skills/vehicle-damage-inspection/SKILL.md`
- Create: `packages/backend/skills/vehicle-damage-inspection/skill.config.yaml`
- Create: `packages/backend/skills/vehicle-damage-inspection/README.md`
- Create: `packages/backend/skills/vehicle-damage-inspection/prompts/damage_detect.md`
- Create: `packages/backend/skills/vehicle-damage-inspection/prompts/grounding.md`
- Create: `packages/backend/skills/vehicle-damage-inspection/prompts/dedup.md`
- Create: `packages/backend/skills/vehicle-damage-inspection/prompts/best_frame.md`
- [ ] **Step 1: 创建 SKILL.md**
Create `packages/backend/skills/vehicle-damage-inspection/SKILL.md`:
```markdown
---
name: vehicle-damage-inspection
description: >
汽车环车视频旧伤检测。输入一个环车视频文件或 /upload/... 路径,自动抽帧、
检测车身漆面旧伤、定位 bbox 并输出红框标注图和最佳证据帧。
license: MIT
metadata:
version: "1.0.0"
category: vehicle-inspection
skillType: multimodal
tags: ["vehicle", "video", "damage", "bbox"]
---
# 汽车环车视频旧伤检测
当用户需要从汽车环车视频中识别漆面旧伤、划痕、凹陷、掉漆、锈蚀等外观损伤时,使用此 skill。
这是 compute-entry skill。不要手动拆步骤调用直接使用 `execute_skill`
```json
{
"name": "vehicle-damage-inspection",
"input": {
"videoUrl": "/upload/20260504/car.mp4",
"fps": 3,
"topN": 1
}
}
```
## 输入
- `videoUrl`: 必填。视频本地路径或 `/upload/...` 路径;第一版不支持 http(s) URL。
- `taskId`: 可选。未传时自动生成。
- `fps`: 可选,默认 3。
- `quality`: 可选,默认 90。
- `batchSize`: 可选,默认 12。
- `concurrency`: 可选,默认 2。
- `groundingWindow`: 可选,默认 1.5 秒。
- `topN`: 可选,默认每处损伤 1 张最佳帧。
- `mode`: 可选,`full``frames-only``detect-only`
## 输出
返回 JSON包含 `success``taskId``workspacePath``summary``damages``artifacts`
生产环境中,所有检测产物写入后端 dataDir 下的 `workspace/vehicle-damage-inspection/{taskId}`
```
- [ ] **Step 2: 创建 skill.config.yaml**
Create `packages/backend/skills/vehicle-damage-inspection/skill.config.yaml`:
```yaml
runtime: node
entrypoint: scripts/index.cjs
timeout: 900000
dependencies:
system:
- name: ffmpeg
check: "ffmpeg -version"
- name: ffprobe
check: "ffprobe -version"
- name: node
check: "node --version"
node:
packages:
- axios
- sharp
- fluent-ffmpeg
- "@ffmpeg-installer/ffmpeg"
- "@ffprobe-installer/ffprobe"
setup:
posix: scripts/make.sh fix
win32: scripts/setup.ps1
env:
- name: ARK_API_KEY
required: true
description: 火山方舟/豆包 OpenAI-compatible API Key
- name: ARK_API_URL
required: false
default: https://ark.cn-beijing.volces.com/api/v3/chat/completions
description: OpenAI-compatible chat completions endpoint
- name: DAMAGE_DETECT_MODEL
required: true
default: doubao-seed-2-0-pro-260215
description: 用于环车旧伤候选检测的多模态模型 ID
- name: DAMAGE_GROUNDING_MODEL
required: true
default: doubao-seed-2-0-pro-260215
description: 支持 bbox/grounding 输出的视觉模型 ID
- name: BEST_FRAME_MODEL
required: false
default: doubao-seed-2-0-pro-260215
description: 用于损伤去重和最佳帧筛选的模型 ID
- name: RZYX_AI_WORKSPACE_ROOT
required: false
description: 生产环境由宿主注入,默认 dataDir/workspace/vehicle-damage-inspection
- name: RZYX_AI_UPLOAD_ROOT
required: false
description: 生产环境由宿主注入,默认 dataDir/uploads
- name: RZYX_AI_DATA_DIR
required: false
description: 后端运行时 dataDir由宿主注入
interface:
input:
videoUrl:
type: string
required: true
description: 视频本地路径或 /upload/... 路径;第一版不支持 http(s) URL
taskId:
type: string
required: false
description: 可选任务 ID
fps:
type: number
required: false
default: "3"
description: 抽帧帧率
quality:
type: number
required: false
default: "90"
description: 输出 JPG 质量,范围 1-100
batchSize:
type: number
required: false
default: "12"
description: 每批发送给检测模型的帧数
concurrency:
type: number
required: false
default: "2"
description: 多模态 API 并发数
groundingWindow:
type: number
required: false
default: "1.5"
description: 每个损伤时间点前后取帧窗口,单位秒
topN:
type: number
required: false
default: "1"
description: 每处损伤返回的最佳证据帧数量
mode:
type: string
required: false
default: full
description: full | frames-only | detect-only
output:
success:
type: boolean
taskId:
type: string
workspacePath:
type: string
summary:
type: object
damages:
type: array
artifacts:
type: object
```
- [ ] **Step 3: 创建 prompt 文件**
Create `packages/backend/skills/vehicle-damage-inspection/prompts/damage_detect.md`:
```markdown
你是专业汽车环车视频旧伤检测专家。请检查给定视频帧中的车身外观可见区域,识别漆面旧伤,包括划痕、凹陷、掉漆、裂纹、锈蚀。
边界:
- 只把可见且明确的车身外观损伤列为 damages。
- 反光、污渍、阴影、雨水、压缩噪声、拍摄模糊不要直接判定为损伤。
- 玻璃、轮胎、车灯、车牌等非漆面问题如明显可见,可标记为 non_paint_damage但不要混入 paint damage。
- 不确定时设置 uncertain=trueconfidence="low"。
只输出 JSON
{
"damages": [
{
"time_second": 12.4,
"part": "左前门",
"type": "划痕",
"severity": "轻微",
"description": "左前门中部可见细长白色划痕",
"confidence": "medium",
"uncertain": false
}
],
"summary": "本批检测简述"
}
```
Create `packages/backend/skills/vehicle-damage-inspection/prompts/grounding.md`:
```markdown
请在图中定位指定汽车外观损伤区域,输出 bbox。bbox 坐标使用 0-1000 归一化坐标,格式:
[12.40 second] <bbox>x1 y1 x2 y2</bbox>
如果某帧看不到该损伤,不要输出该帧 bbox。
```
Create `packages/backend/skills/vehicle-damage-inspection/prompts/dedup.md`:
```markdown
你是汽车旧伤记录去重专家。以下损伤记录可能来自连续视频帧中的同一处物理损伤。
合并规则:
- 位置相同或相近、类型相同,且时间相近,视为同一处物理损伤。
- 位置描述略有差异但明显指同一部位,也应合并。
- 不同部位或不同损伤类型不要合并。
只输出 JSON
{
"groups": [
{
"merged_part": "左前门",
"merged_type": "划痕",
"merged_severity": "轻微",
"merged_description": "左前门中部细长划痕",
"member_indices": [0, 2]
}
]
}
```
Create `packages/backend/skills/vehicle-damage-inspection/prompts/best_frame.md`:
```markdown
以下是同一处汽车外观损伤的多张标注图。请选出最适合作为证据展示的帧。
优先级:
1. 红框准确框住损伤,不偏移、不过大、不遗漏。
2. 损伤区域清晰,不模糊、不遮挡。
3. 光照和角度能看清损伤全貌。
4. 避免反光、过曝、过暗。
只输出 JSON
{
"best_timestamps": [12.4],
"reasons": ["红框准确,划痕清晰可见"]
}
```
- [ ] **Step 4: 创建 README.md**
Create `packages/backend/skills/vehicle-damage-inspection/README.md`:
```markdown
# vehicle-damage-inspection
生产形态Windows 安装包中由后端复制到 `{dataDir}/skills/vehicle-damage-inspection` 后执行。
运行产物必须写入 `{dataDir}/workspace/vehicle-damage-inspection/{taskId}`,不要写入 skill 代码目录。
## 本地检查
```bash
bash scripts/make.sh check
```
## 本地运行
```bash
ARK_API_KEY=xxx bash scripts/make.sh run --video /path/to/car.mp4 --fps 3
```
## 必需模型
默认模型为 `doubao-seed-2-0-pro-260215`用于检测、bbox 定位和最佳帧筛选。
```
- [ ] **Step 5: 验证 Skill Loader 能识别配置**
Run: `pnpm --filter @neta/backend test -- --runInBand test/entity_exports.test.ts`
Expected: PASS. This is a smoke check that backend tests still load.
- [ ] **Step 6: 提交**
```bash
git add packages/backend/skills/vehicle-damage-inspection
git commit -m "feat(skills): scaffold vehicle damage inspection skill"
```
---
### Task 4: JSON utilities and workspace module
**Files:**
- Create: `packages/backend/skills/vehicle-damage-inspection/scripts/lib/json_utils.cjs`
- Create: `packages/backend/skills/vehicle-damage-inspection/scripts/lib/workspace.cjs`
- Test: `packages/backend/skills/vehicle-damage-inspection/tests/json_utils.test.cjs`
- Test: `packages/backend/skills/vehicle-damage-inspection/tests/workspace.test.cjs`
- [ ] **Step 1: 写 json_utils 测试**
Create `packages/backend/skills/vehicle-damage-inspection/tests/json_utils.test.cjs`:
```javascript
const assert = require('node:assert');
const { parseJsonLoose, parseBboxes, clampNumber, runWithConcurrency } = require('../scripts/lib/json_utils.cjs');
(async () => {
assert.deepStrictEqual(parseJsonLoose('```json\n{"a":1}\n```'), { a: 1 });
assert.deepStrictEqual(parseBboxes('[1.0 second] <bbox>10 20 30 40</bbox>'), [
{ x1: 10, y1: 20, x2: 30, y2: 40 },
]);
assert.strictEqual(clampNumber('9', 1, 5, 3), 5);
assert.strictEqual(clampNumber(undefined, 1, 5, 3), 3);
let active = 0;
let maxActive = 0;
const tasks = Array.from({ length: 5 }, (_, i) => async () => {
active += 1;
maxActive = Math.max(maxActive, active);
await new Promise(resolve => setTimeout(resolve, 5));
active -= 1;
return i;
});
const result = await runWithConcurrency(tasks, 2);
assert.deepStrictEqual(result, [0, 1, 2, 3, 4]);
assert.ok(maxActive <= 2);
})();
```
- [ ] **Step 2: 写 workspace 测试**
Create `packages/backend/skills/vehicle-damage-inspection/tests/workspace.test.cjs`:
```javascript
const assert = require('node:assert');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { createWorkspace, writeJson, readJson, resolveVideoPath } = require('../scripts/lib/workspace.cjs');
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vehicle-skill-workspace-'));
const uploads = path.join(root, 'uploads');
fs.mkdirSync(path.join(uploads, '20260504'), { recursive: true });
const video = path.join(uploads, '20260504', 'car.mp4');
fs.writeFileSync(video, 'fake');
try {
const ws = createWorkspace({ taskId: 'task-a', workspaceRoot: path.join(root, 'workspace') });
assert.strictEqual(ws.taskId, 'task-a');
assert.ok(fs.existsSync(path.join(ws.workspacePath, 'frames')));
writeJson(ws, 'video_info.json', { ok: true });
assert.deepStrictEqual(readJson(ws, 'video_info.json'), { ok: true });
assert.strictEqual(resolveVideoPath('/upload/20260504/car.mp4', { uploadRoot: uploads }), video);
assert.throws(() => resolveVideoPath('/upload/../secret.mp4', { uploadRoot: uploads }), /Invalid upload path/);
assert.throws(() => resolveVideoPath('https://example.test/car.mp4', { uploadRoot: uploads }), /Remote video URLs are not supported/);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
```
- [ ] **Step 3: 运行测试确认失败**
Run:
```bash
node packages/backend/skills/vehicle-damage-inspection/tests/json_utils.test.cjs
node packages/backend/skills/vehicle-damage-inspection/tests/workspace.test.cjs
```
Expected: both FAIL with missing module errors.
- [ ] **Step 4: 实现 json_utils.cjs**
Create `packages/backend/skills/vehicle-damage-inspection/scripts/lib/json_utils.cjs`:
```javascript
function parseJsonLoose(text) {
if (typeof text !== 'string') return null;
let value = text.trim();
if (value.startsWith('```')) {
value = value.split('\n').filter(line => !line.trim().startsWith('```')).join('\n').trim();
}
try {
return JSON.parse(value);
} catch {
const start = value.indexOf('{');
const end = value.lastIndexOf('}');
if (start >= 0 && end > start) {
try {
return JSON.parse(value.slice(start, end + 1));
} catch {
return null;
}
}
return null;
}
}
function parseBboxes(text) {
const result = [];
const pattern = /<bbox>\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s*<\/bbox>/g;
let match;
while ((match = pattern.exec(String(text || ''))) !== null) {
result.push({
x1: Number(match[1]),
y1: Number(match[2]),
x2: Number(match[3]),
y2: Number(match[4]),
});
}
return result;
}
function clampNumber(value, min, max, fallback) {
const n = Number(value);
if (!Number.isFinite(n)) return fallback;
return Math.max(min, Math.min(max, n));
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function runWithConcurrency(tasks, limit) {
const results = new Array(tasks.length);
let nextIndex = 0;
async function worker() {
while (nextIndex < tasks.length) {
const index = nextIndex++;
results[index] = await tasks[index]();
}
}
await Promise.all(Array.from({ length: Math.min(Math.max(1, limit), tasks.length) }, () => worker()));
return results;
}
module.exports = {
parseJsonLoose,
parseBboxes,
clampNumber,
sleep,
runWithConcurrency,
};
```
- [ ] **Step 5: 实现 workspace.cjs**
Create `packages/backend/skills/vehicle-damage-inspection/scripts/lib/workspace.cjs`:
```javascript
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
function sanitizeTaskId(taskId) {
const id = taskId || `${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}-${crypto.randomUUID().slice(0, 8)}`;
if (!/^[a-zA-Z0-9_-]+$/.test(id)) throw new Error(`Invalid taskId: ${id}`);
return id;
}
function createWorkspace({ taskId, workspaceRoot }) {
const root = path.resolve(workspaceRoot);
const id = sanitizeTaskId(taskId);
const workspacePath = path.join(root, id);
fs.mkdirSync(path.join(workspacePath, 'source'), { recursive: true });
fs.mkdirSync(path.join(workspacePath, 'frames'), { recursive: true });
fs.mkdirSync(path.join(workspacePath, 'marked_frames'), { recursive: true });
return { taskId: id, root, workspacePath };
}
function ensureInside(root, candidate) {
const rootPath = path.resolve(root);
const fullPath = path.resolve(candidate);
if (fullPath !== rootPath && !fullPath.startsWith(rootPath + path.sep)) {
throw new Error(`Path escapes root: ${candidate}`);
}
return fullPath;
}
function writeJson(workspace, relativePath, value) {
const filePath = ensureInside(workspace.workspacePath, path.join(workspace.workspacePath, relativePath));
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8');
return filePath;
}
function readJson(workspace, relativePath) {
const filePath = ensureInside(workspace.workspacePath, path.join(workspace.workspacePath, relativePath));
if (!fs.existsSync(filePath)) return null;
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
function resolveVideoPath(videoUrl, options) {
if (!videoUrl || typeof videoUrl !== 'string') throw new Error('videoUrl is required');
if (fs.existsSync(videoUrl)) return path.resolve(videoUrl);
const uploadMatch = videoUrl.match(/^\/upload\/(.+)$/);
if (uploadMatch) {
if (!options.uploadRoot) throw new Error('RZYX_AI_UPLOAD_ROOT is required for /upload paths');
const relative = uploadMatch[1].replace(/[\\/]+/g, path.sep);
if (relative.split(path.sep).includes('..')) throw new Error('Invalid upload path');
const filePath = ensureInside(options.uploadRoot, path.join(options.uploadRoot, relative));
if (!fs.existsSync(filePath)) throw new Error(`Video file not found: ${filePath}`);
return filePath;
}
if (/^https?:\/\//i.test(videoUrl)) {
throw new Error('Remote video URLs are not supported in the first version; use a local path or /upload/... path');
}
throw new Error(`Video file not found: ${videoUrl}`);
}
module.exports = {
createWorkspace,
writeJson,
readJson,
resolveVideoPath,
ensureInside,
};
```
- [ ] **Step 6: 运行测试确认通过**
Run:
```bash
node packages/backend/skills/vehicle-damage-inspection/tests/json_utils.test.cjs
node packages/backend/skills/vehicle-damage-inspection/tests/workspace.test.cjs
```
Expected: both commands exit 0.
- [ ] **Step 7: 提交**
```bash
git add packages/backend/skills/vehicle-damage-inspection/scripts/lib/json_utils.cjs packages/backend/skills/vehicle-damage-inspection/scripts/lib/workspace.cjs packages/backend/skills/vehicle-damage-inspection/tests/json_utils.test.cjs packages/backend/skills/vehicle-damage-inspection/tests/workspace.test.cjs
git commit -m "feat(skills): add vehicle inspection workspace utilities"
```
---
### Task 5: Frame extraction module
**Files:**
- Create: `packages/backend/skills/vehicle-damage-inspection/scripts/lib/frame_extractor.cjs`
- Test: `packages/backend/skills/vehicle-damage-inspection/tests/frame_extractor.test.cjs`
- [ ] **Step 1: 写 ffmpeg command builder 测试**
Create `packages/backend/skills/vehicle-damage-inspection/tests/frame_extractor.test.cjs`:
```javascript
const assert = require('node:assert');
const { buildFrameName, qualityToFfmpegQv, estimateFrameTimestamp } = require('../scripts/lib/frame_extractor.cjs');
assert.strictEqual(buildFrameName(1, 0), 'frame_000001_0.00s.jpg');
assert.strictEqual(buildFrameName(38, 12.4), 'frame_000038_12.40s.jpg');
assert.strictEqual(qualityToFfmpegQv(100), 1);
assert.strictEqual(qualityToFfmpegQv(1), 31);
assert.strictEqual(estimateFrameTimestamp(6, 3), 2);
```
- [ ] **Step 2: 运行测试确认失败**
Run: `node packages/backend/skills/vehicle-damage-inspection/tests/frame_extractor.test.cjs`
Expected: FAIL with missing module.
- [ ] **Step 3: 实现 frame_extractor.cjs**
Create `packages/backend/skills/vehicle-damage-inspection/scripts/lib/frame_extractor.cjs`:
```javascript
const fs = require('node:fs');
const path = require('node:path');
const ffmpeg = require('fluent-ffmpeg');
const ffmpegInstaller = require('@ffmpeg-installer/ffmpeg');
const ffprobeInstaller = require('@ffprobe-installer/ffprobe');
const { writeJson } = require('./workspace.cjs');
ffmpeg.setFfmpegPath(process.env.FFMPEG_PATH || ffmpegInstaller.path);
ffmpeg.setFfprobePath(process.env.FFPROBE_PATH || ffprobeInstaller.path);
function buildFrameName(index, timestamp) {
return `frame_${String(index).padStart(6, '0')}_${Number(timestamp).toFixed(2)}s.jpg`;
}
function qualityToFfmpegQv(quality) {
const q = Math.max(1, Math.min(100, Number(quality) || 90));
return Math.max(1, Math.min(31, Math.round(31 - (q / 100) * 30)));
}
function estimateFrameTimestamp(indexZeroBased, fps) {
return Number((indexZeroBased / fps).toFixed(2));
}
function probeVideo(videoPath) {
return new Promise((resolve, reject) => {
ffmpeg.ffprobe(videoPath, (err, data) => err ? reject(err) : resolve(data));
});
}
async function extractFrames({ workspace, videoPath, fps, quality }) {
const framesDir = path.join(workspace.workspacePath, 'frames');
fs.rmSync(framesDir, { recursive: true, force: true });
fs.mkdirSync(framesDir, { recursive: true });
const probe = await probeVideo(videoPath);
const stream = (probe.streams || []).find(item => item.codec_type === 'video');
if (!stream) throw new Error('No video stream found');
const duration = Number(probe.format && probe.format.duration ? probe.format.duration : 0);
const width = stream.width || 0;
const height = stream.height || 0;
const fpsValue = Number(fps) || 3;
const qv = qualityToFfmpegQv(quality);
const tempPattern = path.join(framesDir, 'raw_%06d.jpg');
await new Promise((resolve, reject) => {
ffmpeg(videoPath)
.outputOptions(['-vf', `fps=${fpsValue}`, '-q:v', String(qv)])
.output(tempPattern)
.on('end', resolve)
.on('error', reject)
.run();
});
const rawFiles = fs.readdirSync(framesDir).filter(name => /^raw_\d+\.jpg$/.test(name)).sort();
if (rawFiles.length === 0) throw new Error('No frames extracted');
const frames = rawFiles.map((name, index) => {
const timestamp = estimateFrameTimestamp(index, fpsValue);
const finalName = buildFrameName(index + 1, timestamp);
fs.renameSync(path.join(framesDir, name), path.join(framesDir, finalName));
return {
index,
timestamp,
relativePath: `frames/${finalName}`,
path: path.join(framesDir, finalName),
};
});
const videoInfo = {
duration: Number(duration.toFixed(2)),
videoFps: stream.r_frame_rate || '',
resolution: `${width}x${height}`,
extractFps: fpsValue,
extractedFrames: frames.length,
};
writeJson(workspace, 'video_info.json', {
videoPath,
videoInfo,
frames: frames.map(({ index, timestamp, relativePath }) => ({ index, timestamp, relativePath })),
});
return { videoInfo, frames };
}
module.exports = {
buildFrameName,
qualityToFfmpegQv,
estimateFrameTimestamp,
extractFrames,
};
```
- [ ] **Step 4: 运行测试确认通过**
Run: `node packages/backend/skills/vehicle-damage-inspection/tests/frame_extractor.test.cjs`
Expected: exits 0.
- [ ] **Step 5: 提交**
```bash
git add packages/backend/skills/vehicle-damage-inspection/scripts/lib/frame_extractor.cjs packages/backend/skills/vehicle-damage-inspection/tests/frame_extractor.test.cjs
git commit -m "feat(skills): add vehicle video frame extractor"
```
---
### Task 6: Vision client module
**Files:**
- Create: `packages/backend/skills/vehicle-damage-inspection/scripts/lib/vision_client.cjs`
- Test: `packages/backend/skills/vehicle-damage-inspection/tests/vision_client.test.cjs`
- [ ] **Step 1: 写 vision client 纯函数测试**
Create `packages/backend/skills/vehicle-damage-inspection/tests/vision_client.test.cjs`:
```javascript
const assert = require('node:assert');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { resolveModelConfig, encodeImageDataUri, createTimestampedImageContent } = require('../scripts/lib/vision_client.cjs');
const env = {
ARK_API_KEY: 'key',
ARK_API_URL: 'https://example.test/v1/chat/completions',
DAMAGE_DETECT_MODEL: 'detect-model',
DAMAGE_GROUNDING_MODEL: 'ground-model',
};
assert.deepStrictEqual(resolveModelConfig(env), {
apiKey: 'key',
apiUrl: 'https://example.test/v1/chat/completions',
detectModel: 'detect-model',
groundingModel: 'ground-model',
bestFrameModel: 'detect-model',
});
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vision-client-'));
try {
const img = path.join(dir, 'a.jpg');
fs.writeFileSync(img, Buffer.from([1, 2, 3]));
assert.strictEqual(encodeImageDataUri(img), 'data:image/jpeg;base64,AQID');
const content = createTimestampedImageContent([{ timestamp: 1.25, path: img }]);
assert.strictEqual(content[0].text, '[1.25 second]');
assert.ok(content[1].image_url.url.startsWith('data:image/jpeg;base64,'));
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
```
- [ ] **Step 2: 运行测试确认失败**
Run: `node packages/backend/skills/vehicle-damage-inspection/tests/vision_client.test.cjs`
Expected: FAIL with missing module.
- [ ] **Step 3: 实现 vision_client.cjs**
Create `packages/backend/skills/vehicle-damage-inspection/scripts/lib/vision_client.cjs`:
```javascript
const fs = require('node:fs');
const axios = require('axios');
const { sleep } = require('./json_utils.cjs');
function resolveModelConfig(env) {
const apiKey = env.ARK_API_KEY;
if (!apiKey) throw new Error('Missing required env: ARK_API_KEY');
const apiUrl = env.ARK_API_URL || 'https://ark.cn-beijing.volces.com/api/v3/chat/completions';
const detectModel = env.DAMAGE_DETECT_MODEL || 'doubao-seed-2-0-pro-260215';
const groundingModel = env.DAMAGE_GROUNDING_MODEL || detectModel;
const bestFrameModel = env.BEST_FRAME_MODEL || detectModel;
return { apiKey, apiUrl, detectModel, groundingModel, bestFrameModel };
}
function encodeImageDataUri(filePath) {
const buffer = fs.readFileSync(filePath);
return `data:image/jpeg;base64,${buffer.toString('base64')}`;
}
function createTimestampedImageContent(frames) {
const content = [];
for (const frame of frames) {
content.push({ type: 'text', text: `[${Number(frame.timestamp).toFixed(2)} second]` });
content.push({ type: 'image_url', image_url: { url: encodeImageDataUri(frame.path) } });
}
return content;
}
async function callVisionApi({ config, model, messages, maxTokens = 4096, temperature = 0.1, timeoutMs = 600000, maxRetries = 3 }) {
let lastError = '';
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
try {
const response = await axios.post(
config.apiUrl,
{ model, messages, max_tokens: maxTokens, temperature },
{
headers: {
Authorization: `Bearer ${config.apiKey}`,
'Content-Type': 'application/json',
},
timeout: timeoutMs,
maxBodyLength: Infinity,
maxContentLength: Infinity,
},
);
return {
ok: true,
content: response.data && response.data.choices && response.data.choices[0] && response.data.choices[0].message
? response.data.choices[0].message.content || ''
: '',
usage: response.data ? response.data.usage : undefined,
};
} catch (error) {
const status = error.response && error.response.status;
const body = error.response && error.response.data ? JSON.stringify(error.response.data).slice(0, 500) : error.message;
lastError = `API调用失败(${status || 'network'}): ${body}`;
const retryable = status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
if (retryable && attempt < maxRetries) {
await sleep(1000 * Math.pow(2, attempt));
continue;
}
break;
}
}
return { ok: false, error: lastError };
}
module.exports = {
resolveModelConfig,
encodeImageDataUri,
createTimestampedImageContent,
callVisionApi,
};
```
- [ ] **Step 4: 运行测试确认通过**
Run: `node packages/backend/skills/vehicle-damage-inspection/tests/vision_client.test.cjs`
Expected: exits 0.
- [ ] **Step 5: 提交**
```bash
git add packages/backend/skills/vehicle-damage-inspection/scripts/lib/vision_client.cjs packages/backend/skills/vehicle-damage-inspection/tests/vision_client.test.cjs
git commit -m "feat(skills): add doubao vision client for vehicle inspection"
```
---
### Task 7: Damage candidate detector
**Files:**
- Create: `packages/backend/skills/vehicle-damage-inspection/scripts/lib/damage_detector.cjs`
- Test: `packages/backend/skills/vehicle-damage-inspection/tests/damage_detector.test.cjs`
- [ ] **Step 1: 写 mock 检测测试**
Create `packages/backend/skills/vehicle-damage-inspection/tests/damage_detector.test.cjs`:
```javascript
const assert = require('node:assert');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { createWorkspace, readJson } = require('../scripts/lib/workspace.cjs');
const { detectDamageCandidates } = require('../scripts/lib/damage_detector.cjs');
(async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'damage-detector-'));
try {
const workspace = createWorkspace({ taskId: 'task-detect', workspaceRoot: root });
const framePath = path.join(workspace.workspacePath, 'frames', 'frame_000001_0.00s.jpg');
fs.writeFileSync(framePath, Buffer.from([1, 2, 3]));
const frames = [{ index: 0, timestamp: 0, path: framePath, relativePath: 'frames/frame_000001_0.00s.jpg' }];
const mockVision = async () => ({
ok: true,
content: JSON.stringify({
damages: [{ time_second: 0, part: '左前门', type: '划痕', severity: '轻微', description: '细长划痕', confidence: 'medium' }],
summary: '发现一处损伤',
}),
});
const result = await detectDamageCandidates({
workspace,
frames,
modelConfig: { detectModel: 'm', apiKey: 'k', apiUrl: 'u' },
prompt: 'prompt',
batchSize: 1,
concurrency: 1,
callVisionApi: mockVision,
});
assert.strictEqual(result.candidates.length, 1);
assert.strictEqual(result.candidates[0].id, 'cand_001');
assert.strictEqual(readJson(workspace, 'damage_candidates.json').candidates.length, 1);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
})();
```
- [ ] **Step 2: 运行测试确认失败**
Run: `node packages/backend/skills/vehicle-damage-inspection/tests/damage_detector.test.cjs`
Expected: FAIL with missing module.
- [ ] **Step 3: 实现 damage_detector.cjs**
Create `packages/backend/skills/vehicle-damage-inspection/scripts/lib/damage_detector.cjs`:
```javascript
const { parseJsonLoose, runWithConcurrency } = require('./json_utils.cjs');
const { createTimestampedImageContent, callVisionApi: defaultCallVisionApi } = require('./vision_client.cjs');
const { writeJson } = require('./workspace.cjs');
function normalizeCandidate(raw, index, batchNumber) {
return {
id: `cand_${String(index + 1).padStart(3, '0')}`,
timestamp: Number(raw.time_second ?? raw.timestamp ?? 0),
part: raw.part || raw.location || '',
type: raw.type || '',
severity: raw.severity || '',
description: raw.description || '',
confidence: raw.confidence || 'medium',
uncertain: Boolean(raw.uncertain),
batch: batchNumber,
};
}
async function detectDamageCandidates(params) {
const {
workspace,
frames,
modelConfig,
prompt,
batchSize,
concurrency,
callVisionApi = defaultCallVisionApi,
} = params;
const batches = [];
for (let start = 0; start < frames.length; start += batchSize) {
batches.push(frames.slice(start, start + batchSize));
}
const batchRecords = [];
const collected = [];
await runWithConcurrency(batches.map((batch, batchIndex) => async () => {
const content = [
{ type: 'text', text: prompt },
...createTimestampedImageContent(batch),
{ type: 'text', text: '请只输出 JSON。' },
];
const result = await callVisionApi({
config: modelConfig,
model: modelConfig.detectModel,
messages: [{ role: 'user', content }],
});
const record = {
batch: batchIndex + 1,
frameStart: batch[0].index,
frameEnd: batch[batch.length - 1].index,
status: result.ok ? 'success' : 'error',
error: result.ok ? undefined : result.error,
};
batchRecords[batchIndex] = record;
if (!result.ok) return;
const parsed = parseJsonLoose(result.content || '');
const damages = Array.isArray(parsed && parsed.damages) ? parsed.damages : [];
for (const damage of damages) {
collected.push({ damage, batchNumber: batchIndex + 1 });
}
}), concurrency);
const candidates = collected.map((item, index) => normalizeCandidate(item.damage, index, item.batchNumber));
const output = {
totalFrames: frames.length,
batches: batchRecords,
candidates,
};
writeJson(workspace, 'damage_candidates.json', output);
return output;
}
module.exports = {
detectDamageCandidates,
normalizeCandidate,
};
```
- [ ] **Step 4: 运行测试确认通过**
Run: `node packages/backend/skills/vehicle-damage-inspection/tests/damage_detector.test.cjs`
Expected: exits 0.
- [ ] **Step 5: 提交**
```bash
git add packages/backend/skills/vehicle-damage-inspection/scripts/lib/damage_detector.cjs packages/backend/skills/vehicle-damage-inspection/tests/damage_detector.test.cjs
git commit -m "feat(skills): detect vehicle damage candidates from frames"
```
---
### Task 8: Image marker and damage grounding
**Files:**
- Create: `packages/backend/skills/vehicle-damage-inspection/scripts/lib/image_marker.cjs`
- Create: `packages/backend/skills/vehicle-damage-inspection/scripts/lib/damage_grounding.cjs`
- Test: `packages/backend/skills/vehicle-damage-inspection/tests/image_marker.test.cjs`
- Test: `packages/backend/skills/vehicle-damage-inspection/tests/damage_grounding.test.cjs`
- [ ] **Step 1: 写 image marker 测试**
Create `packages/backend/skills/vehicle-damage-inspection/tests/image_marker.test.cjs`:
```javascript
const assert = require('node:assert');
const { normalizedToPixel } = require('../scripts/lib/image_marker.cjs');
assert.deepStrictEqual(
normalizedToPixel({ x1: 100, y1: 200, x2: 300, y2: 400 }, { width: 1000, height: 500 }),
{ x1: 100, y1: 100, x2: 300, y2: 200 },
);
```
- [ ] **Step 2: 写 grounding mock 测试**
Create `packages/backend/skills/vehicle-damage-inspection/tests/damage_grounding.test.cjs`:
```javascript
const assert = require('node:assert');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { createWorkspace, readJson } = require('../scripts/lib/workspace.cjs');
const { groundDamages } = require('../scripts/lib/damage_grounding.cjs');
(async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'damage-grounding-'));
try {
const workspace = createWorkspace({ taskId: 'task-ground', workspaceRoot: root });
const framePath = path.join(workspace.workspacePath, 'frames', 'frame_000001_0.00s.jpg');
fs.writeFileSync(framePath, Buffer.from([1, 2, 3]));
const frames = [{ index: 0, timestamp: 0, path: framePath, relativePath: 'frames/frame_000001_0.00s.jpg' }];
const candidates = [{ id: 'cand_001', timestamp: 0, part: '左前门', type: '划痕', severity: '轻微', description: '细长划痕' }];
const mockVision = async () => ({ ok: true, content: '[0.00 second] <bbox>10 20 30 40</bbox>' });
const mockMarker = async ({ dstPath }) => {
fs.writeFileSync(dstPath, 'marked');
return true;
};
const result = await groundDamages({
workspace,
frames,
candidates,
modelConfig: { groundingModel: 'm', apiKey: 'k', apiUrl: 'u' },
prompt: 'prompt',
groundingWindow: 1.5,
callVisionApi: mockVision,
drawBbox: mockMarker,
});
assert.strictEqual(result.annotations.length, 1);
assert.strictEqual(result.annotations[0].markedFrames.length, 1);
assert.strictEqual(readJson(workspace, 'damage_annotations.json').annotations.length, 1);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
})();
```
- [ ] **Step 3: 运行测试确认失败**
Run:
```bash
node packages/backend/skills/vehicle-damage-inspection/tests/image_marker.test.cjs
node packages/backend/skills/vehicle-damage-inspection/tests/damage_grounding.test.cjs
```
Expected: both FAIL with missing module.
- [ ] **Step 4: 实现 image_marker.cjs**
Create `packages/backend/skills/vehicle-damage-inspection/scripts/lib/image_marker.cjs`:
```javascript
const fs = require('node:fs');
const path = require('node:path');
const sharp = require('sharp');
function normalizedToPixel(box, metadata) {
const width = metadata.width || 1;
const height = metadata.height || 1;
return {
x1: Math.round((box.x1 * width) / 1000),
y1: Math.round((box.y1 * height) / 1000),
x2: Math.round((box.x2 * width) / 1000),
y2: Math.round((box.y2 * height) / 1000),
};
}
async function drawBbox({ srcPath, dstPath, bboxes, label }) {
fs.mkdirSync(path.dirname(dstPath), { recursive: true });
const metadata = await sharp(srcPath).metadata();
const width = metadata.width || 1;
const height = metadata.height || 1;
const parts = [];
for (const box of bboxes) {
const pixel = normalizedToPixel(box, { width, height });
const rectWidth = Math.max(1, pixel.x2 - pixel.x1);
const rectHeight = Math.max(1, pixel.y2 - pixel.y1);
parts.push(`<rect x="${pixel.x1}" y="${pixel.y1}" width="${rectWidth}" height="${rectHeight}" fill="none" stroke="red" stroke-width="4"/>`);
if (label) {
const safeLabel = String(label).replace(/[<>&"]/g, '');
parts.push(`<text x="${pixel.x1}" y="${Math.max(18, pixel.y1 - 6)}" font-size="18" fill="red">${safeLabel}</text>`);
}
}
const overlay = Buffer.from(`<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">${parts.join('')}</svg>`);
await sharp(srcPath).composite([{ input: overlay, top: 0, left: 0 }]).jpeg({ quality: 90 }).toFile(dstPath);
return true;
}
module.exports = {
normalizedToPixel,
drawBbox,
};
```
- [ ] **Step 5: 实现 damage_grounding.cjs**
Create `packages/backend/skills/vehicle-damage-inspection/scripts/lib/damage_grounding.cjs`:
```javascript
const fs = require('node:fs');
const path = require('node:path');
const { parseBboxes } = require('./json_utils.cjs');
const { createTimestampedImageContent, callVisionApi: defaultCallVisionApi } = require('./vision_client.cjs');
const { drawBbox: defaultDrawBbox, normalizedToPixel } = require('./image_marker.cjs');
const { writeJson } = require('./workspace.cjs');
function selectNearbyFrames(frames, timestamp, windowSeconds) {
return frames.filter(frame => Math.abs(Number(frame.timestamp) - Number(timestamp)) <= windowSeconds);
}
async function groundDamages(params) {
const {
workspace,
frames,
candidates,
modelConfig,
prompt,
groundingWindow,
callVisionApi = defaultCallVisionApi,
drawBbox = defaultDrawBbox,
} = params;
const annotations = [];
for (let i = 0; i < candidates.length; i += 1) {
const candidate = candidates[i];
const nearbyFrames = selectNearbyFrames(frames, candidate.timestamp, groundingWindow);
const content = [
...createTimestampedImageContent(nearbyFrames),
{ type: 'text', text: `${prompt}\n损伤描述${candidate.part} ${candidate.type} ${candidate.description}` },
];
const result = await callVisionApi({
config: modelConfig,
model: modelConfig.groundingModel,
messages: [{ role: 'user', content }],
maxTokens: 2048,
temperature: 0.1,
});
const bboxes = result.ok ? parseBboxes(result.content || '') : [];
const markedFrames = [];
for (const frame of nearbyFrames) {
const markedName = `dmg_${String(i + 1).padStart(3, '0')}_${Number(frame.timestamp).toFixed(2)}s.jpg`;
const dstPath = path.join(workspace.workspacePath, 'marked_frames', markedName);
if (bboxes.length > 0) {
await drawBbox({ srcPath: frame.path, dstPath, bboxes, label: `${candidate.part}-${candidate.type}` });
} else {
fs.copyFileSync(frame.path, dstPath);
}
const pixel = bboxes[0] ? normalizedToPixel(bboxes[0], { width: 1000, height: 1000 }) : null;
markedFrames.push({
timestamp: frame.timestamp,
sourceRelativePath: frame.relativePath,
markedRelativePath: `marked_frames/${markedName}`,
path: dstPath,
bbox: bboxes[0] ? { normalized: bboxes[0], pixel } : null,
groundingStatus: bboxes.length > 0 ? 'bbox' : 'no_bbox',
groundingRaw: result.ok ? result.content : result.error,
});
}
annotations.push({
candidateId: candidate.id,
damageId: `dmg_${String(i + 1).padStart(3, '0')}`,
part: candidate.part,
type: candidate.type,
severity: candidate.severity,
description: candidate.description,
markedFrames,
});
}
const output = { annotations };
writeJson(workspace, 'damage_annotations.json', output);
return output;
}
module.exports = {
selectNearbyFrames,
groundDamages,
};
```
- [ ] **Step 6: 运行测试确认通过**
Run:
```bash
node packages/backend/skills/vehicle-damage-inspection/tests/image_marker.test.cjs
node packages/backend/skills/vehicle-damage-inspection/tests/damage_grounding.test.cjs
```
Expected: both commands exit 0.
- [ ] **Step 7: 提交**
```bash
git add packages/backend/skills/vehicle-damage-inspection/scripts/lib/image_marker.cjs packages/backend/skills/vehicle-damage-inspection/scripts/lib/damage_grounding.cjs packages/backend/skills/vehicle-damage-inspection/tests/image_marker.test.cjs packages/backend/skills/vehicle-damage-inspection/tests/damage_grounding.test.cjs
git commit -m "feat(skills): ground and mark vehicle damage frames"
```
---
### Task 9: Best frame selector
**Files:**
- Create: `packages/backend/skills/vehicle-damage-inspection/scripts/lib/best_frame_selector.cjs`
- Test: `packages/backend/skills/vehicle-damage-inspection/tests/best_frame_selector.test.cjs`
- [ ] **Step 1: 写最佳帧筛选测试**
Create `packages/backend/skills/vehicle-damage-inspection/tests/best_frame_selector.test.cjs`:
```javascript
const assert = require('node:assert');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { createWorkspace, readJson } = require('../scripts/lib/workspace.cjs');
const { heuristicFrameScore, selectBestFrames, buildBestFramePrompt } = require('../scripts/lib/best_frame_selector.cjs');
(async () => {
assert.ok(heuristicFrameScore({ bbox: { normalized: { x1: 100, y1: 100, x2: 300, y2: 300 } } }) > heuristicFrameScore({ bbox: null }));
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'best-frame-'));
try {
const workspace = createWorkspace({ taskId: 'task-best', workspaceRoot: root });
const annotations = [{
damageId: 'dmg_001',
part: '左前门',
type: '划痕',
severity: '轻微',
description: '细长划痕',
markedFrames: [
{ timestamp: 0, markedRelativePath: 'marked_frames/a.jpg', path: 'a.jpg', bbox: null },
{ timestamp: 1, markedRelativePath: 'marked_frames/b.jpg', path: 'b.jpg', bbox: { normalized: { x1: 100, y1: 100, x2: 300, y2: 300 } } },
],
}];
const result = await selectBestFrames({
workspace,
annotations,
topN: 1,
modelConfig: { bestFrameModel: 'doubao-seed-2-0-pro-260215' },
prompt: '选择最佳证据帧',
callVisionApi: async () => ({ ok: true, content: '{"best_timestamps":[1],"reasons":["bbox准确"]}' }),
});
assert.ok(buildBestFramePrompt(annotations[0], '选择最佳证据帧').includes('dmg_001'));
assert.strictEqual(result.damages[0].bestFrames[0].timestamp, 1);
assert.strictEqual(result.damages[0].bestFrames[0].selectionReason, 'bbox准确');
assert.strictEqual(readJson(workspace, 'best_frames.json').bestFrameCount, 1);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
})();
```
- [ ] **Step 2: 运行测试确认失败**
Run: `node packages/backend/skills/vehicle-damage-inspection/tests/best_frame_selector.test.cjs`
Expected: FAIL with missing module.
- [ ] **Step 3: 实现 best_frame_selector.cjs**
Create `packages/backend/skills/vehicle-damage-inspection/scripts/lib/best_frame_selector.cjs`:
```javascript
const { writeJson } = require('./workspace.cjs');
const { parseJsonLoose } = require('./json_utils.cjs');
const { callVisionApi: defaultCallVisionApi } = require('./vision_client.cjs');
function heuristicFrameScore(frame) {
let score = 0;
if (frame.bbox && frame.bbox.normalized) {
score += 100;
const box = frame.bbox.normalized;
const area = Math.max(0, box.x2 - box.x1) * Math.max(0, box.y2 - box.y1);
if (area >= 10000 && area <= 250000) score += 30;
if (box.x1 > 10 && box.y1 > 10 && box.x2 < 990 && box.y2 < 990) score += 20;
}
return score;
}
function dedupeByTimestamp(frames) {
const map = new Map();
for (const frame of frames) {
const key = Number(frame.timestamp).toFixed(2);
const existing = map.get(key);
if (!existing || heuristicFrameScore(frame) > heuristicFrameScore(existing)) {
map.set(key, frame);
}
}
return Array.from(map.values());
}
function buildBestFramePrompt(annotation, prompt) {
const frames = (annotation.markedFrames || []).map(frame => ({
timestamp: frame.timestamp,
hasBbox: !!frame.bbox,
bbox: frame.bbox ? frame.bbox.normalized : null,
}));
return `${prompt}\n损伤ID${annotation.damageId}\n部位${annotation.part}\n类型${annotation.type}\n描述${annotation.description}\n候选帧${JSON.stringify(frames)}`;
}
function parseBestFrameSelection(content) {
const parsed = parseJsonLoose(content || '');
if (!parsed || !Array.isArray(parsed.best_timestamps)) return { timestamps: [], reasons: [] };
return {
timestamps: parsed.best_timestamps.map(Number).filter(Number.isFinite),
reasons: Array.isArray(parsed.reasons) ? parsed.reasons.map(String) : [],
};
}
async function chooseFramesWithModel({ annotation, candidates, topN, modelConfig, prompt, callVisionApi }) {
if (!modelConfig || !modelConfig.bestFrameModel || candidates.length <= 1) return null;
const result = await callVisionApi({
config: modelConfig,
model: modelConfig.bestFrameModel,
messages: [{ role: 'user', content: [{ type: 'text', text: buildBestFramePrompt(annotation, prompt) }] }],
maxTokens: 1024,
temperature: 0.1,
});
if (!result.ok) return null;
const selection = parseBestFrameSelection(result.content);
const picked = [];
for (const timestamp of selection.timestamps) {
const frame = candidates.find(item => Math.abs(Number(item.timestamp) - timestamp) < 0.01);
if (frame && !picked.includes(frame)) picked.push(frame);
if (picked.length >= topN) break;
}
if (picked.length === 0) return null;
return picked.map((frame, index) => ({ frame, reason: selection.reasons[index] || 'BEST_FRAME_MODEL selected this frame' }));
}
async function selectBestFrames({ workspace, annotations, topN, modelConfig, prompt = '', callVisionApi = defaultCallVisionApi }) {
const damages = [];
for (let index = 0; index < annotations.length; index += 1) {
const annotation = annotations[index];
const candidates = dedupeByTimestamp(annotation.markedFrames || []);
const modelSelected = await chooseFramesWithModel({ annotation, candidates, topN, modelConfig, prompt, callVisionApi });
const selected = modelSelected || candidates
.slice()
.sort((a, b) => heuristicFrameScore(b) - heuristicFrameScore(a))
.slice(0, topN)
.map(frame => ({ frame, reason: 'heuristic fallback' }));
const bestFrames = selected.map(({ frame, reason }) => ({
timestamp: frame.timestamp,
path: frame.path,
relativePath: frame.markedRelativePath,
bbox: frame.bbox ? frame.bbox.normalized : null,
selectionReason: reason,
}));
damages.push({
id: annotation.damageId || `dmg_${String(index + 1).padStart(3, '0')}`,
part: annotation.part,
type: annotation.type,
severity: annotation.severity,
description: annotation.description,
timestamps: candidates.map(frame => frame.timestamp),
bestFrames,
});
}
const bestFrameCount = damages.reduce((sum, item) => sum + item.bestFrames.length, 0);
const output = {
damageCount: damages.length,
bestFrameCount,
damages,
};
writeJson(workspace, 'best_frames.json', output);
return output;
}
module.exports = {
heuristicFrameScore,
dedupeByTimestamp,
buildBestFramePrompt,
parseBestFrameSelection,
selectBestFrames,
};
```
- [ ] **Step 4: 运行测试确认通过**
Run: `node packages/backend/skills/vehicle-damage-inspection/tests/best_frame_selector.test.cjs`
Expected: exits 0.
- [ ] **Step 5: 提交**
```bash
git add packages/backend/skills/vehicle-damage-inspection/scripts/lib/best_frame_selector.cjs packages/backend/skills/vehicle-damage-inspection/tests/best_frame_selector.test.cjs
git commit -m "feat(skills): select best vehicle damage evidence frames"
```
---
### Task 10: Compute-entry pipeline entrypoint
**Files:**
- Create: `packages/backend/skills/vehicle-damage-inspection/scripts/index.cjs`
- Test: `packages/backend/skills/vehicle-damage-inspection/tests/index_frames_only.test.cjs`
- [ ] **Step 1: 写 frames-only 入口测试**
Create `packages/backend/skills/vehicle-damage-inspection/tests/index_frames_only.test.cjs`:
```javascript
const assert = require('node:assert');
const { normalizeInput, buildFinalOutput } = require('../scripts/index.cjs');
const input = normalizeInput({ videoUrl: '/upload/a.mp4', fps: '20', quality: '120', mode: 'frames-only' });
assert.strictEqual(input.videoUrl, '/upload/a.mp4');
assert.strictEqual(input.fps, 10);
assert.strictEqual(input.quality, 100);
assert.strictEqual(input.mode, 'frames-only');
const output = buildFinalOutput({
workspace: { taskId: 'task', workspacePath: 'C:/data/workspace/task' },
videoInfo: { duration: 1, resolution: '1x1', extractedFrames: 3 },
candidates: [],
damages: [],
artifacts: { videoInfo: 'video_info.json' },
});
assert.strictEqual(output.success, true);
assert.strictEqual(output.summary.frameCount, 3);
```
- [ ] **Step 2: 运行测试确认失败**
Run: `node packages/backend/skills/vehicle-damage-inspection/tests/index_frames_only.test.cjs`
Expected: FAIL with missing module.
- [ ] **Step 3: 实现 scripts/index.cjs**
Create `packages/backend/skills/vehicle-damage-inspection/scripts/index.cjs`:
```javascript
const fs = require('node:fs');
const path = require('node:path');
const { clampNumber } = require('./lib/json_utils.cjs');
const { createWorkspace, resolveVideoPath, writeJson } = require('./lib/workspace.cjs');
const { extractFrames } = require('./lib/frame_extractor.cjs');
const { resolveModelConfig } = require('./lib/vision_client.cjs');
const { detectDamageCandidates } = require('./lib/damage_detector.cjs');
const { groundDamages } = require('./lib/damage_grounding.cjs');
const { selectBestFrames } = require('./lib/best_frame_selector.cjs');
function normalizeInput(raw) {
if (!raw || typeof raw !== 'object') throw new Error('Input JSON object is required');
if (!raw.videoUrl) throw new Error('videoUrl is required');
return {
videoUrl: String(raw.videoUrl),
taskId: raw.taskId ? String(raw.taskId) : undefined,
fps: clampNumber(raw.fps, 0.2, 10, 3),
quality: clampNumber(raw.quality, 1, 100, 90),
batchSize: clampNumber(raw.batchSize, 1, 20, 12),
concurrency: clampNumber(raw.concurrency, 1, 4, 2),
groundingWindow: clampNumber(raw.groundingWindow, 0.5, 5, 1.5),
topN: clampNumber(raw.topN, 1, 5, 1),
mode: ['full', 'frames-only', 'detect-only'].includes(raw.mode) ? raw.mode : 'full',
};
}
function readPrompt(name) {
return fs.readFileSync(path.join(__dirname, '..', 'prompts', name), 'utf8');
}
function resolveWorkspaceRoot(env) {
if (env.RZYX_AI_WORKSPACE_ROOT) return env.RZYX_AI_WORKSPACE_ROOT;
if (env.RZYX_AI_DATA_DIR) return path.join(env.RZYX_AI_DATA_DIR, 'workspace', 'vehicle-damage-inspection');
if (env.NODE_ENV === 'production') throw new Error('RZYX_AI_DATA_DIR or RZYX_AI_WORKSPACE_ROOT is required in production');
return path.join(__dirname, '..', '.workspace');
}
function buildFinalOutput({ workspace, videoInfo, candidates, damages, artifacts }) {
const bestFrameCount = damages.reduce((sum, item) => sum + (item.bestFrames || []).length, 0);
return {
success: true,
taskId: workspace.taskId,
workspacePath: workspace.workspacePath,
summary: {
duration: videoInfo.duration,
resolution: videoInfo.resolution,
frameCount: videoInfo.extractedFrames,
candidateDamageCount: candidates.length,
mergedDamageCount: damages.length,
bestFrameCount,
},
damages,
artifacts,
};
}
async function run(rawInput, env = process.env) {
const input = normalizeInput(rawInput);
const workspaceRoot = resolveWorkspaceRoot(env);
const workspace = createWorkspace({ taskId: input.taskId, workspaceRoot });
const uploadRoot = env.RZYX_AI_UPLOAD_ROOT || (env.RZYX_AI_DATA_DIR ? path.join(env.RZYX_AI_DATA_DIR, 'uploads') : undefined);
const videoPath = resolveVideoPath(input.videoUrl, { uploadRoot });
const { videoInfo, frames } = await extractFrames({
workspace,
videoPath,
fps: input.fps,
quality: input.quality,
});
if (input.mode === 'frames-only') {
const output = buildFinalOutput({
workspace,
videoInfo,
candidates: [],
damages: [],
artifacts: { videoInfo: 'video_info.json' },
});
writeJson(workspace, 'run_summary.json', output);
return output;
}
const modelConfig = resolveModelConfig(env);
const detection = await detectDamageCandidates({
workspace,
frames,
modelConfig,
prompt: readPrompt('damage_detect.md'),
batchSize: input.batchSize,
concurrency: input.concurrency,
});
if (input.mode === 'detect-only' || detection.candidates.length === 0) {
const output = buildFinalOutput({
workspace,
videoInfo,
candidates: detection.candidates,
damages: [],
artifacts: { videoInfo: 'video_info.json', damageCandidates: 'damage_candidates.json' },
});
writeJson(workspace, 'run_summary.json', output);
return output;
}
const grounding = await groundDamages({
workspace,
frames,
candidates: detection.candidates,
modelConfig,
prompt: readPrompt('grounding.md'),
groundingWindow: input.groundingWindow,
});
const best = await selectBestFrames({
workspace,
annotations: grounding.annotations,
topN: input.topN,
modelConfig,
prompt: readPrompt('best_frame.md'),
});
const output = buildFinalOutput({
workspace,
videoInfo,
candidates: detection.candidates,
damages: best.damages,
artifacts: {
videoInfo: 'video_info.json',
damageCandidates: 'damage_candidates.json',
damageAnnotations: 'damage_annotations.json',
bestFrames: 'best_frames.json',
runSummary: 'run_summary.json',
},
});
writeJson(workspace, 'run_summary.json', output);
return output;
}
async function main() {
try {
const stdin = fs.readFileSync(0, 'utf8');
const input = JSON.parse(stdin || '{}');
const output = await run(input);
process.stdout.write(JSON.stringify(output));
} catch (error) {
process.stdout.write(JSON.stringify({ success: false, error: error.message }));
process.exitCode = 0;
}
}
if (require.main === module) {
main();
}
module.exports = {
normalizeInput,
buildFinalOutput,
run,
};
```
- [ ] **Step 4: 运行测试确认通过**
Run: `node packages/backend/skills/vehicle-damage-inspection/tests/index_frames_only.test.cjs`
Expected: exits 0.
- [ ] **Step 5: 提交**
```bash
git add packages/backend/skills/vehicle-damage-inspection/scripts/index.cjs packages/backend/skills/vehicle-damage-inspection/tests/index_frames_only.test.cjs
git commit -m "feat(skills): wire vehicle inspection compute entrypoint"
```
---
### Task 11: make.sh and Windows setup diagnostics
**Files:**
- Create: `packages/backend/skills/vehicle-damage-inspection/scripts/make.sh`
- Create: `packages/backend/skills/vehicle-damage-inspection/scripts/setup.ps1`
- [ ] **Step 1: 创建 make.sh**
Create `packages/backend/skills/vehicle-damage-inspection/scripts/make.sh`:
```bash
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cmd_check() {
local ok=true
command -v node >/dev/null 2>&1 || { echo "node missing"; ok=false; }
command -v ffmpeg >/dev/null 2>&1 || echo "ffmpeg not found in PATH; npm installer fallback may be used"
command -v ffprobe >/dev/null 2>&1 || echo "ffprobe not found in PATH; npm installer fallback may be used"
node -e "require('axios'); require('sharp'); require('fluent-ffmpeg')" || ok=false
$ok
}
cmd_fix() {
cd "$ROOT"
npm install axios sharp fluent-ffmpeg @ffmpeg-installer/ffmpeg @ffprobe-installer/ffprobe
}
cmd_run() {
local video=""
local fps="3"
local mode="full"
while [[ $# -gt 0 ]]; do
case "$1" in
--video) video="$2"; shift 2 ;;
--fps) fps="$2"; shift 2 ;;
--mode) mode="$2"; shift 2 ;;
*) echo "Unknown option: $1"; exit 1 ;;
esac
done
if [[ -z "$video" ]]; then
echo "Usage: bash scripts/make.sh run --video FILE [--fps 3] [--mode full]"
exit 1
fi
printf '{"videoUrl":%s,"fps":%s,"mode":%s}\n' \
"$(node -e "process.stdout.write(JSON.stringify(process.argv[1]))" "$video")" \
"$fps" \
"$(node -e "process.stdout.write(JSON.stringify(process.argv[1]))" "$mode")" \
| node "$ROOT/scripts/index.cjs"
}
cmd_tests() {
for file in "$ROOT"/tests/*.test.cjs; do
node "$file"
done
}
case "${1:-}" in
check) cmd_check ;;
fix) cmd_fix ;;
run) shift; cmd_run "$@" ;;
test) cmd_tests ;;
*) echo "Usage: bash scripts/make.sh check|fix|run|test"; exit 1 ;;
esac
```
- [ ] **Step 2: 运行本地测试集合**
Run: `bash packages/backend/skills/vehicle-damage-inspection/scripts/make.sh test`
Expected: every `*.test.cjs` exits 0.
- [ ] **Step 3: 运行 check**
Run: `bash packages/backend/skills/vehicle-damage-inspection/scripts/make.sh check`
Expected: exits 0 if node packages are installed. If it fails because packages are not installed in the skill directory, run `bash packages/backend/skills/vehicle-damage-inspection/scripts/make.sh fix`, then rerun check.
- [ ] **Step 4: 提交**
- [ ] **Step 4: 创建 Windows setup.ps1**
Create `packages/backend/skills/vehicle-damage-inspection/scripts/setup.ps1`:
```powershell
$ErrorActionPreference = "Stop"
$SkillRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
Set-Location $SkillRoot
npm install axios sharp fluent-ffmpeg @ffmpeg-installer/ffmpeg @ffprobe-installer/ffprobe
```
- [ ] **Step 5: 验证 Windows setup 脚本语法**
Run: `powershell -NoProfile -ExecutionPolicy Bypass -File packages/backend/skills/vehicle-damage-inspection/scripts/setup.ps1`
Expected: npm dependencies install successfully in the skill directory. For production packaging, run this during build/install, not during every skill execution.
- [ ] **Step 6: 提交**
```bash
git add packages/backend/skills/vehicle-damage-inspection/scripts/make.sh packages/backend/skills/vehicle-damage-inspection/scripts/setup.ps1
git commit -m "feat(skills): add vehicle inspection skill cli"
```
---
### Task 12: End-to-end backend integration validation
**Files:**
- Test only: no source files expected unless validation exposes a bug.
- [ ] **Step 1: 运行 Skill 纯测试**
Run: `bash packages/backend/skills/vehicle-damage-inspection/scripts/make.sh test`
Expected: exits 0.
- [ ] **Step 2: 运行后端 runtime env 测试**
Run: `pnpm --filter @neta/backend test -- --runInBand test/skill_runtime_env.test.ts`
Expected: PASS.
- [ ] **Step 3: 运行后端 build**
Run: `pnpm --filter @neta/backend build`
Expected: build completes without TypeScript errors.
- [ ] **Step 4: 模拟 Windows dataDir 路径运行 frames-only**
Prepare a small MP4 file. If no sample is available, use ffmpeg to generate one:
```bash
mkdir -p C:/tmp/rzyx-ai-skill-test/uploads/20260504
ffmpeg -f lavfi -i testsrc=duration=2:size=320x240:rate=10 C:/tmp/rzyx-ai-skill-test/uploads/20260504/sample.mp4 -y
```
Run:
```bash
RZYX_AI_DATA_DIR=C:/tmp/rzyx-ai-skill-test \
RZYX_AI_UPLOAD_ROOT=C:/tmp/rzyx-ai-skill-test/uploads \
RZYX_AI_WORKSPACE_ROOT=C:/tmp/rzyx-ai-skill-test/workspace/vehicle-damage-inspection \
bash packages/backend/skills/vehicle-damage-inspection/scripts/make.sh run \
--video /upload/20260504/sample.mp4 \
--fps 1 \
--mode frames-only
```
Expected:
- stdout is valid JSON with `"success":true`.
- `C:/tmp/rzyx-ai-skill-test/workspace/vehicle-damage-inspection/.../video_info.json` exists.
- No `workspace` directory is created under `packages/backend/skills/vehicle-damage-inspection` during this production-path simulation.
- [ ] **Step 5: 提交 validation fixes only if needed**
If any validation step required code fixes, first inspect the exact modified files:
```bash
git status --short
```
Then add only files related to this skill implementation. The expected fix scope is one or more of these paths:
```bash
git add packages/backend/src/modules/netaclaw/service/skill_runtime_env.ts packages/backend/src/modules/netaclaw/service/skill_executor.ts packages/backend/test/skill_runtime_env.test.ts packages/backend/skills/vehicle-damage-inspection
git commit -m "fix(skills): stabilize vehicle inspection integration"
```
If no files changed, skip this commit.
---
## Self-Review Checklist
- [x] Spec coverage: Tasks cover host env injection, Skill metadata, prompts, workspace protocol, frame extraction, vision client, detection, grounding, image marking, best-frame selection, compute entrypoint, CLI, tests, and Windows dataDir validation.
- [x] Placeholder scan: The plan contains no unresolved markers, no undefined file paths, and no deferred-implementation instructions.
- [x] Type consistency: The public Skill name is consistently `vehicle-damage-inspection`; runtime env names are consistently `RZYX_AI_DATA_DIR`, `RZYX_AI_WORKSPACE_ROOT`, and `RZYX_AI_UPLOAD_ROOT`; default model is consistently `doubao-seed-2-0-pro-260215`.