# Conflicts: # .claude/skills/replay-eval/SKILL.md # .claude/skills/replay-eval/scripts/load_reference_work.py
613 lines
28 KiB
Python
613 lines
28 KiB
Python
#!/usr/bin/env python3
|
|
"""回放快照的 fail-closed 校验。
|
|
|
|
输入是冻结脚本产出的结构化 manifest 和运行时登记信息;本模块不访问数据库、不调用模型。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Mapping, Sequence
|
|
|
|
from build_snapshot import normalize_chapter, normalize_chapter_range
|
|
|
|
|
|
STATUS_READY = "ready"
|
|
STATUS_BLOCKED_AUTHORIZATION = "blocked_authorization"
|
|
STATUS_INVALID_SNAPSHOT = "invalid_snapshot"
|
|
STATUS_INVALID_ARM_DIFF = "invalid_arm_diff"
|
|
STATUS_TARGET_SOURCE_FORBIDDEN = "target_source_forbidden"
|
|
STATUS_SCHEMA_INVALID = "schema_invalid"
|
|
|
|
FORBIDDEN_SOURCE_STATUSES = frozenset(
|
|
{"revoked", "delisted", "recalled", "blocked", "owner_missing", "unauthorized"}
|
|
)
|
|
ALLOWED_SOURCE_STATUSES = frozenset({"active", "approved", "authorized", "licensed"})
|
|
ALLOWED_COPYRIGHT_STATUSES = frozenset({"licensed", "public_domain", "research_only"})
|
|
FORBIDDEN_COPYRIGHT_STATUSES = frozenset(
|
|
{"unauthorized", "unlicensed", "revoked", "expired", "blocked"}
|
|
)
|
|
SOURCE_HASH_PATTERN = re.compile(r"^sha256:[0-9a-f]{64}$")
|
|
SOURCE_VERSION_PATTERN = re.compile(r"^raw-file-v1:sha256:[0-9a-f]{64}$")
|
|
SANITIZED_FIXTURE_VERSION_PATTERN = re.compile(
|
|
r"^sanitized-fixture-v1:sha256:[0-9a-f]{64}$"
|
|
)
|
|
CARD_KEYS = frozenset(
|
|
{
|
|
"arm",
|
|
"card",
|
|
"cards",
|
|
"cardInjection",
|
|
"cardInjectionSha256",
|
|
"cardInjectionCount",
|
|
"cardSection",
|
|
"cardSections",
|
|
"cardManifest",
|
|
"cardIds",
|
|
"cardSourceIds",
|
|
"cardStrategy",
|
|
"l2-card",
|
|
"l2-placebo",
|
|
"card_injection",
|
|
"card_injection_sha256",
|
|
"card_injection_count",
|
|
"card_section",
|
|
"card_sections",
|
|
"card_manifest",
|
|
"card_ids",
|
|
"card_source_ids",
|
|
"card_strategy",
|
|
"l2_card",
|
|
"l2_placebo",
|
|
}
|
|
)
|
|
NORMALIZED_CARD_KEYS = frozenset(key.lower() for key in CARD_KEYS)
|
|
REQUIRED_CANDIDATE_FIELDS = (
|
|
"targetChapter",
|
|
"chapterGoal",
|
|
"keyEvents",
|
|
"entities",
|
|
"foreshadowing",
|
|
"stateChanges",
|
|
"hook",
|
|
"unknowns",
|
|
"assumptions",
|
|
)
|
|
ALLOWED_CANDIDATE_FIELDS = frozenset((*REQUIRED_CANDIDATE_FIELDS, "sourceRefs"))
|
|
EVENT_FIELDS = {
|
|
"id": str,
|
|
"order": int,
|
|
"event": str,
|
|
"participants": list,
|
|
"trigger": str,
|
|
"resultDirection": str,
|
|
}
|
|
ENTITY_FIELDS = {"name": str, "type": str, "role": str}
|
|
FORESHADOWING_FIELDS = {"action": str, "subject": str, "evidence": str}
|
|
FORBIDDEN_CANDIDATE_FIELDS = frozenset(
|
|
{"body", "raw", "rawText", "content", "正文", "原文", "正文全文", "完整目标细纲"}
|
|
)
|
|
|
|
|
|
def _result(status: str, errors: Sequence[str] = (), warnings: Sequence[str] = ()) -> dict[str, Any]:
|
|
return {"status": status, "ok": status == STATUS_READY, "errors": list(errors), "warnings": list(warnings)}
|
|
|
|
|
|
def _field(value: Mapping[str, Any], *keys: str) -> Any:
|
|
for key in keys:
|
|
if key in value:
|
|
return value[key]
|
|
return None
|
|
|
|
|
|
def _parse_utc_time(value: Any, field: str) -> tuple[datetime | None, str | None]:
|
|
"""解析带时区的 ISO-8601 时间;格式含糊时按失败关闭处理。"""
|
|
|
|
if not isinstance(value, str) or not value.strip():
|
|
return None, f"授权快照 {field} 不是有效时间"
|
|
try:
|
|
parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
|
|
except ValueError:
|
|
return None, f"授权快照 {field} 不是有效 ISO-8601 时间"
|
|
if parsed.tzinfo is None:
|
|
return None, f"授权快照 {field} 缺少时区"
|
|
return parsed.astimezone(timezone.utc), None
|
|
|
|
|
|
def check_authorization(authorization: Mapping[str, Any] | None) -> dict[str, Any]:
|
|
"""授权信息缺失、用途不符或来源进入危险状态时关闭评测。"""
|
|
|
|
if not isinstance(authorization, Mapping):
|
|
return _result(STATUS_BLOCKED_AUTHORIZATION, ["缺少不可变授权快照"])
|
|
snapshot = _field(authorization, "authorizationSnapshot", "authorization_snapshot")
|
|
if not isinstance(snapshot, Mapping) or not snapshot:
|
|
return _result(STATUS_BLOCKED_AUTHORIZATION, ["授权快照为空"])
|
|
|
|
snapshot_required = (
|
|
"id",
|
|
"version",
|
|
"immutable",
|
|
"sourceHash",
|
|
"sourceVersion",
|
|
"sourceStatus",
|
|
"copyrightStatus",
|
|
"authorizationBasis",
|
|
"allowedPurpose",
|
|
"checkedAt",
|
|
)
|
|
missing_snapshot = [field for field in snapshot_required if not snapshot.get(field)]
|
|
if missing_snapshot:
|
|
return _result(
|
|
STATUS_BLOCKED_AUTHORIZATION,
|
|
[f"授权快照缺少字段: {','.join(missing_snapshot)}"],
|
|
)
|
|
if snapshot.get("immutable") is not True:
|
|
return _result(STATUS_BLOCKED_AUTHORIZATION, ["授权快照不是不可变快照"])
|
|
if not snapshot.get("expiresAt") and not snapshot.get("revalidationAt"):
|
|
return _result(STATUS_BLOCKED_AUTHORIZATION, ["授权快照缺少过期或重验时间"])
|
|
|
|
now = datetime.now(timezone.utc)
|
|
checked_at, error = _parse_utc_time(snapshot.get("checkedAt"), "checkedAt")
|
|
if error:
|
|
return _result(STATUS_BLOCKED_AUTHORIZATION, [error])
|
|
if checked_at is not None and checked_at > now:
|
|
return _result(STATUS_BLOCKED_AUTHORIZATION, ["授权快照 checkedAt 晚于当前时间"])
|
|
for field in ("expiresAt", "revalidationAt"):
|
|
raw_time = snapshot.get(field)
|
|
if not raw_time:
|
|
continue
|
|
deadline, error = _parse_utc_time(raw_time, field)
|
|
if error:
|
|
return _result(STATUS_BLOCKED_AUTHORIZATION, [error])
|
|
if deadline is not None and deadline <= now:
|
|
return _result(STATUS_BLOCKED_AUTHORIZATION, [f"授权快照 {field} 已到期"])
|
|
|
|
source_status = str(_field(authorization, "sourceStatus", "source_status") or "").lower()
|
|
if not source_status:
|
|
return _result(STATUS_BLOCKED_AUTHORIZATION, ["缺少 sourceStatus"])
|
|
if source_status in FORBIDDEN_SOURCE_STATUSES:
|
|
return _result(STATUS_BLOCKED_AUTHORIZATION, [f"来源状态禁止评测: {source_status}"])
|
|
if source_status not in ALLOWED_SOURCE_STATUSES:
|
|
return _result(STATUS_BLOCKED_AUTHORIZATION, [f"来源状态未登记,拒绝评测: {source_status}"])
|
|
|
|
copyright_status = str(
|
|
_field(authorization, "copyrightStatus", "copyright_status") or ""
|
|
).lower()
|
|
if not copyright_status:
|
|
return _result(STATUS_BLOCKED_AUTHORIZATION, ["缺少 copyrightStatus"])
|
|
if copyright_status in FORBIDDEN_COPYRIGHT_STATUSES:
|
|
return _result(STATUS_BLOCKED_AUTHORIZATION, [f"版权状态禁止评测: {copyright_status}"])
|
|
if copyright_status not in ALLOWED_COPYRIGHT_STATUSES:
|
|
return _result(STATUS_BLOCKED_AUTHORIZATION, [f"版权状态未登记,拒绝评测: {copyright_status}"])
|
|
if str(snapshot["copyrightStatus"]).lower() != copyright_status:
|
|
return _result(STATUS_BLOCKED_AUTHORIZATION, ["授权快照 copyrightStatus 不一致"])
|
|
|
|
source_hash = str(_field(authorization, "sourceHash", "source_hash") or "")
|
|
if not SOURCE_HASH_PATTERN.fullmatch(source_hash):
|
|
return _result(STATUS_BLOCKED_AUTHORIZATION, ["sourceHash 必须是 sha256:<64位小写十六进制>"])
|
|
if str(snapshot.get("sourceHash")) != source_hash:
|
|
return _result(STATUS_BLOCKED_AUTHORIZATION, ["授权快照 sourceHash 不一致"])
|
|
|
|
authorization_basis = str(snapshot.get("authorizationBasis") or "")
|
|
source_version = str(_field(authorization, "sourceVersion", "source_version") or "")
|
|
if authorization_basis == "sanitized_contract_fixture":
|
|
if not SANITIZED_FIXTURE_VERSION_PATTERN.fullmatch(source_version):
|
|
return _result(
|
|
STATUS_BLOCKED_AUTHORIZATION,
|
|
["脱敏夹具 sourceVersion 必须是 sanitized-fixture-v1:sha256:<64位小写十六进制>"],
|
|
)
|
|
if source_version != f"sanitized-fixture-v1:{source_hash}":
|
|
return _result(STATUS_BLOCKED_AUTHORIZATION, ["脱敏夹具 sourceVersion 与 sourceHash 不一致"])
|
|
else:
|
|
if not SOURCE_VERSION_PATTERN.fullmatch(source_version):
|
|
return _result(
|
|
STATUS_BLOCKED_AUTHORIZATION,
|
|
["sourceVersion 必须是 raw-file-v1:sha256:<64位小写十六进制>"],
|
|
)
|
|
if source_version != f"raw-file-v1:{source_hash}":
|
|
return _result(STATUS_BLOCKED_AUTHORIZATION, ["sourceVersion 与 sourceHash 不一致"])
|
|
if str(snapshot.get("sourceVersion")) != source_version:
|
|
return _result(STATUS_BLOCKED_AUTHORIZATION, ["授权快照 sourceVersion 不一致"])
|
|
if str(snapshot["sourceStatus"]).lower() != source_status:
|
|
return _result(STATUS_BLOCKED_AUTHORIZATION, ["授权快照 sourceStatus 不一致"])
|
|
|
|
allowed = _field(authorization, "allowedPurpose", "allowed_purpose")
|
|
if isinstance(allowed, str):
|
|
allowed = [allowed]
|
|
if not isinstance(allowed, Sequence) or isinstance(allowed, (str, bytes)):
|
|
return _result(STATUS_BLOCKED_AUTHORIZATION, ["allowedPurpose 不是用途列表"])
|
|
if "offline_evaluation" not in allowed:
|
|
return _result(STATUS_BLOCKED_AUTHORIZATION, ["allowedPurpose 不包含 offline_evaluation"])
|
|
snapshot_allowed = snapshot.get("allowedPurpose", snapshot.get("allowed_purpose"))
|
|
if not isinstance(snapshot_allowed, Sequence) or isinstance(snapshot_allowed, (str, bytes)):
|
|
return _result(STATUS_BLOCKED_AUTHORIZATION, ["授权快照缺少 allowedPurpose"])
|
|
if "offline_evaluation" not in snapshot_allowed:
|
|
return _result(STATUS_BLOCKED_AUTHORIZATION, ["授权快照用途不包含 offline_evaluation"])
|
|
if set(snapshot_allowed) != set(allowed):
|
|
return _result(STATUS_BLOCKED_AUTHORIZATION, ["授权快照用途与运行用途不一致"])
|
|
forbidden = _field(authorization, "forbiddenPurpose", "forbidden_purpose")
|
|
if not isinstance(forbidden, list):
|
|
return _result(STATUS_BLOCKED_AUTHORIZATION, ["forbiddenPurpose 不是用途列表"])
|
|
snapshot_forbidden = snapshot.get("forbiddenPurpose", snapshot.get("forbidden_purpose"))
|
|
if not isinstance(snapshot_forbidden, list):
|
|
return _result(STATUS_BLOCKED_AUTHORIZATION, ["授权快照 forbiddenPurpose 不是用途列表"])
|
|
if set(snapshot_forbidden) != set(forbidden):
|
|
return _result(STATUS_BLOCKED_AUTHORIZATION, ["授权快照禁止用途与运行禁止用途不一致"])
|
|
if "offline_evaluation" in forbidden:
|
|
return _result(STATUS_BLOCKED_AUTHORIZATION, ["forbiddenPurpose 禁止 offline_evaluation"])
|
|
if set(allowed) & set(forbidden):
|
|
return _result(STATUS_BLOCKED_AUTHORIZATION, ["allowedPurpose 与 forbiddenPurpose 存在冲突"])
|
|
if authorization_basis == "user_authorization":
|
|
if copyright_status != "research_only":
|
|
return _result(STATUS_BLOCKED_AUTHORIZATION, ["用户授权不能登记为 licensed"])
|
|
if list(allowed) != ["offline_evaluation"]:
|
|
return _result(STATUS_BLOCKED_AUTHORIZATION, ["用户授权仅允许 offline_evaluation"])
|
|
if authorization_basis == "sanitized_contract_fixture":
|
|
if copyright_status != "research_only":
|
|
return _result(STATUS_BLOCKED_AUTHORIZATION, ["脱敏合同夹具只能登记为 research_only"])
|
|
if list(allowed) != ["offline_evaluation"]:
|
|
return _result(STATUS_BLOCKED_AUTHORIZATION, ["脱敏合同夹具仅允许 offline_evaluation"])
|
|
return _result(STATUS_READY)
|
|
|
|
|
|
def check_target_sources(target_chapter: int, sources: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
|
|
"""规划器侧来源不得包含目标章或更晚章号。"""
|
|
|
|
target = normalize_chapter(target_chapter)
|
|
if target is None:
|
|
return _result(STATUS_INVALID_SNAPSHOT, ["目标章号无效"])
|
|
if not isinstance(sources, Sequence) or isinstance(sources, (str, bytes)) or not sources:
|
|
return _result(STATUS_INVALID_SNAPSHOT, ["缺少规划来源"])
|
|
errors: list[str] = []
|
|
for index, source in enumerate(sources):
|
|
if not isinstance(source, Mapping):
|
|
errors.append(f"source[{index}] 不是对象")
|
|
continue
|
|
if not source.get("sourceId") or not source.get("sourceVersion"):
|
|
errors.append(f"source[{index}] 缺少 sourceId/sourceVersion")
|
|
continue
|
|
value = _field(source, "chapter", "chapterNo", "chapter_no", "章", "章号")
|
|
bounds = normalize_chapter_range(value)
|
|
if bounds is None:
|
|
value = _field(
|
|
source,
|
|
"chapterRange",
|
|
"chapter_range",
|
|
"range",
|
|
"章节范围",
|
|
)
|
|
bounds = normalize_chapter_range(value)
|
|
if bounds is None and "from_order" in source:
|
|
value = f"{source.get('from_order')}-{source.get('to_order')}"
|
|
bounds = normalize_chapter_range(value)
|
|
if bounds is None:
|
|
scope = str(source.get("scope") or "chapter").lower()
|
|
if scope not in {"work", "authorization", "metadata"}:
|
|
errors.append(f"source[{index}] 缺少可验证绝对章号或完整区间")
|
|
continue
|
|
if bounds is not None and bounds[1] >= target:
|
|
errors.append(f"source[{index}] 包含目标章或未来章: {bounds[0]}-{bounds[1]}")
|
|
return _result(STATUS_TARGET_SOURCE_FORBIDDEN if errors else STATUS_READY, errors)
|
|
|
|
|
|
def _without_card_fields(value: Any) -> Any:
|
|
"""去掉三臂允许变化的 arm/card 分区,保留所有公共输入用于字节级比较。"""
|
|
|
|
if isinstance(value, list):
|
|
return [_without_card_fields(item) for item in value]
|
|
if not isinstance(value, Mapping):
|
|
return value
|
|
result: dict[str, Any] = {}
|
|
for key, item in value.items():
|
|
key_text = str(key)
|
|
if key_text.lower() in NORMALIZED_CARD_KEYS or key_text in {"卡", "知识卡", "卡片"}:
|
|
continue
|
|
result[key_text] = _without_card_fields(item)
|
|
return result
|
|
|
|
|
|
def _canonical_hash(value: Any) -> str:
|
|
"""用 JSON 类型保真的规范序列化比较公共区。"""
|
|
|
|
import hashlib
|
|
|
|
encoded = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
return hashlib.sha256(encoded).hexdigest()
|
|
|
|
|
|
def _check_arm_semantics(name: str, manifest: Mapping[str, Any]) -> list[str]:
|
|
"""校验生产三臂的无卡/正确卡/placebo 语义。"""
|
|
|
|
count = manifest.get("cardInjectionCount", 0)
|
|
source_ids = manifest.get("cardSourceIds", [])
|
|
strategy = str(manifest.get("cardStrategy") or "none")
|
|
has_payload = bool(manifest.get("cardInjection"))
|
|
if name == "outline_only":
|
|
if has_payload or count not in (0, None) or source_ids or strategy not in {"none", ""}:
|
|
return ["outline_only 必须不含卡注入"]
|
|
return []
|
|
if name not in {"outline_plus_cards", "outline_plus_placebo_cards"}:
|
|
return [f"未登记评测臂: {name}"]
|
|
if has_payload or not isinstance(count, int) or count <= 0 or not source_ids:
|
|
return [f"{name} 必须包含带来源的卡注入元数据"]
|
|
expected_strategy = "correct" if name == "outline_plus_cards" else "placebo"
|
|
if strategy != expected_strategy:
|
|
return [f"{name} 的 cardStrategy 必须是 {expected_strategy}"]
|
|
return []
|
|
|
|
|
|
def check_arm_manifests(
|
|
manifests: Mapping[str, Mapping[str, Any]],
|
|
required_arms: Sequence[str] | None = None,
|
|
*,
|
|
as_of_chapter: int | None = None,
|
|
target_chapter: int | None = None,
|
|
enforce_semantics: bool = False,
|
|
smoke: bool = False,
|
|
) -> dict[str, Any]:
|
|
"""确保三臂除卡注入区外完全一致。"""
|
|
|
|
required = set(required_arms or ("outline_only", "outline_plus_cards", "outline_plus_placebo_cards"))
|
|
if not smoke and required != {"outline_only", "outline_plus_cards", "outline_plus_placebo_cards"}:
|
|
return _result(STATUS_INVALID_ARM_DIFF, ["生产回放必须使用完整三臂;双臂只允许 smoke"])
|
|
actual = set(manifests)
|
|
missing = sorted(required - actual)
|
|
if missing:
|
|
return _result(STATUS_INVALID_ARM_DIFF, [f"缺少评测臂: {','.join(missing)}"])
|
|
unexpected = sorted(actual - required)
|
|
if unexpected:
|
|
return _result(STATUS_INVALID_ARM_DIFF, [f"存在未登记评测臂: {','.join(unexpected)}"])
|
|
if any(not isinstance(manifest, Mapping) for manifest in manifests.values()):
|
|
return _result(STATUS_INVALID_ARM_DIFF, ["评测臂 manifest 必须是对象"])
|
|
|
|
if as_of_chapter is not None or target_chapter is not None:
|
|
expected_as_of = normalize_chapter(as_of_chapter)
|
|
expected_target = normalize_chapter(target_chapter)
|
|
if expected_as_of is None or expected_target != expected_as_of + 1:
|
|
return _result(STATUS_INVALID_SNAPSHOT, ["as_of/target 不是连续章节"])
|
|
for name in sorted(required):
|
|
manifest = manifests[name]
|
|
if normalize_chapter(manifest.get("asOfChapter")) != expected_as_of:
|
|
return _result(STATUS_INVALID_SNAPSHOT, [f"{name} 的 asOfChapter 不一致"])
|
|
if normalize_chapter(manifest.get("targetChapter")) != expected_target:
|
|
return _result(STATUS_INVALID_SNAPSHOT, [f"{name} 的 targetChapter 不一致"])
|
|
|
|
if enforce_semantics:
|
|
semantic_errors = [
|
|
f"{name}: {error}"
|
|
for name in sorted(required)
|
|
for error in _check_arm_semantics(name, manifests[name])
|
|
]
|
|
if semantic_errors:
|
|
return _result(STATUS_INVALID_ARM_DIFF, semantic_errors)
|
|
|
|
names = sorted(required)
|
|
baseline = _without_card_fields(manifests[names[0]])
|
|
baseline_hash = _canonical_hash(baseline)
|
|
differences = [
|
|
name for name in names[1:]
|
|
if _canonical_hash(_without_card_fields(manifests[name])) != baseline_hash
|
|
]
|
|
if differences:
|
|
return _result(STATUS_INVALID_ARM_DIFF, [f"公共输入区不一致: {','.join(differences)}"])
|
|
return _result(STATUS_READY)
|
|
|
|
|
|
def check_candidate_output(
|
|
candidate: Mapping[str, Any],
|
|
target_chapter: int,
|
|
source_catalog: Sequence[Mapping[str, Any]] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""校验细纲候选的结构边界,不判断内容是否命中标准答案。"""
|
|
|
|
if not isinstance(candidate, Mapping):
|
|
return _result(STATUS_SCHEMA_INVALID, ["候选不是对象"])
|
|
expected_target = normalize_chapter(target_chapter)
|
|
if expected_target is None:
|
|
return _result(STATUS_INVALID_SNAPSHOT, ["目标章号无效"])
|
|
errors = [f"缺少字段: {field}" for field in REQUIRED_CANDIDATE_FIELDS if field not in candidate]
|
|
unexpected = sorted(set(candidate) - ALLOWED_CANDIDATE_FIELDS)
|
|
if unexpected:
|
|
errors.append(f"候选包含未登记字段: {','.join(unexpected)}")
|
|
forbidden = sorted(set(candidate) & FORBIDDEN_CANDIDATE_FIELDS)
|
|
if forbidden:
|
|
errors.append(f"候选包含正文/原文字段: {','.join(forbidden)}")
|
|
for field in ("keyEvents", "entities", "foreshadowing", "stateChanges", "unknowns", "assumptions"):
|
|
if field in candidate and not isinstance(candidate[field], list):
|
|
errors.append(f"字段必须是数组: {field}")
|
|
for field in ("chapterGoal", "hook"):
|
|
if field in candidate and not isinstance(candidate[field], str):
|
|
errors.append(f"字段必须是字符串: {field}")
|
|
events = candidate.get("keyEvents")
|
|
if isinstance(events, list):
|
|
if not events:
|
|
errors.append("keyEvents 不能为空")
|
|
event_ids = [item.get("id") for item in events if isinstance(item, Mapping) and item.get("id")]
|
|
if len(event_ids) != len(set(event_ids)):
|
|
errors.append("keyEvents 包含重复事件 id")
|
|
if any(not isinstance(item, Mapping) for item in events):
|
|
errors.append("keyEvents 每项必须是对象")
|
|
if any(isinstance(item, Mapping) and not item.get("id") for item in events):
|
|
errors.append("keyEvents 每项必须有 id")
|
|
for index, event in enumerate(events):
|
|
if not isinstance(event, Mapping):
|
|
continue
|
|
unexpected_event_fields = sorted(set(event) - set(EVENT_FIELDS))
|
|
if unexpected_event_fields:
|
|
errors.append(f"keyEvents[{index}] 包含未登记字段: {','.join(unexpected_event_fields)}")
|
|
for field, expected_type in EVENT_FIELDS.items():
|
|
if field not in event:
|
|
errors.append(f"keyEvents[{index}] 缺少字段: {field}")
|
|
elif expected_type is int and (isinstance(event[field], bool) or not isinstance(event[field], int)):
|
|
errors.append(f"keyEvents[{index}].{field} 必须是整数")
|
|
elif expected_type is not int and not isinstance(event[field], expected_type):
|
|
errors.append(f"keyEvents[{index}].{field} 类型错误")
|
|
event_orders = [
|
|
event.get("order")
|
|
for event in events
|
|
if isinstance(event, Mapping)
|
|
and isinstance(event.get("order"), int)
|
|
and not isinstance(event.get("order"), bool)
|
|
]
|
|
if len(event_orders) == len(events) and event_orders != list(range(1, len(events) + 1)):
|
|
errors.append("keyEvents.order 必须唯一且从 1 开始连续严格递增")
|
|
entities = candidate.get("entities")
|
|
if isinstance(entities, list):
|
|
for index, entity in enumerate(entities):
|
|
if not isinstance(entity, Mapping):
|
|
errors.append(f"entities[{index}] 必须是对象")
|
|
continue
|
|
for field, expected_type in ENTITY_FIELDS.items():
|
|
if field not in entity:
|
|
errors.append(f"entities[{index}] 缺少字段: {field}")
|
|
elif not isinstance(entity[field], expected_type):
|
|
errors.append(f"entities[{index}].{field} 类型错误")
|
|
unexpected_entity_fields = sorted(set(entity) - set(ENTITY_FIELDS))
|
|
if unexpected_entity_fields:
|
|
errors.append(f"entities[{index}] 包含未登记字段: {','.join(unexpected_entity_fields)}")
|
|
foreshadowing = candidate.get("foreshadowing")
|
|
if isinstance(foreshadowing, list):
|
|
for index, item in enumerate(foreshadowing):
|
|
if not isinstance(item, Mapping):
|
|
errors.append(f"foreshadowing[{index}] 必须是对象")
|
|
continue
|
|
for field, expected_type in FORESHADOWING_FIELDS.items():
|
|
if field not in item:
|
|
errors.append(f"foreshadowing[{index}] 缺少字段: {field}")
|
|
elif not isinstance(item[field], expected_type):
|
|
errors.append(f"foreshadowing[{index}].{field} 类型错误")
|
|
unexpected_foreshadowing_fields = sorted(set(item) - set(FORESHADOWING_FIELDS))
|
|
if unexpected_foreshadowing_fields:
|
|
errors.append(
|
|
f"foreshadowing[{index}] 包含未登记字段: {','.join(unexpected_foreshadowing_fields)}"
|
|
)
|
|
for field in ("stateChanges", "unknowns", "assumptions"):
|
|
values = candidate.get(field)
|
|
if isinstance(values, list) and any(not isinstance(item, str) for item in values):
|
|
errors.append(f"{field} 每项必须是字符串")
|
|
source_errors: list[str] = []
|
|
source_refs = candidate.get("sourceRefs")
|
|
if source_refs is not None:
|
|
if not isinstance(source_refs, list):
|
|
errors.append("sourceRefs 必须是数组")
|
|
else:
|
|
if source_catalog is None:
|
|
return _result(STATUS_SCHEMA_INVALID, ["sourceRefs 校验缺少冻结来源目录"])
|
|
catalog = {
|
|
str(source.get("sourceId")): source
|
|
for source in source_catalog
|
|
if isinstance(source, Mapping) and source.get("sourceId")
|
|
}
|
|
for index, reference in enumerate(source_refs):
|
|
if not isinstance(reference, str):
|
|
errors.append(f"sourceRefs[{index}] 必须是 sourceId 字符串")
|
|
continue
|
|
source = catalog.get(reference)
|
|
if source is None:
|
|
errors.append(f"sourceRefs[{index}] 未登记来源")
|
|
continue
|
|
source_value = _field(
|
|
source,
|
|
"chapter",
|
|
"chapterNo",
|
|
"chapter_no",
|
|
"章",
|
|
"章号",
|
|
"chapterRange",
|
|
"chapter_range",
|
|
"range",
|
|
)
|
|
bounds = normalize_chapter_range(source_value)
|
|
if bounds is None and "from_order" in source:
|
|
bounds = normalize_chapter_range(f"{source.get('from_order')}-{source.get('to_order')}")
|
|
if bounds is None:
|
|
scope = str(source.get("scope") or "chapter").lower()
|
|
if scope not in {"work", "authorization", "metadata"}:
|
|
source_errors.append(f"sourceRefs[{index}] 缺少可验证绝对章号或完整区间")
|
|
elif bounds[1] >= expected_target:
|
|
source_errors.append(
|
|
f"sourceRefs[{index}] 包含目标章或未来章: {bounds[0]}-{bounds[1]}"
|
|
)
|
|
actual_target = normalize_chapter(candidate.get("targetChapter"))
|
|
if actual_target != expected_target:
|
|
errors.append(f"候选目标章错误: expected={expected_target}, actual={actual_target}")
|
|
if source_errors:
|
|
return _result(STATUS_TARGET_SOURCE_FORBIDDEN, source_errors)
|
|
if errors:
|
|
return _result(STATUS_SCHEMA_INVALID, errors)
|
|
return _result(STATUS_READY)
|
|
|
|
|
|
def check_replay(
|
|
*,
|
|
authorization: Mapping[str, Any] | None,
|
|
as_of_chapter: int,
|
|
target_chapter: int,
|
|
planner_sources: Sequence[Mapping[str, Any]],
|
|
arm_manifests: Mapping[str, Mapping[str, Any]],
|
|
required_arms: Sequence[str] | None = None,
|
|
smoke: bool = False,
|
|
) -> dict[str, Any]:
|
|
"""执行回放前置门,任何一项失败都不允许进入模型调用。"""
|
|
|
|
as_of = normalize_chapter(as_of_chapter)
|
|
target = normalize_chapter(target_chapter)
|
|
if as_of is None or target != as_of + 1:
|
|
return _result(STATUS_INVALID_SNAPSHOT, ["as_of/target 不是连续章节"])
|
|
|
|
checks = [
|
|
check_authorization(authorization),
|
|
check_target_sources(target_chapter, planner_sources),
|
|
check_arm_manifests(
|
|
arm_manifests,
|
|
required_arms,
|
|
as_of_chapter=as_of,
|
|
target_chapter=target,
|
|
enforce_semantics=True,
|
|
smoke=smoke,
|
|
),
|
|
]
|
|
failures = [check for check in checks if not check["ok"]]
|
|
if failures:
|
|
return _result(
|
|
failures[0]["status"],
|
|
[error for check in failures for error in check["errors"]],
|
|
[warning for check in failures for warning in check["warnings"]],
|
|
)
|
|
return _result(STATUS_READY)
|
|
|
|
|
|
def _parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description="校验回放快照和三臂输入")
|
|
parser.add_argument("--authorization", type=Path, required=True)
|
|
parser.add_argument("--sources", type=Path, required=True)
|
|
parser.add_argument("--arms", type=Path, required=True)
|
|
parser.add_argument("--as-of-chapter", type=int, required=True)
|
|
parser.add_argument("--target-chapter", type=int, required=True)
|
|
parser.add_argument("--output", type=Path, required=True)
|
|
return parser.parse_args()
|
|
|
|
|
|
def main() -> int:
|
|
args = _parse_args()
|
|
authorization = json.loads(args.authorization.read_text(encoding="utf-8"))
|
|
sources = json.loads(args.sources.read_text(encoding="utf-8"))
|
|
arms = json.loads(args.arms.read_text(encoding="utf-8"))
|
|
result = check_replay(
|
|
authorization=authorization,
|
|
as_of_chapter=args.as_of_chapter,
|
|
target_chapter=args.target_chapter,
|
|
planner_sources=sources,
|
|
arm_manifests=arms,
|
|
)
|
|
args.output.write_text(json.dumps(result, ensure_ascii=False, sort_keys=True) + "\n", encoding="utf-8")
|
|
return 0 if result["ok"] else 2
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|