Files
Video-Tree-TRM5/research-wiki/designs/2026-07-09-question-gen-synth-design.md
T
iomgaa 0b48b889e0 docs(wiki): 赛题生成工具设计 + 实现计划
design: synthesizer + factory + CLI 三模块架构
plan: 9 个 Task(前置修复 + synthesizer 4 步 + factory + CLI generate/calibrate + re-export)
calibrate: Fisher exact test 组合判定替代固定阈值

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 05:05:57 -04:00

478 lines
19 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.
---
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 promptsystem + 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()
├── 加载 TreeIndexstore_dir/videos/{video_id}/tree.json
├── 构建 TreeEnvironmentindex=tree, frames_dir=videos/{video_id}/frames
├── 构建 SkillRegistryskills_dir,可选)
├── 构建 SearchToolDispatcherenv, tool_llm, vlm, ocr, prompts_dir,
│ skills, embed_fn, verify_vision, anchor, assemble_mode
├── 构建 PromptManagerprompts_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. 实现约束
- 完整类型注解 + 中文 DocstringCLAUDE.md §4.2
- 禁用 `print()`,使用 loguruCLAUDE.md §4.2
- 脚本放 `tools/`,不被其他模块 importCLAUDE.md §5
- 并发模式:`asyncio.Semaphore`CLI `--concurrency` 指定(沿用项目既有模式)
- 所有 VLM 调用经过 `GovernedLLMClient` 治理栈(CLAUDE.md §4.9