# question_gen 模块实现计划

> **For agentic workers:** REQUIRED SUB-SKILL: Use subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 从 TRM4 迁移出题数据结构与采样逻辑到 TRM5 Clean Architecture，预留 LLM 出题 Protocol。

**Architecture:** `GeneratedQuestion` 放 `core/types.py`（跨层共享），加载和采样逻辑放 `app/question_gen/loader.py`，`QuestionGenerator` Protocol 追加到 `app/ports.py`。

**Tech Stack:** Python 3.11, dataclasses, pytest, loguru

**设计文档:** `research-wiki/designs/2026-07-07-question-gen-design.md`

**核心算法保真:** 本计划不涉及 ARCHITECTURE.md §6 中 13 项核心算法的迁移。`stratified_sample` 是采样工具函数，不在保真清单内，但仍逐行比对 TRM4 实现保证行为一致。

---

### Task 1: GeneratedQuestion 数据类型

**Files:**
- Modify: `core/types.py`
- Modify: `tests/unit/test_core_types.py`

- [ ] **Step 1: 在 test_core_types.py 追加 GeneratedQuestion 测试**

```python
from core.types import GeneratedQuestion


class TestGeneratedQuestion:
    @pytest.fixture()
    def sample_question(self) -> GeneratedQuestion:
        return GeneratedQuestion(
            question_id="719-1",
            video_id="B7Hh0PY1kks",
            task_type="Action Reasoning",
            question="What are the differing motivations?",
            options=("A. Option 1", "B. Option 2", "C. Option 3", "D. Option 4"),
            answer="B",
            source_nodes=(),
            difficulty="medium",
        )

    def test_frozen_prevents_mutation(self, sample_question: GeneratedQuestion) -> None:
        with pytest.raises(AttributeError):
            sample_question.question = "篡改"

    def test_all_fields_accessible(self, sample_question: GeneratedQuestion) -> None:
        assert sample_question.question_id == "719-1"
        assert sample_question.video_id == "B7Hh0PY1kks"
        assert sample_question.task_type == "Action Reasoning"
        assert sample_question.question == "What are the differing motivations?"
        assert sample_question.options == ("A. Option 1", "B. Option 2", "C. Option 3", "D. Option 4")
        assert sample_question.answer == "B"
        assert sample_question.source_nodes == ()
        assert sample_question.difficulty == "medium"

    def test_options_is_tuple(self, sample_question: GeneratedQuestion) -> None:
        assert isinstance(sample_question.options, tuple)

    def test_source_nodes_is_tuple(self, sample_question: GeneratedQuestion) -> None:
        assert isinstance(sample_question.source_nodes, tuple)
```

- [ ] **Step 2: 运行测试确认失败**

Run: `conda activate Video-Tree-TRM & pytest tests/unit/test_core_types.py::TestGeneratedQuestion -v`
Expected: FAIL — `ImportError: cannot import name 'GeneratedQuestion'`

- [ ] **Step 3: 在 core/types.py 追加 GeneratedQuestion**

在 `LLMResponse` 类之后追加：

```python
@dataclass(frozen=True)
class GeneratedQuestion:
    """单条生成/加载的题目。

    跨层共享类型，被 core/evolution/ 和 app/harness/、app/question_gen/ 使用。
    frozen=True 确保题目不可变。

    属性:
        question_id: 题目唯一标识。
        video_id: 所属视频标识。
        task_type: 题型（如 "Action Reasoning"）。
        question: 题目文本。
        options: 选项元组（如 ("A. ...", "B. ...", "C. ...", "D. ...")）。
        answer: 正确答案字母（如 "B"）。
        source_nodes: 来源节点 ID 元组。
        difficulty: 难度等级。
    """

    question_id: str
    video_id: str
    task_type: str
    question: str
    options: tuple[str, ...]
    answer: str
    source_nodes: tuple[str, ...]
    difficulty: str
```

- [ ] **Step 4: 运行测试确认通过**

Run: `conda activate Video-Tree-TRM & pytest tests/unit/test_core_types.py -v`
Expected: 全部 PASS（含原有 LLMResponse 测试 + 新增 GeneratedQuestion 测试）

- [ ] **Step 5: 提交**

```
feat(core): 追加 GeneratedQuestion frozen dataclass
```

---

### Task 2: load_benchmark 加载函数

**Files:**
- Create: `app/question_gen/loader.py`
- Create: `tests/unit/test_question_loader.py`

- [ ] **Step 1: 编写 load_benchmark 测试**

在 `tests/unit/test_question_loader.py` 中创建：

```python
"""app/question_gen/loader.py 单元测试。"""
from __future__ import annotations

import json
from pathlib import Path

import pytest

from app.question_gen.loader import load_benchmark
from core.types import GeneratedQuestion


@pytest.fixture()
def benchmark_dir(tmp_path: Path) -> Path:
    """创建包含 benchmark JSON 的临时目录。"""
    data = [
        {
            "question_id": "1-1",
            "task_type": "Action Reasoning",
            "question": "What happened?",
            "options": ["A. X", "B. Y", "C. Z", "D. W"],
            "answer": "A",
        },
        {
            "question_id": "1-2",
            "task_type": "OCR Problems",
            "question": "What text is shown?",
            "options": ["A. Hello", "B. World", "C. Foo", "D. Bar"],
            "answer": "B",
        },
    ]
    (tmp_path / "video_abc.json").write_text(json.dumps(data), encoding="utf-8")
    return tmp_path


class TestLoadBenchmark:
    def test_loads_questions_from_json(self, benchmark_dir: Path) -> None:
        questions = load_benchmark(benchmark_dir)
        assert len(questions) == 2

    def test_video_id_from_filename(self, benchmark_dir: Path) -> None:
        questions = load_benchmark(benchmark_dir)
        assert all(q.video_id == "video_abc" for q in questions)

    def test_fields_mapped_correctly(self, benchmark_dir: Path) -> None:
        questions = load_benchmark(benchmark_dir)
        q = questions[0]
        assert q.question_id == "1-1"
        assert q.task_type == "Action Reasoning"
        assert q.question == "What happened?"
        assert q.options == ("A. X", "B. Y", "C. Z", "D. W")
        assert q.answer == "A"

    def test_options_is_tuple(self, benchmark_dir: Path) -> None:
        questions = load_benchmark(benchmark_dir)
        assert isinstance(questions[0].options, tuple)

    def test_source_nodes_is_empty_tuple(self, benchmark_dir: Path) -> None:
        questions = load_benchmark(benchmark_dir)
        assert questions[0].source_nodes == ()

    def test_difficulty_defaults_to_medium_for_legacy(self, benchmark_dir: Path) -> None:
        questions = load_benchmark(benchmark_dir)
        assert questions[0].difficulty == "medium"

    def test_difficulty_from_json_when_present(self, tmp_path: Path) -> None:
        data = [
            {
                "question_id": "2-1",
                "task_type": "OCR Problems",
                "question": "Q?",
                "options": ["A. 1", "B. 2", "C. 3", "D. 4"],
                "answer": "C",
                "difficulty": "hard",
            }
        ]
        (tmp_path / "vid.json").write_text(json.dumps(data), encoding="utf-8")
        questions = load_benchmark(tmp_path)
        assert questions[0].difficulty == "hard"

    def test_empty_directory_returns_empty_list(self, tmp_path: Path) -> None:
        questions = load_benchmark(tmp_path)
        assert questions == []

    def test_sorted_by_filename(self, tmp_path: Path) -> None:
        for name in ["z_video.json", "a_video.json"]:
            data = [{"question_id": f"{name}-1", "task_type": "T", "question": "Q?", "options": ["A", "B", "C", "D"], "answer": "A"}]
            (tmp_path / name).write_text(json.dumps(data), encoding="utf-8")
        questions = load_benchmark(tmp_path)
        assert questions[0].video_id == "a_video"
        assert questions[1].video_id == "z_video"

    def test_returns_generated_question_instances(self, benchmark_dir: Path) -> None:
        questions = load_benchmark(benchmark_dir)
        assert all(isinstance(q, GeneratedQuestion) for q in questions)

    def test_loads_real_benchmark(self) -> None:
        """使用真实 benchmark 数据验证加载正确性。"""
        real_dir = Path("store/questions/benchmarks/Video-MME")
        if not real_dir.exists():
            pytest.skip("真实 benchmark 数据不存在")
        questions = load_benchmark(real_dir)
        assert len(questions) > 0
        for q in questions:
            assert isinstance(q, GeneratedQuestion)
            assert len(q.options) == 4
            assert q.answer in ("A", "B", "C", "D")

    def test_malformed_json_raises(self, tmp_path: Path) -> None:
        """非法 JSON 文件应抛出 json.JSONDecodeError。"""
        (tmp_path / "bad.json").write_text("not valid json{{{", encoding="utf-8")
        with pytest.raises(json.JSONDecodeError):
            load_benchmark(tmp_path)

    def test_missing_required_field_raises(self, tmp_path: Path) -> None:
        """缺少必需字段（如 question_id）应抛出 KeyError。"""
        data = [{"task_type": "T", "question": "Q?", "options": ["A"], "answer": "A"}]
        (tmp_path / "vid.json").write_text(json.dumps(data), encoding="utf-8")
        with pytest.raises(KeyError):
            load_benchmark(tmp_path)
```

- [ ] **Step 2: 运行测试确认失败**

Run: `conda activate Video-Tree-TRM & pytest tests/unit/test_question_loader.py::TestLoadBenchmark -v`
Expected: FAIL — `ModuleNotFoundError: No module named 'app.question_gen.loader'`

- [ ] **Step 3: 实现 loader.py 的 load_benchmark**

创建 `app/question_gen/loader.py`：

```python
"""题目加载与分层采样。

从 benchmark JSON 目录加载题目，提供按对错比例的分层采样。
对应训练循环中的 DataLoader 角色。
"""

from __future__ import annotations

import json
from pathlib import Path

from core.types import GeneratedQuestion

_LEGACY_DEFAULT_DIFFICULTY = "medium"


def load_benchmark(questions_dir: Path) -> list[GeneratedQuestion]:
    """从 benchmark JSON 目录加载题目列表。

    每个 JSON 文件以文件名（不含扩展名）作为 video_id，
    文件内容为题目数组。

    参数:
        questions_dir: 包含 *.json 文件的目录路径。

    返回:
        按文件名排序加载的题目列表。
    """
    results: list[GeneratedQuestion] = []
    for path in sorted(questions_dir.glob("*.json")):
        video_id = path.stem
        with open(path, encoding="utf-8") as f:
            qa_list: list[dict] = json.load(f)
        for qa in qa_list:
            results.append(
                GeneratedQuestion(
                    question_id=qa["question_id"],
                    video_id=video_id,
                    task_type=qa["task_type"],
                    question=qa["question"],
                    options=tuple(qa["options"]),
                    answer=qa["answer"],
                    source_nodes=tuple(qa.get("source_nodes", ())),
                    difficulty=qa.get("difficulty", _LEGACY_DEFAULT_DIFFICULTY),
                )
            )
    return results
```

- [ ] **Step 4: 运行测试确认通过**

Run: `conda activate Video-Tree-TRM & pytest tests/unit/test_question_loader.py::TestLoadBenchmark -v`
Expected: 全部 PASS

- [ ] **Step 5: 提交**

```
feat(question_gen): load_benchmark — benchmark JSON 加载
```

---

### Task 3: stratified_sample 分层采样

**Files:**
- Modify: `app/question_gen/loader.py`
- Modify: `tests/unit/test_question_loader.py`

- [ ] **Step 1: 编写 stratified_sample 测试**

在 `tests/unit/test_question_loader.py` 追加：

```python
from app.question_gen.loader import stratified_sample


def _make_questions(n: int, task_type: str = "T") -> list[GeneratedQuestion]:
    """辅助函数：批量构造题目。"""
    return [
        GeneratedQuestion(
            question_id=f"{task_type}-{i}",
            video_id="v1",
            task_type=task_type,
            question=f"Q{i}?",
            options=("A", "B", "C", "D"),
            answer="A",
            source_nodes=(),
            difficulty="medium",
        )
        for i in range(n)
    ]


class TestStratifiedSample:
    def test_natural_distribution(self) -> None:
        """correct_ratio=None 时走自然分布随机抽样。"""
        questions = _make_questions(20)
        result = stratified_sample(
            questions=questions,
            correctness={},
            size=10,
            correct_ratio=None,
            task_types=None,
            seed=42,
            min_per_class=None,
        )
        assert len(result) == 10

    def test_natural_distribution_pool_insufficient(self) -> None:
        """自然分布时池不足应 ValueError。"""
        questions = _make_questions(5)
        with pytest.raises(ValueError, match="自然分布采样不足"):
            stratified_sample(
                questions=questions,
                correctness={},
                size=10,
                correct_ratio=None,
                task_types=None,
                seed=42,
                min_per_class=None,
            )

    def test_ratio_stratified(self) -> None:
        """按对错比例分层采样。"""
        questions = _make_questions(20)
        correctness = {f"T-{i}": i < 10 for i in range(20)}
        result = stratified_sample(
            questions=questions,
            correctness=correctness,
            size=10,
            correct_ratio=0.6,
            task_types=None,
            seed=42,
            min_per_class=None,
        )
        assert len(result) == 10
        correct_count = sum(1 for q in result if correctness.get(q.question_id, False))
        assert correct_count == 6

    def test_ratio_stratified_correct_first(self) -> None:
        """分层采样返回顺序：对题在前、错题在后。"""
        questions = _make_questions(20)
        correctness = {f"T-{i}": i < 10 for i in range(20)}
        result = stratified_sample(
            questions=questions,
            correctness=correctness,
            size=10,
            correct_ratio=0.5,
            task_types=None,
            seed=42,
            min_per_class=None,
        )
        n_correct = round(10 * 0.5)
        for q in result[:n_correct]:
            assert correctness.get(q.question_id, False) is True
        for q in result[n_correct:]:
            assert correctness.get(q.question_id, False) is False

    def test_ratio_stratified_pool_insufficient(self) -> None:
        """分层时对题或错题不足应 ValueError。"""
        questions = _make_questions(10)
        correctness = {f"T-{i}": True for i in range(10)}
        with pytest.raises(ValueError, match="分层不足"):
            stratified_sample(
                questions=questions,
                correctness=correctness,
                size=10,
                correct_ratio=0.5,
                task_types=None,
                seed=42,
                min_per_class=None,
            )

    def test_task_types_filter(self) -> None:
        """task_types 过滤只保留指定题型。"""
        q_a = _make_questions(10, task_type="TypeA")
        q_b = _make_questions(10, task_type="TypeB")
        result = stratified_sample(
            questions=q_a + q_b,
            correctness={},
            size=5,
            correct_ratio=None,
            task_types=["TypeA"],
            seed=42,
            min_per_class=None,
        )
        assert all(q.task_type == "TypeA" for q in result)

    def test_unknown_correctness_treated_as_wrong(self) -> None:
        """correctness 中不存在的 question_id 被当作错题。"""
        questions = _make_questions(20)
        correctness = {f"T-{i}": True for i in range(10)}
        result = stratified_sample(
            questions=questions,
            correctness=correctness,
            size=10,
            correct_ratio=0.5,
            task_types=None,
            seed=42,
            min_per_class=None,
        )
        n_correct = round(10 * 0.5)
        for q in result[:n_correct]:
            assert q.question_id in correctness

    def test_seed_reproducibility(self) -> None:
        """相同种子产生相同结果。"""
        questions = _make_questions(20)
        r1 = stratified_sample(questions=questions, correctness={}, size=10, correct_ratio=None, task_types=None, seed=123, min_per_class=None)
        r2 = stratified_sample(questions=questions, correctness={}, size=10, correct_ratio=None, task_types=None, seed=123, min_per_class=None)
        assert [q.question_id for q in r1] == [q.question_id for q in r2]

    def test_different_seeds_differ(self) -> None:
        """不同种子产生不同结果（概率性，但 20 选 10 几乎必然不同）。"""
        questions = _make_questions(20)
        r1 = stratified_sample(questions=questions, correctness={}, size=10, correct_ratio=None, task_types=None, seed=1, min_per_class=None)
        r2 = stratified_sample(questions=questions, correctness={}, size=10, correct_ratio=None, task_types=None, seed=2, min_per_class=None)
        assert [q.question_id for q in r1] != [q.question_id for q in r2]

    def test_min_per_class_backfill(self) -> None:
        """min_per_class 补足稀疏题型。"""
        q_a = _make_questions(10, task_type="TypeA")
        q_b = _make_questions(10, task_type="TypeB")
        all_q = q_a + q_b
        correctness = {q.question_id: True for q in q_a[:5]}
        result = stratified_sample(
            questions=all_q,
            correctness=correctness,
            size=3,
            correct_ratio=None,
            task_types=None,
            seed=42,
            min_per_class=2,
        )
        type_counts: dict[str, int] = {}
        for q in result:
            type_counts[q.task_type] = type_counts.get(q.task_type, 0) + 1
        assert type_counts.get("TypeA", 0) >= 2
        assert type_counts.get("TypeB", 0) >= 2

    def test_min_per_class_partial_backfill(self) -> None:
        """题型可补题数不足缺口时全取，不报错。"""
        q_sparse = _make_questions(1, task_type="Sparse")
        q_main = _make_questions(10, task_type="Main")
        result = stratified_sample(
            questions=q_sparse + q_main,
            correctness={},
            size=5,
            correct_ratio=None,
            task_types=None,
            seed=42,
            min_per_class=3,
        )
        sparse_in_result = [q for q in result if q.task_type == "Sparse"]
        assert len(sparse_in_result) == 1

    def test_min_per_class_no_duplicates(self) -> None:
        """补足后不产生重复 question_id。"""
        q_a = _make_questions(5, task_type="TypeA")
        q_b = _make_questions(5, task_type="TypeB")
        result = stratified_sample(
            questions=q_a + q_b,
            correctness={},
            size=3,
            correct_ratio=None,
            task_types=None,
            seed=42,
            min_per_class=2,
        )
        ids = [q.question_id for q in result]
        assert len(ids) == len(set(ids))

    def test_backfill_enumerates_all_pool_types(self) -> None:
        """补足遍历 pool 全部题型，包括主采样未命中的。"""
        q_main = _make_questions(10, task_type="Main")
        q_rare = _make_questions(3, task_type="Rare")
        result = stratified_sample(
            questions=q_main + q_rare,
            correctness={},
            size=2,
            correct_ratio=None,
            task_types=None,
            seed=0,
            min_per_class=1,
        )
        types_in_result = {q.task_type for q in result}
        assert "Rare" in types_in_result
```

- [ ] **Step 2: 运行测试确认失败**

Run: `conda activate Video-Tree-TRM & pytest tests/unit/test_question_loader.py::TestStratifiedSample -v`
Expected: FAIL — `ImportError: cannot import name 'stratified_sample'`

- [ ] **Step 3: 实现 stratified_sample 及内部辅助函数**

在 `app/question_gen/loader.py` 的导入区追加 `import random`（标准库，放在 `import json` 之后），然后在文件末尾追加以下函数：

```python
def 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]:
    """按题型过滤后采样 size 道题，可选按对错比例分层并按题型保底。

    参数:
        questions: 候选题目全集。
        correctness: question_id → 基线是否答对。
        size: 采样总量。
        correct_ratio: 采样中"基线答对"题的占比；None 表示自然分布。
        task_types: 限定题型；None 表示不限。
        seed: 随机种子，保证可复现。
        min_per_class: 每个题型补足到的下限；None 表示不补足。

    返回:
        采样后的题目列表。

    异常:
        ValueError: 自然分布时池不足 size，或分层时某层题目不足。
    """
    rng = random.Random(seed)
    pool = [q for q in questions if task_types is None or q.task_type in task_types]

    if correct_ratio is None:
        if len(pool) < size:
            raise ValueError(f"自然分布采样不足: 需 {size} 道, 实有 {len(pool)} 道")
        sampled = rng.sample(pool, size)
    else:
        sampled = _ratio_stratified_sample(pool, correctness, size, correct_ratio, rng)

    if min_per_class is not None:
        sampled = _backfill_per_class(sampled, pool, min_per_class, rng)
    return sampled


def _ratio_stratified_sample(
    pool: list[GeneratedQuestion],
    correctness: dict[str, bool],
    size: int,
    correct_ratio: float,
    rng: random.Random,
) -> list[GeneratedQuestion]:
    """按对错比例分层采样：对题占 correct_ratio，其余为错题。

    参数:
        pool: 题型过滤后的候选题。
        correctness: question_id → 基线是否答对。
        size: 采样总量。
        correct_ratio: 对题占比。
        rng: 随机数发生器。

    返回:
        采样后的题目列表（对题在前、错题在后）。

    异常:
        ValueError: 对题或错题层不足。
    """
    correct = [q for q in pool if correctness.get(q.question_id, False)]
    wrong = [q for q in pool if not correctness.get(q.question_id, False)]
    n_correct = round(size * correct_ratio)
    n_wrong = size - n_correct
    if len(correct) < n_correct or len(wrong) < n_wrong:
        raise ValueError(
            f"分层不足: 需对{n_correct}/错{n_wrong}, "
            f"实有对{len(correct)}/错{len(wrong)}"
        )
    return rng.sample(correct, n_correct) + rng.sample(wrong, n_wrong)


def _backfill_per_class(
    sampled: list[GeneratedQuestion],
    pool: list[GeneratedQuestion],
    min_per_class: int,
    rng: random.Random,
) -> list[GeneratedQuestion]:
    """对候选池中出现的每个题型，将采样结果补足到 min_per_class 道。

    遍历对象是候选池 pool 里出现的全部题型（非仅 sampled 命中的），
    保证任意稀疏题型都能拿到足额样本。

    参数:
        sampled: 主采样结果（不修改，返回新列表）。
        pool: 候选题全集（补足来源 + 题型枚举来源）。
        min_per_class: 每个题型的下限。
        rng: 随机数发生器。

    返回:
        补足后的题目列表。
    """
    selected_ids = {q.question_id for q in sampled}
    result = list(sampled)
    counts: dict[str, int] = {}
    for q in sampled:
        counts[q.task_type] = counts.get(q.task_type, 0) + 1
    ordered_task_types: dict[str, None] = {}
    for q in pool:
        ordered_task_types.setdefault(q.task_type, None)
    for task_type in ordered_task_types:
        deficit = min_per_class - counts.get(task_type, 0)
        if deficit <= 0:
            continue
        candidates = [
            q
            for q in pool
            if q.task_type == task_type and q.question_id not in selected_ids
        ]
        take = rng.sample(candidates, min(deficit, len(candidates)))
        for q in take:
            selected_ids.add(q.question_id)
            result.append(q)
    return result
```

- [ ] **Step 4: 运行测试确认通过**

Run: `conda activate Video-Tree-TRM & pytest tests/unit/test_question_loader.py -v`
Expected: 全部 PASS（TestLoadBenchmark + TestStratifiedSample）

- [ ] **Step 5: 提交**

```
feat(question_gen): stratified_sample — 分层采样 + 题型保底
```

---

### Task 4: QuestionGenerator Protocol 与模块公开 API

**Files:**
- Modify: `app/ports.py`
- Modify: `app/question_gen/__init__.py`
- Create: `tests/unit/test_question_gen_api.py`

- [ ] **Step 1: 编写 Protocol 可导入性和 __init__ 公开 API 测试**

创建 `tests/unit/test_question_gen_api.py`：

```python
"""app/ports.py QuestionGenerator Protocol 与 app/question_gen 公开 API 测试。"""
from __future__ import annotations

import importlib
from typing import runtime_checkable

from app.ports import QuestionGenerator
from core.types import GeneratedQuestion


class TestQuestionGeneratorProtocol:
    def test_importable(self) -> None:
        """QuestionGenerator 可从 app.ports 导入。"""
        assert QuestionGenerator is not None

    def test_is_runtime_checkable(self) -> None:
        """QuestionGenerator 是 runtime_checkable Protocol。"""
        assert hasattr(QuestionGenerator, "__protocol_attrs__") or hasattr(
            QuestionGenerator, "__abstractmethods__"
        )

    def test_generate_method_exists(self) -> None:
        """Protocol 定义了 generate 方法。"""
        assert hasattr(QuestionGenerator, "generate")


class TestQuestionGenPublicAPI:
    def test_load_benchmark_importable_from_package(self) -> None:
        """load_benchmark 可从 app.question_gen 直接导入。"""
        mod = importlib.import_module("app.question_gen")
        assert hasattr(mod, "load_benchmark")

    def test_stratified_sample_importable_from_package(self) -> None:
        """stratified_sample 可从 app.question_gen 直接导入。"""
        mod = importlib.import_module("app.question_gen")
        assert hasattr(mod, "stratified_sample")

    def test_all_exports(self) -> None:
        """__all__ 包含预期的公开 API。"""
        mod = importlib.import_module("app.question_gen")
        assert set(mod.__all__) == {"load_benchmark", "stratified_sample"}
```

- [ ] **Step 2: 运行测试确认失败**

Run: `conda activate Video-Tree-TRM & pytest tests/unit/test_question_gen_api.py -v`
Expected: FAIL — `ImportError: cannot import name 'QuestionGenerator' from 'app.ports'`

- [ ] **Step 3: 在 app/ports.py 追加 QuestionGenerator Protocol**

将已有的 `if TYPE_CHECKING:` 块扩展，追加 `TreeIndex` 和 `GeneratedQuestion` 导入，然后在 `EmbeddingProvider` 之后追加：

```python
@runtime_checkable
class QuestionGenerator(Protocol):
    """LLM 驱动的题目生成端口（预留接口）。

    参数:
        video_id: 视频标识。
        task_type: 题型。
        tree: 视频树索引，提供锚节点上下文。
        exemplars: 风格示例题目列表。

    返回:
        生成的单条题目。
    """

    async def generate(
        self,
        video_id: str,
        task_type: str,
        tree: TreeIndex,
        *,
        exemplars: list[GeneratedQuestion],
    ) -> GeneratedQuestion: ...
```

合并后的 `TYPE_CHECKING` 块：

```python
if TYPE_CHECKING:
    import numpy as np

    from app.tree.index import TreeIndex
    from core.types import GeneratedQuestion
```

- [ ] **Step 4: 更新 app/question_gen/__init__.py 公开 API**

```python
"""出题模块 — benchmark 加载与分层采样。"""

from app.question_gen.loader import load_benchmark, stratified_sample

__all__ = ["load_benchmark", "stratified_sample"]
```

- [ ] **Step 5: 运行测试确认通过**

Run: `conda activate Video-Tree-TRM & pytest tests/unit/test_question_gen_api.py -v`
Expected: 全部 PASS

- [ ] **Step 6: 运行全量测试确认无回归**

Run: `conda activate Video-Tree-TRM & pytest tests/ -v`
Expected: 全部 PASS

- [ ] **Step 7: 提交**

```
feat(question_gen): QuestionGenerator Protocol + 模块公开 API
```

---

### Task 5: 文档同步与 lint

**Files:**
- Modify: `research-wiki/ARCHITECTURE.md`
- Modify: `CLAUDE.md`

- [ ] **Step 1: 更新 ARCHITECTURE.md**

需要修改 4 处：

1. **§1 表格**（第 17 行附近）：

```
| DataLoader | 出题 question_gen | `app/question_gen/generator.py` |
```
→
```
| DataLoader | 出题 question_gen | `app/question_gen/loader.py` |
```

2. **§2.2 Mermaid**（第 83 行附近）：

```
CLI --> QGEN["app/question_gen/generator.py\n新题构建"]
```
→
```
CLI --> QGEN["app/question_gen/loader.py\n新题构建"]
```

3. **§2.3 目录树**（第 132 行附近）：

```
│   │   ├── question_gen.py  # 数据加载、三池切分
```

此行描述 `harness/` 内部的数据加载，但在 TRM5 中数据加载已移至 `question_gen/loader.py`。删除此行（`harness/` 的三池切分模块在未来开发 harness 时再规划）。

4. **§2.3 目录树**（第 138-141 行）：

```
│   ├── question_gen/        # 模块3：新题构建
│   │   ├── generator.py     # 题目生成
│   │   ├── calibrator.py    # 基线校准
│   │   └── dedup.py         # 去重
```
→
```
│   ├── question_gen/        # 模块3：出题（加载 + 采样 + 未来 LLM 生成）
│   │   └── loader.py        # benchmark 加载、分层采样
```

- [ ] **Step 2: 更新 CLAUDE.md**

1. **§1.5 表格**（第 22 行附近）：

```
| `DataLoader` | 出题 question_gen | `app/question_gen/generator.py` |
```
→
```
| `DataLoader` | 出题 question_gen | `app/question_gen/loader.py` |
```

- [ ] **Step 3: 运行 lint**

Run: `conda activate Video-Tree-TRM & ruff check app/ core/ --fix && ruff format app/ core/`
Expected: 无错误或仅自动修复

- [ ] **Step 4: 运行全量测试**

Run: `conda activate Video-Tree-TRM & pytest tests/ -v`
Expected: 全部 PASS

- [ ] **Step 5: 提交**

```
docs: 同步 question_gen 模块路径到 ARCHITECTURE.md 和 CLAUDE.md
```
