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>
This commit is contained in:
2026-07-09 05:28:25 -04:00
parent 40b04f886e
commit 90f17e330e
2 changed files with 290 additions and 2 deletions
+151 -1
View File
@@ -1,4 +1,4 @@
"""synthesizer 模块单元测试 — AnchorContext + 题型映射常量 + sample_anchor。"""
"""synthesizer 模块单元测试 — AnchorContext + 题型映射常量 + sample_anchor + prompt 构造 + VLM 解析"""
from __future__ import annotations
@@ -12,9 +12,12 @@ 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",
@@ -229,3 +232,150 @@ class TestSampleAnchor:
# 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"