fix(question_gen): sample_anchor Codex 审查修正

- C1/C2: Temporal Reasoning ≥3 L2 + Object Reasoning ≥2 L2 下限检查
- I1: L2 题型子帧不足时 ValueError
- I2: L3 过滤无 frame_path 的节点
- I3/I4: distractor_texts 扩展到整棵树范围
- I5-I7: 测试补强 Information Synopsis/Temporal Reasoning/Object Reasoning
- M1: Spatial Reasoning 测试断言 spatial_layout

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-09 05:24:30 -04:00
parent a597a9f901
commit 40b04f886e
2 changed files with 50 additions and 28 deletions
+41 -20
View File
@@ -12,7 +12,7 @@ from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
import random import random
from app.tree.index import L1Node, L2Node, L3Node, TreeIndex from app.tree.index import L2Node, L3Node, TreeIndex
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -170,27 +170,33 @@ def _sample_l3(
异常: 异常:
ValueError: 候选 L3 节点不足。 ValueError: 候选 L3 节点不足。
""" """
# Phase 1: 收集所有 L3 候选 # Phase 1: 收集所有 L3 候选(必须有 frame_path
candidates: list[tuple[L3Node, L2Node]] = [] candidates: list[tuple[L3Node, L2Node]] = []
for root in tree.roots: for root in tree.roots:
for l2 in root.children: for l2 in root.children:
for l3 in l2.children: for l3 in l2.children:
if l3.id not in used_node_ids: if l3.id not in used_node_ids and l3.frame_path:
candidates.append((l3, l2)) candidates.append((l3, l2))
if not candidates: if not candidates:
raise ValueError(f"锚节点不足: {task_type} 无可用 L3 节点") raise ValueError(f"锚节点不足: {task_type} 无可用 L3 节点(需具备 frame_path")
# Phase 2: 随机选取 # Phase 2: 随机选取
chosen_l3, parent_l2 = rng.choice(candidates) chosen_l3, parent_l2 = rng.choice(candidates)
# Phase 3: 构造上下文 # Phase 3: 构造上下文frame_path 已在候选过滤中保证非 None)
card_text = _serialize_l3_card(chosen_l3, spec.context_fields) card_text = _serialize_l3_card(chosen_l3, spec.context_fields)
frame_paths = [chosen_l3.frame_path] if chosen_l3.frame_path else [] frame_paths = [chosen_l3.frame_path] # type: ignore[list-item]
subtitle = chosen_l3.subtitle or "" subtitle = chosen_l3.subtitle or ""
# Phase 4: 干扰项——同 L2 下其他 L3 的 frame_summary # Phase 4: 干扰项——整棵树中其他 L3 的 frame_summary
distractor_texts = [l3.card.frame_summary for l3 in parent_l2.children if l3.id != chosen_l3.id] 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( return AnchorContext(
node_id=chosen_l3.id, node_id=chosen_l3.id,
@@ -227,17 +233,17 @@ def _sample_l2(
ValueError: 候选 L2 节点不足。 ValueError: 候选 L2 节点不足。
""" """
# Phase 1: 收集所有 L2 候选 # Phase 1: 收集所有 L2 候选
all_l2: list[tuple[L2Node, L1Node]] = [] all_l2: list[L2Node] = []
for root in tree.roots: for root in tree.roots:
for l2 in root.children: for l2 in root.children:
if l2.id not in used_node_ids: if l2.id not in used_node_ids:
all_l2.append((l2, root)) all_l2.append(l2)
if not all_l2: if not all_l2:
raise ValueError(f"锚节点不足: {task_type} 无可用 L2 节点") raise ValueError(f"锚节点不足: {task_type} 无可用 L2 节点")
# Phase 2: 随机选取 # Phase 2: 随机选取
chosen_l2, parent_l1 = rng.choice(all_l2) chosen_l2 = rng.choice(all_l2)
is_temporal_perception = task_type == "Temporal Perception" is_temporal_perception = task_type == "Temporal Perception"
@@ -252,8 +258,13 @@ def _sample_l2(
else: else:
# 普通 L2:随机采样 2-3 个 L3 帧 # 普通 L2:随机采样 2-3 个 L3 帧
children_with_frames = [l3 for l3 in chosen_l2.children if l3.frame_path] 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)) n_frames = min(rng.randint(2, 3), len(children_with_frames))
sampled = rng.sample(children_with_frames, n_frames) if n_frames > 0 else [] sampled = rng.sample(children_with_frames, n_frames)
frame_paths = [l3.frame_path for l3 in sampled if l3.frame_path] frame_paths = [l3.frame_path for l3 in sampled if l3.frame_path]
# Phase 4: card_text # Phase 4: card_text
@@ -266,9 +277,12 @@ def _sample_l2(
if chosen_l2.children and chosen_l2.children[0].subtitle: if chosen_l2.children and chosen_l2.children[0].subtitle:
subtitle = chosen_l2.children[0].subtitle subtitle = chosen_l2.children[0].subtitle
# Phase 6: 干扰项——同 L1 下其他 L2 的 event_description # Phase 6: 干扰项——整棵树中其他 L2 的 event_description
distractor_texts = [ distractor_texts = [
l2.card.event_description for l2 in parent_l1.children if l2.id != chosen_l2.id l2.card.event_description
for root in tree.roots
for l2 in root.children
if l2.id != chosen_l2.id
] ]
return AnchorContext( return AnchorContext(
@@ -290,7 +304,7 @@ def _sample_l1(
"""L1 层级锚节点采样(Temporal Reasoning / Information Synopsis)。 """L1 层级锚节点采样(Temporal Reasoning / Information Synopsis)。
Information Synopsis:使用目标 L1 下全部 L2 子节点。 Information Synopsis:使用目标 L1 下全部 L2 子节点。
Temporal Reasoning使用 >=3 个 L2 子节点不足 3 个则全部使用) Temporal Reasoning严格要求 >=3 个 L2 子节点不足则抛 ValueError
L2 按 time_range 升序排列,每个 L2 取一帧代表。 L2 按 time_range 升序排列,每个 L2 取一帧代表。
参数: 参数:
@@ -304,7 +318,7 @@ def _sample_l1(
AnchorContext 实例。 AnchorContext 实例。
异常: 异常:
ValueError: 候选 L1 节点不足。 ValueError: 候选 L1 节点不足,或 Temporal Reasoning 的 L2 子节点 <3
""" """
# Phase 1: 收集可用 L1 # Phase 1: 收集可用 L1
candidates = [r for r in tree.roots if r.id not in used_node_ids] candidates = [r for r in tree.roots if r.id not in used_node_ids]
@@ -319,8 +333,13 @@ def _sample_l1(
# 必须使用全部 L2 # 必须使用全部 L2
selected_l2 = list(chosen_l1.children) selected_l2 = list(chosen_l1.children)
else: else:
# Temporal Reasoning>=3 个 L2(不足则全部) # Temporal Reasoning严格要求 >=3 个 L2
if len(chosen_l1.children) <= 3: 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) selected_l2 = list(chosen_l1.children)
else: else:
selected_l2 = rng.sample(chosen_l1.children, rng.randint(3, len(chosen_l1.children))) selected_l2 = rng.sample(chosen_l1.children, rng.randint(3, len(chosen_l1.children)))
@@ -385,8 +404,10 @@ def _sample_l1_l2(
if l2.id not in used_node_ids: if l2.id not in used_node_ids:
all_l2.append(l2) all_l2.append(l2)
if not all_l2: if len(all_l2) < 2:
raise ValueError(f"锚节点不足: {task_type} 无可用 L2 节点") raise ValueError(
f"锚节点不足: {task_type} 需要 >=2 个 L2 节点,但仅有 {len(all_l2)} 个可用"
)
# Phase 2: 随机选 2-3 个 # Phase 2: 随机选 2-3 个
n_pick = min(rng.randint(2, 3), len(all_l2)) n_pick = min(rng.randint(2, 3), len(all_l2))
+9 -8
View File
@@ -185,15 +185,15 @@ class TestSampleAnchor:
"""Information Synopsis 必须使用目标 L1 下所有 L2 子节点。""" """Information Synopsis 必须使用目标 L1 下所有 L2 子节点。"""
tree, _vid = _load_test_tree() tree, _vid = _load_test_tree()
ctx = sample_anchor(tree, "Information Synopsis", set(), random.Random(42)) ctx = sample_anchor(tree, "Information Synopsis", set(), random.Random(42))
# 至少应有帧(每个 L2 取一帧代表) # 找到被选中的 L1,验证 frame_paths 数量 == 该 L1 下全部 L2 数量
total_l2 = sum(len(r.children) for r in tree.roots) chosen_l1 = next(r for r in tree.roots if r.id == ctx.node_id)
assert len(ctx.frame_paths) >= min(total_l2, 1) assert len(ctx.frame_paths) == len(chosen_l1.children)
def test_l1_type_l2_nodes_in_time_order(self) -> None: def test_l1_type_l2_nodes_in_time_order(self) -> None:
"""L1 题型(Temporal Reasoning)的 card_text 有实质内容。""" """Temporal Reasoning 应返回 >=3 帧且 card_text 有实质内容。"""
tree, _vid = _load_test_tree() tree, _vid = _load_test_tree()
ctx = sample_anchor(tree, "Temporal Reasoning", set(), random.Random(42)) ctx = sample_anchor(tree, "Temporal Reasoning", set(), random.Random(42))
assert len(ctx.frame_paths) >= 1 assert len(ctx.frame_paths) >= 3
assert len(ctx.card_text) > 20 assert len(ctx.card_text) > 20
def test_used_node_ids_excluded(self) -> None: def test_used_node_ids_excluded(self) -> None:
@@ -219,12 +219,13 @@ class TestSampleAnchor:
"""Object Reasoning (L1-L2) 应选 2-3 个 L2 并按时间排序。""" """Object Reasoning (L1-L2) 应选 2-3 个 L2 并按时间排序。"""
tree, _vid = _load_test_tree() tree, _vid = _load_test_tree()
ctx = sample_anchor(tree, "Object Reasoning", set(), random.Random(42)) ctx = sample_anchor(tree, "Object Reasoning", set(), random.Random(42))
assert 1 <= len(ctx.frame_paths) <= 3 assert 2 <= len(ctx.frame_paths) <= 3
assert len(ctx.card_text) > 10 assert len(ctx.card_text) > 10
def test_spatial_reasoning_context_fields(self) -> None: def test_spatial_reasoning_context_fields(self) -> None:
"""Spatial Reasoning 的 card_text 应包含 spatial_layout 内容""" """Spatial Reasoning 的 card_text 应包含 spatial_layout 字段"""
tree, _vid = _load_test_tree() tree, _vid = _load_test_tree()
ctx = sample_anchor(tree, "Spatial Reasoning", set(), random.Random(42)) ctx = sample_anchor(tree, "Spatial Reasoning", set(), random.Random(42))
# context_fields 包含 spatial_layoutcard_text 应含有该字段内容 # context_fields 包含 spatial_layoutcard_text 必须出现该字段
assert "spatial_layout" in ctx.card_text
assert len(ctx.card_text) > 20 assert len(ctx.card_text) > 20