---
id: question-gen
title: 出题模块迁移设计（question_gen）
type: design
created: 2026-07-07
status: approved
---

# 出题模块迁移设计

## 1. 目标

从 TRM4 `core/harness/question_gen.py` 迁移出题数据结构与采样逻辑到 TRM5 Clean Architecture，同时预留 LLM 驱动出题的 Protocol 接口。

| 维度 | 说明 |
|------|------|
| 迁移范围 | benchmark 加载 + 分层采样（纯函数，180 行） |
| 预留接口 | `QuestionGenerator` Protocol（不实现，后续参考 TRM4 `research-wiki/designs/2026-07-06-question-gen-synth-design.md`） |
| 不做 | LLM 出题实现、校准脚本、去重机制 |

## 2. Clean Architecture 分层决策

### 2.1 类型放置

`GeneratedQuestion` 被 `core/evolution/`（diagnose、validate）和 `app/harness/`（runner、batching、pools、inference）跨层使用。按依赖方向（core 不可依赖 app），必须放 `core/types.py`，与 `LLMResponse` 同级。

```mermaid
flowchart LR
    CT["core/types.py\nGeneratedQuestion"] --> CE["core/evolution/\ndiagnose · validate"]
    CT --> AH["app/harness/\nrunner · batching · pools"]
    CT --> AQ["app/question_gen/\nloader"]
```

### 2.2 模块结构

```
core/types.py                ← 追加 GeneratedQuestion
app/ports.py                 ← 追加 QuestionGenerator Protocol
app/question_gen/
├── __init__.py              ← 公开 API re-export
└── loader.py                ← load_benchmark() + stratified_sample()
```

**否决方案**：

| 方案 | 否决理由 |
|------|---------|
| `GeneratedQuestion` 放 `app/question_gen/types.py` | `core/evolution/` 无法 import `app/` 层，违反依赖方向 |
| loader / sampler 拆两文件 | sampler 仅 ~100 行，不值得独立文件 |
| Protocol 放 `app/question_gen/protocols.py` | 与 `EmbeddingProvider` 在 `app/ports.py` 的既有模式不一致 |

## 3. 类型定义

### 3.1 GeneratedQuestion（`core/types.py` 追加）

```python
@dataclass(frozen=True)
class GeneratedQuestion:
    """单条生成/加载的题目。跨层共享类型。"""
    question_id: str
    video_id: str
    task_type: str
    question: str
    options: tuple[str, ...]
    answer: str
    source_nodes: tuple[str, ...]
    difficulty: str
```

**与 TRM4 的有意变更**：

| 变更 | 理由 |
|------|------|
| `options: list → tuple` | 配合 `frozen=True` 不可变语义 |
| `source_nodes: list → tuple` | 同上 |
| `difficulty` 移除默认值 `"medium"` | 显式传入（§4.1 P4: 显式优于隐式） |

**移除 `QuestionGenResult`**：TRM5 无消费者，YAGNI。

### 3.2 QuestionGenerator Protocol（`app/ports.py` 追加）

```python
@runtime_checkable
class QuestionGenerator(Protocol):
    """LLM 驱动的题目生成端口（预留接口）。"""
    async def generate(
        self,
        video_id: str,
        task_type: str,
        tree: TreeIndex,
        *,
        exemplars: list[GeneratedQuestion],
    ) -> GeneratedQuestion: ...
```

接口设计参考 TRM4 仓库 `research-wiki/designs/2026-07-06-question-gen-synth-design.md`（位于 `/home/iomgaa/Projects/Video-Tree-TRM4/`，不复制到 TRM5）中的"题型-层级映射 + few-shot exemplar"模式。`tree` 参数提供锚节点上下文，`exemplars` 提供风格示例。具体实现在后续 `tools/generate_questions.py`（一次性脚本）中完成，通过 `adapters/` 层的 Protocol 实现注入。

## 4. 函数接口

### 4.1 load_benchmark

```
load_benchmark(questions_dir: Path) -> list[GeneratedQuestion]
```

从指定目录 glob `*.json`，每个文件以 `stem` 为 `video_id`，解析为 `GeneratedQuestion` 列表。JSON 格式与 `store/questions/benchmarks/Video-MME/*.json` 完全一致。

**与 TRM4 对比**：算法 100% 保真。`options` 和 `source_nodes` 转为 `tuple`。

**`difficulty` 字段处理规则**：现有 benchmark JSON（`store/questions/benchmarks/Video-MME/`）不含 `difficulty` 字段，这是 legacy schema 特征。加载时按如下规则显式转换（非默认值掩盖）：

| JSON 情况 | 处理 |
|-----------|------|
| 有 `difficulty` 字段 | 取 JSON 值 |
| 无 `difficulty` 字段 | 赋 `_LEGACY_DEFAULT_DIFFICULTY = "medium"` 常量 |

常量集中定义在 `loader.py` 顶部，测试用例覆盖两种情况。

### 4.2 stratified_sample

```
stratified_sample(
    questions: list[GeneratedQuestion],
    correctness: dict[str, bool],
    size: int,
    correct_ratio: float | None,
    task_types: list[str] | None,
    seed: int,
    min_per_class: int | None,
) -> list[GeneratedQuestion]
```

所有参数显式传入，无默认值（§4.1 P4）。

**算法保真清单**（逐一比对 TRM4）：

| 逻辑点 | TRM4 行为 | TRM5 保持 |
|--------|----------|----------|
| `task_types` 过滤 | `task_types` 非 None 时，先过滤 pool 只保留指定题型 | 保持 |
| `correct_ratio=None` | 自然分布分支，随机抽样 `size` 道 | 保持 |
| `correct_ratio` 有值 | 按对错比例分层，对题 `round(size * ratio)` | 保持 |
| `correctness.get(id, False)` | 未知 correctness 的题统一当错题处理 | 保持 |
| 分层返回顺序 | 对题在前、错题在后 | 保持 |
| 池不足 | `ValueError` 报错，不静默降级 | 保持 |
| `min_per_class` 补足 | 遍历 pool 全部题型（非仅 sampled 命中的），按首次出现顺序确定性枚举 | 保持 |
| 补足不足时 | 全取，不报错 | 保持 |
| 随机种子 | `random.Random(seed)` 局部实例 | 保持 |

内部辅助函数 `_ratio_stratified_sample` 和 `_backfill_per_class` 完整保留。

## 5. 职责边界

| 组件 | 职责 | 位置 | 谁 import 谁 |
|------|------|------|-------------|
| `GeneratedQuestion` | 题目数据结构 | `core/types.py` | 被所有层 import |
| `load_benchmark` / `stratified_sample` | 加载 + 采样 | `app/question_gen/loader.py` | 被 `app/harness/` import |
| `QuestionGenerator` Protocol | LLM 出题接口定义 | `app/ports.py` | 被未来 `adapters/` 实现 |
| `tools/generate_questions.py`（未来） | LLM 出题一次性脚本 | `tools/` | 独立工具，不被其他模块 import |

`tools/generate_questions.py` 未来可实例化 `QuestionGenerator` 的 adapter 实现，但 `tools/` 本身不被 `app/` import（§5 硬性规则）。

## 6. 文档同步

以下章节需要更新：

| 文档 | 章节 | 变更 |
|------|------|------|
| `ARCHITECTURE.md` §1 表格 | DataLoader 行 `app/question_gen/generator.py` | → `app/question_gen/loader.py` |
| `ARCHITECTURE.md` §2.2 Mermaid | `QGEN` 节点 `generator.py` | → `loader.py` |
| `CLAUDE.md` §1.5 表格 | DataLoader 行 `app/question_gen/generator.py` | → `app/question_gen/loader.py` |

**不变更**：`ARCHITECTURE.md §6` 核心算法保真清单 — `stratified_sample` 是采样工具函数，不属于 13 项核心算法（那些是建树 + 训练的关键算法）。

## 7. 测试策略

| 测试 | 路径 | 覆盖点 |
|------|------|--------|
| `GeneratedQuestion` 冻结性 | `tests/unit/test_core_types.py`（追加） | frozen 不可变、字段完整性 |
| `load_benchmark` | `tests/unit/test_question_loader.py` | 正常加载、空目录、JSON 格式异常 |
| `stratified_sample` | `tests/unit/test_question_loader.py` | 自然分布、分层采样、题型过滤、未知 correctness 当错题、对题在前返回顺序、题型保底、池不足报错、种子可复现 |
