feat(harness): add factory.py — InferenceDeps dataclass + build_inference_deps
组装一次推理所需的全套依赖的工厂函数: - TreeIndex 加载(FileNotFoundError if missing) - TreeEnvironment 构建 - SkillRegistry 按需发现 - SearchToolDispatcher 装配 - PromptManager + prompt_builder 闭包 测试覆盖:正常路径、缺失树文件、skills 注入、frozen 不可变性。 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
"""app/harness/factory.py 的单元测试。
|
||||
|
||||
验证 build_inference_deps 的返回类型、字段连接、以及错误路径。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from app.harness.factory import InferenceDeps, build_inference_deps
|
||||
from core.types import GeneratedQuestion
|
||||
|
||||
|
||||
class TestBuildInferenceDeps:
|
||||
"""build_inference_deps 工厂函数测试。"""
|
||||
|
||||
def test_returns_inference_deps(self, tmp_path: Path) -> None:
|
||||
"""用 fake adapters 验证返回类型和字段非 None。"""
|
||||
# 准备一棵最小树
|
||||
vid_dir = tmp_path / "videos" / "test_vid"
|
||||
vid_dir.mkdir(parents=True)
|
||||
(vid_dir / "frames").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 实际可用(连接正确)
|
||||
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: Path) -> None:
|
||||
"""tree.json 不存在时应抛出 FileNotFoundError。"""
|
||||
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",
|
||||
)
|
||||
|
||||
def test_with_skills_dir(self, tmp_path: Path) -> None:
|
||||
"""提供 skills_dir 时 skill 信息应正确注入到 prompt_builder 输出。"""
|
||||
# 准备树
|
||||
vid_dir = tmp_path / "videos" / "vid1"
|
||||
vid_dir.mkdir(parents=True)
|
||||
(vid_dir / "frames").mkdir()
|
||||
minimal_tree = {
|
||||
"metadata": {"source_path": "test", "modality": "video"},
|
||||
"roots": [
|
||||
{
|
||||
"id": "L1_000",
|
||||
"card": {
|
||||
"scene_summary": "test scene",
|
||||
"main_setting": "indoor",
|
||||
"key_entities": [],
|
||||
"main_actions": [],
|
||||
"topic_keywords": [],
|
||||
"visible_text": [],
|
||||
"temporal_flow": "linear",
|
||||
},
|
||||
"time_range": [0, 5],
|
||||
"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("Base system prompt.")
|
||||
|
||||
# skills
|
||||
skills_dir = tmp_path / "skills"
|
||||
skills_dir.mkdir()
|
||||
(skills_dir / "always_nav.md").write_text(
|
||||
"---\nname: always_nav\nalways: true\n---\nAlways navigate broadly."
|
||||
)
|
||||
(skills_dir / "action_skill.md").write_text(
|
||||
"---\nname: action_skill\ntask_type: Action Reasoning\n---\nFocus on actions."
|
||||
)
|
||||
|
||||
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="vid1",
|
||||
prompts_dir=prompts_dir,
|
||||
skills_dir=skills_dir,
|
||||
skill_mode="auto",
|
||||
embed_provider=fake_embed,
|
||||
llm=fake_llm,
|
||||
vlm=fake_vlm,
|
||||
ocr=None,
|
||||
verify_vision=False,
|
||||
anchor=False,
|
||||
assemble_mode="ids",
|
||||
)
|
||||
|
||||
fake_q = GeneratedQuestion(
|
||||
question_id="q2",
|
||||
video_id="vid1",
|
||||
task_type="Action Reasoning",
|
||||
question="What happened?",
|
||||
options=("A. X", "B. Y", "C. Z", "D. W"),
|
||||
answer="B",
|
||||
source_nodes=(),
|
||||
difficulty="easy",
|
||||
)
|
||||
system, user = deps.prompt_builder(fake_q)
|
||||
# always skill 文本和 task_type skill 文本应出现在 system prompt 中
|
||||
assert "Always navigate broadly" in system
|
||||
assert "Focus on actions" in system
|
||||
assert "What happened?" in user
|
||||
|
||||
def test_frozen_dataclass(self) -> None:
|
||||
"""InferenceDeps 是 frozen dataclass,不可修改属性。"""
|
||||
deps = InferenceDeps(
|
||||
llm=AsyncMock(),
|
||||
tool_dispatch_fn=lambda: None,
|
||||
prompt_builder=lambda q: ("", ""),
|
||||
)
|
||||
with pytest.raises(AttributeError):
|
||||
deps.llm = AsyncMock() # type: ignore[misc]
|
||||
Reference in New Issue
Block a user