feat(pipeline): replace QuestionFamilySpec with TaskTypeStrategy
- SlotAssignment: remove family field, strategy looked up at process time - PipelineConfig: remove family_ratios field - _assign_slots: remove family_ratios and rng params (pure deterministic) - _process_one_slot: use get_strategy() for sampling, generation, gates - Add sub_pattern support (level/constraint override, instruction injection) - Add strategy.extra_gates() check after standard gates - load_pipeline_config: stop reading family_ratios from YAML - Update tools/generate_questions.py seed override and dry-run log - Update all integration tests to match new API Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -34,11 +34,11 @@ import numpy as np
|
||||
import yaml
|
||||
from loguru import logger
|
||||
|
||||
from app.question_gen.families import QuestionFamilySpec, get_family_for_slot
|
||||
from app.question_gen.gates import GateReport, GateResult, GateVerdict, run_gates
|
||||
from app.question_gen.generator_v2 import CandidateQuestion, generate_one_v2
|
||||
from app.question_gen.postprocess import run_postprocess
|
||||
from app.question_gen.sampler_v2 import _TASK_TYPE_TO_LEVEL, sample_material_v2
|
||||
from app.question_gen.sampler_v2 import sample_material_v2
|
||||
from app.question_gen.strategy import get_strategy
|
||||
from core.types import GeneratedQuestion
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -62,14 +62,12 @@ class SlotAssignment:
|
||||
slot_id: slot 唯一标识。
|
||||
video_id: 分配到的视频 ID。
|
||||
task_type: 任务类型。
|
||||
family: 分配的问题家族规格。
|
||||
seq: slot 序号(同 task_type 内从 1 开始)。
|
||||
seq: slot 序号。
|
||||
"""
|
||||
|
||||
slot_id: str
|
||||
video_id: str
|
||||
task_type: str
|
||||
family: QuestionFamilySpec
|
||||
seq: int
|
||||
|
||||
|
||||
@@ -93,7 +91,6 @@ class PipelineConfig:
|
||||
"""管线配置。
|
||||
|
||||
属性:
|
||||
family_ratios: 家族名到权重的映射。
|
||||
per_type: 每种 task_type 生成的题目数。
|
||||
retry_limit: 单 slot 最大重出次数。
|
||||
heavy_sample_rate: 重量抽检采样比例 [0.0, 1.0]。
|
||||
@@ -103,7 +100,6 @@ class PipelineConfig:
|
||||
output_dir: 输出目录。
|
||||
"""
|
||||
|
||||
family_ratios: dict[str, float]
|
||||
per_type: int
|
||||
retry_limit: int
|
||||
heavy_sample_rate: float
|
||||
@@ -146,12 +142,7 @@ def load_pipeline_config(yaml_path: Path) -> PipelineConfig:
|
||||
|
||||
section = raw["question_gen_v2"]
|
||||
|
||||
# 家族名统一为大写
|
||||
raw_ratios = section["family_ratios"]
|
||||
family_ratios = {k.upper(): float(v) for k, v in raw_ratios.items()}
|
||||
|
||||
return PipelineConfig(
|
||||
family_ratios=family_ratios,
|
||||
per_type=int(section["per_type"]),
|
||||
retry_limit=int(section["retry_limit"]),
|
||||
heavy_sample_rate=float(section["heavy_sample_rate"]),
|
||||
@@ -171,20 +162,16 @@ def _assign_slots(
|
||||
video_ids: list[str],
|
||||
task_types: list[str],
|
||||
per_type: int,
|
||||
family_ratios: dict[str, float],
|
||||
rng: random.Random,
|
||||
) -> list[SlotAssignment]:
|
||||
"""将出题目标分配为具体 slot 列表。
|
||||
|
||||
总 slot 数 = len(task_types) * per_type。
|
||||
在视频间 round-robin 分配,每个 slot 通过 get_family_for_slot 决定家族。
|
||||
在视频间 round-robin 分配。不再选择 family — strategy 在处理时查找。
|
||||
|
||||
参数:
|
||||
video_ids: 视频 ID 列表。
|
||||
task_types: 任务类型列表。
|
||||
per_type: 每种 task_type 的目标题数。
|
||||
family_ratios: 家族权重映射。
|
||||
rng: 可控随机数生成器。
|
||||
|
||||
返回:
|
||||
SlotAssignment 列表。
|
||||
@@ -195,7 +182,6 @@ def _assign_slots(
|
||||
for task_type in task_types:
|
||||
for i in range(per_type):
|
||||
video_id = video_ids[i % len(video_ids)]
|
||||
family = get_family_for_slot(task_type, family_ratios, rng)
|
||||
global_seq += 1
|
||||
slot_id = f"{task_type}_{global_seq:04d}"
|
||||
slots.append(
|
||||
@@ -203,7 +189,6 @@ def _assign_slots(
|
||||
slot_id=slot_id,
|
||||
video_id=video_id,
|
||||
task_type=task_type,
|
||||
family=family,
|
||||
seq=global_seq,
|
||||
)
|
||||
)
|
||||
@@ -348,6 +333,9 @@ async def _process_one_slot(
|
||||
_RESAMPLE_VIDEO_INTERVAL = 1
|
||||
|
||||
async with sem:
|
||||
strategy = get_strategy(slot.task_type)
|
||||
sub_pattern = strategy.select_sub_pattern(rng)
|
||||
|
||||
prev_reason: str | None = None
|
||||
current_tree = tree
|
||||
current_video_id = slot.video_id
|
||||
@@ -366,15 +354,25 @@ async def _process_one_slot(
|
||||
current_video_id,
|
||||
)
|
||||
|
||||
# Phase 1: 采样素材
|
||||
# Phase 1: 采样素材(sub_pattern 可覆盖 level 和 constraint)
|
||||
level = (
|
||||
sub_pattern.sampling_level_override
|
||||
if sub_pattern and sub_pattern.sampling_level_override is not None
|
||||
else strategy.sampling_level
|
||||
)
|
||||
constraint = (
|
||||
sub_pattern.constraint_override
|
||||
if sub_pattern and sub_pattern.constraint_override is not None
|
||||
else strategy.sampling_constraint
|
||||
)
|
||||
try:
|
||||
material = sample_material_v2(
|
||||
tree=current_tree,
|
||||
task_type=slot.task_type,
|
||||
used_node_ids=used_node_ids,
|
||||
rng=rng,
|
||||
level=_TASK_TYPE_TO_LEVEL[slot.task_type],
|
||||
constraint=slot.family.sampling,
|
||||
level=level,
|
||||
constraint=constraint,
|
||||
)
|
||||
except (RuntimeError, KeyError) as e:
|
||||
logger.warning(
|
||||
@@ -392,11 +390,14 @@ async def _process_one_slot(
|
||||
vlm=vlm,
|
||||
tree=current_tree,
|
||||
material=material,
|
||||
family_spec=slot.family,
|
||||
task_type=slot.task_type,
|
||||
seq=slot.seq,
|
||||
video_id=current_video_id,
|
||||
prompt_template=strategy.prompt_template,
|
||||
strategy_name=strategy.strategy_name,
|
||||
skill_target=strategy.skill_target,
|
||||
reject_reason=prev_reason,
|
||||
sub_pattern_instruction=sub_pattern.instruction if sub_pattern else None,
|
||||
session_id=session_id,
|
||||
)
|
||||
except (ValueError, FileNotFoundError, OSError, Exception) as e:
|
||||
@@ -416,11 +417,12 @@ async def _process_one_slot(
|
||||
run_id=run_id,
|
||||
slot_id=slot.slot_id,
|
||||
video_id=current_video_id,
|
||||
family=slot.family.name,
|
||||
family=strategy.strategy_name,
|
||||
task_type=slot.task_type,
|
||||
skill_target=slot.family.skill_target,
|
||||
skill_target=strategy.skill_target,
|
||||
attempt=attempt,
|
||||
question_text=candidate.question,
|
||||
sub_pattern=sub_pattern.name if sub_pattern else None,
|
||||
)
|
||||
|
||||
# Phase 4: 后处理
|
||||
@@ -468,7 +470,7 @@ async def _process_one_slot(
|
||||
candidate=candidate,
|
||||
tree=tree,
|
||||
llm=llm,
|
||||
family_spec=slot.family,
|
||||
leak_probe_template=strategy.leak_probe_template,
|
||||
postprocess=pp,
|
||||
vlm=vlm,
|
||||
session_id=session_id,
|
||||
@@ -485,6 +487,21 @@ async def _process_one_slot(
|
||||
continue
|
||||
store.update_gates(item_id, report)
|
||||
|
||||
# 题型专属额外 gate
|
||||
extra_results = strategy.extra_gates(candidate)
|
||||
if any(r.verdict == GateVerdict.FAIL for r in extra_results):
|
||||
prev_reason = "; ".join(
|
||||
r.reason for r in extra_results if r.verdict == GateVerdict.FAIL
|
||||
)
|
||||
logger.info(
|
||||
"slot {} 额外 gate 失败 (attempt {}/{}): {}",
|
||||
slot.slot_id,
|
||||
attempt,
|
||||
config.retry_limit,
|
||||
prev_reason,
|
||||
)
|
||||
continue
|
||||
|
||||
if not report.passed:
|
||||
prev_reason = report.reject_reason
|
||||
logger.info(
|
||||
@@ -511,7 +528,7 @@ async def _process_one_slot(
|
||||
# Phase 8: 通过全部检查 → 接受(使用洗牌后的选项和答案)
|
||||
result = _to_generated_question(
|
||||
candidate,
|
||||
family=slot.family.name,
|
||||
family=strategy.strategy_name,
|
||||
options=pp.options,
|
||||
answer=pp.answer,
|
||||
)
|
||||
@@ -762,7 +779,7 @@ async def run_pipeline_v2(
|
||||
rng = random.Random(config.seed)
|
||||
|
||||
# Phase 1: 分配 slot + 创建 run 记录
|
||||
slots = _assign_slots(video_ids, task_types, config.per_type, config.family_ratios, rng)
|
||||
slots = _assign_slots(video_ids, task_types, config.per_type)
|
||||
logger.info(
|
||||
"管线启动: {} slots, {} 视频, {} 任务类型", len(slots), len(video_ids), len(task_types)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user