Files
iomgaa 5b51f4bd0c fix(synthesizer): generate_one 捕获所有异常避免 VLM 超时穿透崩溃
except (ValueError, KeyError) → except Exception,
覆盖 StreamLivenessTimeout 等网络/超时异常。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-10 01:08:03 -04:00

757 lines
24 KiB
Python
Raw Permalink 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.
"""赛题合成核心逻辑 — 节点采样、prompt 构造、VLM 响应解析、去重。
纯函数为主,异步编排仅 generate_one。
通过 DI 接收 VLMProvider / EmbeddingProvider,不 import adapters/。
"""
from __future__ import annotations
import contextlib
import json
import re
from dataclasses import dataclass
from typing import TYPE_CHECKING
import numpy as np
from loguru import logger
if TYPE_CHECKING:
import random
from collections.abc import Callable
from app.tree.index import L2Node, L3Node, TreeIndex
from core.protocols import VLMProvider
from core.types import GeneratedQuestion
@dataclass(frozen=True)
class AnchorContext:
"""锚节点上下文——生成单道题所需的全部素材。
属性:
node_id: 锚节点 ID。
card_text: 锚节点 card 序列化文本。
frame_paths: 帧图片路径列表。
subtitle: 对应字幕(可空)。
distractor_texts: 同视频其他节点摘要(供 VLM 生成干扰项)。
"""
node_id: str
card_text: str
frame_paths: list[str]
subtitle: str
distractor_texts: list[str]
@dataclass(frozen=True)
class TaskTypeSpec:
"""题型的生成规格。
属性:
level: 锚定层级("L3" / "L2" / "L1" / "L1-L2")。
needs_frames: 是否必须提供帧图。
frame_count: 帧数范围描述(如 "1", "2-3", "0-1")。
context_fields: 需要提取的 card 字段元组。
"""
level: str
needs_frames: bool
frame_count: str
context_fields: tuple[str, ...]
# ---------------------------------------------------------------------------
# 12 种 Video-MME 题型 → 树层级 + 生成规格映射
# ---------------------------------------------------------------------------
TASK_TYPE_LEVEL_MAP: dict[str, TaskTypeSpec] = {
# --- L3 单帧题型 ---
"Object Recognition": TaskTypeSpec("L3", True, "1", ("frame_summary",)),
"Attribute Perception": TaskTypeSpec("L3", True, "1", ("frame_summary",)),
"OCR Problems": TaskTypeSpec("L3", True, "1", ("frame_summary",)),
"Spatial Reasoning": TaskTypeSpec("L3", True, "1", ("frame_summary", "spatial_layout")),
"Spatial Perception": TaskTypeSpec("L3", True, "1", ("frame_summary",)),
# --- L2 多帧 / 事件级题型 ---
"Action Recognition": TaskTypeSpec("L2", True, "2-3", ("event_description",)),
"Action Reasoning": TaskTypeSpec("L2", True, "2-3", ("event_description",)),
"Counting Problem": TaskTypeSpec("L2", True, "2-3", ("event_description",)),
"Temporal Perception": TaskTypeSpec("L2", False, "0-1", ("event_description", "time_range")),
# --- L1 / 跨层级题型 ---
"Temporal Reasoning": TaskTypeSpec("L1", True, "per-L2", ("scene_summary",)),
"Information Synopsis": TaskTypeSpec("L1", True, "per-L2", ("scene_summary",)),
"Object Reasoning": TaskTypeSpec("L1-L2", True, "per-L2", ("event_description",)),
}
# ---------------------------------------------------------------------------
# 内部辅助函数
# ---------------------------------------------------------------------------
def _serialize_l3_card(l3: L3Node, context_fields: tuple[str, ...]) -> str:
"""将 L3 节点 card 按 context_fields 序列化为可读文本。
参数:
l3: L3 节点。
context_fields: 需提取的字段名元组。
返回:
多行 "field: value" 格式的文本。
"""
parts: list[str] = []
for fld in context_fields:
val = getattr(l3.card, fld, None)
if val is not None:
parts.append(f"{fld}: {val}")
return "\n".join(parts)
def _l2_time_range_str(l2: L2Node) -> str:
"""将 L2 的 time_range 格式化为可读字符串。
参数:
l2: L2 节点。
返回:
"time_range: (start, end)" 格式,或 "time_range: unknown"。
"""
if l2.time_range is not None:
return f"time_range: ({l2.time_range[0]:.2f}, {l2.time_range[1]:.2f})"
return "time_range: unknown"
def _representative_frame(l2: L2Node) -> str | None:
"""取 L2 的代表帧路径——第一个有 frame_path 的 L3 子节点。
参数:
l2: L2 节点。
返回:
帧路径字符串,或 None(无可用帧时)。
"""
for l3 in l2.children:
if l3.frame_path:
return l3.frame_path
return None
def _sort_l2_by_time(l2_nodes: list[L2Node]) -> list[L2Node]:
"""按 time_range 升序排列 L2 节点(None 排末尾)。
参数:
l2_nodes: 待排序的 L2 节点列表。
返回:
排序后的新列表(不修改原列表)。
"""
return sorted(
l2_nodes,
key=lambda n: n.time_range[0] if n.time_range is not None else float("inf"),
)
# ---------------------------------------------------------------------------
# 各层级采样策略
# ---------------------------------------------------------------------------
def _sample_l3(
tree: TreeIndex,
task_type: str,
spec: TaskTypeSpec,
used_node_ids: set[str],
rng: random.Random,
) -> AnchorContext:
"""L3 层级锚节点采样。
收集全部 L3 节点,排除已用节点,随机选取一个。
参数:
tree: 三层树索引。
task_type: 题型名称。
spec: 题型规格。
used_node_ids: 已用节点 ID 集合。
rng: 随机数生成器。
返回:
AnchorContext 实例。
异常:
ValueError: 候选 L3 节点不足。
"""
# Phase 1: 收集所有 L3 候选(必须有 frame_path
candidates: list[tuple[L3Node, L2Node]] = []
for root in tree.roots:
for l2 in root.children:
for l3 in l2.children:
if l3.id not in used_node_ids and l3.frame_path:
candidates.append((l3, l2))
if not candidates:
raise ValueError(f"锚节点不足: {task_type} 无可用 L3 节点(需具备 frame_path")
# Phase 2: 随机选取
chosen_l3, parent_l2 = rng.choice(candidates)
# Phase 3: 构造上下文(frame_path 已在候选过滤中保证非 None)
card_text = _serialize_l3_card(chosen_l3, spec.context_fields)
frame_paths = [chosen_l3.frame_path] # type: ignore[list-item]
subtitle = chosen_l3.card.subtitle or ""
# Phase 4: 干扰项——整棵树中其他 L3 的 frame_summary
distractor_texts = [
l3.card.frame_summary
for root in tree.roots
for l2 in root.children
for l3 in l2.children
if l3.id != chosen_l3.id
]
return AnchorContext(
node_id=chosen_l3.id,
card_text=card_text,
frame_paths=frame_paths,
subtitle=subtitle,
distractor_texts=distractor_texts,
)
def _sample_l2(
tree: TreeIndex,
task_type: str,
spec: TaskTypeSpec,
used_node_ids: set[str],
rng: random.Random,
) -> AnchorContext:
"""L2 层级锚节点采样(含 Temporal Perception 特殊处理)。
普通 L2 题型:随机选 1 个 L2,取 2-3 个子 L3 帧。
Temporal Perception0-1 帧,card_text 必含 time_range。
参数:
tree: 三层树索引。
task_type: 题型名称。
spec: 题型规格。
used_node_ids: 已用节点 ID 集合。
rng: 随机数生成器。
返回:
AnchorContext 实例。
异常:
ValueError: 候选 L2 节点不足。
"""
# Phase 1: 收集所有 L2 候选
all_l2: list[L2Node] = []
for root in tree.roots:
for l2 in root.children:
if l2.id not in used_node_ids:
all_l2.append(l2)
if not all_l2:
raise ValueError(f"锚节点不足: {task_type} 无可用 L2 节点")
# Phase 2: 随机选取
chosen_l2 = rng.choice(all_l2)
is_temporal_perception = task_type == "Temporal Perception"
# Phase 3: 帧路径
if is_temporal_perception:
# 0-1 帧:有子节点则取 1 帧,否则 0 帧
frame_paths: list[str] = []
if chosen_l2.children:
first_frame = chosen_l2.children[0].frame_path
if first_frame:
frame_paths = [first_frame]
else:
# 普通 L2:随机采样 2-3 个 L3 帧
children_with_frames = [l3 for l3 in chosen_l2.children if l3.frame_path]
if len(children_with_frames) < 2:
raise ValueError(
f"锚节点不足: {task_type} 需要 >=2 个子帧,"
f"但 {chosen_l2.id} 仅有 {len(children_with_frames)} 个可用帧"
)
n_frames = min(rng.randint(2, 3), len(children_with_frames))
sampled = rng.sample(children_with_frames, n_frames)
frame_paths = [l3.frame_path for l3 in sampled if l3.frame_path]
# Phase 4: card_text
card_text = f"event_description: {chosen_l2.card.event_description}"
if is_temporal_perception:
card_text += f"\n{_l2_time_range_str(chosen_l2)}"
# Phase 5: 字幕(优先使用 L2 自身字幕,否则取首个子 L3 字幕)
subtitle = chosen_l2.card.subtitle or ""
if not subtitle and chosen_l2.children and chosen_l2.children[0].card.subtitle:
subtitle = chosen_l2.children[0].card.subtitle
# Phase 6: 干扰项——整棵树中其他 L2 的 event_description
distractor_texts = [
l2.card.event_description
for root in tree.roots
for l2 in root.children
if l2.id != chosen_l2.id
]
return AnchorContext(
node_id=chosen_l2.id,
card_text=card_text,
frame_paths=frame_paths,
subtitle=subtitle,
distractor_texts=distractor_texts,
)
def _sample_l1(
tree: TreeIndex,
task_type: str,
spec: TaskTypeSpec,
used_node_ids: set[str],
rng: random.Random,
) -> AnchorContext:
"""L1 层级锚节点采样(Temporal Reasoning / Information Synopsis)。
Information Synopsis:使用目标 L1 下全部 L2 子节点。
Temporal Reasoning:严格要求 >=3 个 L2 子节点,不足则抛 ValueError。
L2 按 time_range 升序排列,每个 L2 取一帧代表。
参数:
tree: 三层树索引。
task_type: 题型名称。
spec: 题型规格。
used_node_ids: 已用节点 ID 集合。
rng: 随机数生成器。
返回:
AnchorContext 实例。
异常:
ValueError: 候选 L1 节点不足,或 Temporal Reasoning 的 L2 子节点 <3。
"""
# Phase 1: 收集可用 L1
candidates = [r for r in tree.roots if r.id not in used_node_ids]
if not candidates:
raise ValueError(f"锚节点不足: {task_type} 无可用 L1 节点")
# Phase 2: 随机选取
chosen_l1 = rng.choice(candidates)
# Phase 3: 选定 L2 子集
if task_type == "Information Synopsis":
# 必须使用全部 L2
selected_l2 = list(chosen_l1.children)
else:
# Temporal Reasoning:严格要求 >=3 个 L2
if len(chosen_l1.children) < 3:
raise ValueError(
f"锚节点不足: {task_type} 需要 >=3 个 L2 子节点,"
f"但 {chosen_l1.id} 仅有 {len(chosen_l1.children)} 个"
)
if len(chosen_l1.children) == 3:
selected_l2 = list(chosen_l1.children)
else:
selected_l2 = rng.sample(chosen_l1.children, rng.randint(3, len(chosen_l1.children)))
# Phase 4: 按 time_range 升序排列
selected_l2 = _sort_l2_by_time(selected_l2)
# Phase 5: card_text(场景摘要)
card_text = f"scene_summary: {chosen_l1.card.scene_summary}"
# Phase 6: 帧路径——每个 L2 取一帧代表
frame_paths: list[str] = []
for l2 in selected_l2:
rep = _representative_frame(l2)
if rep:
frame_paths.append(rep)
# Phase 7: 字幕(L1 无字幕)
subtitle = ""
# Phase 8: 干扰项——其他 L1 的 scene_summary
distractor_texts = [r.card.scene_summary for r in tree.roots if r.id != chosen_l1.id]
return AnchorContext(
node_id=chosen_l1.id,
card_text=card_text,
frame_paths=frame_paths,
subtitle=subtitle,
distractor_texts=distractor_texts,
)
def _sample_l1_l2(
tree: TreeIndex,
task_type: str,
spec: TaskTypeSpec,
used_node_ids: set[str],
rng: random.Random,
) -> AnchorContext:
"""L1-L2 跨层级锚节点采样(Object Reasoning)。
从全部 L2 中随机选 2-3 个,按 time_range 排序,
card_text 为各 L2 的 event_description 拼接。
参数:
tree: 三层树索引。
task_type: 题型名称。
spec: 题型规格。
used_node_ids: 已用节点 ID 集合。
rng: 随机数生成器。
返回:
AnchorContext 实例。
异常:
ValueError: 候选 L2 节点不足。
"""
# Phase 1: 收集全部 L2
all_l2: list[L2Node] = []
for root in tree.roots:
for l2 in root.children:
if l2.id not in used_node_ids:
all_l2.append(l2)
if len(all_l2) < 2:
raise ValueError(
f"锚节点不足: {task_type} 需要 >=2 个 L2 节点,但仅有 {len(all_l2)} 个可用"
)
# Phase 2: 随机选 2-3 个
n_pick = min(rng.randint(2, 3), len(all_l2))
selected = rng.sample(all_l2, n_pick)
# Phase 3: 按 time_range 升序排列
selected = _sort_l2_by_time(selected)
# Phase 4: card_text = 各 L2 event_description 拼接
card_text = "\n".join(f"event_description: {l2.card.event_description}" for l2 in selected)
# Phase 5: 帧路径——每个 L2 取一帧代表
frame_paths: list[str] = []
for l2 in selected:
rep = _representative_frame(l2)
if rep:
frame_paths.append(rep)
# Phase 6: 字幕
subtitle = ""
# Phase 7: 干扰项——未被选中的 L2 的 event_description
selected_ids = {l2.id for l2 in selected}
distractor_texts = [l2.card.event_description for l2 in all_l2 if l2.id not in selected_ids]
# 使用第一个被选中节点的 ID 作为锚节点 ID
anchor_id = selected[0].id
return AnchorContext(
node_id=anchor_id,
card_text=card_text,
frame_paths=frame_paths,
subtitle=subtitle,
distractor_texts=distractor_texts,
)
# ---------------------------------------------------------------------------
# 公开接口
# ---------------------------------------------------------------------------
def sample_anchor(
tree: TreeIndex,
task_type: str,
used_node_ids: set[str],
rng: random.Random,
) -> AnchorContext:
"""根据题型从视频树中采样锚节点及上下文素材。
依据 TASK_TYPE_LEVEL_MAP 中的层级规格,分发到对应的层级采样策略。
每种层级有不同的帧选取、card 序列化和干扰项收集逻辑。
参数:
tree: 三层树索引。
task_type: 12 种 Video-MME 题型之一。
used_node_ids: 本轮已用节点 ID 集合(避免重复采样)。
rng: 可控随机数生成器(保证可复现)。
返回:
AnchorContext 实例,包含锚节点 ID、card 文本、帧路径、字幕和干扰项。
异常:
KeyError: task_type 不在 TASK_TYPE_LEVEL_MAP 中。
ValueError: 候选节点不足(全部被 used_node_ids 排除)。
"""
spec = TASK_TYPE_LEVEL_MAP[task_type]
if spec.level == "L3":
return _sample_l3(tree, task_type, spec, used_node_ids, rng)
elif spec.level == "L2":
return _sample_l2(tree, task_type, spec, used_node_ids, rng)
elif spec.level == "L1":
return _sample_l1(tree, task_type, spec, used_node_ids, rng)
elif spec.level == "L1-L2":
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}-{task_type.lower().replace(' ', '_')}-{seq:03d}",
"question": data["question"],
"options": list(options),
"answer": answer,
}
# ---------------------------------------------------------------------------
# Embedding 去重
# ---------------------------------------------------------------------------
def is_duplicate(
question_text: str,
pool_embeddings: np.ndarray,
embed_fn: Callable[[str | list[str]], np.ndarray],
threshold: float,
) -> bool:
"""embedding 去重判定。
参数:
question_text: 待检查的题目文本。
pool_embeddings: 已有题目的 embedding 矩阵 [N, D]L2 归一化)。
embed_fn: 文本嵌入函数,返回 [N, D] ndarrayL2 归一化)。
threshold: 余弦相似度阈值。
返回:
True 表示与池中某题重复。空池永远返回 False。
"""
if pool_embeddings.shape[0] == 0:
return False
query = embed_fn(question_text) # [1, D]
query = query.squeeze(0) # [D]
similarities = pool_embeddings @ query # [N]
return bool(np.max(similarities) >= threshold)
# ---------------------------------------------------------------------------
# 单题生成
# ---------------------------------------------------------------------------
async def generate_one(
vlm: VLMProvider,
tree: TreeIndex,
video_id: str,
task_type: str,
seq: int,
*,
exemplars: list[GeneratedQuestion],
used_node_ids: set[str],
max_retries: int,
rng: random.Random,
session_id: str,
) -> GeneratedQuestion | None:
"""生成单道候选题(不含去重——去重在调用方汇总点原子执行)。
循环最多 max_retries 次尝试生成。每次尝试:
1. 采样锚节点
2. 构造 prompt
3. 调用 VLM
4. 解析响应
5. 构造 GeneratedQuestion
返回 None 表示耗尽重试。
参数:
vlm: VLM 调用端口。
tree: 三层树索引。
video_id: 所属视频标识。
task_type: 12 种 Video-MME 题型之一。
seq: 序列号,用于生成 question_id。
exemplars: 少样本示例列表。
used_node_ids: 已用节点 ID 集合。
max_retries: 最大重试次数。
rng: 可控随机数生成器。
session_id: 会话 ID(传递给 VLM 遥测)。
返回:
GeneratedQuestion 实例,或 None(耗尽重试)。
"""
from core.types import GeneratedQuestion as _GeneratedQuestion
for attempt in range(max_retries):
try:
# Phase 1: 采样锚节点
anchor = sample_anchor(tree, task_type, used_node_ids, rng)
# Phase 2: 构造 prompt
messages, images = build_generation_prompt(task_type, anchor, exemplars)
# Phase 3: 调用 VLM
response = await vlm.chat_with_images(
messages,
images,
session_id=session_id,
)
# Phase 4: 解析响应
parsed = parse_vlm_response(response.content, video_id, task_type, seq)
# Phase 5: 构造 GeneratedQuestion
return _GeneratedQuestion(
question_id=parsed["question_id"],
video_id=video_id,
task_type=task_type,
question=parsed["question"],
options=tuple(parsed["options"]),
answer=parsed["answer"],
source_nodes=(anchor.node_id,),
difficulty="medium",
)
except Exception as exc:
logger.warning(
"generate_one 尝试 {}/{} 失败 ({}): {}",
attempt + 1,
max_retries,
task_type,
exc,
)
continue
logger.warning(
"generate_one 耗尽 {} 次重试 (video={}, task_type={})",
max_retries,
video_id,
task_type,
)
return None