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
+139 -1
View File
@@ -1,4 +1,4 @@
"""赛题合成核心逻辑 — 节点采样、prompt 构造、去重。
"""赛题合成核心逻辑 — 节点采样、prompt 构造、VLM 响应解析、去重。
纯函数为主,异步编排仅 generate_one。
通过 DI 接收 VLMProvider / EmbeddingProvider,不 import adapters/。
@@ -6,6 +6,9 @@
from __future__ import annotations
import contextlib
import json
import re
from dataclasses import dataclass
from typing import TYPE_CHECKING
@@ -13,6 +16,7 @@ if TYPE_CHECKING:
import random
from app.tree.index import L2Node, L3Node, TreeIndex
from core.types import GeneratedQuestion
@dataclass(frozen=True)
@@ -486,3 +490,137 @@ def sample_anchor(
return _sample_l1_l2(tree, task_type, spec, used_node_ids, rng)
else:
raise ValueError(f"未知层级: {spec.level}")
# ---------------------------------------------------------------------------
# Prompt 构造与 VLM 响应解析
# ---------------------------------------------------------------------------
_VALID_ANSWERS = frozenset({"A", "B", "C", "D"})
def build_generation_prompt(
task_type: str,
anchor: AnchorContext,
exemplars: list[GeneratedQuestion],
) -> tuple[list[dict[str, str]], list[str]]:
"""组装 VLM 出题 prompt。
构造 OpenAI 格式的 messages 列表和帧图片路径列表,
供 VLMProvider.chat_with_images 直接消费。
参数:
task_type: 题型名称(如 "Object Recognition")。
anchor: 锚节点上下文(card_text, subtitle, distractor_texts, frame_paths)。
exemplars: 少样本示例列表(可为空)。
返回:
(messages, image_paths) — messages 为 OpenAI 格式消息列表,
image_paths 为帧图片路径列表,直接喂给 VLMProvider.chat_with_images。
"""
# Phase 1: 构造 system message
system_parts: list[str] = [
"你是一个视频理解题目生成器。",
f"题型: {task_type}",
"约束:",
"- 题目必须基于提供的节点内容",
"- 干扰选项应来自其他节点的信息",
"- 生成风格应与示例保持一致",
'- 以 JSON 格式返回: {"question": "...", "options": ["A. ...", "B. ...", "C. ...", "D. ..."], "answer": "A/B/C/D"}',
]
# Phase 2: 加入 few-shot 示例
if exemplars:
system_parts.append("\n示例:")
for i, ex in enumerate(exemplars, 1):
system_parts.append(f" 示例 {i}:")
system_parts.append(f" question: {ex.question}")
system_parts.append(f" options: {list(ex.options)}")
system_parts.append(f" answer: {ex.answer}")
system_content = "\n".join(system_parts)
# Phase 3: 构造 user message
user_parts: list[str] = [f"节点内容:\n{anchor.card_text}"]
if anchor.subtitle:
user_parts.append(f"\n字幕:\n{anchor.subtitle}")
if anchor.distractor_texts:
user_parts.append("\n干扰项来源节点摘要:")
for dt in anchor.distractor_texts:
user_parts.append(f"- {dt}")
user_content = "\n".join(user_parts)
messages = [
{"role": "system", "content": system_content},
{"role": "user", "content": user_content},
]
return messages, list(anchor.frame_paths)
def parse_vlm_response(
raw: str,
video_id: str,
task_type: str,
seq: int,
) -> dict:
"""解析 VLM 返回的 JSON → 部分字段字典。
尝试直接解析 JSON;若失败,从 markdown 代码块中提取后重试。
校验必需字段、选项数量和答案合法性。
参数:
raw: VLM 原始返回文本。
video_id: 所属视频标识。
task_type: 题型名称(用于错误消息)。
seq: 序列号,用于生成 question_id。
返回:
{"question_id": "gen-{video_id}-{seq:03d}", "question": ..., "options": [...], "answer": ...}
调用方(generate_one)补齐 source_nodes/difficulty 后构造 GeneratedQuestion。
异常:
ValueError: JSON 解析失败、缺必需字段、options 非 4 项、answer 不在 A-D。
"""
# Phase 1: 尝试直接解析 JSON
data = None
with contextlib.suppress(json.JSONDecodeError):
data = json.loads(raw)
# Phase 2: 从 markdown 代码块提取 JSON
if data is None:
match = re.search(r"```(?:json)?\s*\n?(.*?)\n?\s*```", raw, re.DOTALL)
if match:
with contextlib.suppress(json.JSONDecodeError):
data = json.loads(match.group(1))
if data is None:
raise ValueError(f"VLM 返回无法解析为 JSON: {raw[:200]}")
# Phase 3: 校验必需字段
required = ("question", "options", "answer")
missing = [f for f in required if f not in data]
if missing:
raise ValueError(f"VLM 返回缺少必需字段 {missing}: {raw[:200]}")
# Phase 4: options 必须恰好 4 项
options = data["options"]
if not isinstance(options, list) or len(options) != 4:
raise ValueError(
f"options 必须恰好 4 项,实际 {len(options) if isinstance(options, list) else type(options).__name__}: {raw[:200]}"
)
# Phase 5: answer 必须是 A-D
answer = data["answer"]
if answer not in _VALID_ANSWERS:
raise ValueError(f"answer 必须是 A/B/C/D 之一,实际 '{answer}': {raw[:200]}")
return {
"question_id": f"gen-{video_id}-{seq:03d}",
"question": data["question"],
"options": list(options),
"answer": answer,
}