"""v2 素材采样器 — 基于采样约束的树节点采样与上下文收集。 在 v1 synthesizer 的基础上引入 SamplingConstraint 约束验证, 为每次出题提供更丰富的素材上下文(字幕、跨 L2 上下文、帧路径)。 典型调用路径:: material = sample_material_v2( tree=tree_index, task_type="Action Reasoning", used_node_ids=already_used, rng=rng, level=2, constraint=my_constraint, ) """ from __future__ import annotations from dataclasses import dataclass from typing import TYPE_CHECKING from loguru import logger if TYPE_CHECKING: import random from app.question_gen.families import SamplingConstraint from app.tree.index import L1Node, L2Node, TreeIndex # --------------------------------------------------------------------------- # 题型 → 采样层级映射 # --------------------------------------------------------------------------- _TASK_TYPE_TO_LEVEL: dict[str, int] = { # Level 3(细粒度帧级) "Action Recognition": 3, "Object Recognition": 3, "Attribute Perception": 3, "OCR Problems": 3, # Level 2(片段/事件级) "Action Reasoning": 2, "Object Reasoning": 2, "Information Synopsis": 2, "Counting Problem": 2, # Level 1(段落/场景级) "Temporal Reasoning": 1, "Temporal Perception": 1, "Spatial Reasoning": 1, "Spatial Perception": 1, } # --------------------------------------------------------------------------- # 数据类型 # --------------------------------------------------------------------------- @dataclass(frozen=True) class AnchorContext: """采样锚点上下文。 属性: node_id: 锚节点 ID。 level: 锚节点所在层级(1/2/3)。 l2_id: 锚节点所属的 L2 节点 ID(若自身为 L2 则等于 node_id; 若为 L1 则取其第一个 L2 子节点 ID)。 """ node_id: str level: int l2_id: str @dataclass(frozen=True) class MaterialContext: """采样素材上下文 — 出题所需的全部素材打包。 属性: anchor: 采样锚点信息。 source_nodes: 参与采样的节点 ID 元组。 subtitle_sentences: 锚节点子树中收集的字幕句列表。 frame_paths: 锚节点子树中可用的帧路径列表。 cross_l2_texts: 跨 L2 段的上下文文本列表(仅 cross_l2_span 时填充)。 """ anchor: AnchorContext source_nodes: tuple[str, ...] subtitle_sentences: list[str] frame_paths: list[str] cross_l2_texts: list[str] # --------------------------------------------------------------------------- # 内部索引辅助 # --------------------------------------------------------------------------- @dataclass(frozen=True) class _ResolvedSubtree: """节点解析结果 — 将任意层级 node_id 统一解析为 L2 节点列表 + 父 L1。 属性: l2_nodes: 与目标节点关联的 L2 节点列表。 parent_l1: 父 L1 节点(用于跨 L2 判断)。 """ l2_nodes: list[L2Node] parent_l1: L1Node | None def _find_l2_node(tree: TreeIndex, l2_id: str) -> tuple[L2Node, L1Node] | None: """按 ID 定位 L2 节点及其父 L1。 参数: tree: 三层树索引。 l2_id: L2 节点 ID。 返回: (L2Node, 父L1Node) 元组;未找到返回 None。 """ for l1 in tree.roots: for l2 in l1.children: if l2.id == l2_id: return (l2, l1) return None def _find_l1_node(tree: TreeIndex, l1_id: str) -> L1Node | None: """按 ID 定位 L1 节点。 参数: tree: 三层树索引。 l1_id: L1 节点 ID。 返回: L1Node;未找到返回 None。 """ for l1 in tree.roots: if l1.id == l1_id: return l1 return None def _find_l3_parent(tree: TreeIndex, l3_id: str) -> tuple[L2Node, L1Node] | None: """按 L3 ID 定位其所属的 L2 和 L1 节点。 参数: tree: 三层树索引。 l3_id: L3 节点 ID。 返回: (L2Node, L1Node) 元组;未找到返回 None。 """ for l1 in tree.roots: for l2 in l1.children: for l3 in l2.children: if l3.id == l3_id: return (l2, l1) return None def _resolve_subtree(tree: TreeIndex, node_id: str) -> _ResolvedSubtree | None: """将任意层级节点 ID 解析为关联的 L2 节点列表和父 L1。 参数: tree: 三层树索引。 node_id: 任意层级的节点 ID。 返回: _ResolvedSubtree 实例;节点不存在时返回 None。 """ # 尝试作为 L2 result = _find_l2_node(tree, node_id) if result is not None: l2_node, parent_l1 = result return _ResolvedSubtree(l2_nodes=[l2_node], parent_l1=parent_l1) # 尝试作为 L1 l1_node = _find_l1_node(tree, node_id) if l1_node is not None: return _ResolvedSubtree(l2_nodes=list(l1_node.children), parent_l1=l1_node) # 尝试作为 L3 l3_result = _find_l3_parent(tree, node_id) if l3_result is not None: l2_node, parent_l1 = l3_result return _ResolvedSubtree(l2_nodes=[l2_node], parent_l1=parent_l1) return None # --------------------------------------------------------------------------- # 约束检查辅助(单一职责) # --------------------------------------------------------------------------- def _count_l3_descendants(l2_nodes: list[L2Node]) -> int: """统计 L2 节点列表下的 L3 总数。 参数: l2_nodes: L2 节点列表。 返回: L3 节点总数。 """ return sum(len(l2.children) for l2 in l2_nodes) def _has_frames(l2_nodes: list[L2Node]) -> bool: """检查 L2 节点列表的子树中是否有可用帧。 参数: l2_nodes: L2 节点列表。 返回: True 表示至少有一个 L3 有 frame_path。 """ return any(l3.frame_path for l2 in l2_nodes for l3 in l2.children) def _count_subtitles(l2_nodes: list[L2Node]) -> int: """统计 L2 节点列表中全部字幕数(L2 + L3)。 参数: l2_nodes: L2 节点列表。 返回: 非空字幕总数。 """ count = 0 for l2 in l2_nodes: if l2.card.subtitle: count += 1 count += sum(1 for l3 in l2.children if l3.card.subtitle) return count # --------------------------------------------------------------------------- # 公开辅助函数 # --------------------------------------------------------------------------- def _validate_sampling_constraints( tree: TreeIndex, node_id: str, constraint: SamplingConstraint ) -> bool: """校验指定节点是否满足采样约束。 根据节点层级自动判断检查范围: - L2 节点:检查其子 L3 的帧/字幕数量。 - L1 节点:检查其下全部 L2/L3 的帧/字幕总数。 - L3 节点:检查其所属 L2 的子树。 参数: tree: 三层树索引。 node_id: 待检查节点 ID。 constraint: 采样约束条件。 返回: True 表示满足所有约束,False 表示至少一项不满足。 """ resolved = _resolve_subtree(tree, node_id) if resolved is None: return False if _count_l3_descendants(resolved.l2_nodes) < constraint.min_l3_nodes: return False if constraint.require_frames and not _has_frames(resolved.l2_nodes): return False if _count_subtitles(resolved.l2_nodes) < constraint.min_subtitles: return False return not ( constraint.cross_l2_span and (resolved.parent_l1 is None or len(resolved.parent_l1.children) < 2) ) def _subtitles_from_l2_list(l2_nodes: list[L2Node]) -> list[str]: """从 L2 节点列表收集全部非空字幕。 参数: l2_nodes: L2 节点列表。 返回: 非空字幕字符串列表。 """ sentences: list[str] = [] for l2 in l2_nodes: if l2.card.subtitle: sentences.append(l2.card.subtitle) for l3 in l2.children: if l3.card.subtitle: sentences.append(l3.card.subtitle) return sentences def _collect_subtitle_sentences(tree: TreeIndex, node_ids: tuple[str, ...]) -> list[str]: """从指定节点集合中收集字幕句。 遍历每个 node_id 对应的子树,提取非空字幕。 对 L2 节点提取自身 + 子 L3 字幕;对 L1 提取下属全部。 参数: tree: 三层树索引。 node_ids: 待收集字幕的节点 ID 元组。 返回: 非空字幕句列表。 """ sentences: list[str] = [] for nid in node_ids: resolved = _resolve_subtree(tree, nid) if resolved is not None: sentences.extend(_subtitles_from_l2_list(resolved.l2_nodes)) return sentences def _collect_cross_l2_context(tree: TreeIndex, anchor_l2_id: str, max_peers: int = 3) -> list[str]: """收集锚 L2 的同级 L2 节点描述文本(跨 L2 上下文)。 找到锚 L2 所属的 L1 父节点,取该父节点下除锚 L2 之外的其他 L2 描述。 参数: tree: 三层树索引。 anchor_l2_id: 锚 L2 节点 ID。 max_peers: 最多返回的同级 L2 描述数量。 返回: 同级 L2 的 event_description 列表(最多 max_peers 条)。 """ result = _find_l2_node(tree, anchor_l2_id) if result is None: return [] _, parent_l1 = result peers: list[str] = [] for l2 in parent_l1.children: if l2.id != anchor_l2_id: peers.append(l2.card.event_description) if len(peers) >= max_peers: break return peers # --------------------------------------------------------------------------- # 层级采样策略 # --------------------------------------------------------------------------- def _sample_l3_node( tree: TreeIndex, used_node_ids: set[str], rng: random.Random, ) -> tuple[str, str] | None: """随机采样一个未使用的 L3 节点,返回 (l3_id, 所属l2_id)。 参数: tree: 三层树索引。 used_node_ids: 已用节点 ID 集合。 rng: 随机数生成器。 返回: (l3_id, l2_id) 元组;无候选返回 None。 """ candidates: list[tuple[str, str]] = [] for l1 in tree.roots: for l2 in l1.children: for l3 in l2.children: if l3.id not in used_node_ids: candidates.append((l3.id, l2.id)) if not candidates: return None return rng.choice(candidates) def _sample_l2_node( tree: TreeIndex, used_node_ids: set[str], rng: random.Random, ) -> tuple[str, str] | None: """随机采样一个未使用的 L2 节点,返回 (l2_id, l2_id)。 参数: tree: 三层树索引。 used_node_ids: 已用节点 ID 集合。 rng: 随机数生成器。 返回: (l2_id, l2_id) 元组;无候选返回 None。 """ candidates: list[str] = [] for l1 in tree.roots: for l2 in l1.children: if l2.id not in used_node_ids: candidates.append(l2.id) if not candidates: return None chosen = rng.choice(candidates) return (chosen, chosen) def _sample_l1_node( tree: TreeIndex, used_node_ids: set[str], rng: random.Random, ) -> tuple[str, str] | None: """随机采样一个未使用的 L1 节点,返回 (l1_id, 首个子l2_id)。 参数: tree: 三层树索引。 used_node_ids: 已用节点 ID 集合。 rng: 随机数生成器。 返回: (l1_id, first_l2_id) 元组;无候选返回 None。 """ candidates: list[tuple[str, str]] = [] for l1 in tree.roots: if l1.id not in used_node_ids and l1.children: candidates.append((l1.id, l1.children[0].id)) if not candidates: return None return rng.choice(candidates) def _frames_from_l2_list(l2_nodes: list[L2Node]) -> list[str]: """从 L2 节点列表收集全部可用帧路径。 参数: l2_nodes: L2 节点列表。 返回: 帧路径字符串列表。 """ return [l3.frame_path for l2 in l2_nodes for l3 in l2.children if l3.frame_path] def _collect_frame_paths(tree: TreeIndex, node_id: str) -> list[str]: """收集节点子树下的所有可用帧路径。 参数: tree: 三层树索引。 node_id: 目标节点 ID。 返回: 帧路径列表。 """ resolved = _resolve_subtree(tree, node_id) if resolved is None: return [] return _frames_from_l2_list(resolved.l2_nodes) def _collect_source_nodes(tree: TreeIndex, node_id: str) -> tuple[str, ...]: """收集节点子树涉及的全部节点 ID(包含自身)。 参数: tree: 三层树索引。 node_id: 目标节点 ID。 返回: 相关节点 ID 元组。 """ ids: list[str] = [node_id] # L2 节点:加入子 L3 result = _find_l2_node(tree, node_id) if result is not None: l2_node, _ = result for l3 in l2_node.children: ids.append(l3.id) return tuple(ids) # L1 节点:加入子 L2 + L3 l1_node = _find_l1_node(tree, node_id) if l1_node is not None: for l2 in l1_node.children: ids.append(l2.id) for l3 in l2.children: ids.append(l3.id) return tuple(ids) # L3 节点:仅自身 return tuple(ids) # --------------------------------------------------------------------------- # 主入口 # --------------------------------------------------------------------------- def sample_material_v2( tree: TreeIndex, task_type: str, used_node_ids: set[str], rng: random.Random, *, level: int, constraint: SamplingConstraint, max_attempts: int = 10, ) -> MaterialContext: """基于采样约束从视频树中采样素材上下文。 采样流程: 1. 按指定 level 确定采样层级 2. 随机选取候选节点(排除 used_node_ids) 3. 验证 SamplingConstraint 约束 4. 约束不满足则重试(最多 max_attempts 次) 5. 收集字幕、帧路径、跨 L2 上下文 参数: tree: 三层树索引。 task_type: 任务类型字符串。 used_node_ids: 本轮已用节点 ID 集合。 rng: 可控随机数生成器。 level: 采样层级(1/2/3)。 constraint: 采样约束条件。 max_attempts: 最大尝试次数。 返回: MaterialContext 实例。 异常: ValueError: level 不在 {1, 2, 3} 中。 RuntimeError: 耗尽 max_attempts 次尝试仍无法满足约束。 """ if level not in (1, 2, 3): msg = f"level 必须为 1、2 或 3,收到: {level}" raise ValueError(msg) for attempt in range(max_attempts): # Phase 1: 按层级采样候选节点 if level == 3: sampled = _sample_l3_node(tree, used_node_ids, rng) elif level == 2: sampled = _sample_l2_node(tree, used_node_ids, rng) else: sampled = _sample_l1_node(tree, used_node_ids, rng) if sampled is None: logger.debug( "sample_material_v2 尝试 {}/{}: 无可用候选节点 (level={})", attempt + 1, max_attempts, level, ) continue node_id, l2_id = sampled # Phase 2: 验证约束 if not _validate_sampling_constraints(tree, node_id, constraint): logger.debug( "sample_material_v2 尝试 {}/{}: 约束违反 (node={})", attempt + 1, max_attempts, node_id, ) continue # Phase 3: 构造 AnchorContext anchor = AnchorContext(node_id=node_id, level=level, l2_id=l2_id) # Phase 4: 收集素材 source_nodes = _collect_source_nodes(tree, node_id) subtitle_sentences = _collect_subtitle_sentences(tree, (node_id,)) frame_paths = _collect_frame_paths(tree, node_id) # Phase 5: 跨 L2 上下文(仅 cross_l2_span 时收集) cross_l2_texts: list[str] = [] if constraint.cross_l2_span: cross_l2_texts = _collect_cross_l2_context(tree, l2_id) logger.debug( "sample_material_v2 成功: node={}, level={}, attempt={}/{}", node_id, level, attempt + 1, max_attempts, ) return MaterialContext( anchor=anchor, source_nodes=source_nodes, subtitle_sentences=subtitle_sentences, frame_paths=frame_paths, cross_l2_texts=cross_l2_texts, ) raise RuntimeError( f"sample_material_v2: 耗尽 max_attempts={max_attempts} 次尝试," f"无法为 task_type='{task_type}' (level={level}) 满足采样约束" )