Files
Video-Tree-TRM5/tests/unit/test_synthesizer.py
T
iomgaa 90f17e330e feat(question_gen): build_generation_prompt + parse_vlm_response
- prompt 组装:system(角色+题型+约束+few-shot) + user(card+字幕+干扰项)
- VLM 响应解析:JSON 直接 + markdown code block 回退,四选一 schema 校验

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 05:28:25 -04:00

382 lines
15 KiB
Python
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.
"""synthesizer 模块单元测试 — AnchorContext + 题型映射常量 + sample_anchor + prompt 构造 + VLM 解析。"""
from __future__ import annotations
import dataclasses
import random
from pathlib import Path
import pytest
from app.question_gen.synthesizer import (
TASK_TYPE_LEVEL_MAP,
AnchorContext,
TaskTypeSpec,
build_generation_prompt,
parse_vlm_response,
sample_anchor,
)
from app.tree.index import TreeIndex
from core.types import GeneratedQuestion
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",
]
class TestTaskTypeLevelMap:
"""TASK_TYPE_LEVEL_MAP 覆盖性与结构测试。"""
def test_covers_all_12_types(self) -> None:
"""映射表必须覆盖全部 12 种 Video-MME 题型。"""
assert set(TASK_TYPE_LEVEL_MAP.keys()) == set(ALL_12_TYPES)
def test_no_extra_types(self) -> None:
"""映射表不得包含 12 种标准题型之外的条目。"""
assert len(TASK_TYPE_LEVEL_MAP) == 12
def test_all_values_are_task_type_spec(self) -> None:
"""每个映射值必须是 TaskTypeSpec 实例。"""
for task_type, spec in TASK_TYPE_LEVEL_MAP.items():
assert isinstance(spec, TaskTypeSpec), f"{task_type} 映射值类型错误: {type(spec)}"
def test_level_values_valid(self) -> None:
"""每个 spec 的 level 必须是合法层级标识。"""
valid_levels = {"L1", "L2", "L3", "L1-L2"}
for task_type, spec in TASK_TYPE_LEVEL_MAP.items():
assert spec.level in valid_levels, (
f"{task_type} 层级 '{spec.level}' 不在 {valid_levels}"
)
def test_context_fields_non_empty(self) -> None:
"""每个 spec 的 context_fields 至少有一个字段。"""
for task_type, spec in TASK_TYPE_LEVEL_MAP.items():
assert len(spec.context_fields) >= 1, f"{task_type} 的 context_fields 为空"
class TestAnchorContext:
"""AnchorContext 数据类测试。"""
def test_frozen(self) -> None:
"""AnchorContext 是不可变的。"""
ctx = AnchorContext(
node_id="L3_001",
card_text="A person walks into a room",
frame_paths=["/data/frames/001.jpg"],
subtitle="Hello there",
distractor_texts=["A car drives by"],
)
assert ctx.node_id == "L3_001"
assert ctx.card_text == "A person walks into a room"
assert ctx.frame_paths == ["/data/frames/001.jpg"]
assert ctx.subtitle == "Hello there"
assert ctx.distractor_texts == ["A car drives by"]
def test_mutation_raises(self) -> None:
"""frozen dataclass 拒绝赋值修改。"""
ctx = AnchorContext(
node_id="L3_001",
card_text="test",
frame_paths=["a.jpg"],
subtitle="",
distractor_texts=["other node"],
)
try:
ctx.node_id = "L3_002" # type: ignore[misc]
raise AssertionError("应抛出 FrozenInstanceError")
except dataclasses.FrozenInstanceError:
pass
def test_empty_subtitle_allowed(self) -> None:
"""subtitle 可以为空字符串。"""
ctx = AnchorContext(
node_id="L2_010",
card_text="scene card",
frame_paths=[],
subtitle="",
distractor_texts=[],
)
assert ctx.subtitle == ""
def test_multiple_frame_paths(self) -> None:
"""frame_paths 可包含多个路径。"""
paths = ["/data/f1.jpg", "/data/f2.jpg", "/data/f3.jpg"]
ctx = AnchorContext(
node_id="L2_005",
card_text="multi-frame event",
frame_paths=paths,
subtitle="Dialogue line",
distractor_texts=["other1", "other2"],
)
assert len(ctx.frame_paths) == 3
class TestTaskTypeSpec:
"""TaskTypeSpec 数据类测试。"""
def test_frozen(self) -> None:
"""TaskTypeSpec 是不可变的。"""
spec = TaskTypeSpec(
level="L3",
needs_frames=True,
frame_count="1",
context_fields=("frame_summary",),
)
try:
spec.level = "L2" # type: ignore[misc]
raise AssertionError("应抛出 FrozenInstanceError")
except dataclasses.FrozenInstanceError:
pass
def test_context_fields_is_tuple(self) -> None:
"""context_fields 应为 tuple(不可变)。"""
for task_type, spec in TASK_TYPE_LEVEL_MAP.items():
assert isinstance(spec.context_fields, tuple), (
f"{task_type} 的 context_fields 不是 tuple"
)
# ---------------------------------------------------------------------------
# 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:
"""sample_anchor 锚节点采样测试(基于真实树数据)。"""
def test_l3_type_returns_single_frame(self) -> None:
"""L3 题型(Object Recognition)应返回恰好 1 帧。"""
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") or "_L3_" in ctx.node_id
assert len(ctx.distractor_texts) > 0
def test_l2_type_returns_multiple_frames(self) -> None:
"""L2 题型(Action Reasoning)应返回 2-3 帧。"""
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) -> None:
"""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) -> None:
"""Information Synopsis 必须使用目标 L1 下所有 L2 子节点。"""
tree, _vid = _load_test_tree()
ctx = sample_anchor(tree, "Information Synopsis", set(), random.Random(42))
# 找到被选中的 L1,验证 frame_paths 数量 == 该 L1 下全部 L2 数量
chosen_l1 = next(r for r in tree.roots if r.id == ctx.node_id)
assert len(ctx.frame_paths) == len(chosen_l1.children)
def test_l1_type_l2_nodes_in_time_order(self) -> None:
"""Temporal Reasoning 应返回 >=3 帧且 card_text 有实质内容。"""
tree, _vid = _load_test_tree()
ctx = sample_anchor(tree, "Temporal Reasoning", set(), random.Random(42))
assert len(ctx.frame_paths) >= 3
assert len(ctx.card_text) > 20
def test_used_node_ids_excluded(self) -> None:
"""used_node_ids 中的节点不应被再次选中。"""
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) -> None:
"""所有候选节点均被排除时应抛出 ValueError。"""
tree, _vid = _load_test_tree()
all_l3_ids: set[str] = set()
for root in tree.roots:
for l2 in root.children:
for l3 in l2.children:
all_l3_ids.add(l3.id)
with pytest.raises(ValueError, match="锚节点不足"):
sample_anchor(tree, "Object Recognition", all_l3_ids, random.Random(42))
def test_object_reasoning_l1_l2_type(self) -> None:
"""Object Reasoning (L1-L2) 应选 2-3 个 L2 并按时间排序。"""
tree, _vid = _load_test_tree()
ctx = sample_anchor(tree, "Object Reasoning", set(), random.Random(42))
assert 2 <= len(ctx.frame_paths) <= 3
assert len(ctx.card_text) > 10
def test_spatial_reasoning_context_fields(self) -> None:
"""Spatial Reasoning 的 card_text 应包含 spatial_layout 字段。"""
tree, _vid = _load_test_tree()
ctx = sample_anchor(tree, "Spatial Reasoning", set(), random.Random(42))
# context_fields 包含 spatial_layoutcard_text 必须出现该字段名
assert "spatial_layout" in ctx.card_text
assert len(ctx.card_text) > 20
# ---------------------------------------------------------------------------
# build_generation_prompt 测试
# ---------------------------------------------------------------------------
class TestBuildGenerationPrompt:
"""build_generation_prompt 消息结构与内容测试。"""
def test_messages_structure(self) -> None:
"""带 exemplars 时,system 包含题型和示例,image_paths 来自 anchor。"""
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) -> None:
"""干扰项文本应出现在 user message 中。"""
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"]
def test_no_exemplars_no_crash(self) -> None:
"""exemplars 为空时不应报错,system 消息中无示例段落。"""
anchor = AnchorContext(
node_id="L3_010",
card_text="Some card text",
frame_paths=["frame.jpg"],
subtitle="",
distractor_texts=[],
)
messages, image_paths = build_generation_prompt("OCR Problems", anchor, [])
assert len(messages) >= 2
assert image_paths == ["frame.jpg"]
def test_subtitle_included_when_non_empty(self) -> None:
"""非空 subtitle 应出现在 user message 中。"""
anchor = AnchorContext(
node_id="L3_002",
card_text="Card text here",
frame_paths=["f.jpg"],
subtitle="This is a subtitle line",
distractor_texts=[],
)
messages, _ = build_generation_prompt("Attribute Perception", anchor, [])
user_msg = [m for m in messages if m["role"] == "user"][0]
assert "This is a subtitle line" in user_msg["content"]
def test_empty_subtitle_not_in_user_message(self) -> None:
"""空 subtitle 不应在 user message 中产生 subtitle 段落。"""
anchor = AnchorContext(
node_id="L3_003",
card_text="Card",
frame_paths=["f.jpg"],
subtitle="",
distractor_texts=[],
)
messages, _ = build_generation_prompt("OCR Problems", anchor, [])
user_msg = [m for m in messages if m["role"] == "user"][0]
# 不应出现空的 subtitle 标记
assert "字幕" not in user_msg["content"]
# ---------------------------------------------------------------------------
# parse_vlm_response 测试
# ---------------------------------------------------------------------------
class TestParseVlmResponse:
"""parse_vlm_response 解析与校验测试。"""
def test_valid_json(self) -> None:
"""合法 JSON 正常解析,question_id 格式正确。"""
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
assert result["question_id"] == "gen-vid1-001"
def test_json_in_code_block(self) -> None:
"""从 markdown 代码块中提取 JSON。"""
raw = '```json\n{"question": "Q?", "options": ["A. 1", "B. 2", "C. 3", "D. 4"], "answer": "B"}\n```'
result = parse_vlm_response(raw, "vid1", "Object Recognition", 2)
assert result["question"] == "Q?"
assert result["question_id"] == "gen-vid1-002"
def test_invalid_json_raises(self) -> None:
"""非 JSON 文本应抛出 ValueError。"""
with pytest.raises(ValueError, match="VLM 返回"):
parse_vlm_response("not json", "vid1", "Object Recognition", 1)
def test_missing_fields_raises(self) -> None:
"""缺少必需字段应抛出 ValueError。"""
raw = '{"question": "What?"}'
with pytest.raises(ValueError):
parse_vlm_response(raw, "vid1", "Object Recognition", 1)
def test_options_must_be_four(self) -> None:
"""options 非 4 项应抛出 ValueError。"""
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) -> None:
"""answer 不在 A-D 范围应抛出 ValueError。"""
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)
def test_seq_zero_padded(self) -> None:
"""seq 应按 3 位零填充格式化到 question_id 中。"""
raw = '{"question": "Q?", "options": ["A. 1", "B. 2", "C. 3", "D. 4"], "answer": "C"}'
result = parse_vlm_response(raw, "video_abc", "Action Reasoning", 42)
assert result["question_id"] == "gen-video_abc-042"