feat: add question_units helper as pair contract entry point
build_units/flatten_units/validate_units/unit_correctness——pair 契约唯一入口。 build_units 对孤儿/超员/角色缺失重复 fail-fast raise ValueError(防 next 静默 StopIteration);unit_correctness 走 per_q[qid] KeyError 防静默兜底。
This commit is contained in:
@@ -0,0 +1,97 @@
|
|||||||
|
"""QuestionUnit 组装/展开/校验/单元正确性——pair 契约的唯一入口。
|
||||||
|
|
||||||
|
pool 构建、批处理、推理、评测(Task 3+)均通过本模块聚合/展开孪生对,
|
||||||
|
保证 AR pair 的"两题作为整体调度"契约只在一处实现、fail-fast 暴露非法配对。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from core.types import GeneratedQuestion, QuestionUnit
|
||||||
|
|
||||||
|
|
||||||
|
def build_units(questions: list[GeneratedQuestion]) -> list[QuestionUnit]:
|
||||||
|
"""将扁平题目列表聚合为单元列表:single 单封、pair 按 pair_id 成对聚合。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
questions: 待聚合的题目列表,可混含 single 与孪生对成员。
|
||||||
|
|
||||||
|
返回:
|
||||||
|
单元列表,先 single 后 pair,顺序稳定(single 保留输入顺序,
|
||||||
|
pair 按首次出现的 pair_id 顺序)。
|
||||||
|
|
||||||
|
关键实现:
|
||||||
|
- pair_id 为空 → single 单元;非空 → 归入对应 pair 桶。
|
||||||
|
- 每个 pair 桶必须恰好 2 条,否则视为孤儿/超员,raise ValueError。
|
||||||
|
- 显式检查 pair_original / pair_mirror 角色齐备且唯一,缺失或重复
|
||||||
|
直接 raise ValueError(防 next(...) 静默 StopIteration),保持
|
||||||
|
fail-fast。合法孪生对交由 QuestionUnit.from_pair 做一致性断言。
|
||||||
|
"""
|
||||||
|
by_pair: dict[str, list[GeneratedQuestion]] = defaultdict(list)
|
||||||
|
singles: list[QuestionUnit] = []
|
||||||
|
for q in questions:
|
||||||
|
if q.pair_id:
|
||||||
|
by_pair[q.pair_id].append(q)
|
||||||
|
else:
|
||||||
|
singles.append(QuestionUnit.from_single(q))
|
||||||
|
|
||||||
|
pairs: list[QuestionUnit] = []
|
||||||
|
for pid, qs in by_pair.items():
|
||||||
|
if len(qs) != 2:
|
||||||
|
raise ValueError(f"pair {pid} 数量={len(qs)}≠2(孤儿或超员)")
|
||||||
|
originals = [q for q in qs if q.question_role == "pair_original"]
|
||||||
|
mirrors = [q for q in qs if q.question_role == "pair_mirror"]
|
||||||
|
if len(originals) != 1 or len(mirrors) != 1:
|
||||||
|
raise ValueError(
|
||||||
|
f"pair {pid} 角色非法:original={len(originals)} mirror={len(mirrors)},"
|
||||||
|
"需各恰好 1 条"
|
||||||
|
)
|
||||||
|
pairs.append(QuestionUnit.from_pair(originals[0], mirrors[0]))
|
||||||
|
return singles + pairs
|
||||||
|
|
||||||
|
|
||||||
|
def flatten_units(units: list[QuestionUnit]) -> list[GeneratedQuestion]:
|
||||||
|
"""将单元列表无损展开回扁平题目列表。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
units: 单元列表。
|
||||||
|
|
||||||
|
返回:
|
||||||
|
展开后的题目列表,保持单元顺序及单元内题目顺序。
|
||||||
|
"""
|
||||||
|
return [q for u in units for q in u.questions]
|
||||||
|
|
||||||
|
|
||||||
|
def validate_units(units: list[QuestionUnit]) -> list[QuestionUnit]:
|
||||||
|
"""校验单元列表结构合法性,通过则原样返回(便于链式调用)。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
units: 待校验单元列表。
|
||||||
|
|
||||||
|
返回:
|
||||||
|
校验通过的原单元列表。
|
||||||
|
|
||||||
|
关键实现:
|
||||||
|
pair 单元必须恰好含 2 题,否则 raise ValueError;single 单元无需额外
|
||||||
|
校验(构造时即为 1 题)。用于消费方在使用前做一道防御闸门。
|
||||||
|
"""
|
||||||
|
for u in units:
|
||||||
|
if u.kind == "pair" and u.size != 2:
|
||||||
|
raise ValueError(f"unit {u.unit_id} pair 不成对(size={u.size})")
|
||||||
|
return units
|
||||||
|
|
||||||
|
|
||||||
|
def unit_correctness(unit: QuestionUnit, per_q: dict[str, bool]) -> bool:
|
||||||
|
"""计算单元级正确性:AR pair 走双向 AND,single 即单题正确性。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
unit: 目标单元。
|
||||||
|
per_q: 题目 question_id → 该题是否作答正确的映射。
|
||||||
|
|
||||||
|
返回:
|
||||||
|
单元内所有题目均正确时为 True,否则 False。
|
||||||
|
|
||||||
|
关键实现:
|
||||||
|
直接以 per_q[q.question_id] 取值,缺任一题触发 KeyError(防静默兜底),
|
||||||
|
强制上游先补齐全部单题结果再计单元正确性。
|
||||||
|
"""
|
||||||
|
return all(per_q[q.question_id] for q in unit.questions)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""question_units helper 单元测试:组装/展开/校验/单元正确性。
|
||||||
|
|
||||||
|
覆盖 pair 契约唯一入口的核心行为:
|
||||||
|
- build_units 聚合孪生对、保留 single;
|
||||||
|
- 孤儿 pair、角色缺失/重复直接报错(fail-fast);
|
||||||
|
- flatten_units 无损展开;
|
||||||
|
- unit_correctness 对 pair 走 AND、缺题走 KeyError(防静默)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.harness.question_units import (
|
||||||
|
build_units,
|
||||||
|
flatten_units,
|
||||||
|
unit_correctness,
|
||||||
|
validate_units,
|
||||||
|
)
|
||||||
|
from core.types import GeneratedQuestion
|
||||||
|
|
||||||
|
|
||||||
|
def _q(qid: str, role: str = "single", pid: str | None = None) -> GeneratedQuestion:
|
||||||
|
"""构造测试题目;pid 非空时视为孪生对成员。"""
|
||||||
|
return GeneratedQuestion(
|
||||||
|
question_id=qid,
|
||||||
|
video_id="v",
|
||||||
|
task_type="AR",
|
||||||
|
question="?",
|
||||||
|
options=("A. a", "B. b", "C. c", "D. d"),
|
||||||
|
answer="A",
|
||||||
|
source_nodes=("n",),
|
||||||
|
difficulty="hard",
|
||||||
|
pair_id=pid,
|
||||||
|
question_role=role,
|
||||||
|
unit_id=pid or qid,
|
||||||
|
flip_axis="ax" if pid else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_units_groups_pair_and_keeps_single():
|
||||||
|
qs = [_q("s1"), _q("po", "pair_original", "p"), _q("pm", "pair_mirror", "p")]
|
||||||
|
units = build_units(qs)
|
||||||
|
kinds = sorted(u.kind for u in units)
|
||||||
|
assert kinds == ["pair", "single"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_units_rejects_orphan_pair():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
validate_units(build_units([_q("po", "pair_original", "p")])) # 只 1 条
|
||||||
|
|
||||||
|
|
||||||
|
def test_flatten_roundtrip():
|
||||||
|
qs = [_q("po", "pair_original", "p"), _q("pm", "pair_mirror", "p")]
|
||||||
|
assert {q.question_id for q in flatten_units(build_units(qs))} == {"po", "pm"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_unit_correctness_bidirectional_and():
|
||||||
|
qs = [_q("po", "pair_original", "p"), _q("pm", "pair_mirror", "p")]
|
||||||
|
u = build_units(qs)[0]
|
||||||
|
assert unit_correctness(u, {"po": True, "pm": True}) is True
|
||||||
|
assert unit_correctness(u, {"po": True, "pm": False}) is False # AND
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_units_rejects_duplicate_role():
|
||||||
|
qs = [_q("po1", "pair_original", "p"), _q("po2", "pair_original", "p")]
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
build_units(qs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unit_correctness_missing_question_raises_keyerror():
|
||||||
|
qs = [_q("po", "pair_original", "p"), _q("pm", "pair_mirror", "p")]
|
||||||
|
u = build_units(qs)[0]
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
unit_correctness(u, {"po": True}) # 缺 pm → KeyError 防静默
|
||||||
Reference in New Issue
Block a user