# 汽车环车视频旧伤检测 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=true，confidence="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`.
