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:
2026-07-14 06:05:23 -04:00
parent b9616de21e
commit 36c712defa
4 changed files with 190 additions and 164 deletions
+111 -47
View File
@@ -349,6 +349,8 @@ def _append_to_json(output_dir: Path, question: GeneratedQuestion) -> None:
"answer": question.answer,
"source_nodes": list(question.source_nodes),
"difficulty": question.difficulty,
"family": question.family,
"skill_target": question.skill_target,
}
existing.append(entry)
@@ -850,6 +852,13 @@ def _add_generate_v2_parser(subparsers: argparse._SubParsersAction) -> None:
default=None,
help="随机种子(覆盖配置文件中的 seed",
)
p.add_argument(
"--task-types",
type=str,
nargs="+",
default=None,
help="只生成指定的 task_type(默认全部 12 类)",
)
p.add_argument(
"--dry-run",
action="store_true",
@@ -889,7 +898,6 @@ async def _run_generate_v2(args: argparse.Namespace) -> None:
# Phase 2: 覆盖 seed
if args.seed is not None:
config = PipelineConfig(
family_ratios=config.family_ratios,
per_type=config.per_type,
retry_limit=config.retry_limit,
heavy_sample_rate=config.heavy_sample_rate,
@@ -897,8 +905,6 @@ async def _run_generate_v2(args: argparse.Namespace) -> None:
concurrency=config.concurrency,
seed=args.seed,
output_dir=config.output_dir,
gate_models=config.gate_models,
heavy_agent_model=config.heavy_agent_model,
)
logger.info("seed 覆盖为: {}", args.seed)
@@ -918,20 +924,21 @@ async def _run_generate_v2(args: argparse.Namespace) -> None:
logger.info("发现 {} 个视频: {}", len(video_ids), video_ids[:5])
# Phase 4: dry-run 模式
task_types = [
all_task_types = [
"Action Recognition",
"Action Reasoning",
"Action Prediction",
"Action Sequence",
"Attribute Perception",
"Counting Problem",
"Information Synopsis",
"Object Recognition",
"Object Reasoning",
"Object Interaction",
"Scene Understanding",
"Event Reasoning",
"Causal Reasoning",
"Temporal Reasoning",
"OCR Problems",
"Spatial Perception",
"Spatial Reasoning",
"Temporal Perception",
"Temporal Reasoning",
]
task_types = args.task_types if args.task_types else all_task_types
total_slots = len(task_types) * config.per_type
if args.dry_run:
logger.info("[dry-run] 管线配置摘要:")
@@ -941,7 +948,6 @@ async def _run_generate_v2(args: argparse.Namespace) -> None:
logger.info("[dry-run] 总 slot 数: {}", total_slots)
logger.info("[dry-run] 并发: {}", config.concurrency)
logger.info("[dry-run] 去重阈值: {}", config.dedup_threshold)
logger.info("[dry-run] 家族权重: {}", config.family_ratios)
logger.info("[dry-run] 退出(不调用 LLM/VLM")
return
@@ -955,6 +961,8 @@ async def _run_generate_v2(args: argparse.Namespace) -> None:
telemetry = SQLiteTelemetryRecorder(str(PROJECT_ROOT / "logs" / "generate_v2_telemetry.db"))
breaker_threshold = int(os.getenv("LLM_CIRCUIT_BREAKER_THRESHOLD", "5"))
# 并发 slot 共享客户端,熔断阈值需按并发度缩放(与 build_trees 一致)
breaker_threshold = max(breaker_threshold, config.concurrency * 2)
breaker_cooldown = int(os.getenv("LLM_CIRCUIT_BREAKER_COOLDOWN", "60"))
timeout_s = float(os.getenv("LLM_TIMEOUT", "120"))
max_retries = int(os.getenv("LLM_MAX_RETRIES", "3"))
@@ -985,11 +993,11 @@ async def _run_generate_v2(args: argparse.Namespace) -> None:
)
vlm = GovernedVLMClient(vlm_base)
# LLM 客户端(门控用)
# LLM 客户端(门控用,复用 JUDGE_LLM 配置
llm = GovernedLLMClient(
model=os.environ.get("LLM_MODEL", "gpt-4.1-mini"),
base_url=os.environ["LLM_BASE_URL"],
api_key=os.environ["LLM_API_KEY"],
model=os.environ.get("JUDGE_LLM_MODEL", "gpt-4.1-mini"),
base_url=os.environ["JUDGE_LLM_BASE_URL"],
api_key=os.environ["JUDGE_LLM_API_KEY"],
provider="openai",
thinking=False,
breaker=_make_breaker(),
@@ -1041,6 +1049,16 @@ async def _run_generate_v2(args: argparse.Namespace) -> None:
if not trees:
logger.error("所有视频的树加载均失败,无法继续")
sys.exit(1)
# 将相对帧路径解析为绝对路径(与 v1 generate 一致)
for vid, tree in trees.items():
video_dir = videos_dir / vid
for l1 in tree.roots:
for l2 in l1.children:
for l3 in l2.children:
if l3.frame_path and not Path(l3.frame_path).is_absolute():
l3.frame_path = str(video_dir / l3.frame_path)
logger.info("成功加载 {} / {} 棵视频树", len(trees), len(video_ids))
# Phase 7: 初始化 QuestionGenStore
@@ -1050,10 +1068,81 @@ async def _run_generate_v2(args: argparse.Namespace) -> None:
db_path.parent.mkdir(parents=True, exist_ok=True)
store = QuestionGenStore(str(db_path))
# Phase 8: 加载断点续跑进度
# Phase 8: 加载断点续跑进度(DB 进度 + 已有 JSON 中已满的 slot
progress: dict[str, str] = store.load_progress()
# Phase 9: 运行管线
output_path_check = config.output_dir / "accepted_questions.json"
if output_path_check.exists():
try:
existing_qs = json.loads(output_path_check.read_text(encoding="utf-8"))
filled_slots = 0
for q in existing_qs:
qid = q.get("question_id", "")
vid = q.get("video_id", "")
if not qid or not vid or not qid.startswith(vid + "_"):
logger.warning("跳过格式异常的已有题目: question_id={}", qid)
continue
slot_id = qid[len(vid) + 1 :]
if slot_id not in progress:
progress[slot_id] = "accepted"
filled_slots += 1
logger.info(
"从已有 JSON 补充 progress: {} 个 slot 标记为已完成 (已有 {} 题)",
filled_slots,
len(existing_qs),
)
except (json.JSONDecodeError, OSError, ValueError) as exc:
logger.warning("已有 JSON 读取失败,跳过 progress 补充: {}", exc)
# Phase 9: 准备实时持久化回调(v1 式逐题追加,崩溃最多丢一题)
output_dir = config.output_dir
output_dir.mkdir(parents=True, exist_ok=True)
output_path = output_dir / "accepted_questions.json"
_accepted_texts: set[str] = set()
if output_path.exists():
try:
_existing = json.loads(output_path.read_text(encoding="utf-8"))
_accepted_texts = {q["question"] for q in _existing}
except (json.JSONDecodeError, OSError):
pass
def _on_accept(q: GeneratedQuestion) -> None:
"""每接受一题立即追加到 JSON(原子写入)。"""
if q.question in _accepted_texts:
return
_accepted_texts.add(q.question)
existing: list[dict] = []
if output_path.exists():
try:
existing = json.loads(output_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
existing = []
existing.append(
{
"question_id": q.question_id,
"video_id": q.video_id,
"task_type": q.task_type,
"question": q.question,
"options": list(q.options),
"answer": q.answer,
"source_nodes": list(q.source_nodes),
"difficulty": q.difficulty,
"family": q.family,
"skill_target": q.skill_target,
}
)
tmp_path = output_path.with_suffix(".tmp")
tmp_path.write_text(
json.dumps(existing, ensure_ascii=False, indent=2),
encoding="utf-8",
)
os.replace(str(tmp_path), str(output_path))
# Phase 10: 运行管线(on_accept 实时持久化每道接受的题)
active_video_ids = [vid for vid in video_ids if vid in trees]
result = await run_pipeline_v2(
video_ids=active_video_ids,
@@ -1065,38 +1154,13 @@ async def _run_generate_v2(args: argparse.Namespace) -> None:
config=config,
task_types=task_types,
progress=progress,
on_accept=_on_accept,
)
# Phase 10: 保存输出
output_dir = config.output_dir
output_dir.mkdir(parents=True, exist_ok=True)
output_path = output_dir / "accepted_questions.json"
accepted_data = []
for q in result.accepted:
accepted_data.append(
{
"question_id": q.question_id,
"video_id": q.video_id,
"task_type": q.task_type,
"question": q.question,
"options": list(q.options),
"answer": q.answer,
"source_nodes": list(q.source_nodes),
"difficulty": q.difficulty,
"skill_target": q.skill_target,
}
)
output_path.write_text(
json.dumps(accepted_data, ensure_ascii=False, indent=2),
encoding="utf-8",
)
logger.info(
"输出已保存: {} ({} 题)",
output_path,
len(accepted_data),
final_count = (
len(json.loads(output_path.read_text(encoding="utf-8"))) if output_path.exists() else 0
)
logger.info("输出已保存: {} (总计 {} 题)", output_path, final_count)
# 统计报告
logger.info("=" * 60)