--- 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)