feat: add mirror question generation with canonical distinctness check

This commit is contained in:
2026-07-14 16:08:13 -04:00
parent d4e9852864
commit 4af99b700b
3 changed files with 234 additions and 1 deletions
+134 -1
View File
@@ -12,14 +12,20 @@ from __future__ import annotations
import enum
import hashlib
import json
from pathlib import Path
from typing import TYPE_CHECKING, Protocol
from json_repair import repair_json
from loguru import logger
from core.types import GeneratedQuestion
if TYPE_CHECKING:
from app.question_gen.adversarial_config import AdversarialFilterConfig
from app.question_gen.run_store import QuestionGenStore
from core.types import GeneratedQuestion
from app.question_gen.sampler_v2 import MaterialContext
from app.tree.index import TreeIndex
from core.protocols import VLMProvider
_VALID_LETTERS = ("A", "B", "C", "D")
@@ -213,3 +219,130 @@ async def run_cheater_gate(
len(questions), len(completed), len(todo), len(survivors),
)
return survivors
_PROMPTS_DIR = Path(__file__).resolve().parent.parent.parent / "store" / "prompts" / "question_gen"
def _rebuild_material(tree: TreeIndex, source_nodes: tuple[str, ...]) -> MaterialContext:
"""从 source_nodes 重建镜像生成所需素材(字幕 + 帧)。
复用 sampler_v2 的采集辅助;anchor/cross_l2_texts 镜像生成不需要,置空。
参数:
tree: 三层树索引(Phase B 持树)。
source_nodes: 原题来源节点 ID 元组。
返回:
仅含 subtitle_sentences / frame_paths 的 MaterialContext。
"""
from app.question_gen.sampler_v2 import (
MaterialContext as _MaterialContext,
)
from app.question_gen.sampler_v2 import (
_collect_frame_paths,
_collect_subtitle_sentences,
)
subtitles = _collect_subtitle_sentences(tree, source_nodes)
frames: list[str] = []
for nid in source_nodes:
frames.extend(_collect_frame_paths(tree, nid))
return _MaterialContext(
anchor=None, # 镜像 prompt 不用 anchor
source_nodes=source_nodes,
subtitle_sentences=subtitles,
frame_paths=frames,
cross_l2_texts=[],
)
def _parse_mirror(raw: str) -> dict | None:
"""解析 VLM 镜像响应;{"mirror": null} 或解析失败 → None。
参数:
raw: VLM 原始文本响应。
返回:
mirror 字典;null / 非法结构 / 解析失败一律 None。
"""
content = raw.strip()
if "```" in content:
for part in content.split("```"):
s = part.strip()
if s.startswith("json"):
s = s[4:].strip()
if s.startswith("{"):
content = s
break
try:
data = json.loads(repair_json(content, return_objects=False))
except (json.JSONDecodeError, TypeError, ValueError):
# 畸形 VLM 响应绝不中断本轮:解析失败 → None(上游按 flip_skipped 处理,设计 §4.2
return None
if not isinstance(data, dict):
return None
mirror = data.get("mirror")
return mirror if isinstance(mirror, dict) else None
async def generate_mirror_question(
question: GeneratedQuestion,
*,
flip_axis: str,
vlm: VLMProvider,
material: MaterialContext,
session_id: str,
) -> GeneratedQuestion | None:
"""VLM 生成翻转 flip_axis 的镜像题;正解 canonical 与原题相同则返 None。
参数:
question: 原题。
flip_axis: 翻转轴("before/after" | "first/last")。
vlm: VLM 端口。
material: 重建素材(frame_paths / subtitles)。
session_id: 遥测会话 ID。
返回:
镜像 GeneratedQuestionquestion_id 加 "_mirror" 后缀,不进题库);
无法造出有效对(null / 正解相同 / 解析失败)返回 None。
"""
system = (_PROMPTS_DIR / "ar_mirror_question.md").read_text(encoding="utf-8")
subs = "\n".join(f" - {s}" for s in material.subtitle_sentences)
user = (
f"## Original Question\n{question.question}\n"
"## Options\n" + "\n".join(question.options) + "\n"
f"## Correct Answer\n{question.answer}\n"
f"## Flip Axis\n{flip_axis}\n"
f"## Subtitles\n{subs}\n"
)
messages = [{"role": "system", "content": system}, {"role": "user", "content": user}]
resp = await vlm.chat_with_images(
messages, list(material.frame_paths), session_id=session_id
)
mirror = _parse_mirror(resp.content)
if mirror is None:
return None
try:
options = tuple(str(o) for o in mirror["options"])
answer = str(mirror["answer"]).strip().upper()
m_question = str(mirror["question"])
except (KeyError, TypeError):
return None
mirror_q = GeneratedQuestion(
question_id=f"{question.question_id}_mirror",
video_id=question.video_id,
task_type=question.task_type,
question=m_question,
options=options,
answer=answer,
source_nodes=question.source_nodes,
difficulty=question.difficulty,
sub_pattern=question.sub_pattern,
)
# 镜像正解字面校验:canonical(P) 必须 != canonical(Q)(按选项文本比较,非字母)
p_text = canonical_answer_text(question.options, question.answer)
q_text = canonical_answer_text(mirror_q.options, answer)
if p_text is None or q_text is None or p_text.strip() == q_text.strip():
return None
return mirror_q