Files
Video-Tree-TRM5/research-wiki/plans/2026-07-07-question-gen.md
T
iomgaa dbc9d38cd7 plan(tree/repair): 三项改造实现计划(遥测加固+断点续跑+并发)
6 个 Task: telemetry 防御加固 → call_id 根因修复 → detector L2/L1 扩展
→ progress 管理 → 并发编排+CLI → lint+全量测试
2026-07-09 00:08:23 -04:00

895 lines
30 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.
# 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: 全部 PASSTestLoadBenchmark + 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
```