# main.py 推理入口 + 初始 Prompt 集 实现计划 > **For agentic workers:** REQUIRED SUB-SKILL: Use subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 实现 `main.py` CLI 入口(infer 模式),准备初始 prompt/skill 集,完成 900 道题推理基线。 **Architecture:** Clean Architecture Composition Root 模式。main.py 构建全套 adapters 和 InferenceDepsRouter,注入 Runner。Router 按 video_id 懒加载 per-video InferenceDeps,在 prompt_builder 调用时注册 question_id→video_id 映射,dispatch 通过 context["session_id"] 路由。 **Tech Stack:** Python 3.11, asyncio, argparse, pydantic-settings, loguru **设计文档:** `research-wiki/designs/2026-07-09-main-inference-entry-design.md` --- ## File Structure | 操作 | 文件 | 职责 | |------|------|------| | **Task 0: subtitle 迁入 Card** | | | | Modify | `app/tree/index.py:73-194` | L3Card/L2Card 加 subtitle 字段;L3Node 去 subtitle 字段 | | Modify | `app/tree/index.py:267-390` | to_dict/from_dict 序列化适配 | | Modify | `app/tree/subtitle.py:239-308` | Voronoi 写入 Card.subtitle 而非 Node.subtitle | | Modify | `app/tree/environment.py:448-489` | _node_full_text/_node_anchored_text 简化 | | Modify | `app/tree/environment.py:367-381` | get_subtitle 读 Card.subtitle | | Modify | `app/tree/video_builder.py:475-486` | Phase 7 保留但改写 Card.subtitle | | Modify | `app/tree/verify.py` | `l3.subtitle` → `l3.card.subtitle` | | Modify | `app/question_gen/synthesizer.py` | `l3.subtitle` / `l2.subtitle` → `card.subtitle` | | Create | `tools/migrate_subtitle_to_card.py` | 迁移脚本:300 棵树 subtitle 从 Node 移入 Card | | Modify | 9 个测试文件 | Card 构造加 subtitle 参数 | | **Task 1-8: 原有任务** | | | | Create | `app/harness/deps_router.py` | 按 video_id 懒加载 InferenceDeps 并路由 dispatch/prompt_builder | | Modify | `app/ports.py` (文件末尾追加) | 新增 4 个 Protocol | | Modify | `app/harness/runner.py:454-469` | __init__ 增加 2 个 factory 参数 + fail-fast 校验 | | Modify | `app/harness/runner.py:2064-2082` | _make_* 方法优先用注入值 | | Create | `main.py` | Composition Root | | Move | `store/prompts/*.md` → `store/prompts/v1/` | 版本化目录重组 | | Create | `store/skills/v1/` (13 files) | 从 TRM4 v1 精简 + 注入 TRM5 card 字段 | | Modify | `config/default.yaml:29,31` | concurrency=24, max_steps=40 | | Test | `tests/unit/test_deps_router.py` | Router 单元测试 | | Test | `tests/unit/test_ports_factory.py` | Protocol 结构测试 | --- ### Task 0: subtitle 迁入 Card + 建树管线修正 + 300 棵树迁移 **Files:** - Modify: `app/tree/index.py` (L3Card, L2Card, L3Node, to_dict, from_dict) - Modify: `app/tree/subtitle.py:239-308` (assign_subtitles_voronoi) - Modify: `app/tree/environment.py:367-381,448-489` (_node_full_text, get_subtitle) - Modify: `app/tree/video_builder.py:475-486,902-996` (builder pipeline) - Create: `tools/migrate_subtitle_to_card.py` - Modify: 9 个测试文件 (Card 构造适配) **设计决策:** - L3Card/L2Card 加 `subtitle: str = ""` 字段(放在字段列表末尾,`_collect_card_strings` 自动收集) - L1Card 不加 subtitle(用户确认) - L3Node 移除 `subtitle: str | None` 字段(数据迁入 Card) - Card 保持 `frozen=True`,建树时在 Card 创建前计算好 subtitle 传入构造函数;迁移脚本用 `dataclasses.replace()` 创建新 Card - 建树管线中 L2/L3 直接在 Card 构造时注入字幕,移除 Phase 7 Voronoi 后处理 - L2 subtitle = `extract_subtitle_for_range(srt_entries, l2_time_range)` 在 `_build_l2_video_async` 中计算 - L3 subtitle 仍用 Voronoi 逻辑(精确分配帧级字幕),但写入 Card 而非 Node - [ ] **Step 1: 修改 Card dataclass** 在 `app/tree/index.py` 中: **L3Card** (第 73-92 行) — 末尾加 `subtitle: str = ""`: ```python @dataclass(frozen=True) class L3Card: """L3 帧级语义卡片(不可变)。""" frame_summary: str visible_entities: list[str] ongoing_actions: list[str] visible_text: list[str] spatial_layout: str visual_attributes: dict[str, Any] subtitle: str = "" ``` **L2Card** (第 96-117 行) — 末尾加 `subtitle: str = ""`: ```python @dataclass(frozen=True) class L2Card: """L2 事件级语义卡片(不可变)。""" event_description: str entities: list[str] actions: list[str] action_subjects: list[str] visible_text: list[str] spatial_relations: str state_changes: str | None subtitle: str = "" ``` **L3Node** (第 176-194 行) — 移除 `subtitle` 字段: ```python @dataclass class L3Node: """L3 帧级语义节点(叶层)。""" id: str card: L3Card embedding: np.ndarray | None = None timestamp: float | None = None frame_path: str | None = None # subtitle 已迁入 L3Card,此处不再保留 ``` - [ ] **Step 2: 修改序列化/反序列化** **to_dict** — L3 `l3_to_dict` (第 267-284 行): subtitle 从 card 输出,移除节点级 subtitle: ```python def l3_to_dict(n: L3Node) -> dict[str, Any]: d: dict[str, Any] = { "id": n.id, "card": { "frame_summary": n.card.frame_summary, "visible_entities": n.card.visible_entities, "ongoing_actions": n.card.ongoing_actions, "visible_text": n.card.visible_text, "spatial_layout": n.card.spatial_layout, "visual_attributes": n.card.visual_attributes, "subtitle": n.card.subtitle, }, "timestamp": n.timestamp, "frame_path": n.frame_path, } if include_embedding: d["embedding"] = _embed_to_str(n.embedding) return d ``` **to_dict** — L2 `l2_to_dict` (第 286-303 行): card 字典加 subtitle: ```python def l2_to_dict(n: L2Node) -> dict[str, Any]: d: dict[str, Any] = { "id": n.id, "card": { "event_description": n.card.event_description, "entities": n.card.entities, "actions": n.card.actions, "action_subjects": n.card.action_subjects, "visible_text": n.card.visible_text, "spatial_relations": n.card.spatial_relations, "state_changes": n.card.state_changes, "subtitle": n.card.subtitle, }, "time_range": list(n.time_range) if n.time_range else None, "children": [l3_to_dict(c) for c in n.children], } if include_embedding: d["embedding"] = _embed_to_str(n.embedding) return d ``` **from_dict** — L3 反序列化 (第 337-354 行): subtitle 从 card 读取,兼容旧格式从节点级读取: ```python l3_card = L3Card( frame_summary=l3d["card"]["frame_summary"], visible_entities=l3d["card"]["visible_entities"], ongoing_actions=l3d["card"]["ongoing_actions"], visible_text=l3d["card"]["visible_text"], spatial_layout=l3d["card"]["spatial_layout"], visual_attributes=l3d["card"]["visual_attributes"], subtitle=l3d["card"].get("subtitle", "") or l3d.get("subtitle", "") or "", ) l3_nodes.append( L3Node( id=l3d["id"], card=l3_card, embedding=_embed_from_str(l3d.get("embedding")), timestamp=l3d.get("timestamp"), frame_path=l3d.get("frame_path"), ) ) ``` **from_dict** — L2 反序列化 (第 355-363 行): 加 subtitle: ```python l2_card = L2Card( event_description=l2d["card"]["event_description"], entities=l2d["card"]["entities"], actions=l2d["card"]["actions"], action_subjects=l2d["card"]["action_subjects"], visible_text=l2d["card"]["visible_text"], spatial_relations=l2d["card"]["spatial_relations"], state_changes=l2d["card"]["state_changes"], subtitle=l2d["card"].get("subtitle", ""), ) ``` - [ ] **Step 3: 修改 environment.py** **`get_subtitle`** (第 367-381 行) — 读 Card.subtitle,L1/L2/L3 均支持: ```python def get_subtitle(self, node_id: str) -> str: """返回节点字幕文本。L2/L3 从 Card 读取,L1 返回空串。""" node = self._id_to_node.get(node_id) if node is None: return "" if isinstance(node, (L2Node, L3Node)): return node.card.subtitle or "" return "" ``` **`_collect_card_strings`** (第 67-78 行) — 新增 `skip_fields` 参数,排除 subtitle(subtitle 需单独标签/锚标处理): ```python def _collect_card_strings(node: AnyNode, *, skip_fields: frozenset[str] = frozenset()) -> list[str]: """从节点 card 中递归收集所有非空字符串字段,可排除指定字段。""" result: list[str] = [] _collect_from_obj(node.card, result, skip_fields=skip_fields) return result ``` `_collect_from_obj` (第 81-101 行) — 在 dataclass 分支中跳过 `skip_fields`: ```python def _collect_from_obj(obj: object, out: list[str], *, skip_fields: frozenset[str] = frozenset()) -> None: if isinstance(obj, str): stripped = obj.strip() if stripped: out.append(stripped) elif isinstance(obj, dict): for v in obj.values(): _collect_from_obj(v, out, skip_fields=skip_fields) elif isinstance(obj, (list, tuple)): for item in obj: _collect_from_obj(item, out, skip_fields=skip_fields) elif hasattr(obj, "__dataclass_fields__"): for field_name in obj.__dataclass_fields__: if field_name in skip_fields: continue _collect_from_obj(getattr(obj, field_name), out, skip_fields=skip_fields) ``` **`_node_full_text`** (第 448-461 行) — 保持"字幕:"标签,改为读 `card.subtitle`: ```python _SUBTITLE_SKIP = frozenset({"subtitle"}) def _node_full_text(self, node: AnyNode) -> str: """获取节点完整文本(card 非 subtitle 字段 + 带标签的字幕)。""" card_strings = _collect_card_strings(node, skip_fields=_SUBTITLE_SKIP) text = "\n".join(card_strings) if isinstance(node, (L2Node, L3Node)) and node.card.subtitle: text += f"\n字幕: {node.card.subtitle}" return text ``` **`_node_anchored_text`** (第 463-489 行) — 保持 [cN]/[sN] 双锚语义,改为读 `card.subtitle`: ```python def _node_anchored_text(self, node: AnyNode) -> str: """获取带行号锚的节点文本。card 字段 [cN],字幕 [sN]。""" card_strings = _collect_card_strings(node, skip_fields=_SUBTITLE_SKIP) card_lines: list[str] = [] for s in card_strings: card_lines.extend(ln for ln in s.splitlines() if ln.strip()) sub_lines: list[str] = [] if isinstance(node, (L2Node, L3Node)) and node.card.subtitle: sub_lines = [ln for ln in node.card.subtitle.splitlines() if ln.strip()] anchored: list[str] = [] for i, line in enumerate(card_lines, 1): anchored.append(f"[c{i}] {line}") for i, line in enumerate(sub_lines, 1): anchored.append(f"[s{i}] {line}") return "\n".join(anchored) ``` - [ ] **Step 4: 修改 subtitle.py — Voronoi 写入 Card** `assign_subtitles_voronoi` (第 239-308 行) — 用 `dataclasses.replace()` 写入 Card.subtitle: 在函数顶部加 `import dataclasses`,然后将第 302 行: ```python # 原: l3.subtitle = subtitle_text if subtitle_text else None # 改: l3.card = dataclasses.replace(l3.card, subtitle=subtitle_text or "") ``` 同时在函数末尾为每个 L2 聚合子节点字幕: ```python # L2 字幕聚合(在 L3 分配完成后) for l1 in index.roots: for l2 in l1.children: l2_sub_parts = [c.card.subtitle for c in l2.children if c.card.subtitle] if l2_sub_parts: l2.card = dataclasses.replace(l2.card, subtitle="\n".join(l2_sub_parts)) ``` - [ ] **Step 5: 修改 video_builder.py — 建树管线** Phase 7 保留 `assign_subtitles_voronoi`(它现在写 Card.subtitle + L2 聚合),不在 `_build_l2_video_async` 中注入字幕(避免双写)。仅更新注释: ```python # Phase 7: 字幕注入 Card(L3 Voronoi 分配 + L2 聚合) if srt_entries: assign_subtitles_voronoi(index, srt_entries) logger.info("字幕已注入 Card: L3 Voronoi + L2 聚合", n_entries=len(srt_entries)) ``` `_build_l2_video_async` 和 `_build_l3_video_async` 不做字幕注入改动。字幕注入统一由 Phase 7 `assign_subtitles_voronoi` 负责。 - [ ] **Step 5.5: 修改 verify.py 和 synthesizer.py 中的 subtitle 访问** 搜索并替换所有 `l3.subtitle` → `l3.card.subtitle`,`l2.subtitle` → `l2.card.subtitle`(若存在): ```bash grep -rn "\.subtitle" app/tree/verify.py app/question_gen/synthesizer.py ``` 对每处命中做替换,例如: - `app/tree/verify.py`: `node.subtitle` → `node.card.subtitle`(仅 L3/L2 节点) - `app/question_gen/synthesizer.py`: `l3.subtitle` → `l3.card.subtitle` - [ ] **Step 6: 迁移脚本** ```python # tools/migrate_subtitle_to_card.py """迁移 300 棵树:L3 subtitle 从 Node 级移入 Card 级,L2 聚合子节点字幕。""" import dataclasses import json import sys from pathlib import Path from loguru import logger def migrate_tree(tree_path: Path) -> bool: """迁移单棵树,返回是否有变更。幂等:已迁移的树不会被修改。""" data = json.loads(tree_path.read_text(encoding="utf-8")) changed = False for l1 in data.get("roots", []): for l2 in l1.get("children", []): l2_sub_parts: list[str] = [] for l3 in l2.get("children", []): # L3: node 级 subtitle 迁入 card(幂等:已有 card subtitle 则跳过) card_sub = l3.get("card", {}).get("subtitle", "") node_sub = l3.get("subtitle") # None = 从未有字幕 if node_sub is not None and "subtitle" in l3: # 旧格式:有 node 级 subtitle final_sub = card_sub or (node_sub if node_sub else "") l3["card"]["subtitle"] = final_sub del l3["subtitle"] changed = True elif "subtitle" not in l3.get("card", {}): # 无任何 subtitle 数据 l3.setdefault("card", {})["subtitle"] = "" changed = True # 收集 L3 字幕用于 L2 聚合 effective_sub = l3.get("card", {}).get("subtitle", "") if effective_sub: l2_sub_parts.append(effective_sub) # L2: 聚合 L3 字幕(幂等:已有 card subtitle 则跳过) l2_card = l2.setdefault("card", {}) if "subtitle" not in l2_card: l2_card["subtitle"] = "\n".join(l2_sub_parts) if l2_sub_parts else "" changed = True if changed: tree_path.write_text( json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8", ) return changed def main() -> None: """迁移 store/videos/ 下所有 tree.json。""" videos_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("store/videos") tree_files = sorted(videos_dir.glob("*/tree.json")) logger.info("发现 {} 棵树待迁移", len(tree_files)) migrated = 0 for tf in tree_files: if migrate_tree(tf): migrated += 1 logger.info("迁移完成: {}/{} 棵树有变更", migrated, len(tree_files)) if __name__ == "__main__": main() ``` Run: `conda run -n Video-Tree-TRM python tools/migrate_subtitle_to_card.py store/videos` 验证: ```bash python3 -c " import json; d=json.loads(open('store/videos/068rdc75mHM/tree.json').read()) l1=d['roots'][0]; l2=l1['children'][0]; l3=l2['children'][0] print('L3 card subtitle:', bool(l3['card'].get('subtitle'))) print('L3 node subtitle:', 'subtitle' in l3 and l3.get('subtitle') is not None) print('L2 card subtitle:', bool(l2['card'].get('subtitle'))) " ``` Expected: L3 card subtitle: True, L3 node subtitle: False, L2 card subtitle: True - [ ] **Step 7: 更新测试文件** 需要更新的 9 个文件中所有 `L3Card(...)` 和 `L2Card(...)` 构造调用。由于 `subtitle` 有默认值 `""`,大多数测试不需要改动(默认空串即可)。但 `L3Node(...)` 构造中如果传了 `subtitle=` 参数需要移除。 需要检查并修改的具体文件: ``` tests/integration/test_tree_build_e2e.py tests/unit/test_repair_detector.py tests/unit/test_repair_regenerator.py tests/unit/test_search_tools.py tests/unit/test_subtitle.py tests/unit/test_tree_environment.py tests/unit/test_tree_index.py tests/unit/test_verify.py tests/unit/test_video_builder.py ``` 对每个文件: 1. `L3Node(..., subtitle=xxx)` → 移除 `subtitle=` 参数,改为在 L3Card 构造时传入 `subtitle=xxx` 2. `L3Card(a, b, c, d, e, f)` 位置参数调用 → 不影响(subtitle 有默认值) 3. 确认 `L2Card(...)` 构造不受影响(subtitle 有默认值) - [ ] **Step 8: 运行全量测试** Run: `conda run -n Video-Tree-TRM pytest tests/ -x -q` Expected: ALL PASSED - [ ] **Step 9: Commit** ```bash git add app/tree/index.py app/tree/subtitle.py app/tree/environment.py app/tree/video_builder.py tools/migrate_subtitle_to_card.py tests/ git commit -m "refactor(tree): subtitle 迁入 L3Card/L2Card + 建树管线修正 + 300 棵树迁移 - L3Card/L2Card 新增 subtitle: str 字段 - L3Node 移除 subtitle 字段(数据迁入 Card) - assign_subtitles_voronoi 改写 Card.subtitle - _node_full_text 简化(Card 已含 subtitle) - 建树管线 L2 直接注入字幕到 Card - 迁移脚本处理 300 棵现有树" ``` --- ### Task 1: store/ 目录重组 **Files:** - Move: `store/prompts/*.md` → `store/prompts/v1/*.md` - Create: `store/skills/v1/` (empty, 后续 Task 填充) - [ ] **Step 1: 创建 v1 子目录并移动 prompt 文件** ```bash mkdir -p store/prompts/v1 git mv store/prompts/system.md store/prompts/v1/ git mv store/prompts/observe_frame_extract.md store/prompts/v1/ git mv store/prompts/observe_frame_verify.md store/prompts/v1/ git mv store/prompts/search_similar_extract.md store/prompts/v1/ git mv store/prompts/search_similar_verify.md store/prompts/v1/ git mv store/prompts/view_node_extract.md store/prompts/v1/ git mv store/prompts/view_node_verify.md store/prompts/v1/ git mv store/prompts/view_node_children_extract.md store/prompts/v1/ git mv store/prompts/view_node_children_verify.md store/prompts/v1/ ``` - [ ] **Step 2: 创建 skills/v1 目录** ```bash mkdir -p store/skills/v1 ``` - [ ] **Step 3: 修正 system.md — L1 去掉 subtitle 描述** 编辑 `store/prompts/v1/system.md`,在 L1 字段表中删除 `| subtitle | 完整字幕(较长) |` 行。在信任层级段落末尾将"三个层级都包含 visible_text 和 subtitle 字段"改为"三个层级都包含 visible_text 字段,L2 和 L3 额外包含 subtitle 字段"。 - [ ] **Step 4: 验证目录结构** Run: `ls store/prompts/v1/ && ls store/skills/v1/` Expected: 9 个 .md 文件在 prompts/v1/ 下,skills/v1/ 为空目录。 Run: `grep subtitle store/prompts/v1/system.md` Expected: 仅 L2 和 L3 部分出现 subtitle。 - [ ] **Step 4: Commit** ```bash git add store/prompts/ store/skills/ git commit -m "refactor(store): prompts 版本化目录重组 + skills/v1 骨架" ``` --- ### Task 2: store/skills/v1 — 13 个精简 + 注入 skill **Files:** - Create: `store/skills/v1/default-strategy.md` - Create: `store/skills/v1/action-reasoning.md` - Create: `store/skills/v1/action-recognition.md` - Create: `store/skills/v1/attribute-perception.md` - Create: `store/skills/v1/counting-problem.md` - Create: `store/skills/v1/information-synopsis.md` - Create: `store/skills/v1/object-reasoning.md` - Create: `store/skills/v1/object-recognition.md` - Create: `store/skills/v1/ocr-problems.md` - Create: `store/skills/v1/spatial-perception.md` - Create: `store/skills/v1/spatial-reasoning.md` - Create: `store/skills/v1/temporal-perception.md` - Create: `store/skills/v1/temporal-reasoning.md` **变换规则**(每个 skill 文件统一适用): | 操作 | 对象 | 说明 | |------|------|------| | 保留 | YAML frontmatter | name, description, task_type 原样保留 | | 保留 | `## 适用场景` 节 | 原样保留 | | 保留 | `## 搜索步骤` 的 Step 标题 | 如 `### Step 1: 事件定位` | | 精简 | Step 正文 | 保留第一句话意图描述,移除数据驱动统计(如 "75% 正确率")、精确转换条件、详细操作指令 | | 保留 | `## 输出格式` 节 | JSON schema (reflect/plan/action) 原样保留 | | 精简 | `## 自检信号` 节 | 最多保留 1 条最通用的自检信号 | | 精简 | `## 常见陷阱` 节 | 最多保留 2 条最通用的陷阱警告,移除特定失败模式 | | **新增** | `## 视频树字段索引` 节 | 插入 card 字段索引表(见下方) | **card 字段索引表**(全部 13 个 skill 共用,插入在 `## 搜索步骤` 之前): ```markdown ## 视频树字段索引 | 层级 | 字段 | 适用场景 | |------|------|---------| | L1 | scene_summary | 整体概况 | | L1 | key_entities | 查找人物/物体 | | L1 | main_actions | 主要动作 | | L1 | temporal_flow | 时间线概览 | | L1 | topic_keywords | 主题定位 | | L2 | event_description | 事件因果 | | L2 | entities / actions | 实体和动作细节 | | L2 | state_changes | 状态转变 | | L2 | spatial_relations | 空间关系变化 | | L3 | frame_summary | 精确视觉证据 | | L3 | visible_entities | 具体物体确认 | | L3 | ongoing_actions | 正在发生的动作 | | L3 | spatial_layout | 精确空间位置 | | L3 | visual_attributes | 光照、色调、机位 | | L2/L3 | subtitle | 字幕转写(L1 无此字段) | | 全层 | visible_text | 画面文字(OCR) | ``` **TRM4 v1 源文件路径:** `/home/iomgaa/Projects/Video-Tree-TRM4/store/skills/v1/` - [ ] **Step 1: 逐个读取 TRM4 v1 skill,按变换规则精简 + 注入,写入 store/skills/v1/** 对每个 skill 文件执行: 1. 读取 `/home/iomgaa/Projects/Video-Tree-TRM4/store/skills/v1/.md` 2. 应用上述变换规则 3. 写入 `store/skills/v1/.md` 以 `default-strategy.md` 为首个示例(最重要的通用策略)。 - [ ] **Step 2: 验证 13 个文件完整性** Run: `ls store/skills/v1/ | wc -l` Expected: 13 Run: `head -5 store/skills/v1/default-strategy.md` Expected: YAML frontmatter with `task_type: _default` Run: `grep "视频树字段索引" store/skills/v1/*.md | wc -l` Expected: 13(每个文件都有字段索引表) - [ ] **Step 3: Commit** ```bash git add store/skills/v1/ git commit -m "feat(store): skills/v1 初始集 — TRM4 精简 + TRM5 card 字段注入" ``` --- ### Task 3: app/ports.py — 新增 4 个 Protocol **Files:** - Modify: `app/ports.py:76` (文件末尾追加) - Test: `tests/unit/test_ports_factory.py` - [ ] **Step 1: 编写 Protocol 结构测试** ```python # tests/unit/test_ports_factory.py """ToolDispatchFactory / PromptBuilderFactory Protocol 结构验证。""" from __future__ import annotations from pathlib import Path from typing import Any import pytest from app.ports import ( PromptBuilderFactory, PromptBuilderFn, ToolDispatchFactory, ToolDispatchFn, ) class TestToolDispatchFnProtocol: """ToolDispatchFn 签名检查。""" def test_conforming_callable_passes_isinstance(self) -> None: async def dispatch( tool_name: str, args: dict[str, Any], *, context: dict[str, Any] ) -> str: return "" assert isinstance(dispatch, ToolDispatchFn) def test_wrong_return_type_noted(self) -> None: """仅验证签名存在;runtime_checkable 不检查返回类型。""" async def bad( tool_name: str, args: dict[str, Any], *, context: dict[str, Any] ) -> int: return 0 # runtime_checkable 仅检查方法存在,不验证类型注解 assert isinstance(bad, ToolDispatchFn) class TestToolDispatchFactoryProtocol: """ToolDispatchFactory 签名检查。""" def test_conforming_class_passes(self) -> None: class Factory: def __call__(self, *, skills_dir: Path | None = None) -> Any: return None assert isinstance(Factory(), ToolDispatchFactory) class TestPromptBuilderFnProtocol: """PromptBuilderFn 签名检查。""" def test_conforming_callable_passes(self) -> None: from core.types import GeneratedQuestion def builder(qa: GeneratedQuestion) -> tuple[str, str]: return ("", "") assert isinstance(builder, PromptBuilderFn) class TestPromptBuilderFactoryProtocol: """PromptBuilderFactory 签名检查。""" def test_conforming_class_passes(self) -> None: class Factory: def __call__( self, *, skills_dir: Path | None = None, prompts_dir: Path | None = None ) -> Any: return None assert isinstance(Factory(), PromptBuilderFactory) ``` - [ ] **Step 2: 运行测试确认失败** Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_ports_factory.py -v` Expected: ImportError(ToolDispatchFn 等尚未定义) - [ ] **Step 3: 在 app/ports.py 末尾追加 4 个 Protocol** 在 `app/ports.py` 文件末尾(第 77 行之后)追加: ```python @runtime_checkable class ToolDispatchFn(Protocol): """工具调度函数签名。 参数: tool_name: 工具名称。 args: 工具参数字典。 context: 上下文字典(包含 session_id)。 返回: 工具执行结果文本。 """ async def __call__( self, tool_name: str, args: dict[str, Any], *, context: dict[str, Any] ) -> str: ... @runtime_checkable class ToolDispatchFactory(Protocol): """per-version 工具调度工厂。 根据可选的 skills_dir 覆盖构建工具调度函数。 参数: skills_dir: 可选的 skills 版本目录覆盖。 返回: ToolDispatchFn 实例。 """ def __call__(self, *, skills_dir: Path | None = None) -> ToolDispatchFn: ... @runtime_checkable class PromptBuilderFn(Protocol): """Prompt 构建函数签名。 参数: qa: 生成的题目实例。 返回: (system_prompt, user_prompt) 二元组。 """ def __call__(self, qa: GeneratedQuestion) -> tuple[str, str]: ... @runtime_checkable class PromptBuilderFactory(Protocol): """per-version prompt 构建工厂。 参数: skills_dir: 可选的 skills 版本目录覆盖。 prompts_dir: 可选的 prompts 版本目录覆盖。 返回: PromptBuilderFn 实例。 """ def __call__( self, *, skills_dir: Path | None = None, prompts_dir: Path | None = None, ) -> PromptBuilderFn: ... ``` 同时在文件顶部 `if TYPE_CHECKING:` 块中确保 `Any` 已导入(已有 `from typing import ... Protocol`,需追加 `Any`)。 - [ ] **Step 4: 运行测试确认通过** Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_ports_factory.py -v` Expected: 5 tests PASSED - [ ] **Step 5: Commit** ```bash git add app/ports.py tests/unit/test_ports_factory.py git commit -m "feat(ports): 新增 ToolDispatchFactory/PromptBuilderFactory Protocol" ``` --- ### Task 4: app/harness/runner.py — factory 注入 **Files:** - Modify: `app/harness/runner.py:440-469` (__init__) - Modify: `app/harness/runner.py:2064-2082` (_make_* 方法) - Test: `tests/unit/test_harness_runner.py` (追加) - [ ] **Step 1: 编写 factory 注入测试** 在 `tests/unit/test_harness_runner.py` 末尾追加: ```python class TestRunnerFactoryInjection: """Runner factory 注入 fail-fast 校验。""" def test_infer_mode_missing_factory_raises(self, tmp_path: Path) -> None: """mode=infer 时缺少 factory 参数 → 立即 ValueError。""" from unittest.mock import AsyncMock from app.harness.config import RunConfig config = RunConfig( workspace_dir=tmp_path, store_dir=tmp_path, mode="infer", concurrency=1, max_steps=5, skill_mode="none", n_samples=0, questions="benchmarks/Video-MME", skills_version="v1", prompts_version="v1", epochs=1, diag_size=10, diag_correct_ratio=0.5, val_size=24, val_correct_ratio=0.5, edit_budget_start=5, edit_budget_end=2, batch_size=5, min_class_per_batch=2, eval_min_per_class=2, early_stop_patience=3, test_size=10, use_slow_momentum=False, gate_e_confirm=20.0, gate_e_provisional=3.0, gate_w_net_min=2, gate_delta_min=0.02, gate_lambda_dir=-0.642, gate_e_rollback=10.0, gate_block=8, gate_n_max=40, gate_p_low=0.05, gate_p_high=0.95, gate_probe_quota=0.2, gate_gamma_decay=0.9, gate_cooldown_steps=2, gate_guard_err=0.10, skill_update_mode="patch", appendix_consolidate_threshold=6, ) with pytest.raises(ValueError, match="tool_dispatch_factory"): Runner( config, llm=AsyncMock(), evolve_llm=AsyncMock(), vlm=AsyncMock(), telemetry=AsyncMock(), ) def test_diagnose_mode_allows_none_factory(self, tmp_path: Path) -> None: """mode=diagnose 不需要 factory(不走推理路径)→ 不报错。""" from unittest.mock import AsyncMock from app.harness.config import RunConfig ws = tmp_path / "ws" ws.mkdir() (ws / "manifest.json").write_text('{"name":"ws","created_at":"","store":"../store","current":{"videos":"v","questions":"q","skills":"s","prompts":"p"},"history":[]}') config = RunConfig( workspace_dir=ws, store_dir=tmp_path, mode="diagnose", run_id="test_run", concurrency=1, max_steps=5, skill_mode="none", n_samples=0, questions="benchmarks/Video-MME", skills_version="v1", prompts_version="v1", epochs=1, diag_size=10, diag_correct_ratio=0.5, val_size=24, val_correct_ratio=0.5, edit_budget_start=5, edit_budget_end=2, batch_size=5, min_class_per_batch=2, eval_min_per_class=2, early_stop_patience=3, test_size=10, use_slow_momentum=False, gate_e_confirm=20.0, gate_e_provisional=3.0, gate_w_net_min=2, gate_delta_min=0.02, gate_lambda_dir=-0.642, gate_e_rollback=10.0, gate_block=8, gate_n_max=40, gate_p_low=0.05, gate_p_high=0.95, gate_probe_quota=0.2, gate_gamma_decay=0.9, gate_cooldown_steps=2, gate_guard_err=0.10, skill_update_mode="patch", appendix_consolidate_threshold=6, ) # 不应抛异常 runner = Runner( config, llm=AsyncMock(), evolve_llm=AsyncMock(), vlm=AsyncMock(), telemetry=AsyncMock(), ) assert runner is not None ``` - [ ] **Step 2: 运行测试确认失败** Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_harness_runner.py::TestRunnerFactoryInjection -v` Expected: FAIL(Runner.__init__ 不接受 factory 参数 / 不做校验) - [ ] **Step 3: 修改 Runner.__init__ 和 _make_* 方法** 在 `app/harness/runner.py` 中: **3a.** 修改 `Runner.__init__`(第 454-469 行),增加 2 个可选参数 + fail-fast 校验: ```python def __init__( self, config: RunConfig, *, llm: LLMProvider, evolve_llm: LLMProvider, vlm: VLMProvider, telemetry: TelemetryRecorder, tool_dispatch_factory: Any | None = None, prompt_builder_factory: Any | None = None, ) -> None: # fail-fast 校验必须在 _ensure_workspace 之前,避免 workspace 报错掩盖 factory 缺失 if config.mode in {"infer", "eval", "train"}: if tool_dispatch_factory is None or prompt_builder_factory is None: raise ValueError( f"mode={config.mode!r} 需要 tool_dispatch_factory 和 " f"prompt_builder_factory(不可为 None)" ) self._config = config self._llm = llm self._evolve_llm = evolve_llm self._vlm = vlm self._telemetry = telemetry self._tool_dispatch_factory = tool_dispatch_factory self._prompt_builder_factory = prompt_builder_factory self._ensure_workspace() self._paths: ResolvedPaths = resolve_paths(config.workspace_dir) ``` **3b.** 修改 `_make_tool_dispatch_fn`(第 2064-2072 行): ```python def _make_tool_dispatch_fn(self, *, skills_dir: Path | None = None): """构造工具调度函数。优先用注入的 factory,fallback 为显式报错。""" if self._tool_dispatch_factory is not None: return self._tool_dispatch_factory(skills_dir=skills_dir) async def _noop_dispatch(tool_name: str, args: dict, *, context: dict) -> str: raise NotImplementedError( f"工具 {tool_name} 调度未配置(需由 main.py 注入 tool_dispatch_fn)" ) return _noop_dispatch ``` **3c.** 修改 `_make_prompt_builder`(第 2074-2082 行): ```python def _make_prompt_builder( self, *, skills_dir: Path | None = None, prompts_dir: Path | None = None ): """构造 prompt 构建函数。优先用注入的 factory,fallback 为显式报错。""" if self._prompt_builder_factory is not None: return self._prompt_builder_factory( skills_dir=skills_dir, prompts_dir=prompts_dir ) def _noop_builder(qa: GeneratedQuestion) -> tuple[str, str]: raise NotImplementedError("prompt_builder 未配置(需由 main.py 注入)") return _noop_builder ``` **3d.** 更新 docstring(第 440-452 行)增加两个新参数说明。 - [ ] **Step 4: 运行测试确认通过** Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_harness_runner.py -v` Expected: ALL tests PASSED(含新增的 2 个 + 原有 34 个) - [ ] **Step 5: Commit** ```bash git add app/harness/runner.py tests/unit/test_harness_runner.py git commit -m "feat(runner): 注入 tool_dispatch_factory/prompt_builder_factory + fail-fast" ``` --- ### Task 5: app/harness/deps_router.py — per-video 路由器 **Files:** - Create: `app/harness/deps_router.py` - Test: `tests/unit/test_deps_router.py` - [ ] **Step 1: 编写 Router 单元测试** ```python # tests/unit/test_deps_router.py """InferenceDepsRouter 单元测试。""" from __future__ import annotations from pathlib import Path from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest from app.harness.deps_router import InferenceDepsRouter @pytest.fixture() def mock_deps(): """构造 mock InferenceDeps。""" deps = MagicMock() deps.prompt_builder = lambda qa: (f"system_{qa.video_id}", f"user_{qa.question}") deps.tool_dispatch_fn = AsyncMock(return_value="tool_result") return deps @pytest.fixture() def router(mock_deps): """构造带 mock build_inference_deps 的 Router。""" r = InferenceDepsRouter( store_dir=Path("store"), embed_provider=MagicMock(), llm=MagicMock(), vlm=MagicMock(), ocr=None, default_prompts_dir=Path("store/prompts/v1"), default_skills_dir=Path("store/skills/v1"), skill_mode="auto", verify_vision=True, anchor=True, assemble_mode="default", ) # 替换 _build_deps 为 mock r._build_deps = MagicMock(return_value=mock_deps) return r class TestPromptBuilder: """prompt_builder 注册映射并返回 prompt。""" def test_registers_qid_to_vid_mapping(self, router, mock_deps) -> None: from core.types import GeneratedQuestion qa = GeneratedQuestion( question_id="q1", video_id="vid1", task_type="Action Reasoning", question="test?", options=("A. a", "B. b", "C. c", "D. d"), answer="A", source_nodes=(), difficulty="medium", ) builder = router.create_prompt_builder() builder(qa) assert router._qid_to_vid["q1"] == "vid1" def test_returns_prompt_from_deps(self, router, mock_deps) -> None: from core.types import GeneratedQuestion qa = GeneratedQuestion( question_id="q1", video_id="vid1", task_type="Action Reasoning", question="test?", options=("A. a", "B. b", "C. c", "D. d"), answer="A", source_nodes=(), difficulty="medium", ) builder = router.create_prompt_builder() system, user = builder(qa) assert "vid1" in system class TestDispatch: """dispatch 通过 session_id 路由到正确视频。""" @pytest.mark.asyncio() async def test_routes_by_session_id(self, router, mock_deps) -> None: from core.types import GeneratedQuestion qa = GeneratedQuestion( question_id="q1", video_id="vid1", task_type="Action Reasoning", question="test?", options=("A. a", "B. b", "C. c", "D. d"), answer="A", source_nodes=(), difficulty="medium", ) # 先注册映射 builder = router.create_prompt_builder() builder(qa) dispatch = router.create_dispatch() result = await dispatch("view_node", {"node_id": "L1_000"}, context={"session_id": "q1"}) assert result == "tool_result" mock_deps.tool_dispatch_fn.assert_called_once() @pytest.mark.asyncio() async def test_unknown_session_id_raises(self, router) -> None: dispatch = router.create_dispatch() with pytest.raises(KeyError, match="未注册"): await dispatch("view_node", {}, context={"session_id": "unknown"}) @pytest.mark.asyncio() async def test_missing_session_id_raises(self, router) -> None: dispatch = router.create_dispatch() with pytest.raises(KeyError, match="未注册"): await dispatch("view_node", {}, context={}) class TestDepsCache: """同一 video_id 复用缓存。""" def test_same_video_reuses_deps(self, router, mock_deps) -> None: from core.types import GeneratedQuestion qa1 = GeneratedQuestion( question_id="q1", video_id="vid1", task_type="Action Reasoning", question="test1?", options=("A. a", "B. b", "C. c", "D. d"), answer="A", ) qa2 = GeneratedQuestion( question_id="q2", video_id="vid1", task_type="Action Reasoning", question="test2?", options=("A. a", "B. b", "C. c", "D. d"), answer="B", ) builder = router.create_prompt_builder() builder(qa1) builder(qa2) # 同一 video_id 只调用一次 _build_deps assert router._build_deps.call_count == 1 class TestClearCache: """clear_cache 清空缓存和映射。""" def test_clears_deps_and_mapping(self, router, mock_deps) -> None: from core.types import GeneratedQuestion qa = GeneratedQuestion( question_id="q1", video_id="vid1", task_type="Action Reasoning", question="test?", options=("A. a", "B. b", "C. c", "D. d"), answer="A", source_nodes=(), difficulty="medium", ) builder = router.create_prompt_builder() builder(qa) assert len(router._qid_to_vid) == 1 router.clear_cache() assert len(router._qid_to_vid) == 0 assert len(router._deps_cache) == 0 ``` - [ ] **Step 2: 运行测试确认失败** Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_deps_router.py -v` Expected: ModuleNotFoundError(deps_router 尚未创建) - [ ] **Step 3: 实现 InferenceDepsRouter** ```python # app/harness/deps_router.py """按 video_id 懒加载 InferenceDeps 并路由 dispatch/prompt_builder。 将 Runner 的全局统一 dispatch/prompt_builder 接口路由到 per-video 的 InferenceDeps。时序保证:prompt_builder(qa) 先于 dispatch 被调用, 在 prompt_builder 中注册 question_id → video_id 映射。 """ from __future__ import annotations from typing import TYPE_CHECKING, Any from loguru import logger from app.harness.factory import InferenceDeps, build_inference_deps if TYPE_CHECKING: from pathlib import Path from app.ports import EmbeddingProvider, OCRProvider from core.protocols import LLMProvider, VLMProvider from core.types import GeneratedQuestion class InferenceDepsRouter: """按 video_id 懒加载 InferenceDeps 并路由工具调度和 prompt 构建。 参数: store_dir: store 根目录。 embed_provider: 嵌入端口。 llm: LLM 端口。 vlm: VLM 端口。 ocr: OCR 端口(None 不启用)。 default_prompts_dir: 默认 prompts 版本目录。 default_skills_dir: 默认 skills 版本目录。 skill_mode: skill 加载模式。 verify_vision: observe_frame 是否验证。 anchor: view_node 是否锚模式。 assemble_mode: 锚模式装配形态。 """ def __init__( self, *, store_dir: Path, embed_provider: EmbeddingProvider, llm: LLMProvider, vlm: VLMProvider, ocr: OCRProvider | None, default_prompts_dir: Path, default_skills_dir: Path, skill_mode: str, verify_vision: bool, anchor: bool, assemble_mode: str, ) -> None: self._store_dir = store_dir self._embed = embed_provider self._llm = llm self._vlm = vlm self._ocr = ocr self._default_prompts_dir = default_prompts_dir self._default_skills_dir = default_skills_dir self._skill_mode = skill_mode self._verify_vision = verify_vision self._anchor = anchor self._assemble_mode = assemble_mode self._deps_cache: dict[tuple[str, str, str], InferenceDeps] = {} self._qid_to_vid: dict[str, str] = {} def create_dispatch( self, *, skills_dir: Path | None = None ) -> Any: """创建工具调度函数,通过 context["session_id"] 路由到 per-video dispatcher。 参数: skills_dir: 可选的 skills 版本目录覆盖。 返回: async (tool_name, args, *, context) -> str。 """ effective_skills = skills_dir or self._default_skills_dir async def _dispatch( tool_name: str, args: dict[str, Any], *, context: dict[str, Any] ) -> str: session_id = context.get("session_id") if not session_id or session_id not in self._qid_to_vid: raise KeyError( f"未注册的 session_id={session_id!r}," f"已注册 {len(self._qid_to_vid)} 条映射" ) video_id = self._qid_to_vid[session_id] deps = self._ensure_deps(video_id, effective_skills, self._default_prompts_dir) return await deps.tool_dispatch_fn(tool_name, args, context=context) return _dispatch def create_prompt_builder( self, *, skills_dir: Path | None = None, prompts_dir: Path | None = None, ) -> Any: """创建 prompt 构建函数,在调用时注册 question_id→video_id 映射。 参数: skills_dir: 可选的 skills 版本目录覆盖。 prompts_dir: 可选的 prompts 版本目录覆盖。 返回: (GeneratedQuestion) -> (system_prompt, user_prompt)。 """ effective_skills = skills_dir or self._default_skills_dir effective_prompts = prompts_dir or self._default_prompts_dir def _builder(qa: GeneratedQuestion) -> tuple[str, str]: self._qid_to_vid[qa.question_id] = qa.video_id deps = self._ensure_deps(qa.video_id, effective_skills, effective_prompts) return deps.prompt_builder(qa) return _builder def _ensure_deps( self, video_id: str, skills_dir: Path, prompts_dir: Path ) -> InferenceDeps: """懒加载 per-video InferenceDeps,按 (video_id, skills_dir, prompts_dir) 缓存。""" key = (video_id, str(skills_dir), str(prompts_dir)) if key not in self._deps_cache: self._deps_cache[key] = self._build_deps(video_id, skills_dir, prompts_dir) logger.debug("InferenceDeps 已缓存: video_id={}", video_id) return self._deps_cache[key] def _build_deps( self, video_id: str, skills_dir: Path, prompts_dir: Path ) -> InferenceDeps: """调用 factory.build_inference_deps 构建 per-video 依赖。""" return build_inference_deps( store_dir=self._store_dir, video_id=video_id, prompts_dir=prompts_dir, skills_dir=skills_dir, skill_mode=self._skill_mode, embed_provider=self._embed, llm=self._llm, vlm=self._vlm, ocr=self._ocr, verify_vision=self._verify_vision, anchor=self._anchor, assemble_mode=self._assemble_mode, ) def clear_cache(self) -> None: """清空 deps 缓存和 question_id 映射。""" self._deps_cache.clear() self._qid_to_vid.clear() ``` - [ ] **Step 4: 运行测试确认通过** Run: `conda run -n Video-Tree-TRM pytest tests/unit/test_deps_router.py -v` Expected: 7 tests PASSED - [ ] **Step 5: Commit** ```bash git add app/harness/deps_router.py tests/unit/test_deps_router.py git commit -m "feat(harness): InferenceDepsRouter per-video 路由器" ``` --- ### Task 6: 配置变更 **Files:** - Modify: `config/default.yaml:29,31` - Modify: `.env` (LLM_CIRCUIT_BREAKER_THRESHOLD) - [ ] **Step 1: 更新 default.yaml** 在 `config/default.yaml` 中修改: ```yaml harness: concurrency: 24 # was 12 max_steps: 40 # was 15 ``` - [ ] **Step 2: 更新 .env 和 .env.example** 在 `.env` 中修改(本地生效,不提交): ``` LLM_CIRCUIT_BREAKER_THRESHOLD=48 ``` 在 `.env.example` 中同步(提交到 Git): ``` LLM_CIRCUIT_BREAKER_THRESHOLD=48 # 实际阈值 = max(此值, concurrency*2) ``` - [ ] **Step 3: 验证配置加载** Run: `conda run -n Video-Tree-TRM python -c "from app.harness.config import load_config; from pathlib import Path; c = load_config(Path('config/default.yaml')); print(f'concurrency={c.concurrency}, max_steps={c.max_steps}')"` Expected: `concurrency=24, max_steps=40` - [ ] **Step 4: Commit** ```bash git add config/default.yaml .env.example git commit -m "config: concurrency=24, max_steps=40, breaker_threshold=48" ``` --- ### Task 7: main.py — Composition Root **Files:** - Create: `main.py` - [ ] **Step 1: 实现 main.py** ```python """CLI 入口 — Composition Root:构建适配器,注入 Runner,调度执行。 三层配置合并(YAML > .env > CLI)由 load_config 完成。 适配器参数通过 InfraSettings(BaseSettings) 从 .env 加载。 """ from __future__ import annotations import argparse import asyncio import os from pathlib import Path from typing import NamedTuple from dotenv import load_dotenv from loguru import logger from pydantic_settings import BaseSettings, SettingsConfigDict class InfraSettings(BaseSettings): """工程配置(少变/敏感),从 .env 加载。""" model_config = SettingsConfigDict(env_file=".env", extra="ignore") search_llm_model: str = "" search_llm_base_url: str = "" search_llm_api_key: str = "" vl_llm_model: str = "" vl_llm_base_url: str = "" vl_llm_api_key: str = "" evolve_llm_model: str = "" evolve_llm_base_url: str = "" evolve_llm_api_key: str = "" embed_api_key: str = "" embed_api_url: str = "" monkey_ocr_urls: str = "" redis_url: str = "" redis_cache_ttl: int = 86400 llm_timeout: float = 120.0 llm_max_retries: int = 3 llm_retry_base_delay: float = 2.0 llm_retry_max_delay: float = 30.0 llm_circuit_breaker_threshold: int = 48 llm_circuit_breaker_cooldown: float = 60.0 llm_ttft_timeout: float = 30.0 llm_inter_token_timeout: float = 15.0 class _Adapters(NamedTuple): """全套适配器实例。""" llm: object evolve_llm: object vlm: object telemetry: object embed: object ocr: object def _build_adapters(settings: InfraSettings, embed_cfg: dict) -> _Adapters: """从 InfraSettings 构建全套适配器。 参数: settings: 工程配置。 embed_cfg: 嵌入配置字典(来自 YAML tree.embed 或 harness 段)。 返回: _Adapters 命名元组。 """ from adapters.breaker import CircuitBreaker from adapters.embedding import LocalEmbeddingProvider, RemoteEmbeddingProvider from adapters.llm import GovernedLLMClient from adapters.telemetry import SQLiteTelemetryRecorder from adapters.vlm import GovernedVLMClient breaker = CircuitBreaker( fail_threshold=max(settings.llm_circuit_breaker_threshold, 1), cooldown_s=settings.llm_circuit_breaker_cooldown, ) cache = None if settings.redis_url: try: from adapters.redis_cache import RedisResponseCache cache = RedisResponseCache( redis_url=settings.redis_url, ttl=settings.redis_cache_ttl ) except Exception: logger.warning("Redis 缓存不可用,降级为无缓存模式") telemetry_db = Path("logs/telemetry.db") telemetry_db.parent.mkdir(parents=True, exist_ok=True) telemetry = SQLiteTelemetryRecorder(telemetry_db) def _make_llm(model: str, base_url: str, api_key: str, *, thinking: bool) -> GovernedLLMClient: return GovernedLLMClient( model=model, base_url=base_url, api_key=api_key, provider=model.split("-")[0] if model else "unknown", thinking=thinking, breaker=breaker, cache=cache, telemetry=telemetry, timeout_s=settings.llm_timeout, ttft_timeout_s=settings.llm_ttft_timeout, inter_token_timeout_s=settings.llm_inter_token_timeout, max_retries=settings.llm_max_retries, retry_base_delay_s=settings.llm_retry_base_delay, retry_max_delay_s=settings.llm_retry_max_delay, ) llm = _make_llm( settings.search_llm_model, settings.search_llm_base_url, settings.search_llm_api_key, thinking=True, ) evolve_llm = llm # 设计要求本次传同一实例 vl_llm = _make_llm( settings.vl_llm_model, settings.vl_llm_base_url, settings.vl_llm_api_key, thinking=False, ) vlm = GovernedVLMClient(governed_llm=vl_llm) backend = embed_cfg.get("backend", "local") if backend == "local": embed = LocalEmbeddingProvider( model_name=embed_cfg.get("model_name", "BAAI/bge-base-zh-v1.5"), embed_dim=embed_cfg.get("embed_dim", 768), device=embed_cfg.get("device", "cpu"), ) else: from adapters.embedding import RemoteEmbeddingProvider embed = RemoteEmbeddingProvider( model_name=embed_cfg.get("model_name", ""), embed_dim=embed_cfg.get("embed_dim", 768), api_key=settings.embed_api_key, api_url=settings.embed_api_url, ) ocr = None if settings.monkey_ocr_urls: from adapters.ocr import MonkeyOCRClient urls = [u.strip() for u in settings.monkey_ocr_urls.split(",") if u.strip()] if urls: ocr = MonkeyOCRClient(urls=urls) return _Adapters( llm=llm, evolve_llm=evolve_llm, vlm=vlm, telemetry=telemetry, embed=embed, ocr=ocr, ) def _build_parser() -> argparse.ArgumentParser: """构建 CLI 参数解析器。所有参数 default=None,未传入时使用 YAML 默认值。""" parser = argparse.ArgumentParser(description="Video-Tree-TRM5 实验运行器") parser.add_argument( "--config", type=Path, default=Path("config/default.yaml"), help="YAML 配置文件路径", ) parser.add_argument("--workspace-dir", type=Path, dest="workspace_dir") parser.add_argument("--store-dir", type=Path, dest="store_dir") parser.add_argument("--mode", choices=["infer", "train", "diagnose", "evolve", "eval", "promote"]) parser.add_argument("--run-id", type=str, dest="run_id") parser.add_argument("--concurrency", type=int) parser.add_argument("--max-steps", type=int, dest="max_steps") parser.add_argument("--skill-mode", choices=["auto", "manual", "none"], dest="skill_mode") parser.add_argument("--n-samples", type=int, dest="n_samples") parser.add_argument("--questions", type=str) parser.add_argument("--skills-version", type=str, dest="skills_version") parser.add_argument("--prompts-version", type=str, dest="prompts_version") parser.add_argument("--task-types", nargs="+", dest="task_types") parser.add_argument("--resume", action="store_true", dest="resume") parser.add_argument("--fresh", action="store_true", dest="fresh") parser.add_argument("--seed", type=str, dest="seed") parser.add_argument("--epochs", type=int) return parser def _log_result(result: object) -> None: """输出推理结果摘要。""" logger.info("=" * 60) logger.info("运行 ID: {}", result.run_id) logger.info("总体准确率: {:.2%} ({}/{})", result.accuracy, result.correct, result.total) logger.info("平均步数: {:.1f}", result.steps_mean) logger.info( "Token 用量: prompt={}, completion={}", result.token_usage["prompt_tokens"], result.token_usage["completion_tokens"], ) if result.per_task_type: logger.info("--- 按任务类型 ---") for task_type, stats in sorted(result.per_task_type.items()): logger.info( " {}: {:.2%} ({}/{})", task_type, stats["accuracy"], stats["correct"], stats["total"], ) logger.info( "停止原因: {}", ", ".join(f"{k}={v}" for k, v in result.stop_reason_counts.items()), ) logger.info("=" * 60) def main() -> None: """入口函数。""" load_dotenv() parser = _build_parser() args = parser.parse_args() import yaml with open(args.config, encoding="utf-8") as f: raw_yaml = yaml.safe_load(f) from app.harness.config import load_config cli_overrides = {k: v for k, v in vars(args).items() if k != "config" and k != "task_types"} config = load_config(args.config, cli_overrides) logger.info("配置加载完成: mode={}, workspace={}", config.mode, config.workspace_dir) settings = InfraSettings() embed_cfg = raw_yaml.get("embed", {}) adapters = _build_adapters(settings, embed_cfg) from app.harness.deps_router import InferenceDepsRouter from app.harness.runner import Runner from app.harness.workspace import resolve_paths router = InferenceDepsRouter( store_dir=Path(config.store_dir), embed_provider=adapters.embed, llm=adapters.llm, vlm=adapters.vlm, ocr=adapters.ocr, default_prompts_dir=Path(config.store_dir) / "prompts" / config.prompts_version, default_skills_dir=Path(config.store_dir) / "skills" / config.skills_version, skill_mode=config.skill_mode, verify_vision=True, anchor=True, assemble_mode="default", ) runner = Runner( config, llm=adapters.llm, evolve_llm=adapters.evolve_llm, vlm=adapters.vlm, telemetry=adapters.telemetry, tool_dispatch_factory=router.create_dispatch, prompt_builder_factory=router.create_prompt_builder, ) if config.mode == "infer": task_types = getattr(args, "task_types", None) result = asyncio.run(runner.infer(task_types=task_types)) _log_result(result) else: raise SystemExit(f"模式 {config.mode!r} 尚未实现") if __name__ == "__main__": main() ``` - [ ] **Step 2: 验证 main.py 可导入** Run: `conda run -n Video-Tree-TRM python -c "import main; print('OK')"` Expected: `OK` - [ ] **Step 3: 验证 --help 输出** Run: `conda run -n Video-Tree-TRM python main.py --help` Expected: 显示参数帮助文本 - [ ] **Step 4: Commit** ```bash git add main.py git commit -m "feat: main.py Composition Root(仅 infer 模式)" ``` --- ### Task 8: 冒烟测试 **Files:** - No new files, end-to-end validation - [ ] **Step 1: 验证 workspace 初始化** 需要先初始化 workspace(将 store 的 v1 资源拷贝到 workspace)。 Run: `conda run -n Video-Tree-TRM python main.py --mode infer --n-samples 1 --concurrency 1 --max-steps 3` 检查: - 是否成功初始化 workspace - 是否加载了题目 - 是否创建了 InferenceDeps - LLM 调用是否经过 GovernedLLMClient 如果出现 workspace 不存在的错误,需要先手动初始化: ```python from pathlib import Path from app.harness.workspace import init_workspace init_workspace( Path("workspaces/default"), Path("store"), "benchmarks/Video-MME", "v1", "v1", ) ``` - [ ] **Step 2: 检查结果输出** Expected: 看到日志输出包含: - `配置加载完成: mode=infer` - `InferenceDeps 已缓存: video_id=...` - `推理完成: accuracy=...` - [ ] **Step 3: 运行全量测试确认无回归** Run: `conda run -n Video-Tree-TRM pytest tests/unit/ -v --tb=short` Expected: ALL PASSED - [ ] **Step 4: 最终提交** ```bash git add -A git commit -m "test: 冒烟测试通过,900 题推理管线就绪" ``` --- ## 核心算法保真校验 Task 0 涉及 Voronoi 字幕分配(算法清单中非独立项,属于建树模块的后处理步骤)。改动范围: | 算法 | 改动性质 | 保真判定 | |------|---------|---------| | Voronoi 字幕分配 (`subtitle.py`) | 写入目标从 `L3Node.subtitle` 改为 `L3Card.subtitle` + 新增 L2 聚合 | 核心逻辑(中点计算、范围扩展)不变,仅写入目标变更。**保真**。 | | L2 建树 (`video_builder.py`) | Card 构造后注入 subtitle | 新增逻辑(原来不存在 L2 subtitle),不涉及算法简化。**不适用**。 | 其余 Task 1-8 不涉及核心算法迁移。