---
id: question-gen-synth
title: 赛题生成工具设计（Question Generation Synthesis）
type: design
created: 2026-07-09
status: draft
---

# 赛题生成工具设计

## 1. 目标与动机

让视频树自行生成与 Video-MME 原始赛题风格、难度近似的四选一选择题，用于自进化训练循环的 DataLoader。原始 900 道 benchmark 题保留为 held-out 最终评测集，避免"直接拿答案调"的审稿质疑。

**角色定位**：生成题 = 训练集，原始题 = 测试集。进化循环的改进效果最终由原始 benchmark 验证泛化能力。

**训练 vs 论文评测的区分**：训练循环全程使用生成题（三池切分——诊断池/验证池/test 池——均来自生成题），论文报告的 held-out 泛化指标是训练结束后，用最终 best 版本对原始 benchmark 全量 900 题单独跑推理得到的结果。两步分离，Runner 代码无需改动。

## 2. 模块结构与职责边界

### 2.1 文件布局

```
app/question_gen/
├── __init__.py         ← 已有：re-export loader API
├── loader.py           ← 已有：load_benchmark + stratified_sample
└── synthesizer.py      ← 新增①：出题核心逻辑

app/harness/
└── factory.py          ← 新增②：推理依赖组装（wiring）

tools/generate_questions.py  ← 新增③：CLI 壳（generate + calibrate）
```

### 2.2 职责切分

| 模块 | 职责 | 消费者 |
|------|------|--------|
| `synthesizer.py` | 题型-层级映射、锚节点采样、prompt 构造（few-shot）、embedding 去重、单题生成编排 | `tools/generate_questions.py` |
| `factory.py` | 给定 store 路径 + config → 组装 LLM/VLM/Embedding/SearchToolDispatcher/PromptManager 全套推理依赖 | `tools/generate_questions.py`（校准）、未来 `main.py`、Runner |
| `tools/generate_questions.py` | CLI 参数解析、并发编排（Semaphore）、进度日志、JSON 输出 | 用户直接运行 |

### 2.3 依赖方向

```mermaid
flowchart LR
    TOOLS["tools/generate_questions.py"] --> SYN["app/question_gen/synthesizer"]
    TOOLS --> FAC["app/harness/factory"]
    TOOLS --> ADP["adapters/*"]
    FAC --> SEARCH["app/search/*"]
    FAC --> ENV["app/tree/environment"]
    FAC --> ADP
    SYN --> PROTO["core/protocols (VLMProvider, EmbeddingProvider via DI)"]
    SYN --> TYPES["core/types (GeneratedQuestion)"]
    SYN --> IDX["app/tree/index (TreeIndex)"]
```

全部合规——外层→内层，`core/` 不依赖任何外层。

### 2.4 与 QuestionGenerator Protocol 的关系

`app/ports.py` 已预留 `QuestionGenerator` Protocol。本设计**不实现该 Protocol**——出题是一次性离线工具而非运行时能力，Runner 不需要运行时出题。`synthesizer.py` 的函数式接口（`generate_one` 等纯函数 + async 编排）比 Protocol class 更适合工具脚本场景。`QuestionGenerator` Protocol 保留但标记为"预留，当前无实现"，不删除——若未来需要运行时出题可基于 synthesizer 的纯函数包装实现。

### 2.5 方案选择与否决

| 方案 | 否决理由 |
|------|---------|
| A: 单体脚本（全部逻辑放 `tools/`） | 业务逻辑（题型映射、采样、prompt、去重）混在 CLI 编排中，不可独立测试；不匹配 repair 管线的 app/ + tools/ 分层惯例 |
| B: Protocol 实现 + 脚本编排（`adapters/` 实现 `QuestionGenerator`） | adapter 层语义是外部服务接口，出题逻辑是应用层业务规则，放 adapter 层语义不匹配 |
| **C: app/ 业务逻辑 + tools/ CLI 壳（采用）** | 与 repair 管线结构一致，Clean Architecture 依赖方向合规，业务逻辑可独立测试 |

## 3. synthesizer.py 核心设计

### 3.1 题型-层级映射

模块级常量，沿用 TRM4 设计文档的映射表：

| 锚定层级 | 题型 | 帧图 | 文本上下文 | 帧数 |
|---------|------|------|-----------|------|
| L3 | Object Recognition | 必须 | frame_summary | 1 |
| L3 | Attribute Perception | 必须 | frame_summary | 1 |
| L3 | OCR Problems | 必须 | frame_summary | 1 |
| L3 | Spatial Reasoning | 必须 | frame_summary + spatial_layout | 1 |
| L3 | Spatial Perception | 必须 | frame_summary | 1 |
| L2 | Action Recognition | 必须 | 事件 card | 2-3（子帧均匀采样） |
| L2 | Action Reasoning | 必须 | 事件 card | 2-3 |
| L2 | Counting Problem | 必须 | 事件 card | 2-3 |
| L2 | Temporal Perception | 可选 | 事件 card + time_range | 0-1 |
| L1 | Temporal Reasoning | 必须 | 根 card + 多个 L2 card（≥3） | 每 L2 取 1 张代表帧 |
| L1 | Information Synopsis | 必须 | 根 card + 全部 L2 card | 每 L2 取 1 张代表帧 |
| L1-L2 | Object Reasoning | 必须 | 2-3 个 L2 card | 每 L2 取 1 张代表帧 |

节点采样：每道题从全部视频树中随机选一棵，在对应层级随机选一个锚节点。同视频同题型不重复。L1 题型使用多个 L2 子节点联合输入时，按时间顺序组织节点，保持叙事连贯性。

### 3.2 AnchorContext 数据结构

```python
@dataclass(frozen=True)
class AnchorContext:
    """锚节点上下文——生成单道题所需的全部素材。"""
    node_id: str                    # 锚节点 ID
    card_text: str                  # 锚节点 card 序列化文本
    frame_paths: list[str]          # 帧图片路径
    subtitle: str                   # 对应字幕（可空）
    distractor_texts: list[str]     # 同视频其他节点摘要（供 VLM 生成干扰项）
```

### 3.3 核心函数签名

```python
# 纯函数：从树中采样锚节点 + 帧 + 上下文
def sample_anchor(
    tree: TreeIndex,
    task_type: str,
    used_node_ids: set[str],
    rng: random.Random,
) -> AnchorContext

# 纯函数：组装 VLM prompt（system + user，含 few-shot exemplar）
def build_generation_prompt(
    task_type: str,
    anchor: AnchorContext,
    exemplars: list[GeneratedQuestion],
) -> tuple[list[dict], list[str]]
    # 返回：(messages, image_paths) — 直接喂给 VLMProvider

# 纯函数：解析 VLM 返回的 JSON → 部分字段字典
# source_nodes 和 difficulty 由 generate_one 在 parse 后用 anchor 信息补齐
def parse_vlm_response(
    raw: str,
    video_id: str,
    task_type: str,
    seq: int,
) -> dict
    # 返回：{"question_id", "question", "options", "answer"} 字典
    # 调用方补齐 source_nodes/difficulty 后构造 GeneratedQuestion

# 纯函数：embedding 去重判定
def is_duplicate(
    question_text: str,
    pool_embeddings: np.ndarray,
    embed_fn: Callable[[str | list[str]], np.ndarray],
    threshold: float,
) -> bool

# 异步编排：生成单道题（含重试 + 去重循环）
async def generate_one(
    vlm: VLMProvider,
    embed_fn: Callable[[str | list[str]], np.ndarray],
    tree: TreeIndex,
    video_id: str,
    task_type: str,
    seq: int,
    *,
    exemplars: list[GeneratedQuestion],
    pool_embeddings: np.ndarray,
    used_node_ids: set[str],
    max_retries: int,
    similarity_threshold: float,
    rng: random.Random,
    session_id: str,
) -> GeneratedQuestion | None
```

**设计要点**：
- 纯函数（sample_anchor、build_generation_prompt、parse_vlm_response、is_duplicate）可独立单测，不需要 VLM
- `generate_one` 是唯一异步函数，接收 `VLMProvider` 通过 DI
- 干扰项来自 `AnchorContext.distractor_texts`——同视频其他节点的真实信息

### 3.4 few-shot exemplar 选择

生成 prompt 包含 2-3 道同题型的原始 benchmark 题作示例，对齐风格和难度。

选择策略：
- 每题型取 `min(3, 该题型 benchmark 总量)` 道
- 按 seed 随机采样 + 跨视频去重（避免 exemplar 全来自同一视频）
- exemplar 是只读引用，不从 benchmark 评测集中移除

### 3.5 prompt 结构

```
System: 视频理解题目生成器，根据视频树节点内容和帧图生成 {task_type} 四选一题。

[2-3 道该题型原始 benchmark 题作示例]

约束：
- 问题必须基于给定节点内容，不能靠常识推断
- 干扰项来自同视频其他节点的真实信息（非凭空捏造）
- 难度和问法风格与示例一致

User: [锚节点 card + 字幕 + 帧图] + [同视频其他节点摘要，供干扰项素材]
```

### 3.6 去重机制

用 `EmbeddingProvider`（nomic-embed-text-v1.5）对 question 文本做 embedding，余弦相似度检查：

| 检查对 | 阈值 | 处理 |
|--------|------|------|
| 生成题 vs 原始 benchmark 同题型题 | ≥ similarity_threshold | 丢弃，换节点重试 |
| 生成题 vs 已生成的同题型题 | ≥ similarity_threshold | 丢弃，换节点重试 |

维护 embedding 池（原始题 + 已通过的生成题），每生成一道新题即时查重。单题最多重试 `max_retries` 次。某题型连续耗尽重试配额时，脚本报错退出并输出已完成/未完成的题型统计，不静默少题。

**并发去重安全**：embedding 池的"检查 + 添加"必须是原子操作。并发 `generate_one` 任务成功后，通过单线程汇总点（asyncio.Queue 或 await 后顺序提交）更新 embedding 池 + 写 JSON + 更新 progress，避免竞态导致相似题同时通过。

## 4. factory.py 推理依赖组装

### 4.1 解决的问题

目前 `Runner._make_tool_dispatch_fn()` 和 `_make_prompt_builder()` 都是 `raise NotImplementedError`，设计为"由 main.py 注入"。组装逻辑涉及 adapter 实例化 + app 组件串联，应提取为可复用的 factory 函数，避免在每个调用方（tools/ 脚本、未来 main.py）重复 wiring。

### 4.2 核心接口

```python
@dataclass(frozen=True)
class InferenceDeps:
    """跑一次推理所需的全套依赖（不含 HarnessLog，其生命周期由调用方管理）。"""
    llm: LLMProvider
    tool_dispatch_fn: Callable      # SearchToolDispatcher.dispatch
    prompt_builder: Callable        # PromptManager 的偏函数

def build_inference_deps(
    *,
    store_dir: Path,
    video_id: str,
    prompts_dir: Path,
    skills_dir: Path | None,
    skill_mode: str,
    embed_provider: EmbeddingProvider,
    llm: LLMProvider,
    vlm: VLMProvider,
    ocr: OCRProvider | None,
    verify_vision: bool,
    anchor: bool,
    assemble_mode: str,
) -> InferenceDeps
```

### 4.3 内部流程

```
build_inference_deps()
  ├── 加载 TreeIndex（store_dir/videos/{video_id}/tree.json）
  ├── 构建 TreeEnvironment（index=tree, frames_dir=videos/{video_id}/frames）
  ├── 构建 SkillRegistry（skills_dir，可选）
  ├── 构建 SearchToolDispatcher（env, tool_llm, vlm, ocr, prompts_dir,
  │     skills, embed_fn, verify_vision, anchor, assemble_mode）
  ├── 构建 PromptManager（prompts_dir）→ 偏函数化 prompt_builder（绑定 skill_mode）
  └── 返回 InferenceDeps
```

注意：`HarnessLog` 不放入 `InferenceDeps`——其生命周期由调用方通过 `with HarnessLog(...) as log` 管理，作为参数传给 `run_inference`。

### 4.4 消费者

| 消费者 | 用法 |
|--------|------|
| `tools/generate_questions.py` calibrate | 按 video_id 分组题目，对每组调 `build_inference_deps` 构建对应视频树的依赖 → 分组 `run_inference` |
| 未来 `main.py --mode infer` | CLI 参数映射到 factory 参数 |
| `Runner` | `_make_tool_dispatch_fn` / `_make_prompt_builder` 改为委托 factory |

### 4.5 设计约束

- factory 只做**组装**，不持有状态——每次调用返回独立的 `InferenceDeps`
- adapter 实例（LLM/VLM/Embedding）由调用方创建并传入，factory 不管 adapter 生命周期
- 调用方自由决定 adapter 的复用策略（共享 vs 按需创建）

## 5. tools/generate_questions.py CLI 设计

### 5.1 子命令

```bash
# 生成
python tools/generate_questions.py generate \
    --store-dir store \
    --output-dir store/questions/generated/Video-MME \
    --per-type 20 \
    --similarity-threshold 0.85 \
    --max-retries 3 \
    --concurrency 8 \
    --seed 42

# 校准（生成题 vs benchmark 基线对比）
python tools/generate_questions.py calibrate \
    --generated-dir store/questions/generated/Video-MME \
    --benchmark-dir store/questions/benchmarks/Video-MME \
    --store-dir store \
    --db-path results/calibrate.db \
    --prompts-dir store/prompts \
    --concurrency 4 \
    --max-steps 15 \
    --skill-mode auto \
    --tolerance 0.10 \
    --alpha 0.05 \
    --baseline-db <可选，已有基线 DB 路径> \
    --baseline-run-id <可选，已有基线 run_id>
```

除 baseline 复用参数外均必传，无默认值（CLAUDE.md §4.5）。`--baseline-db` + `--baseline-run-id` 可选但必须成对出现：有则从 DB 读 benchmark 基线，无则自动跑一次 benchmark 推理。

### 5.2 generate 流程

```
1. 加载 300 棵树的 video_id 列表
2. 加载 benchmark 题目（作为 few-shot exemplar 来源）
3. 初始化 embedding 池（benchmark 题 question text → embedding）
4. 实例化 GovernedVLMClient + EmbeddingProvider
5. 检查断点续跑文件（progress.json）
6. 对 12 题型 × per_type：
   ├── 跳过已完成的（断点续跑）
   ├── 随机选视频 + 锚节点（同视频同题型不重复）
   ├── asyncio.Semaphore(concurrency) 并发调 generate_one
   ├── 成功 → 加入 embedding 池 + 追加到结果 + 更新 progress
   └── 连续耗尽重试 → 报错退出，输出已完成/未完成统计
7. 按 video_id 分组写入 JSON
8. 全部完成后删除 progress.json
```

### 5.3 calibrate 流程

```
1. load_benchmark 加载生成题和 benchmark 题
2. 获取 benchmark 基线：
   ├── 有 --baseline-db + --baseline-run-id → 从 DB 读 per_task_type accuracy
   └── 没有 → 按 video_id 分组 benchmark 题 → 每组 build_inference_deps
         → 分组 run_inference → 汇总存 DB
3. 按 video_id 分组生成题 → 每组 build_inference_deps → 分组 run_inference
   （每组使用对应视频的 TreeEnvironment，避免跨视频树错用）
4. 汇总两组 per_task_type accuracy，对比（Fisher exact test）
5. 输出对比表 + 判定结果
6. 存在 FAIL → 退出码 1
```

### 5.4 tools/ 脚本职责边界

脚本**只做**：argparse、adapter 实例化（读 `.env`）、Semaphore 并发、进度日志（loguru）、JSON 写入、calibrate 时调 factory + run_inference。

脚本**不做**：prompt 构造、节点采样、去重判定（synthesizer.py）、依赖组装逻辑（factory.py）。

## 6. 校准统计方法

### 6.1 问题

benchmark 题型分布极不均匀（Spatial Perception 仅 3 道 vs Object Reasoning 240 道），固定 10% 阈值对小样本题型会产生误判——单题翻转即 33% 波动。

### 6.2 组合判定：Fisher exact test + effect size

用 `scipy.stats.fisher_exact` 对每个题型构造 2×2 列联表：

|  | 答对 | 答错 |
|--|------|------|
| Benchmark | a | b |
| Generated | c | d |

判定规则：

| \|Δ\| > tolerance | p < α | 判定 | 含义 |
|---|---|---|---|
| ✗ | — | **PASS** | 差异在容忍范围内 |
| ✓ | ✓ | **FAIL** | 差异大且统计显著——生成题难度确实偏了 |
| ✓ | ✗ | **WARN** | 差异大但样本不足以确认——可能是噪声 |

### 6.3 优势

- 不需要 ad-hoc 的 `min_calibrate_size` 参数
- 小样本题型自动降级为 WARN——Fisher test 的 p-value 天然反映样本量不足
- CLI 只需两个语义清晰的统计参数：`--tolerance 0.10` + `--alpha 0.05`
- 退出码只看是否存在 FAIL（WARN 不阻塞）

### 6.4 检测灵敏度与 per_type 的关系

| per_type | 可检出的最小差异（大样本 benchmark 侧） |
|----------|---------------------------------------|
| 20 | ~30%（仅极大差异） |
| 50 | ~15%（中等差异） |

用户可根据需要的检测灵敏度选择 `--per-type`。

### 6.5 输出格式

```
题型               | bench  | gen    | Δ       | p-value | 判定
-------------------|--------|--------|---------|---------|--------
Spatial Perception |  66.7% |  40.0% | -26.7%  | 0.590   | ⚠ WARN
Action Reasoning   |  72.2% |  68.0% |  -4.2%  | 0.712   | ✓ PASS
Object Reasoning   |  60.0% |  30.0% | -30.0%  | 0.016   | ✗ FAIL
```

## 7. 断点续跑

生成 240 道题可能中断（VLM 故障、手动 Ctrl-C），沿用项目已有的 progress.json 模式：

```json
{
  "completed": {
    "Action Reasoning": ["gen-xyz-001", "gen-xyz-002"],
    "Object Recognition": ["gen-abc-001"]
  },
  "output_dir": "store/questions/generated/Video-MME"
}
```

- 启动时检查 `{output_dir}/progress.json`，跳过已完成的题
- **恢复 embedding 池**：从已写出的 `{output_dir}/*.json` 重建已生成题的 embedding + `used_node_ids`，避免续跑后产生重复题
- 每道题写入 JSON 后立即更新 progress
- 全部完成后删除 progress.json

## 8. 输出格式

输出路径：`store/questions/generated/Video-MME/{video_id}.json`

```json
[
    {
        "question_id": "gen-{video_id}-{seq}",
        "task_type": "Action Reasoning",
        "question": "...",
        "options": ["A. ...", "B. ...", "C. ...", "D. ..."],
        "answer": "B",
        "source_nodes": ["L1_000_L2_003"],
        "difficulty": "medium"
    }
]
```

与 loader schema 兼容（额外 `source_nodes`/`difficulty` 字段用于溯源），`load_benchmark` 零改动直接加载。

**训练集成**：`--questions generated/Video-MME`。

## 9. 受影响的既有接口

| 接口 | 影响 | 适配 |
|------|------|------|
| `load_benchmark` | 无 | 输出与 loader schema 兼容（额外 source_nodes/difficulty 字段用于溯源） |
| `RunConfig.questions` | 无 | 传 `generated/Video-MME` |
| `build_or_load_pools` | 无 | 三池均来自生成题 |
| `Runner._make_tool_dispatch_fn` | 改造 | 委托 factory.py |
| `Runner._make_prompt_builder` | 改造 | 委托 factory.py |
| `_VIDEO_MME_TASK_TYPE_COUNT` | **前置修复** | 从 11 改为 12（`app/harness/config.py:24`），影响验证池保底下限 |

## 10. 测试策略

### 10.1 synthesizer.py

| 测试 | 覆盖点 |
|------|--------|
| `test_sample_anchor` | 各层级题型正确采锚、同视频同题型不重复、树节点不足时报错 |
| `test_build_generation_prompt` | messages 结构正确、exemplar 注入、图片路径列表、干扰项素材包含 |
| `test_parse_vlm_response` | 正常解析、格式异常（缺字段/非法 JSON）报错 |
| `test_is_duplicate` | 相似度 ≥ 阈值判重、< 阈值通过、空池不判重 |
| `test_generate_one` | mock VLMProvider，验证重试+去重循环、耗尽重试返回 None |

### 10.2 factory.py

| 测试 | 覆盖点 |
|------|--------|
| `test_build_inference_deps` | fake LLM/VLM/Embedding 验证返回各字段非 None、类型正确 |
| `test_missing_tree_file` | tree.json 不存在时报错 |

### 10.3 tools/generate_questions.py（集成级）

| 测试 | 覆盖点 |
|------|--------|
| `test_generate_smoke` | mock VLM + 1 棵真实树 + per_type=1，验证 JSON 输出格式 |
| `test_progress_resume` | 中断后重启，跳过已完成题 |
| `test_calibrate_pass_fail` | mock 两组 accuracy，验证 Fisher + tolerance 组合判定 |

真实 VLM 调用的 integration test 不在此次范围——依赖外部服务，不适合 CI。

## 11. 实现约束

- 完整类型注解 + 中文 Docstring（CLAUDE.md §4.2）
- 禁用 `print()`，使用 loguru（CLAUDE.md §4.2）
- 脚本放 `tools/`，不被其他模块 import（CLAUDE.md §5）
- 并发模式：`asyncio.Semaphore`，CLI `--concurrency` 指定（沿用项目既有模式）
- 所有 VLM 调用经过 `GovernedLLMClient` 治理栈（CLAUDE.md §4.9）
