feat: wire grounded selector into AR slot processing

将 Task 5 的 grounded selector 织入 AR 出题路径(Phase 3.5,位于
record_item 与 postprocess 之间),仅在 strategy.uses_grounded_selector
为真时进入。observation 始终落库(含 hard-fail),硬失败走重出。
PipelineConfig 新增 candidate_pool_size/selector_delta_low/
selector_delta_high 三参,YAML 与 CLI seed override 同步。
This commit is contained in:
2026-07-14 14:15:50 -04:00
parent d0194f5840
commit b13eab0659
5 changed files with 171 additions and 0 deletions
+105
View File
@@ -24,6 +24,7 @@
from __future__ import annotations
import asyncio
import json
import random
import uuid
from dataclasses import dataclass
@@ -98,6 +99,9 @@ class PipelineConfig:
concurrency: 并发 slot 数上限。
seed: 随机种子。
output_dir: 输出目录。
candidate_pool_size: grounded selector 首轮候选干扰项数 N。
selector_delta_low: 干扰项视觉分与正解的最小差(区间上界)。
selector_delta_high: 干扰项视觉分与正解的最大差(区间下界)。
"""
per_type: int
@@ -107,6 +111,9 @@ class PipelineConfig:
concurrency: int
seed: int
output_dir: Path
candidate_pool_size: int = 24
selector_delta_low: float = 0.05
selector_delta_high: float = 0.35
# ---------------------------------------------------------------------------
@@ -150,6 +157,9 @@ def load_pipeline_config(yaml_path: Path) -> PipelineConfig:
concurrency=int(section["concurrency"]),
seed=int(section["seed"]),
output_dir=Path(section["output_dir"]),
candidate_pool_size=int(section.get("candidate_pool_size", 24)),
selector_delta_low=float(section.get("selector_delta_low", 0.05)),
selector_delta_high=float(section.get("selector_delta_high", 0.35)),
)
@@ -281,6 +291,56 @@ def _to_generated_question(
)
def _extract_correct_text(options: tuple[str, ...], answer: str) -> str:
"""从四选项中取正解文本(去掉 "X. " 字母前缀)。
参数:
options: 选项元组,格式 ("A. ...", "B. ...", ...)。
answer: 正解字母(大小写不敏感)。
返回:
正解选项去前缀后的文本。
异常:
ValueError: answer 对应索引超出选项范围。
"""
idx = ord(answer.strip().upper()) - ord("A")
if not 0 <= idx < len(options):
msg = f"answer '{answer}' 超出选项范围 (n={len(options)})"
raise ValueError(msg)
opt = options[idx]
prefix = f"{answer.strip().upper()}. "
return opt[len(prefix) :] if opt.startswith(prefix) else opt
def _replace_candidate_options(
candidate: CandidateQuestion, options: tuple[str, ...], answer: str
) -> CandidateQuestion:
"""用 selector 重组的选项/答案替换候选(CandidateQuestion frozen)。
参数:
candidate: 原候选题目。
options: grounded selector 重组后的四选项。
answer: 重组后的正解字母(恒 "A")。
返回:
仅替换 options/answer、其余字段照搬的新 CandidateQuestion。
"""
return CandidateQuestion(
question_id=candidate.question_id,
video_id=candidate.video_id,
task_type=candidate.task_type,
skill_target=candidate.skill_target,
question=candidate.question,
options=options,
answer=answer,
source_nodes=candidate.source_nodes,
difficulty=candidate.difficulty,
subtitle_sentences=candidate.subtitle_sentences,
frame_paths=candidate.frame_paths,
)
# ---------------------------------------------------------------------------
# 单 Slot 处理(重出循环)
# ---------------------------------------------------------------------------
@@ -428,6 +488,51 @@ async def _process_one_slot(
sub_pattern=sub_pattern.name if sub_pattern else None,
)
# Phase 3.5: grounded selector(仅 AR 路径)
if strategy.uses_grounded_selector:
from app.question_gen.distractor_selector import (
SelectorConfig,
build_grounded_options,
)
correct_text = _extract_correct_text(candidate.options, candidate.answer)
selector_cfg = SelectorConfig(
candidate_pool_size=config.candidate_pool_size,
delta_low=config.selector_delta_low,
delta_high=config.selector_delta_high,
)
try:
outcome = await build_grounded_options(
vlm=vlm,
question=candidate.question,
correct_text=correct_text,
material=material,
config=selector_cfg,
session_id=session_id,
)
except (ValueError, FileNotFoundError) as e:
logger.warning(
"slot {} selector 异常 (attempt {}): {}", slot.slot_id, attempt, e
)
prev_reason = f"selector_error: {e}"
continue
# observation 始终落库(含 hard-fail),供 EOB 退化观测与调参
store.update_selector_scores(
item_id, json.dumps(outcome.observation, ensure_ascii=False)
)
if outcome.hard_fail:
prev_reason = "grounded 干扰项不足(selector 硬失败)"
store.mark_item_rejected(item_id, prev_reason)
logger.info("slot {} selector 硬失败 (attempt {})", slot.slot_id, attempt)
continue
# 用 grounded 四选项替换候选(frozen → 构造新实例)
candidate = _replace_candidate_options(
candidate, outcome.options, outcome.answer
)
# Phase 4: 后处理
source_texts = list(material.subtitle_sentences)
pp = run_postprocess(