refactor: extract grounded selector application into helper
将 Phase 3.5 的 grounded selector 逻辑抽成模块级 _apply_grounded_selector, 内部完成策略门控 / 异常捕获 / observation 落库 / hard-fail 拒绝,调用点仅剩 单一失败分支(selector_reason → 重出)。行为不变,测试全绿。 顺带移除 resample_video_interval 死子表达式(硬编码 1,(attempt-1)%1==0 恒真), 使 _process_one_slot 圈复杂度回落至 Task 6 前的 D(25)。_apply_grounded_selector 自身为 A(4),均满足质量门。
This commit is contained in:
@@ -46,6 +46,8 @@ if TYPE_CHECKING:
|
|||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
|
||||||
from app.question_gen.run_store import QuestionGenStore
|
from app.question_gen.run_store import QuestionGenStore
|
||||||
|
from app.question_gen.sampler_v2 import MaterialContext
|
||||||
|
from app.question_gen.strategy import TaskTypeStrategy
|
||||||
from app.tree.index import TreeIndex
|
from app.tree.index import TreeIndex
|
||||||
from core.protocols import LLMProvider, VLMProvider
|
from core.protocols import LLMProvider, VLMProvider
|
||||||
|
|
||||||
@@ -341,6 +343,80 @@ def _replace_candidate_options(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _apply_grounded_selector(
|
||||||
|
candidate: CandidateQuestion,
|
||||||
|
strategy: TaskTypeStrategy,
|
||||||
|
material: MaterialContext,
|
||||||
|
vlm: VLMProvider,
|
||||||
|
config: PipelineConfig,
|
||||||
|
store: QuestionGenStore,
|
||||||
|
item_id: str,
|
||||||
|
slot_id: str,
|
||||||
|
attempt: int,
|
||||||
|
*,
|
||||||
|
session_id: str,
|
||||||
|
) -> tuple[CandidateQuestion | None, str | None]:
|
||||||
|
"""对 AR 候选跑 grounded selector,落观测,返回 (candidate, reject_reason)。
|
||||||
|
|
||||||
|
完整拥有 Phase 3.5 的分流控制流,使调用方仅需单一失败分支:
|
||||||
|
- 非 AR 策略(`uses_grounded_selector` 为假):直接放行,返回 (candidate, None)。
|
||||||
|
- 成功:返回 (重组后的 candidate, None)。
|
||||||
|
- selector 异常(`build_grounded_options` 内部抛 ValueError/FileNotFoundError):
|
||||||
|
仅置 reason,不落 rejected,返回 (None, "selector_error: ...")。
|
||||||
|
- hard-fail:落 observation + mark_item_rejected,返回 (None, reason)。
|
||||||
|
|
||||||
|
observation 无论成败都落库(供 EOB 退化观测与调参)。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
candidate: 待重组的候选题目。
|
||||||
|
strategy: 题型策略(决定是否走 grounded 路径)。
|
||||||
|
material: 采样素材(提供 frame_paths / subtitles)。
|
||||||
|
vlm: VLM 调用端口。
|
||||||
|
config: 管线配置(提供 selector 三参)。
|
||||||
|
store: 日志记录器(落 selector 观测与拒绝态)。
|
||||||
|
item_id: 当前 item 的唯一 ID。
|
||||||
|
slot_id: slot 标识(日志)。
|
||||||
|
attempt: 当前重出轮次(日志)。
|
||||||
|
session_id: 遥测会话 ID。
|
||||||
|
|
||||||
|
返回:
|
||||||
|
(candidate, None) 放行;(None, reject_reason) 要求调用方重出。
|
||||||
|
"""
|
||||||
|
if not strategy.uses_grounded_selector:
|
||||||
|
return candidate, None
|
||||||
|
|
||||||
|
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_id, attempt, e)
|
||||||
|
return None, f"selector_error: {e}"
|
||||||
|
|
||||||
|
# observation 始终落库(含 hard-fail),供 EOB 退化观测与调参
|
||||||
|
store.update_selector_scores(item_id, json.dumps(outcome.observation, ensure_ascii=False))
|
||||||
|
if outcome.hard_fail:
|
||||||
|
reason = "grounded 干扰项不足(selector 硬失败)"
|
||||||
|
store.mark_item_rejected(item_id, reason)
|
||||||
|
logger.info("slot {} selector 硬失败 (attempt {})", slot_id, attempt)
|
||||||
|
return None, reason
|
||||||
|
|
||||||
|
return _replace_candidate_options(candidate, outcome.options, outcome.answer), None
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 单 Slot 处理(重出循环)
|
# 单 Slot 处理(重出循环)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -393,8 +469,6 @@ async def _process_one_slot(
|
|||||||
返回:
|
返回:
|
||||||
GeneratedQuestion(通过全部检查)或 None(重出耗尽)。
|
GeneratedQuestion(通过全部检查)或 None(重出耗尽)。
|
||||||
"""
|
"""
|
||||||
resample_video_interval = 1
|
|
||||||
|
|
||||||
async with sem:
|
async with sem:
|
||||||
strategy = get_strategy(slot.task_type)
|
strategy = get_strategy(slot.task_type)
|
||||||
sub_pattern = strategy.select_sub_pattern(rng)
|
sub_pattern = strategy.select_sub_pattern(rng)
|
||||||
@@ -404,8 +478,8 @@ async def _process_one_slot(
|
|||||||
current_video_id = slot.video_id
|
current_video_id = slot.video_id
|
||||||
|
|
||||||
for attempt in range(1, config.retry_limit + 1):
|
for attempt in range(1, config.retry_limit + 1):
|
||||||
# 连续失败 resample_video_interval 次后换视频
|
# 每次重试都换视频(连续失败即切换)
|
||||||
if attempt > 1 and (attempt - 1) % resample_video_interval == 0 and all_trees:
|
if attempt > 1 and all_trees:
|
||||||
alt_ids = [v for v in all_trees if v != current_video_id]
|
alt_ids = [v for v in all_trees if v != current_video_id]
|
||||||
if alt_ids:
|
if alt_ids:
|
||||||
current_video_id = rng.choice(alt_ids)
|
current_video_id = rng.choice(alt_ids)
|
||||||
@@ -488,50 +562,22 @@ async def _process_one_slot(
|
|||||||
sub_pattern=sub_pattern.name if sub_pattern else None,
|
sub_pattern=sub_pattern.name if sub_pattern else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Phase 3.5: grounded selector(仅 AR 路径)
|
# Phase 3.5: grounded selector(仅 AR 路径;helper 内部完成分流与落库)
|
||||||
if strategy.uses_grounded_selector:
|
candidate, selector_reason = await _apply_grounded_selector(
|
||||||
from app.question_gen.distractor_selector import (
|
candidate,
|
||||||
SelectorConfig,
|
strategy,
|
||||||
build_grounded_options,
|
material,
|
||||||
)
|
vlm,
|
||||||
|
config,
|
||||||
correct_text = _extract_correct_text(candidate.options, candidate.answer)
|
store,
|
||||||
selector_cfg = SelectorConfig(
|
item_id,
|
||||||
candidate_pool_size=config.candidate_pool_size,
|
slot.slot_id,
|
||||||
delta_low=config.selector_delta_low,
|
attempt,
|
||||||
delta_high=config.selector_delta_high,
|
session_id=session_id,
|
||||||
)
|
)
|
||||||
try:
|
if selector_reason is not None:
|
||||||
outcome = await build_grounded_options(
|
prev_reason = selector_reason
|
||||||
vlm=vlm,
|
continue
|
||||||
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: 后处理
|
# Phase 4: 后处理
|
||||||
source_texts = list(material.subtitle_sentences)
|
source_texts = list(material.subtitle_sentences)
|
||||||
|
|||||||
Reference in New Issue
Block a user