# 赛题生成工具实现计划 > **For agentic workers:** REQUIRED SUB-SKILL: Use subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 实现基于视频树的题目合成工具(generate + calibrate),含推理依赖 factory 提取。 **Architecture:** `app/question_gen/synthesizer.py` 承载核心业务逻辑(节点采样、prompt 构造、去重),`app/harness/factory.py` 提取推理依赖组装(TreeEnvironment + SearchToolDispatcher + PromptManager),`tools/generate_questions.py` 作为 CLI 壳编排并发和 I/O。 **Tech Stack:** Python 3.11, asyncio, GovernedVLMClient, EmbeddingProvider, scipy.stats.fisher_exact, loguru **核心算法保真校验:** 本计划不涉及核心算法迁移(13 项均已在先前 PR 完成),保真校验不适用。 --- ## 文件结构总览 | 动作 | 文件 | 职责 | |------|------|------| | 修改 | `app/harness/config.py:24` | 前置修复 `_VIDEO_MME_TASK_TYPE_COUNT` 11→12 | | 新建 | `app/question_gen/synthesizer.py` | 出题核心逻辑 | | 新建 | `app/harness/factory.py` | 推理依赖组装 | | 新建 | `tools/generate_questions.py` | CLI 壳 | | 新建 | `tests/unit/test_synthesizer.py` | synthesizer 单测 | | 新建 | `tests/unit/test_factory.py` | factory 单测 | | 新建 | `tests/unit/test_generate_questions.py` | CLI 集成测试 | | 修改 | `app/question_gen/__init__.py` | 追加 synthesizer re-export | --- ### Task 0: 前置修复 _VIDEO_MME_TASK_TYPE_COUNT **Files:** - Modify: `app/harness/config.py:24` - Test: `tests/unit/test_harness_config.py` - [ ] **Step 1: 写失败测试** 在 `tests/unit/test_harness_config.py` 中追加: ```python def test_video_mme_task_type_count_is_12(): """Video-MME 实际有 12 种题型,常量必须与之一致。""" from app.harness.config import _VIDEO_MME_TASK_TYPE_COUNT assert _VIDEO_MME_TASK_TYPE_COUNT == 12 ``` - [ ] **Step 2: 运行测试确认失败** ```bash conda activate Video-Tree-TRM & pytest tests/unit/test_harness_config.py::test_video_mme_task_type_count_is_12 -v ``` 预期:FAIL,`assert 11 == 12` - [ ] **Step 3: 修改常量** `app/harness/config.py:24`:`_VIDEO_MME_TASK_TYPE_COUNT = 11` → `_VIDEO_MME_TASK_TYPE_COUNT = 12` - [ ] **Step 4: 运行测试确认通过** ```bash conda activate Video-Tree-TRM & pytest tests/unit/test_harness_config.py -v ``` 预期:全部 PASS - [ ] **Step 5: 提交** ```bash git add app/harness/config.py tests/unit/test_harness_config.py git commit -m "fix(config): _VIDEO_MME_TASK_TYPE_COUNT 11→12,Video-MME 实际有 12 种题型" ``` --- ### Task 1: synthesizer.py — AnchorContext + 题型映射常量 **Files:** - Create: `app/question_gen/synthesizer.py` - Create: `tests/unit/test_synthesizer.py` - [ ] **Step 1: 写失败测试 — 题型映射完整性** ```python # tests/unit/test_synthesizer.py from app.question_gen.synthesizer import TASK_TYPE_LEVEL_MAP, AnchorContext ALL_12_TYPES = [ "Object Recognition", "Attribute Perception", "OCR Problems", "Spatial Reasoning", "Spatial Perception", "Action Recognition", "Action Reasoning", "Counting Problem", "Temporal Perception", "Temporal Reasoning", "Information Synopsis", "Object Reasoning", ] def test_task_type_level_map_covers_all_12_types(): """映射表必须覆盖全部 12 种 Video-MME 题型。""" assert set(TASK_TYPE_LEVEL_MAP.keys()) == set(ALL_12_TYPES) def test_anchor_context_frozen(): """AnchorContext 是不可变的。""" ctx = AnchorContext( node_id="L3_001", card_text="test", frame_paths=["a.jpg"], subtitle="", distractor_texts=["other node"], ) assert ctx.node_id == "L3_001" ``` - [ ] **Step 2: 运行测试确认失败** ```bash conda activate Video-Tree-TRM & pytest tests/unit/test_synthesizer.py -v ``` 预期:ImportError - [ ] **Step 3: 实现 AnchorContext + TASK_TYPE_LEVEL_MAP** 创建 `app/question_gen/synthesizer.py`: ```python """赛题合成核心逻辑 — 节点采样、prompt 构造、去重。 纯函数为主,异步编排仅 generate_one。 通过 DI 接收 VLMProvider / EmbeddingProvider,不 import adapters/。 """ from __future__ import annotations from dataclasses import dataclass from typing import Any @dataclass(frozen=True) class AnchorContext: """锚节点上下文——生成单道题所需的全部素材。 属性: node_id: 锚节点 ID。 card_text: 锚节点 card 序列化文本。 frame_paths: 帧图片路径列表。 subtitle: 对应字幕(可空)。 distractor_texts: 同视频其他节点摘要(供 VLM 生成干扰项)。 """ node_id: str card_text: str frame_paths: list[str] subtitle: str distractor_texts: list[str] @dataclass(frozen=True) class TaskTypeSpec: """题型的生成规格。 属性: level: 锚定层级("L3" / "L2" / "L1" / "L1-L2")。 needs_frames: 是否必须提供帧图。 frame_count: 帧数范围描述(如 "1", "2-3", "0-1")。 context_fields: 需要提取的 card 字段元组。 """ level: str needs_frames: bool frame_count: str context_fields: tuple[str, ...] TASK_TYPE_LEVEL_MAP: dict[str, TaskTypeSpec] = { "Object Recognition": TaskTypeSpec("L3", True, "1", ("frame_summary",)), "Attribute Perception": TaskTypeSpec("L3", True, "1", ("frame_summary",)), "OCR Problems": TaskTypeSpec("L3", True, "1", ("frame_summary",)), "Spatial Reasoning": TaskTypeSpec("L3", True, "1", ("frame_summary", "spatial_layout")), "Spatial Perception": TaskTypeSpec("L3", True, "1", ("frame_summary",)), "Action Recognition": TaskTypeSpec("L2", True, "2-3", ("event_description",)), "Action Reasoning": TaskTypeSpec("L2", True, "2-3", ("event_description",)), "Counting Problem": TaskTypeSpec("L2", True, "2-3", ("event_description",)), "Temporal Perception": TaskTypeSpec("L2", False, "0-1", ("event_description", "time_range")), "Temporal Reasoning": TaskTypeSpec("L1", True, "per-L2", ("scene_summary",)), "Information Synopsis": TaskTypeSpec("L1", True, "per-L2", ("scene_summary",)), "Object Reasoning": TaskTypeSpec("L1-L2", True, "per-L2", ("event_description",)), } ``` - [ ] **Step 4: 运行测试确认通过** ```bash conda activate Video-Tree-TRM & pytest tests/unit/test_synthesizer.py -v ``` 预期:PASS - [ ] **Step 5: 提交** ```bash git add app/question_gen/synthesizer.py tests/unit/test_synthesizer.py git commit -m "feat(question_gen): AnchorContext + 12 题型-层级映射常量" ``` --- ### Task 2: synthesizer.py — sample_anchor **Files:** - Modify: `app/question_gen/synthesizer.py` - Modify: `tests/unit/test_synthesizer.py` - [ ] **Step 1: 写失败测试** ```python import json import random from pathlib import Path from app.tree.index import TreeIndex from app.question_gen.synthesizer import sample_anchor def _load_test_tree() -> tuple[TreeIndex, str]: """加载真实测试树(store/videos/ 下第一棵)。""" videos_dir = Path("store/videos") first_vid = sorted(videos_dir.iterdir())[0] tree = TreeIndex.load_json(str(first_vid / "tree.json")) return tree, first_vid.name class TestSampleAnchor: def test_l3_type_returns_single_frame(self): tree, vid = _load_test_tree() ctx = sample_anchor(tree, "Object Recognition", set(), random.Random(42)) assert len(ctx.frame_paths) == 1 assert ctx.node_id.startswith("L") assert len(ctx.distractor_texts) > 0 def test_l2_type_returns_multiple_frames(self): tree, vid = _load_test_tree() ctx = sample_anchor(tree, "Action Reasoning", set(), random.Random(42)) assert 2 <= len(ctx.frame_paths) <= 3 def test_temporal_perception_zero_or_one_frame(self): """Temporal Perception 帧数 0-1,且 card_text 含 time_range。""" tree, vid = _load_test_tree() ctx = sample_anchor(tree, "Temporal Perception", set(), random.Random(42)) assert len(ctx.frame_paths) <= 1 assert "time_range" in ctx.card_text.lower() or "time" in ctx.card_text.lower() def test_information_synopsis_uses_all_l2(self): """Information Synopsis 必须包含全部 L2 card(非采样子集)。""" tree, vid = _load_test_tree() ctx = sample_anchor(tree, "Information Synopsis", set(), random.Random(42)) total_l2 = sum(len(r.children) for r in tree.roots) # card_text 中应包含全部 L2 的事件描述 assert len(ctx.frame_paths) >= min(total_l2, 1) def test_l1_type_l2_nodes_in_time_order(self): """L1 题型的 L2 子节点应按时间顺序组织。""" tree, vid = _load_test_tree() ctx = sample_anchor(tree, "Temporal Reasoning", set(), random.Random(42)) assert len(ctx.frame_paths) >= 1 assert len(ctx.card_text) > 20 def test_used_node_ids_excluded(self): tree, vid = _load_test_tree() rng = random.Random(42) ctx1 = sample_anchor(tree, "Object Recognition", set(), rng) ctx2 = sample_anchor(tree, "Object Recognition", {ctx1.node_id}, random.Random(43)) assert ctx2.node_id != ctx1.node_id def test_insufficient_nodes_raises(self): tree, vid = _load_test_tree() all_l3_ids = set() for root in tree.roots: for l2 in root.children: for l3 in l2.children: all_l3_ids.add(l3.id) import pytest with pytest.raises(ValueError, match="锚节点不足"): sample_anchor(tree, "Object Recognition", all_l3_ids, random.Random(42)) ``` - [ ] **Step 2: 运行测试确认失败** ```bash conda activate Video-Tree-TRM & pytest tests/unit/test_synthesizer.py::TestSampleAnchor -v ``` 预期:ImportError(sample_anchor 不存在) - [ ] **Step 3: 实现 sample_anchor** 在 `app/question_gen/synthesizer.py` 中追加 `sample_anchor` 函数,核心逻辑: 1. 根据 `TASK_TYPE_LEVEL_MAP[task_type].level` 确定采样层级 2. L3 题型:从所有 L3 节点中随机选一个(排除 used_node_ids),取单帧 + card 3. L2 题型(Action Recognition / Action Reasoning / Counting Problem):随机选一个 L2 节点,均匀采样 2-3 子帧,card 取 event_description 4. **Temporal Perception 特例**:随机选一个 L2 节点,取 0-1 帧(有子帧取 1 帧,无则 0),card_text 必须包含 event_description + time_range 5. L1 题型:取根节点 card(scene_summary)。**Information Synopsis 使用全部 L2 card,Temporal Reasoning 选 ≥3 个**。L2 子节点按 time_range 升序排列,每个 L2 取 1 张代表帧 6. L1-L2 题型:随机选 2-3 个 L2 节点(按 time_range 排序),每个取 1 张代表帧 7. distractor_texts:收集同树中**其他**同层级节点的摘要文本 8. 候选不足时 `raise ValueError("锚节点不足: ...")` 详细实现需参考 `app/tree/index.py` 中 L1Node/L2Node/L3Node 的字段结构(L3Card.frame_summary, L2Card.event_description, L1Card.scene_summary)和 frame_path 位置。 - [ ] **Step 4: 运行测试确认通过** ```bash conda activate Video-Tree-TRM & pytest tests/unit/test_synthesizer.py::TestSampleAnchor -v ``` - [ ] **Step 5: 提交** ```bash git add app/question_gen/synthesizer.py tests/unit/test_synthesizer.py git commit -m "feat(question_gen): sample_anchor — 按题型层级采样锚节点" ``` --- ### Task 3: synthesizer.py — build_generation_prompt + parse_vlm_response **Files:** - Modify: `app/question_gen/synthesizer.py` - Modify: `tests/unit/test_synthesizer.py` - [ ] **Step 1: 写失败测试** ```python from core.types import GeneratedQuestion from app.question_gen.synthesizer import ( build_generation_prompt, parse_vlm_response, AnchorContext, ) class TestBuildGenerationPrompt: def test_messages_structure(self): anchor = AnchorContext( node_id="L3_001", card_text="A person typing on a laptop", frame_paths=["store/videos/test/frames/L1_000_L2_000_L3_000.jpg"], subtitle="Hello world", distractor_texts=["Another person walking in park"], ) exemplars = [ GeneratedQuestion( question_id="ex-1", video_id="v1", task_type="Object Recognition", question="What object?", options=("A. Cat", "B. Dog", "C. Bird", "D. Fish"), answer="A", source_nodes=(), difficulty="medium", ), ] messages, image_paths = build_generation_prompt( "Object Recognition", anchor, exemplars, ) assert messages[0]["role"] == "system" assert "Object Recognition" in messages[0]["content"] assert any("What object?" in str(m) for m in messages) assert image_paths == anchor.frame_paths def test_distractor_in_user_message(self): anchor = AnchorContext( node_id="L2_003", card_text="Event card text", frame_paths=["a.jpg", "b.jpg"], subtitle="", distractor_texts=["Distractor node summary"], ) messages, _ = build_generation_prompt("Action Reasoning", anchor, []) user_msg = [m for m in messages if m["role"] == "user"][0] assert "Distractor node summary" in user_msg["content"] class TestParseVlmResponse: def test_valid_json(self): raw = '{"question": "What?", "options": ["A. X", "B. Y", "C. Z", "D. W"], "answer": "A"}' result = parse_vlm_response(raw, "vid1", "Object Recognition", 1) assert result["question"] == "What?" assert result["answer"] == "A" assert len(result["options"]) == 4 def test_invalid_json_raises(self): import pytest with pytest.raises(ValueError, match="VLM 返回"): parse_vlm_response("not json", "vid1", "Object Recognition", 1) def test_missing_fields_raises(self): import pytest raw = '{"question": "What?"}' with pytest.raises(ValueError): parse_vlm_response(raw, "vid1", "Object Recognition", 1) def test_options_must_be_four(self): import pytest raw = '{"question": "Q?", "options": ["A. X", "B. Y"], "answer": "A"}' with pytest.raises(ValueError, match="4"): parse_vlm_response(raw, "vid1", "Object Recognition", 1) def test_answer_must_be_abcd(self): import pytest raw = '{"question": "Q?", "options": ["A. X", "B. Y", "C. Z", "D. W"], "answer": "E"}' with pytest.raises(ValueError, match="A.*D"): parse_vlm_response(raw, "vid1", "Object Recognition", 1) ``` - [ ] **Step 2: 运行测试确认失败** ```bash conda activate Video-Tree-TRM & pytest tests/unit/test_synthesizer.py::TestBuildGenerationPrompt -v conda activate Video-Tree-TRM & pytest tests/unit/test_synthesizer.py::TestParseVlmResponse -v ``` 预期:ImportError - [ ] **Step 3: 实现 build_generation_prompt + parse_vlm_response** `build_generation_prompt(task_type, anchor, exemplars) -> (messages, image_paths)`: - system message:角色设定 + 题型 + exemplar 示例 + 约束(基于节点内容、干扰项来自其他节点) - user message:锚节点 card_text + subtitle + distractor_texts - image_paths:直接取 anchor.frame_paths `parse_vlm_response(raw, video_id, task_type, seq) -> dict`: - 尝试 `json.loads(raw)`,失败时尝试从 markdown code block 提取 JSON - 校验必需字段 question / options / answer 存在 - 返回 `{"question_id": f"gen-{video_id}-{seq:03d}", "question": ..., "options": [...], "answer": ...}` - 缺字段或解析失败 → `raise ValueError("VLM 返回...")` - [ ] **Step 4: 运行测试确认通过** ```bash conda activate Video-Tree-TRM & pytest tests/unit/test_synthesizer.py -v ``` - [ ] **Step 5: 提交** ```bash git add app/question_gen/synthesizer.py tests/unit/test_synthesizer.py git commit -m "feat(question_gen): build_generation_prompt + parse_vlm_response" ``` --- ### Task 4: synthesizer.py — is_duplicate + generate_one **Files:** - Modify: `app/question_gen/synthesizer.py` - Modify: `tests/unit/test_synthesizer.py` - [ ] **Step 1: 写失败测试** ```python import numpy as np from unittest.mock import AsyncMock, MagicMock from app.question_gen.synthesizer import is_duplicate, generate_one class TestIsDuplicate: @staticmethod def _fake_embed(texts): """确定性 + L2 归一化的 fake embedding。""" if isinstance(texts, str): texts = [texts] vecs = [] for t in texts: rs = np.random.RandomState(hash(t) % 2**31) v = rs.randn(4).astype(np.float32) v /= np.linalg.norm(v) vecs.append(v) return np.array(vecs, dtype=np.float32) def test_empty_pool_never_duplicate(self): pool = np.zeros((0, 4), dtype=np.float32) assert is_duplicate("anything", pool, self._fake_embed, 0.85) is False def test_identical_text_is_duplicate(self): text = "What is happening in the video?" emb = self._fake_embed(text) pool = emb.copy() assert is_duplicate(text, pool, self._fake_embed, 0.85) is True def test_different_text_not_duplicate(self): pool_texts = ["aaa", "bbb", "ccc", "ddd", "eee"] pool = self._fake_embed(pool_texts) assert is_duplicate("completely unique text xyz", pool, self._fake_embed, 0.99) is False class TestGenerateOne: @staticmethod async def test_success_path(): """mock VLM 返回合法 JSON,应成功生成。""" vlm = AsyncMock() vlm.chat_with_images.return_value = MagicMock( content='{"question":"Q?","options":["A. 1","B. 2","C. 3","D. 4"],"answer":"A"}' ) embed_fn = lambda t: np.zeros((1, 4) if isinstance(t, str) else (len(t), 4), dtype=np.float32) tree, vid = _load_test_tree() result = await generate_one( vlm=vlm, embed_fn=embed_fn, tree=tree, video_id=vid, task_type="Object Recognition", seq=1, exemplars=[], pool_embeddings=np.zeros((0, 4), dtype=np.float32), used_node_ids=set(), max_retries=3, similarity_threshold=0.85, rng=random.Random(42), session_id="test", ) assert result is not None assert result.question_id == f"gen-{vid}-001" assert result.task_type == "Object Recognition" @staticmethod async def test_all_retries_exhausted_returns_none(): """VLM 始终返回无效 JSON,耗尽重试后返回 None。""" vlm = AsyncMock() vlm.chat_with_images.return_value = MagicMock(content="invalid") embed_fn = lambda t: np.zeros((1, 4), dtype=np.float32) tree, vid = _load_test_tree() result = await generate_one( vlm=vlm, embed_fn=embed_fn, tree=tree, video_id=vid, task_type="Object Recognition", seq=1, exemplars=[], pool_embeddings=np.zeros((0, 4), dtype=np.float32), used_node_ids=set(), max_retries=2, similarity_threshold=0.85, rng=random.Random(42), session_id="test", ) assert result is None ``` - [ ] **Step 2: 运行测试确认失败** ```bash conda activate Video-Tree-TRM & pytest tests/unit/test_synthesizer.py::TestIsDuplicate -v conda activate Video-Tree-TRM & pytest tests/unit/test_synthesizer.py::TestGenerateOne -v ``` - [ ] **Step 3: 实现 is_duplicate + generate_one** `is_duplicate(question_text, pool_embeddings, embed_fn, threshold) -> bool`: - `embed_fn(question_text)` → `[1, D]`,squeeze 为 `[D]` - `pool_embeddings @ query` 余弦相似度(pool 和 query 都已 L2 归一化) - `max(similarities) >= threshold` → True `generate_one(vlm, embed_fn, tree, video_id, task_type, seq, *, ...)` → `GeneratedQuestion | None`: - 循环最多 `max_retries` 次: 1. `sample_anchor(tree, task_type, used_node_ids, rng)` → anchor 2. `build_generation_prompt(task_type, anchor, exemplars)` → messages, images 3. `await vlm.chat_with_images(messages, images, session_id=session_id)` → response 4. `parse_vlm_response(response.content, video_id, task_type, seq)` → parsed_dict(含四选一 schema 校验) 5. 用 anchor.node_id 补齐 `source_nodes`,`difficulty="medium"` 6. 构造并返回 `GeneratedQuestion`(**不在此处做去重**——去重在调用方的单线程汇总点原子执行) - 全部重试失败(parse 异常)→ return None **并发去重安全**:`generate_one` 只负责生成候选题。调用方(tools/ CLI)在收到候选后,在单线程汇总点(async for + await)原子执行:① `is_duplicate` 检查当前题型的 embedding 池 → ② 通过则添加 embedding + 写 JSON + 更新 progress → ③ 不通过则丢弃并重试。embedding 池按 `dict[str, np.ndarray]`(key=task_type)维护,确保只在同题型内去重。 - [ ] **Step 4: 运行测试确认通过** ```bash conda activate Video-Tree-TRM & pytest tests/unit/test_synthesizer.py -v ``` - [ ] **Step 5: 提交** ```bash git add app/question_gen/synthesizer.py tests/unit/test_synthesizer.py git commit -m "feat(question_gen): is_duplicate + generate_one — 去重与单题生成编排" ``` --- ### Task 5: factory.py — build_inference_deps **Files:** - Create: `app/harness/factory.py` - Create: `tests/unit/test_factory.py` - [ ] **Step 1: 写失败测试** ```python # tests/unit/test_factory.py import random from pathlib import Path from unittest.mock import MagicMock, AsyncMock import numpy as np import pytest from app.harness.factory import build_inference_deps, InferenceDeps class TestBuildInferenceDeps: def test_returns_inference_deps(self, tmp_path): """用 fake adapters 验证返回类型和字段非 None。""" # 准备一棵最小树 import json vid_dir = tmp_path / "videos" / "test_vid" vid_dir.mkdir(parents=True) frames_dir = vid_dir / "frames" frames_dir.mkdir() minimal_tree = { "metadata": {"source_path": "test", "modality": "video"}, "roots": [{ "id": "L1_000", "card": { "scene_summary": "s", "main_setting": "s", "key_entities": [], "main_actions": [], "topic_keywords": [], "visible_text": [], "temporal_flow": "s", }, "time_range": [0, 10], "children": [], }], } (vid_dir / "tree.json").write_text(json.dumps(minimal_tree)) # 准备 prompts prompts_dir = tmp_path / "prompts" prompts_dir.mkdir() (prompts_dir / "system.md").write_text("You are a search agent.") fake_llm = AsyncMock() fake_vlm = AsyncMock() fake_embed = MagicMock() fake_embed.dim = 4 fake_embed.embed = lambda t: np.zeros((1, 4), dtype=np.float32) deps = build_inference_deps( store_dir=tmp_path, video_id="test_vid", prompts_dir=prompts_dir, skills_dir=None, skill_mode="none", embed_provider=fake_embed, llm=fake_llm, vlm=fake_vlm, ocr=None, verify_vision=False, anchor=False, assemble_mode="ids", ) assert isinstance(deps, InferenceDeps) assert deps.llm is fake_llm assert callable(deps.tool_dispatch_fn) assert callable(deps.prompt_builder) # 验证 prompt_builder 真正可调用(wiring 正确) from core.types import GeneratedQuestion fake_q = GeneratedQuestion( question_id="q1", video_id="test_vid", task_type="Object Recognition", question="What?", options=("A. X", "B. Y", "C. Z", "D. W"), answer="A", source_nodes=(), difficulty="medium", ) system, user = deps.prompt_builder(fake_q) assert isinstance(system, str) and len(system) > 0 assert isinstance(user, str) and "What?" in user def test_missing_tree_raises(self, tmp_path): prompts_dir = tmp_path / "prompts" prompts_dir.mkdir() (prompts_dir / "system.md").write_text("x") vid_dir = tmp_path / "videos" / "nonexist" vid_dir.mkdir(parents=True) with pytest.raises(FileNotFoundError): build_inference_deps( store_dir=tmp_path, video_id="nonexist", prompts_dir=prompts_dir, skills_dir=None, skill_mode="none", embed_provider=MagicMock(), llm=AsyncMock(), vlm=AsyncMock(), ocr=None, verify_vision=False, anchor=False, assemble_mode="ids", ) ``` - [ ] **Step 2: 运行测试确认失败** ```bash conda activate Video-Tree-TRM & pytest tests/unit/test_factory.py -v ``` 预期:ImportError - [ ] **Step 3: 实现 factory.py** 创建 `app/harness/factory.py`: ```python """推理依赖组装 — 给定 store + config 构建可工作的推理依赖集。 factory 只做组装,不持有状态。adapter 实例由调用方创建并传入。 消费者:tools/generate_questions.py(校准)、未来 main.py、Runner。 """ from __future__ import annotations from dataclasses import dataclass from functools import partial from pathlib import Path from typing import TYPE_CHECKING, Any from app.search.prompt import PromptManager from app.search.skills import SkillRegistry, discover_skills from app.search.tools import SearchToolDispatcher from app.tree.environment import TreeEnvironment from app.tree.index import TreeIndex if TYPE_CHECKING: from collections.abc import Callable from app.ports import EmbeddingProvider, OCRProvider from core.protocols import LLMProvider, VLMProvider from core.types import GeneratedQuestion @dataclass(frozen=True) class InferenceDeps: """跑一次推理所需的全套依赖(不含 HarnessLog)。 属性: llm: LLM 端口实例。 tool_dispatch_fn: SearchToolDispatcher.dispatch 的绑定方法。 prompt_builder: (GeneratedQuestion) -> (system_prompt, user_prompt)。 """ llm: LLMProvider tool_dispatch_fn: Callable[..., Any] prompt_builder: Callable[[GeneratedQuestion], tuple[str, str]] 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: """组装单个视频的推理依赖。 参数: store_dir: store 根目录(含 videos/{video_id}/tree.json)。 video_id: 视频标识。 prompts_dir: prompt 文件目录(含 system.md)。 skills_dir: skill 文件目录(None 不启用)。 skill_mode: "auto" / "manual" / "none"。 embed_provider: 文本嵌入端口。 llm: LLM 端口。 vlm: VLM 端口。 ocr: OCR 端口(None 不启用)。 verify_vision: observe_frame 是否执行验证轮。 anchor: view_node 是否启用行号锚模式。 assemble_mode: 锚模式装配形态。 返回: InferenceDeps 实例。 异常: FileNotFoundError: tree.json 不存在。 """ # Phase 1: 加载树 tree_path = store_dir / "videos" / video_id / "tree.json" if not tree_path.exists(): raise FileNotFoundError(f"树文件不存在: {tree_path}") tree = TreeIndex.load_json(str(tree_path)) frames_dir = store_dir / "videos" / video_id / "frames" env = TreeEnvironment(tree, frames_dir if frames_dir.exists() else None) # Phase 2: Skills skills: SkillRegistry | None = None always_skills_text = "" task_skill_map: dict[str, str] = {} catalog_text = "" if skills_dir and skills_dir.exists(): always_skills_text, task_skill_map, catalog_text, skills = discover_skills(skills_dir) # Phase 3: SearchToolDispatcher dispatcher = SearchToolDispatcher( env=env, tool_llm=llm, vlm=vlm, ocr=ocr, prompts_dir=prompts_dir, skills=skills, embed_fn=embed_provider.embed, verify_vision=verify_vision, anchor=anchor, assemble_mode=assemble_mode, ) # Phase 4: PromptManager → prompt_builder 偏函数 pm = PromptManager(prompts_dir) l1_ids = [r.id for r in tree.roots] def _prompt_builder( qa: GeneratedQuestion, _pm: PromptManager = pm, _skill_mode: str = skill_mode, _always: str = always_skills_text, _tsm: dict = task_skill_map, _cat: str = catalog_text, _l1_ids: list = l1_ids, ) -> tuple[str, str]: system = _pm.build_inference_prompt( _skill_mode, qa.task_type, _always, _tsm, _cat, ) user = _pm.format_user_prompt( qa.question, list(qa.options), _l1_ids, qa.task_type, ) return system, user return InferenceDeps( llm=llm, tool_dispatch_fn=dispatcher.dispatch, prompt_builder=_prompt_builder, ) ``` - [ ] **Step 4: 运行测试确认通过** ```bash conda activate Video-Tree-TRM & pytest tests/unit/test_factory.py -v ``` - [ ] **Step 5: 提交** ```bash git add app/harness/factory.py tests/unit/test_factory.py git commit -m "feat(harness): factory.py — 推理依赖组装,可复用于 calibrate + main.py" ``` --- ### Task 6: tools/generate_questions.py — generate 子命令 **Files:** - Create: `tools/generate_questions.py` - Modify: `tests/unit/test_generate_questions.py`(新建) - [ ] **Step 1: 写失败测试** ```python # tests/unit/test_generate_questions.py import json import sys from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import numpy as np import pytest class TestGenerateSmoke: def test_generate_writes_json(self, tmp_path): """mock VLM + 1 棵真实树 + per_type=1,验证 JSON 输出格式。""" import shutil from unittest.mock import AsyncMock, MagicMock, patch # 复制一棵真实树到 tmp src = Path("store/videos") / sorted(Path("store/videos").iterdir())[0].name dst = tmp_path / "videos" / src.name shutil.copytree(src, dst) # 准备 benchmark(至少 1 道题做 exemplar) bench_dir = tmp_path / "questions" / "benchmarks" bench_dir.mkdir(parents=True) bench_file = bench_dir / f"{src.name}.json" bench_file.write_text(json.dumps([{ "question_id": "ex-1", "task_type": "Object Recognition", "question": "What?", "options": ["A. X", "B. Y", "C. Z", "D. W"], "answer": "A", }])) output_dir = tmp_path / "output" output_dir.mkdir() # import CLI 模块的内部函数 sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from tools.generate_questions import _load_or_init_progress, _select_exemplars # 验证 progress 初始化 progress = _load_or_init_progress(output_dir) assert progress["completed"] == {} # 验证 exemplar 选择 from app.question_gen.loader import load_benchmark bench_qs = load_benchmark(bench_dir) exemplars = _select_exemplars(bench_qs, "Object Recognition", 3, random.Random(42)) assert len(exemplars) >= 1 assert all(e.task_type == "Object Recognition" for e in exemplars) class TestProgressResume: def test_skips_completed_and_rebuilds_pool(self, tmp_path): """progress.json 中已完成的题应被跳过,embedding 池应从已有 JSON 恢复。""" output_dir = tmp_path / "output" output_dir.mkdir() # 写一个已完成的 JSON (output_dir / "test_vid.json").write_text(json.dumps([{ "question_id": "gen-test_vid-001", "task_type": "Object Recognition", "question": "Existing question?", "options": ["A. X", "B. Y", "C. Z", "D. W"], "answer": "A", "source_nodes": ["L3_001"], "difficulty": "medium", }])) progress = { "completed": {"Object Recognition": ["gen-test_vid-001"]}, "output_dir": str(output_dir), } (output_dir / "progress.json").write_text(json.dumps(progress)) from tools.generate_questions import _load_or_init_progress loaded = _load_or_init_progress(output_dir) assert "gen-test_vid-001" in loaded["completed"]["Object Recognition"] ``` - [ ] **Step 2: 运行测试确认失败** ```bash conda activate Video-Tree-TRM & pytest tests/unit/test_generate_questions.py -v ``` - [ ] **Step 3: 实现 tools/generate_questions.py — generate 子命令** 创建 `tools/generate_questions.py`,核心结构: ```python #!/usr/bin/env python3 """赛题生成工具:generate + calibrate。 用法: conda activate Video-Tree-TRM python tools/generate_questions.py generate --store-dir store ... python tools/generate_questions.py calibrate --generated-dir ... --benchmark-dir ... app/core/adapters 不 import 此脚本。 """ from __future__ import annotations import argparse import asyncio import json import sys from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(PROJECT_ROOT)) from dotenv import load_dotenv from loguru import logger load_dotenv(PROJECT_ROOT / ".env") # generate 子命令: # 1. 加载 video_id 列表 + benchmark exemplars + 初始化 embedding 池 # 2. 断点续跑:读 progress.json + 恢复已生成题 embedding + used_node_ids # 3. 实例化 GovernedVLMClient + EmbeddingProvider(从 .env 读配置) # 4. 对 12 题型 × per_type,asyncio.Semaphore 并发调 generate_one # 5. 单线程汇总:检查去重 → 加入 pool → 写 JSON → 更新 progress # 6. 全部完成删除 progress.json ``` 实现要点: - `_load_or_init_progress(output_dir)` / `_save_progress(output_dir, progress)` 断点续跑 - `_rebuild_embedding_pool(output_dir, embed_fn, benchmark_questions)` 续跑时恢复 embedding - `_build_vlm_client()` / `_build_embed_provider()` 从 .env 实例化 adapters - `_select_exemplars(benchmark, task_type, n, rng)` 跨视频采样 few-shot - `async def _run_generate(args)` 主流程 - 并发模型:Semaphore 限流 VLM 调用,但去重+写入在主协程中顺序执行 - [ ] **Step 4: 运行测试 + lint** ```bash conda activate Video-Tree-TRM & pytest tests/unit/test_generate_questions.py -v conda activate Video-Tree-TRM & ruff check tools/generate_questions.py --fix ``` - [ ] **Step 5: 提交** ```bash git add tools/generate_questions.py tests/unit/test_generate_questions.py git commit -m "feat(tools): generate_questions.py generate 子命令 — VLM 出题 + 去重 + 断点续跑" ``` --- ### Task 7: tools/generate_questions.py — calibrate 子命令 **Files:** - Modify: `tools/generate_questions.py` - Modify: `tests/unit/test_generate_questions.py` - [ ] **Step 1: 写失败测试** ```python from scipy.stats import fisher_exact class TestCalibrateJudgment: def test_pass_when_delta_small(self): """差异小于 tolerance → PASS。""" from tools.generate_questions import _judge_task_type verdict = _judge_task_type( bench_correct=60, bench_total=100, gen_correct=12, gen_total=20, tolerance=0.10, alpha=0.05, ) assert verdict == "PASS" def test_fail_when_delta_large_and_significant(self): """差异大且统计显著 → FAIL。""" from tools.generate_questions import _judge_task_type verdict = _judge_task_type( bench_correct=144, bench_total=240, gen_correct=6, gen_total=20, tolerance=0.10, alpha=0.05, ) assert verdict == "FAIL" def test_warn_when_delta_large_but_not_significant(self): """差异大但样本不足(p > alpha)→ WARN。""" from tools.generate_questions import _judge_task_type verdict = _judge_task_type( bench_correct=2, bench_total=3, gen_correct=8, gen_total=20, tolerance=0.10, alpha=0.05, ) assert verdict == "WARN" class TestCalibrateIntegration: def test_baseline_params_must_be_paired(self): """--baseline-db 和 --baseline-run-id 必须成对出现。""" from tools.generate_questions import _validate_calibrate_args import pytest with pytest.raises(ValueError, match="成对"): _validate_calibrate_args(baseline_db="some.db", baseline_run_id=None) def test_has_fail_returns_exit_code_1(self): """存在 FAIL 判定时,_calibrate_exit_code 返回 1。""" from tools.generate_questions import _calibrate_exit_code verdicts = {"Object Recognition": "PASS", "Action Reasoning": "FAIL"} assert _calibrate_exit_code(verdicts) == 1 def test_all_pass_or_warn_returns_exit_code_0(self): from tools.generate_questions import _calibrate_exit_code verdicts = {"Object Recognition": "PASS", "Spatial Perception": "WARN"} assert _calibrate_exit_code(verdicts) == 0 ``` - [ ] **Step 2: 运行测试确认失败** ```bash conda activate Video-Tree-TRM & pytest tests/unit/test_generate_questions.py::TestCalibrateJudgment -v ``` - [ ] **Step 3: 实现 calibrate 子命令** 在 `tools/generate_questions.py` 中追加: `_judge_task_type(bench_correct, bench_total, gen_correct, gen_total, tolerance, alpha) -> str`: - `delta = abs(gen_correct/gen_total - bench_correct/bench_total)` - `delta <= tolerance` → "PASS" - Fisher exact test p-value:`table = [[bench_correct, bench_total-bench_correct], [gen_correct, gen_total-gen_correct]]` - `p < alpha and delta > tolerance` → "FAIL" - else → "WARN" `async def _run_calibrate(args)` 主流程: 1. `load_benchmark` 加载两组题 2. benchmark 基线:有 `--baseline-db` 则从 DB 读,否则按 video_id 分组 → `build_inference_deps` → `run_inference` 3. 生成题同理按 video_id 分组 → 分组推理 4. 汇总 per_task_type accuracy → `_judge_task_type` 逐题型判定 5. 输出对比表 6. 有 FAIL → `sys.exit(1)` - [ ] **Step 4: 运行测试确认通过** ```bash conda activate Video-Tree-TRM & pytest tests/unit/test_generate_questions.py -v ``` - [ ] **Step 5: 提交** ```bash git add tools/generate_questions.py tests/unit/test_generate_questions.py git commit -m "feat(tools): generate_questions.py calibrate 子命令 — Fisher exact test 校准" ``` --- ### Task 8: __init__.py 更新 + lint + 全量测试 **Files:** - Modify: `app/question_gen/__init__.py` - [ ] **Step 1: 写失败测试** ```python # 在 tests/unit/test_synthesizer.py 中追加 def test_public_api_importable(): """synthesizer 的公共 API 必须可从 app.question_gen 直接 import。""" from app.question_gen import generate_one, AnchorContext, TASK_TYPE_LEVEL_MAP, sample_anchor assert callable(generate_one) assert callable(sample_anchor) ``` 运行确认失败(当前 __init__.py 不 export 这些): ```bash conda activate Video-Tree-TRM & pytest tests/unit/test_synthesizer.py::test_public_api_importable -v ``` - [ ] **Step 2: 更新 __init__.py re-export** ```python """出题模块 — benchmark 加载、分层采样与赛题合成。""" from app.question_gen.loader import load_benchmark, stratified_sample from app.question_gen.synthesizer import ( TASK_TYPE_LEVEL_MAP, AnchorContext, generate_one, sample_anchor, ) __all__ = [ "load_benchmark", "stratified_sample", "TASK_TYPE_LEVEL_MAP", "AnchorContext", "generate_one", "sample_anchor", ] ``` - [ ] **Step 2: 全量 lint** ```bash conda activate Video-Tree-TRM & ruff check app/question_gen/ app/harness/factory.py tools/generate_questions.py --fix conda activate Video-Tree-TRM & ruff format app/question_gen/ app/harness/factory.py tools/generate_questions.py ``` - [ ] **Step 3: 全量测试** ```bash conda activate Video-Tree-TRM & pytest tests/ -v --tb=short ``` 预期:全部 PASS - [ ] **Step 4: 提交** ```bash git add app/question_gen/__init__.py git commit -m "refactor(question_gen): __init__.py 追加 synthesizer re-export" ``` --- ## Self-Review 核对 **范围说明**:设计 §4.4 要求 `Runner._make_tool_dispatch_fn` / `_make_prompt_builder` 委托 factory,本计划不包含该改造——Runner 改造随 `main.py` 一起实施更合理。factory.py 已就绪可复用。 | 设计文档章节 | 对应 Task | |-------------|-----------| | §2 模块结构 | Task 1-5 (synthesizer) + Task 5 (factory) + Task 6-7 (CLI) | | §3.1 题型映射 | Task 1 | | §3.2 AnchorContext | Task 1 | | §3.3 函数签名 | Task 2 (sample_anchor) + Task 3 (prompt/parse) + Task 4 (dedup/generate) | | §3.4 exemplar 选择 | Task 6 (_select_exemplars) | | §3.6 去重 + 并发安全 | Task 4 (is_duplicate) + Task 6 (单线程汇总) | | §4 factory.py | Task 5 | | §5 CLI 设计 | Task 6 (generate) + Task 7 (calibrate) | | §6 Fisher 校准 | Task 7 (_judge_task_type) | | §7 断点续跑 | Task 6 (progress + embedding 恢复) | | §8 输出格式 | Task 6 (JSON 写入) | | §9 前置修复 | Task 0 | | §10 测试策略 | Task 1-7 各含测试 |