Files
Video-Tree-TRM5/app/question_gen/gates.py
T
iomgaa 271d1682c9 feat(question_gen): add lightweight 4-gate quality check
Implement 4 concurrent LLM-based quality gates for generated questions:
- key_verify: validates answer evidence in source material
- blind_answer: rejects questions answerable without video context
- multi_true: detects ambiguous multi-correct options
- leak_test: per-family shortcut detection (5 probe templates)

Includes run_gates orchestrator with verbatim_ratio short-circuit,
JSON response parsing with fallback, and 9 unit tests (all passing).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-11 23:30:13 -04:00

472 lines
14 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.
"""轻量四门质量检查 — 对生成题目执行 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
# ---------------------------------------------------------------------------
# CandidateQuestionTask 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 name, gate in [
("key_verify", self.key_verify),
("blind_answer", self.blind_answer),
("multi_true", self.multi_true),
("leak_test", self.leak_test),
]:
if gate.verdict == GateVerdict.FAIL:
return f"[{name}] {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 前置短路
if postprocess.verbatim_ratio > _VERBATIM_THRESHOLD:
logger.info(
"verbatim_ratio={:.3f} > {:.1f}key_verify 短路 FAIL",
postprocess.verbatim_ratio,
_VERBATIM_THRESHOLD,
)
key_verify_result = GateResult(
verdict=GateVerdict.FAIL,
reason=f"verbatim_ratio={postprocess.verbatim_ratio:.3f} exceeds threshold {_VERBATIM_THRESHOLD}",
raw_response="",
)
# 其余三门仍然并发执行(收集完整诊断信息)
blind_result, multi_result, leak_result = await asyncio.gather(
_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),
)
return GateReport(
key_verify=key_verify_result,
blind_answer=blind_result,
multi_true=multi_result,
leak_test=leak_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