255 lines
9.5 KiB
Python
255 lines
9.5 KiB
Python
#!/usr/bin/env python3
|
|
"""正文回放五维量表与确定性稳定性仲裁。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from statistics import median
|
|
from typing import Any, Mapping, Sequence
|
|
|
|
|
|
RUBRIC_PROFILE = "writer_replay"
|
|
DIMENSIONS = (
|
|
"setting_entity_fidelity",
|
|
"fine_outline_fidelity",
|
|
"style_consistency",
|
|
"narrative_tension",
|
|
"prose_readability",
|
|
)
|
|
EVIDENCE_SOURCE_TYPES = frozenset(
|
|
{"fine_outline", "historical_prose", "judge_inference"}
|
|
)
|
|
REPORT_FIELDS = frozenset(
|
|
{"profile", "reviewerId", "sampleId", "blindCandidateId", "candidateOrder", "scores"}
|
|
)
|
|
|
|
|
|
def _is_half_step(value: Any) -> bool:
|
|
"""只接受 0 到 10 的数字和 0.5 步长,布尔值不算数字。"""
|
|
|
|
return (
|
|
not isinstance(value, bool)
|
|
and isinstance(value, (int, float))
|
|
and 0 <= float(value) <= 10
|
|
and float(value) * 2 == int(float(value) * 2)
|
|
)
|
|
|
|
|
|
def validate_scores(scores: Any) -> list[str]:
|
|
"""校验五维分数、步长和逐项证据归因。"""
|
|
|
|
if not isinstance(scores, Mapping):
|
|
return ["scores 必须是对象"]
|
|
errors: list[str] = []
|
|
missing = [dimension for dimension in DIMENSIONS if dimension not in scores]
|
|
unexpected = sorted(set(scores) - set(DIMENSIONS))
|
|
if missing:
|
|
errors.append(f"缺少正文 rubric 维度: {','.join(missing)}")
|
|
if unexpected:
|
|
errors.append(f"存在未登记正文 rubric 维度: {','.join(unexpected)}")
|
|
for dimension in DIMENSIONS:
|
|
item = scores.get(dimension)
|
|
if not isinstance(item, Mapping):
|
|
errors.append(f"维度必须包含 score/evidence 对象: {dimension}")
|
|
continue
|
|
if set(item) != {"score", "evidence"}:
|
|
errors.append(f"维度字段必须精确为 score/evidence: {dimension}")
|
|
if not _is_half_step(item.get("score")):
|
|
errors.append(f"分数必须在 0-10 且使用 0.5 步长: {dimension}")
|
|
evidence = item.get("evidence")
|
|
if not isinstance(evidence, list) or not evidence:
|
|
errors.append(f"分数缺少证据: {dimension}")
|
|
continue
|
|
for index, raw in enumerate(evidence):
|
|
if not isinstance(raw, Mapping):
|
|
errors.append(f"证据必须是对象: {dimension}[{index}]")
|
|
continue
|
|
if set(raw) != {"sourceType", "sourceRef", "excerpt"}:
|
|
errors.append(f"证据字段必须精确为 sourceType/sourceRef/excerpt: {dimension}[{index}]")
|
|
continue
|
|
if raw.get("sourceType") not in EVIDENCE_SOURCE_TYPES:
|
|
errors.append(f"证据来源类型未登记: {dimension}[{index}]")
|
|
for field in ("sourceRef", "excerpt"):
|
|
if not isinstance(raw.get(field), str) or not raw[field].strip():
|
|
errors.append(f"证据 {field} 不能为空: {dimension}[{index}]")
|
|
return errors
|
|
|
|
|
|
def validate_report(report: Any) -> list[str]:
|
|
"""校验单个盲评报告,真实臂名或额外字段一律失败关闭。"""
|
|
|
|
if not isinstance(report, Mapping):
|
|
return ["评委报告必须是对象"]
|
|
errors: list[str] = []
|
|
if set(report) != REPORT_FIELDS:
|
|
errors.append("评委报告字段非法,去盲前不得包含真实臂名或额外信息")
|
|
if report.get("profile") != RUBRIC_PROFILE:
|
|
errors.append(f"profile 必须是 {RUBRIC_PROFILE}")
|
|
for field in ("reviewerId", "sampleId", "blindCandidateId"):
|
|
if not isinstance(report.get(field), str) or not report[field].strip():
|
|
errors.append(f"{field} 必须是非空字符串")
|
|
order = report.get("candidateOrder")
|
|
if (
|
|
not isinstance(order, list)
|
|
or len(order) < 2
|
|
or any(not isinstance(item, str) or not item for item in order)
|
|
or len(set(order)) != len(order)
|
|
):
|
|
errors.append("candidateOrder 必须是无重复盲化候选 ID 数组")
|
|
elif report.get("blindCandidateId") not in order:
|
|
errors.append("blindCandidateId 不在本轮 candidateOrder 中")
|
|
errors.extend(validate_scores(report.get("scores")))
|
|
return errors
|
|
|
|
|
|
def validate_blind_pair(first: Any, second: Any) -> list[str]:
|
|
"""确认双评独立、同样本、同候选集合且第二评委严格反序。"""
|
|
|
|
errors = [*validate_report(first), *validate_report(second)]
|
|
if errors or not isinstance(first, Mapping) or not isinstance(second, Mapping):
|
|
return errors
|
|
if first["reviewerId"] == second["reviewerId"]:
|
|
errors.append("双评委必须使用独立 reviewerId")
|
|
if first["sampleId"] != second["sampleId"]:
|
|
errors.append("双评委必须评审同一样本")
|
|
if first["blindCandidateId"] != second["blindCandidateId"]:
|
|
errors.append("双评委必须评审同一盲化候选")
|
|
if set(first["candidateOrder"]) != set(second["candidateOrder"]):
|
|
errors.append("双评委的 candidateOrder 必须包含同一盲化候选集合")
|
|
if second["candidateOrder"] != list(reversed(first["candidateOrder"])):
|
|
errors.append("第二评委必须对相同盲化候选严格反序")
|
|
return errors
|
|
|
|
|
|
def deblind_reports(
|
|
reports: Sequence[Mapping[str, Any]], mapping: Mapping[str, str]
|
|
) -> list[dict[str, Any]]:
|
|
"""评分结束后按预注册映射去盲,绝不依赖展示位置推断真实臂。"""
|
|
|
|
if set(mapping.values()) != {"A", "B", "C"} or len(mapping) != 3:
|
|
raise ValueError("去盲映射必须将三个盲 ID 一一映射到 A/B/C")
|
|
result: list[dict[str, Any]] = []
|
|
for index, report in enumerate(reports):
|
|
errors = validate_report(report)
|
|
if errors:
|
|
raise ValueError(f"reports[{index}] 非法: {'; '.join(errors)}")
|
|
blind_id = str(report["blindCandidateId"])
|
|
if blind_id not in mapping:
|
|
raise ValueError(f"reports[{index}] 的 blindCandidateId 未预注册")
|
|
result.append({**dict(report), "arm": mapping[blind_id]})
|
|
return result
|
|
|
|
|
|
def _numeric_scores(report: Mapping[str, Any]) -> dict[str, float]:
|
|
"""从已校验报告提取确定性浮点分数。"""
|
|
|
|
return {
|
|
dimension: float(report["scores"][dimension]["score"])
|
|
for dimension in DIMENSIONS
|
|
}
|
|
|
|
|
|
def _stable_pair_exists(values: Sequence[float], threshold: float) -> bool:
|
|
"""判断三个评分中是否至少存在一对落在稳定阈值内。"""
|
|
|
|
return any(
|
|
abs(values[left] - values[right]) <= threshold
|
|
for left in range(len(values))
|
|
for right in range(left + 1, len(values))
|
|
)
|
|
|
|
|
|
def adjudicate_reviews(
|
|
first: Mapping[str, Any],
|
|
second: Mapping[str, Any],
|
|
third: Mapping[str, Any] | None = None,
|
|
*,
|
|
threshold: float = 0.5,
|
|
) -> dict[str, Any]:
|
|
"""按双评差异触发最多一次第三评委,并输出稳定终态。"""
|
|
|
|
pair_errors = validate_blind_pair(first, second)
|
|
if pair_errors:
|
|
return {"status": "invalid_report", "errors": pair_errors}
|
|
first_scores = _numeric_scores(first)
|
|
second_scores = _numeric_scores(second)
|
|
unstable = [
|
|
dimension
|
|
for dimension in DIMENSIONS
|
|
if abs(first_scores[dimension] - second_scores[dimension]) > threshold
|
|
]
|
|
if not unstable:
|
|
return {
|
|
"status": "stable_report",
|
|
"scores": {
|
|
dimension: (first_scores[dimension] + second_scores[dimension]) / 2
|
|
for dimension in DIMENSIONS
|
|
},
|
|
"unstableDimensions": [],
|
|
"reviewCount": 2,
|
|
}
|
|
if third is None:
|
|
return {
|
|
"status": "needs_third_reviewer",
|
|
"unstableDimensions": unstable,
|
|
"reviewCount": 2,
|
|
}
|
|
third_errors = validate_report(third)
|
|
if third_errors:
|
|
return {"status": "invalid_report", "errors": third_errors}
|
|
if third["sampleId"] != first["sampleId"]:
|
|
return {"status": "invalid_report", "errors": ["第三评委必须评审同一样本"]}
|
|
if third["blindCandidateId"] != first["blindCandidateId"]:
|
|
return {"status": "invalid_report", "errors": ["第三评委必须评审同一盲化候选"]}
|
|
allowed_third_orders = (
|
|
first["candidateOrder"],
|
|
list(reversed(first["candidateOrder"])),
|
|
)
|
|
if third["candidateOrder"] not in allowed_third_orders:
|
|
return {
|
|
"status": "invalid_report",
|
|
"errors": ["第三评委必须使用与双评相同的盲化候选集合及第一评或反序顺序"],
|
|
}
|
|
if third["reviewerId"] in {first["reviewerId"], second["reviewerId"]}:
|
|
return {"status": "invalid_report", "errors": ["第三评委必须使用独立 reviewerId"]}
|
|
|
|
third_scores = _numeric_scores(third)
|
|
unresolved = [
|
|
dimension
|
|
for dimension in unstable
|
|
if not _stable_pair_exists(
|
|
[first_scores[dimension], second_scores[dimension], third_scores[dimension]],
|
|
threshold,
|
|
)
|
|
]
|
|
if unresolved:
|
|
return {
|
|
"status": "invalid_unstable",
|
|
"unstableDimensions": unresolved,
|
|
"reviewCount": 3,
|
|
}
|
|
return {
|
|
"status": "adjudicated_report",
|
|
"scores": {
|
|
dimension: float(
|
|
median(
|
|
[first_scores[dimension], second_scores[dimension], third_scores[dimension]]
|
|
)
|
|
)
|
|
for dimension in DIMENSIONS
|
|
},
|
|
"unstableDimensions": unstable,
|
|
"reviewCount": 3,
|
|
}
|
|
|
|
|
|
__all__ = [
|
|
"RUBRIC_PROFILE",
|
|
"DIMENSIONS",
|
|
"EVIDENCE_SOURCE_TYPES",
|
|
"validate_scores",
|
|
"validate_report",
|
|
"validate_blind_pair",
|
|
"deblind_reports",
|
|
"adjudicate_reviews",
|
|
]
|