"""轻量四门质量检查 — 对生成题目执行 4 个独立的 LLM 质量门控。 四门: 1. key_verify: 验证答案在来源素材中有证据支撑。 2. blind_answer: 无上下文时 LLM 能否答对(若答对 → 题目泄漏)。 3. multi_true: 检测是否有多个选项可被视为正确。 4. leak_test: 按家族特定模板探测答题捷径。 设计要点: - run_gates 先做 verbatim_ratio 前置短路(> 0.5 直接 FAIL key_verify)。 - 四门并发执行(asyncio.gather)。 - 每门加载 store/prompts/question_gen/ 下对应模板,构造 messages,调 LLM。 - LLM 返回 JSON {"verdict": "pass"|"fail", "reason": "..."},解析失败视为 FAIL。 - session_id 必须透传至每次 LLM 调用(遥测关联)。 """ from __future__ import annotations import asyncio import json from dataclasses import dataclass, field from enum import Enum from pathlib import Path from typing import TYPE_CHECKING from loguru import logger if TYPE_CHECKING: from app.question_gen.families import QuestionFamilySpec from app.question_gen.postprocess import PostprocessResult from app.tree.index import TreeIndex from core.protocols import LLMProvider # --------------------------------------------------------------------------- # 常量 # --------------------------------------------------------------------------- _PROMPTS_DIR = Path(__file__).resolve().parent.parent.parent / "store" / "prompts" / "question_gen" _VERBATIM_THRESHOLD = 0.5 # --------------------------------------------------------------------------- # CandidateQuestion(Task 5 尚未实现,本地定义,后续迁移至 generator_v2.py) # --------------------------------------------------------------------------- @dataclass(frozen=True) class CandidateQuestion: """候选题目 — 出题管线生成、待门控审核的题目数据。 属性: question_id: 题目唯一标识。 video_id: 所属视频标识。 task_type: 题型(如 "Action Reasoning")。 skill_target: 目标失败机制编号(M1-M5)。 question: 题目文本。 options: 选项元组(如 ("A. ...", "B. ...", "C. ...", "D. ..."))。 answer: 正确答案字母(如 "A")。 source_nodes: 来源节点 ID 元组。 difficulty: 难度等级。 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) # --------------------------------------------------------------------------- # 门控结果类型 # --------------------------------------------------------------------------- class GateVerdict(Enum): """门控判决枚举。""" PASS = "pass" FAIL = "fail" SKIP = "skip" @dataclass(frozen=True) class GateResult: """单门判决结果。 属性: verdict: 门控判决(PASS / FAIL / SKIP)。 reason: 判决原因说明。 raw_response: LLM 原始返回内容。 """ verdict: GateVerdict reason: str raw_response: str @dataclass(frozen=True) class GateReport: """四门判决汇总报告。 属性: key_verify: 关键验证门结果。 blind_answer: 盲答门结果。 multi_true: 多正确门结果。 leak_test: 泄漏测试门结果。 """ key_verify: GateResult blind_answer: GateResult multi_true: GateResult leak_test: GateResult @property def passed(self) -> bool: """全门 PASS 或 SKIP 时视为通过。""" for gate in (self.key_verify, self.blind_answer, self.multi_true, self.leak_test): if gate.verdict == GateVerdict.FAIL: return False return True @property def reject_reason(self) -> str | None: """首个 FAIL 门的 reason,全部通过返回 None。""" for gate in (self.key_verify, self.blind_answer, self.multi_true, self.leak_test): if gate.verdict == GateVerdict.FAIL: return gate.reason return None # --------------------------------------------------------------------------- # 内部工具函数 # --------------------------------------------------------------------------- def _load_prompt_template(filename: str) -> str: """加载 store/prompts/question_gen/ 下的模板文件。 参数: filename: 模板文件名(如 "gate_key_verify.md")。 返回: 模板内容字符串。 异常: FileNotFoundError: 模板文件不存在。 """ path = _PROMPTS_DIR / filename if not path.exists(): msg = f"门控模板文件不存在: {path}" raise FileNotFoundError(msg) return path.read_text(encoding="utf-8") def _resolve_source_text(candidate: CandidateQuestion, tree: TreeIndex) -> str: """从树中解析候选题的来源节点文本。 遍历树的所有层级,匹配 source_nodes 中的 ID,提取对应描述文本。 参数: candidate: 候选题目。 tree: 视频树索引。 返回: 拼接的来源节点描述文本。 """ texts: list[str] = [] target_ids = set(candidate.source_nodes) for l1 in tree.roots: if l1.id in target_ids: texts.append(f"[L1 {l1.id}] {l1.card.scene_summary}") for l2 in l1.children: if l2.id in target_ids: texts.append(f"[L2 {l2.id}] {l2.card.event_description}") for l3 in l2.children: if l3.id in target_ids: texts.append(f"[L3 {l3.id}] {l3.card.frame_summary}") if not texts: logger.warning( "未找到来源节点: candidate={}, source_nodes={}", candidate.question_id, candidate.source_nodes, ) return "(no source material found)" return "\n".join(texts) def _format_options(options: tuple[str, ...]) -> str: """将选项元组格式化为可读字符串。""" return "\n".join(options) def _parse_gate_response(raw_content: str) -> tuple[GateVerdict, str]: """解析 LLM 返回的门控 JSON 响应。 期望格式: {"verdict": "pass"|"fail", "reason": "..."} 解析失败时返回 FAIL + parse_error。 参数: raw_content: LLM 返回的原始文本。 返回: (verdict, reason) 元组。 """ try: # 尝试从文本中提取 JSON(可能被 markdown 代码块包裹) content = raw_content.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("{"): content = stripped break data = json.loads(content) verdict_str = data.get("verdict", "").lower() reason = data.get("reason", "") if verdict_str == "pass": return GateVerdict.PASS, reason elif verdict_str == "fail": return GateVerdict.FAIL, reason else: return GateVerdict.FAIL, f"parse_error: invalid verdict '{verdict_str}'" except (json.JSONDecodeError, AttributeError, TypeError) as e: return GateVerdict.FAIL, f"parse_error: {e}" # --------------------------------------------------------------------------- # 四门实现 # --------------------------------------------------------------------------- async def _gate_key_verify( candidate: CandidateQuestion, tree: TreeIndex, llm: LLMProvider, *, session_id: str, ) -> GateResult: """关键验证门 — 检查答案在来源素材中是否有证据支撑。 参数: candidate: 候选题目。 tree: 视频树索引。 llm: LLM 调用端口。 session_id: 会话 ID(遥测关联)。 返回: GateResult 实例。 """ source_text = _resolve_source_text(candidate, tree) template = _load_prompt_template("gate_key_verify.md") prompt = template.format( source_text=source_text, question=candidate.question, options=_format_options(candidate.options), answer=candidate.answer, ) response = await llm.chat( [{"role": "user", "content": prompt}], session_id=session_id, ) verdict, reason = _parse_gate_response(response.content) return GateResult(verdict=verdict, reason=reason, raw_response=response.content) async def _gate_blind_answer( candidate: CandidateQuestion, llm: LLMProvider, *, session_id: str, ) -> GateResult: """盲答门 — 无上下文时 LLM 能否答对(答对 → 题目泄漏)。 参数: candidate: 候选题目。 llm: LLM 调用端口。 session_id: 会话 ID(遥测关联)。 返回: GateResult 实例。 """ template = _load_prompt_template("gate_blind_answer.md") prompt = template.format( question=candidate.question, options=_format_options(candidate.options), ) response = await llm.chat( [{"role": "user", "content": prompt}], session_id=session_id, ) verdict, reason = _parse_gate_response(response.content) return GateResult(verdict=verdict, reason=reason, raw_response=response.content) async def _gate_multi_true( candidate: CandidateQuestion, tree: TreeIndex, llm: LLMProvider, *, session_id: str, ) -> GateResult: """多正确门 — 检测是否有多个选项可被视为正确。 参数: candidate: 候选题目。 tree: 视频树索引。 llm: LLM 调用端口。 session_id: 会话 ID(遥测关联)。 返回: GateResult 实例。 """ source_text = _resolve_source_text(candidate, tree) template = _load_prompt_template("gate_multi_true.md") prompt = template.format( source_text=source_text, question=candidate.question, options=_format_options(candidate.options), ) response = await llm.chat( [{"role": "user", "content": prompt}], session_id=session_id, ) verdict, reason = _parse_gate_response(response.content) return GateResult(verdict=verdict, reason=reason, raw_response=response.content) async def _gate_leak_test( candidate: CandidateQuestion, family_spec: QuestionFamilySpec, llm: LLMProvider, *, session_id: str, ) -> GateResult: """泄漏测试门 — 按家族特定模板探测答题捷径。 参数: candidate: 候选题目。 family_spec: 问题家族规格(含 leak_profile)。 llm: LLM 调用端口。 session_id: 会话 ID(遥测关联)。 返回: GateResult 实例。 """ probe_template_name = family_spec.leak_profile.probe_template template = _load_prompt_template(probe_template_name) prompt = template.format( question=candidate.question, options=_format_options(candidate.options), answer=candidate.answer, ) response = await llm.chat( [{"role": "user", "content": prompt}], session_id=session_id, ) verdict, reason = _parse_gate_response(response.content) return GateResult(verdict=verdict, reason=reason, raw_response=response.content) # --------------------------------------------------------------------------- # 编排入口 # --------------------------------------------------------------------------- async def run_gates( candidate: CandidateQuestion, tree: TreeIndex, llm: LLMProvider, family_spec: QuestionFamilySpec, postprocess: PostprocessResult, *, session_id: str, ) -> GateReport: """编排四门并发执行,返回汇总报告。 前置规则:verbatim_ratio > 0.5 时直接短路 key_verify 为 FAIL(不调 LLM)。 参数: candidate: 候选题目。 tree: 视频树索引。 llm: LLM 调用端口。 family_spec: 问题家族规格。 postprocess: 后处理结果(含 verbatim_ratio)。 session_id: 会话 ID(遥测关联)。 返回: GateReport 四门汇总。 """ # Phase 1: verbatim 前置短路 — 不调 LLM,直接返回 if postprocess.verbatim_ratio > _VERBATIM_THRESHOLD: logger.info( "verbatim_ratio={:.3f} > {:.1f},key_verify 短路 FAIL,其余三门 SKIP", postprocess.verbatim_ratio, _VERBATIM_THRESHOLD, ) skip_result = GateResult( verdict=GateVerdict.SKIP, reason="skipped due to verbatim short-circuit", raw_response="", ) return GateReport( key_verify=GateResult( verdict=GateVerdict.FAIL, reason=f"verbatim_ratio={postprocess.verbatim_ratio:.3f} exceeds threshold {_VERBATIM_THRESHOLD}", raw_response="", ), blind_answer=skip_result, multi_true=skip_result, leak_test=skip_result, ) # Phase 2: 四门并发执行 key_result, blind_result, multi_result, leak_result = await asyncio.gather( _gate_key_verify(candidate, tree, llm, session_id=session_id), _gate_blind_answer(candidate, llm, session_id=session_id), _gate_multi_true(candidate, tree, llm, session_id=session_id), _gate_leak_test(candidate, family_spec, llm, session_id=session_id), ) report = GateReport( key_verify=key_result, blind_answer=blind_result, multi_true=multi_result, leak_test=leak_result, ) if report.passed: logger.debug("四门全部通过: question_id={}", candidate.question_id) else: logger.info( "门控拒绝: question_id={}, reason={}", candidate.question_id, report.reject_reason, ) return report