"""accepted 题库的 pair 原子成对落盘 helper(纯件,不依赖 pipeline)。 三件可复用纯件供 Phase 2 新 pipeline 的 on_accept 回调复用:``PairPendingBuffer`` (按 pair_id 收齐才 emit 单元)、``write_accepted``(tmp + os.replace 原子写)、 ``read_accepted``(聚合成单元并剔除磁盘悬挂孤儿)。 wiring 归属:本模块**只是纯件**,不接任何生成侧回调;真正的 on_accept wiring **见 Phase 2**。当前项目真实 accepted 写入点是 ``adversarial_filter.write_final_bank`` (Phase 2 待重建的旧代码),本模块沿用其 tmp + os.replace 模式但不 import/不改动它。 unit_hash 校验:``QuestionUnit.unit_hash`` 目前恒为 ""(填充是 Phase 2 的事),且 ``GeneratedQuestion`` 不携带 unit_hash 字段。故把"unit_hash 不一致→拒"落地为**同 pair_id 两成员的绑定一致性校验**(video_id / task_type / flip_axis),不一致即 fail-fast raise,语义等价——只有 payload 绑定一致的孪生对才允许聚合。 """ from __future__ import annotations import json import os from collections import defaultdict from typing import TYPE_CHECKING from loguru import logger from core.types import GeneratedQuestion, QuestionUnit if TYPE_CHECKING: from pathlib import Path def _check_pair_binding(first: GeneratedQuestion, second: GeneratedQuestion) -> None: """校验同 pair_id 两成员的绑定一致性,不一致 fail-fast(unit_hash 语义代偿)。 参数 first/second 为先后到达的孪生对成员。video_id / task_type / flip_axis 任一 不一致即 raise ValueError——绑定不一致的两条题目不构成同一 payload 的孪生对, 拒绝聚合而非静默兜底。 """ mismatches: list[str] = [] if first.video_id != second.video_id: mismatches.append(f"video_id: {first.video_id} != {second.video_id}") if first.task_type != second.task_type: mismatches.append(f"task_type: {first.task_type} != {second.task_type}") if first.flip_axis != second.flip_axis: mismatches.append(f"flip_axis: {first.flip_axis} != {second.flip_axis}") if mismatches: raise ValueError( f"pair {first.pair_id} 两成员绑定不一致(" + ";".join(mismatches) + "),拒绝聚合" ) class PairPendingBuffer: """按 pair_id 收齐孪生对才 emit 单元的有状态缓冲器。 喂题接口 ``add`` 逐条消费题目:single 立即 emit ``QuestionUnit.from_single``; pair 成员先缓存,等同一 pair_id 的 original+mirror 都到齐才 emit 一个 pair 单元 (复用 ``QuestionUnit.from_pair`` 走 fail-fast 校验)。批次末尾用 ``pending_orphans`` 检测"只落 P 未落 Q"的悬挂项。 """ def __init__(self) -> None: """初始化空缓冲器。pending 以 pair_id 索引首个到达的孪生对成员。""" self._pending: dict[str, GeneratedQuestion] = {} def add(self, q: GeneratedQuestion) -> QuestionUnit | None: """喂入一条题目 q,返回本次凑齐的单元或 None(pair 尚未配齐)。 single 立即返回 kind="single" 单元;pair 首个成员缓存并返回 None,第二个 成员到齐后返回 kind="pair" 单元。同 pair_id 两成员绑定不一致或角色非法(如 两个 original)即 raise ValueError。 关键实现:配齐后**先校验、成功组装出 unit 才从 pending 删除**——若 ``_check_pair_binding`` / ``_order_pair`` raise,首成员仍留在 pending,调用方 ``pending_orphans`` 可取回被拒的悬挂成员。绑定校验用显式 ValueError(不依赖 会被 ``-O`` 剥除的 assert)。 """ if not q.pair_id: return QuestionUnit.from_single(q) partner = self._pending.get(q.pair_id) if partner is None: self._pending[q.pair_id] = q return None _check_pair_binding(partner, q) original, mirror = _order_pair(partner, q) unit = QuestionUnit.from_pair(original, mirror) del self._pending[q.pair_id] return unit def pending_orphans(self) -> list[GeneratedQuestion]: """返回当前仍未配齐的孤儿成员(供批次末尾检测悬挂项)。 返回: 尚在缓冲、未等到伙伴的孪生对成员列表(按 pair_id 插入顺序)。 """ return list(self._pending.values()) def _order_pair( a: GeneratedQuestion, b: GeneratedQuestion ) -> tuple[GeneratedQuestion, GeneratedQuestion]: """按 question_role 把两成员 a/b 定序为 (original, mirror)。 两成员不构成恰好 1 original + 1 mirror(角色缺失或重复)即 raise ValueError, 防 next(...) 静默 StopIteration。 """ originals = [q for q in (a, b) if q.question_role == "pair_original"] mirrors = [q for q in (a, b) if q.question_role == "pair_mirror"] if len(originals) != 1 or len(mirrors) != 1: raise ValueError( f"pair {a.pair_id} 角色非法:original={len(originals)} mirror={len(mirrors)}," "需各恰好 1 条" ) return originals[0], mirrors[0] def _validate_disk_pair(pair_id: str, group: list[GeneratedQuestion]) -> list[GeneratedQuestion]: """三态判定磁盘上同 pair_id 分组,区分悬挂孤儿(drop)与结构损坏(raise)。 - 恰好 1 original + 1 mirror:合法孪生对,额外做 ``_check_pair_binding`` 显式绑定 校验(video_id/task_type/flip_axis,-O 下仍生效),返回其两题。 - size==1(只落 P 未落 Q):业务上合法的悬挂孤儿,warn + drop,返回 []。 - 其余(size>2 超员、或 size==2 角色重复/缺角色):数据损坏/外部篡改,按 P5 fail-loud,raise ValueError(含 pair_id 与成员构成),绝不静默吞。 """ originals = sum(1 for q in group if q.question_role == "pair_original") mirrors = sum(1 for q in group if q.question_role == "pair_mirror") if len(group) == 2 and originals == 1 and mirrors == 1: _check_pair_binding(group[0], group[1]) return group if len(group) == 1: logger.warning("磁盘悬挂孤儿 pair {}:仅 1 成员(缺伙伴),warn+drop 该 unit", pair_id) return [] raise ValueError( f"磁盘 pair {pair_id} 结构损坏:成员数={len(group)}" f"(original={originals} mirror={mirrors}),需恰好 1 original + 1 mirror" ) def _sift_disk_pairs(questions: list[GeneratedQuestion]) -> list[GeneratedQuestion]: """筛选磁盘读回的题目:single 全保留、pair 按 ``_validate_disk_pair`` 三态处理。 悬挂孤儿 warn+drop、结构损坏/绑定不一致 raise ValueError、合法成对保留后交给 build_units(single 全保留)。 """ by_pair: dict[str, list[GeneratedQuestion]] = defaultdict(list) singles: list[GeneratedQuestion] = [] for q in questions: if q.pair_id: by_pair[q.pair_id].append(q) else: singles.append(q) kept_pairs = [q for pid, grp in by_pair.items() for q in _validate_disk_pair(pid, grp)] return singles + kept_pairs def write_accepted(path: Path, units: list[QuestionUnit]) -> None: """把 units 全量原子写到 path(tmp + os.replace),孪生对两题相邻落盘。 parent 不存在则自动创建;任一 pair 单元结构非法(size≠2)落盘前 fail-fast raise。 关键实现:沿用 ``write_final_bank`` 的原子写模式(先写同目录 ``.tmp`` 再 ``os.replace`` 覆盖,保证读到的 JSON 恒完整)。序列化复用 T9 pools.py 的 ``_q_to_dict``(唯一 GeneratedQuestion↔dict schema,含 pair 四字段),函数内 import 规避 app.question_gen↔app.harness 循环依赖。 """ from app.harness.pools import _q_to_dict from app.harness.question_units import flatten_units, validate_units validate_units(units) records = [_q_to_dict(q) for q in flatten_units(units)] path.parent.mkdir(parents=True, exist_ok=True) tmp = path.with_suffix(".tmp") tmp.write_text(json.dumps(records, ensure_ascii=False, indent=2), encoding="utf-8") os.replace(str(tmp), str(path)) logger.info("accepted 题库全量原子写: {} 单元 / {} 题 → {}", len(units), len(records), path) def read_accepted(path: Path) -> list[QuestionUnit]: """读回 path 的 accepted JSON → 聚合为单元列表。 磁盘是外部输入,按 P5 全量校验后再用:``_sift_disk_pairs`` 对 pair 分组三态处理 ——"只落 P 未落 Q"的悬挂孤儿 warn+drop(不进结果、不 raise);结构损坏(超员/ 角色重复)或绑定不一致(video_id/task_type/flip_axis)显式 raise ValueError(不 依赖 build_units 内会被 ``-O`` 剥除的 assert)。single 全保留。 关键实现:反序列化复用 T9 pools.py 的 ``_dict_to_q``(pair 四字段 .get 兼容), 函数内 import 规避循环依赖。sift 后 ``build_units`` 聚合、``validate_units`` 二次 防御闸门。 """ from app.harness.pools import _dict_to_q from app.harness.question_units import build_units, validate_units raw = json.loads(path.read_text(encoding="utf-8")) questions = [_dict_to_q(d) for d in raw] kept = _sift_disk_pairs(questions) return validate_units(build_units(kept))