"""v2 生成器 — 基于策略特化 prompt 模板的单题 VLM 出题模块。 使用 VLMProvider 接口调用视觉语言模型,结合 per-strategy prompt 模板 和 MaterialContext 素材上下文,生成一道四选一候选题。 典型调用路径:: candidate = await generate_one_v2( vlm=vlm_client, tree=tree_index, material=material_ctx, task_type="Action Reasoning", seq=1, video_id="vid_001", prompt_template="retrieval.md", strategy_name="RETRIEVAL", skill_target="M1", session_id="sess_001", ) """ from __future__ import annotations import json from dataclasses import dataclass, field from pathlib import Path from typing import TYPE_CHECKING from json_repair import repair_json from loguru import logger if TYPE_CHECKING: from app.question_gen.sampler_v2 import MaterialContext from app.tree.index import TreeIndex from core.protocols import VLMProvider # --------------------------------------------------------------------------- # 常量 # --------------------------------------------------------------------------- _PROMPTS_DIR = Path(__file__).resolve().parent.parent.parent / "store" / "prompts" / "question_gen" _VALID_ANSWERS = frozenset({"A", "B", "C", "D"}) _VALID_DIFFICULTIES = frozenset({"easy", "medium", "hard"}) # --------------------------------------------------------------------------- # CandidateQuestion(规范定义位置) # --------------------------------------------------------------------------- @dataclass(frozen=True) class CandidateQuestion: """候选题目 — 出题管线生成、待门控审核的题目数据。 属性: question_id: 题目唯一标识(格式 "{video_id}_{task_type}_{seq:04d}")。 video_id: 所属视频标识。 task_type: 题型(如 "Action Reasoning")。 skill_target: 目标失败机制编号(M1-M5)。 question: 题目文本。 options: 选项元组(如 ("A. ...", "B. ...", "C. ...", "D. ..."))。 answer: 正确答案字母(如 "A")。 source_nodes: 来源节点 ID 元组。 difficulty: 难度等级(easy/medium/hard)。 subtitle_sentences: 验证材料 — 字幕句子元组。 frame_paths: 验证材料 — 帧图片路径元组。 """ question_id: str video_id: str task_type: str skill_target: str question: str options: tuple[str, ...] answer: str source_nodes: tuple[str, ...] difficulty: str subtitle_sentences: tuple[str, ...] = field(default_factory=tuple) frame_paths: tuple[str, ...] = field(default_factory=tuple) # --------------------------------------------------------------------------- # Prompt 模板加载 # --------------------------------------------------------------------------- def _load_prompt_template(template_name: str) -> str: """加载 prompt 模板文件。 参数: template_name: store/prompts/question_gen/ 下的模板文件名。 返回: 模板内容字符串。 异常: FileNotFoundError: 模板文件不存在。 """ path = _PROMPTS_DIR / template_name if not path.exists(): msg = f"Prompt 模板文件不存在: {path}" raise FileNotFoundError(msg) return path.read_text(encoding="utf-8") # --------------------------------------------------------------------------- # Prompt 构建 # --------------------------------------------------------------------------- def _build_v2_prompt( prompt_template: str, strategy_name: str, material: MaterialContext, task_type: str, seq: int, *, reject_reason: str | None = None, sub_pattern_instruction: str | None = None, ) -> tuple[list[dict[str, str]], list[str]]: """构建 VLM 出题调用的 messages 和帧路径列表。 参数: prompt_template: prompt 模板文件名(store/prompts/question_gen/ 下)。 strategy_name: 策略名称(如 "RETRIEVAL")。 material: 采样素材上下文。 task_type: 任务类型字符串。 seq: 当前序号。 reject_reason: 上一次被门控拒绝的原因(用于引导 VLM 避免相同错误)。 sub_pattern_instruction: 子模式特殊指令(如有)。 返回: 二元组: - messages: 适配 VLMProvider 的 message 列表(system + user)。 - frame_paths: 需发送给 VLM 的帧路径列表。 """ # Phase 1: 加载策略模板作为 system prompt template_content = _load_prompt_template(prompt_template) system_message = template_content # Phase 2: 构建 user prompt — 聚合素材信息 user_parts: list[str] = [] user_parts.append(f"## Task Type: {task_type}") user_parts.append(f"## Question Family: {strategy_name}") user_parts.append(f"## Sequence: #{seq}") # 字幕素材 if material.subtitle_sentences: user_parts.append("\n## Subtitle Content:") for i, sent in enumerate(material.subtitle_sentences, 1): user_parts.append(f" {i}. {sent}") # 跨 L2 上下文 if material.cross_l2_texts: user_parts.append("\n## Cross-Segment Context:") for text in material.cross_l2_texts: user_parts.append(f" - {text}") # 帧路径提示(VLM 会接收实际图像,此处仅作文本参考) if material.frame_paths: user_parts.append(f"\n## Visual Frames: {len(material.frame_paths)} frames attached.") # 拒绝原因注入 if reject_reason is not None: user_parts.append( f"\n## IMPORTANT - Previous Attempt Rejected:\n" f"Your previous question was rejected for the following reason:\n" f'"{reject_reason}"\n' f"Please generate a NEW question that avoids this issue." ) # 子模式特殊指令注入 if sub_pattern_instruction is not None: user_parts.append(f"\n## Special Focus:\n{sub_pattern_instruction}") # 输出格式指令 user_parts.append( "\n## Output Format:\n" "Respond with ONLY a JSON object in this exact format:\n" "```json\n" "{\n" ' "question": "Your question text here",\n' ' "options": ["A. ...", "B. ...", "C. ...", "D. ..."],\n' ' "answer": "A",\n' ' "difficulty": "easy|medium|hard"\n' "}\n" "```" ) user_content = "\n".join(user_parts) messages = [ {"role": "system", "content": system_message}, {"role": "user", "content": user_content}, ] # Phase 3: 帧路径 frame_paths = list(material.frame_paths) return messages, frame_paths # --------------------------------------------------------------------------- # 响应解析 # --------------------------------------------------------------------------- def _extract_json_from_text(raw: str) -> str: """从可能被 markdown 代码块包裹的文本中提取 JSON 部分。 参数: raw: VLM 原始返回文本。 返回: 清理后的 JSON 字符串。 """ content = raw.strip() if "```" in content: parts = content.split("```") for part in parts: stripped = part.strip() if stripped.startswith("json"): stripped = stripped[4:].strip() if stripped.startswith("{"): return stripped return content @dataclass(frozen=True) class _ValidatedFields: """字段校验通过后的中间结构。""" question: str options: tuple[str, ...] answer: str difficulty: str def _validate_parsed_fields(data: dict) -> _ValidatedFields: """校验 VLM 响应 JSON 的必填字段并规范化。 参数: data: 已解析的 JSON 字典。 返回: _ValidatedFields 实例(字段已规范化)。 异常: ValueError: 缺少必填字段或字段值非法。 """ missing = [f for f in ("question", "options", "answer", "difficulty") if f not in data] if missing: msg = f"VLM 响应缺少必填字段: {', '.join(missing)}" raise ValueError(msg) question_text = str(data["question"]) options_raw = data["options"] answer = str(data["answer"]).strip().upper() difficulty = str(data["difficulty"]).strip().lower() # 校验 options(answer 约束为 A-D,因此必须恰好 4 个选项) if not isinstance(options_raw, list) or len(options_raw) != 4: msg = f"options 字段必须恰好包含 4 个选项,实际数量: {len(options_raw) if isinstance(options_raw, list) else type(options_raw).__name__}" raise ValueError(msg) # 校验 answer if answer not in _VALID_ANSWERS: msg = f"answer 字段值 '{answer}' 非法,必须为 A/B/C/D 之一" raise ValueError(msg) # 校验 difficulty(宽容处理:非法值回退为 medium) if difficulty not in _VALID_DIFFICULTIES: logger.warning( "difficulty '{}' 不在预设范围 {},回退为 'medium'", difficulty, _VALID_DIFFICULTIES, ) difficulty = "medium" return _ValidatedFields( question=question_text, options=tuple(str(o) for o in options_raw), answer=answer, difficulty=difficulty, ) def _parse_v2_response( raw: str, video_id: str, task_type: str, skill_target: str, seq: int, source_nodes: tuple[str, ...], ) -> CandidateQuestion: """解析 VLM 返回的 JSON 响应,构造 CandidateQuestion。 流程: 1. 提取 JSON(处理 markdown 包裹)。 2. json_repair 修复常见格式错误。 3. 校验必填字段(委托 _validate_parsed_fields)。 4. 构造 CandidateQuestion 实例。 参数: raw: VLM 原始返回文本。 video_id: 视频标识。 task_type: 任务类型。 skill_target: 目标技能编号。 seq: 当前序号。 source_nodes: 来源节点 ID 元组。 返回: CandidateQuestion 实例。 异常: ValueError: JSON 解析失败或缺少必填字段或字段值非法。 """ # Phase 1: 提取 + 修复 JSON json_text = _extract_json_from_text(raw) repaired = repair_json(json_text, return_objects=False) # Phase 2: 解析为字典 try: data = json.loads(repaired) except json.JSONDecodeError as e: msg = f"VLM 响应 JSON 解析失败: {e}. 原始文本: {raw[:200]}" raise ValueError(msg) from e if not isinstance(data, dict): msg = f"VLM 响应顶层不是 JSON 对象: type={type(data).__name__}" raise ValueError(msg) # Phase 3: 字段校验 fields = _validate_parsed_fields(data) # Phase 4: 构造 CandidateQuestion question_id = f"{video_id}_{task_type}_{seq:04d}" return CandidateQuestion( question_id=question_id, video_id=video_id, task_type=task_type, skill_target=skill_target, question=fields.question, options=fields.options, answer=fields.answer, source_nodes=source_nodes, difficulty=fields.difficulty, ) # --------------------------------------------------------------------------- # 主入口 # --------------------------------------------------------------------------- async def generate_one_v2( vlm: VLMProvider, tree: TreeIndex, material: MaterialContext, task_type: str, seq: int, *, video_id: str, prompt_template: str, strategy_name: str, skill_target: str, reject_reason: str | None = None, sub_pattern_instruction: str | None = None, session_id: str, ) -> CandidateQuestion: """调用 VLM 生成一道候选题目。 流程: 1. 构建 per-strategy prompt + 帧路径。 2. 调用 VLMProvider.chat_with_images。 3. 解析响应为 CandidateQuestion。 4. 附加素材验证信息(subtitle_sentences、frame_paths)。 参数: vlm: VLM 调用端口。 tree: 视频树索引(当前未直接使用,预留后续扩展)。 material: 采样素材上下文。 task_type: 任务类型字符串。 seq: 当前序号。 video_id: 视频标识。 prompt_template: prompt 模板文件名。 strategy_name: 策略名称(如 "RETRIEVAL")。 skill_target: 目标失败机制编号(M1-M5)。 reject_reason: 上一次被门控拒绝的原因。 sub_pattern_instruction: 子模式特殊指令(如有)。 session_id: 会话 ID(遥测关联)。 返回: CandidateQuestion 实例(包含验证材料)。 异常: ValueError: VLM 响应解析失败。 FileNotFoundError: prompt 模板不存在。 """ # Phase 1: 构建 prompt messages, frame_paths = _build_v2_prompt( prompt_template=prompt_template, strategy_name=strategy_name, material=material, task_type=task_type, seq=seq, reject_reason=reject_reason, sub_pattern_instruction=sub_pattern_instruction, ) # Phase 2: 调用 VLM logger.debug( "generate_one_v2: strategy={}, task_type={}, seq={}, frames={}", strategy_name, task_type, seq, len(frame_paths), ) response = await vlm.chat_with_images( messages, frame_paths, session_id=session_id, ) # Phase 3: 解析响应 candidate = _parse_v2_response( raw=response.content, video_id=video_id, task_type=task_type, skill_target=skill_target, seq=seq, source_nodes=material.source_nodes, ) # Phase 4: 附加验证材料(构造新实例,因 frozen=True) candidate = CandidateQuestion( question_id=candidate.question_id, video_id=candidate.video_id, task_type=candidate.task_type, skill_target=candidate.skill_target, question=candidate.question, options=candidate.options, answer=candidate.answer, source_nodes=candidate.source_nodes, difficulty=candidate.difficulty, subtitle_sentences=tuple(material.subtitle_sentences), frame_paths=tuple(material.frame_paths), ) logger.debug( "generate_one_v2 完成: question_id={}, difficulty={}", candidate.question_id, candidate.difficulty, ) return candidate