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:
+111
-47
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user