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

19 KiB
Raw Blame History

id, title, type, created, status
id title type created status
question-gen-synth 赛题生成工具设计(Question Generation Synthesis design 2026-07-09 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 依赖方向

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 数据结构

@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 核心函数签名

# 纯函数:从树中采样锚节点 + 帧 + 上下文
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 去重机制

EmbeddingProvidernomic-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 核心接口

@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 子命令

# 生成
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 模式:

{
  "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

[
    {
        "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 改为 12app/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.SemaphoreCLI --concurrency 指定(沿用项目既有模式)
  • 所有 VLM 调用经过 GovernedLLMClient 治理栈(CLAUDE.md §4.9