#!/usr/bin/env python3 """回放快照的 fail-closed 校验。 输入是冻结脚本产出的结构化 manifest 和运行时登记信息;本模块不访问数据库、不调用模型。 """ from __future__ import annotations import argparse import json 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({"active", "approved", "authorized", "licensed", "owned"}) FORBIDDEN_COPYRIGHT_STATUSES = frozenset( {"unauthorized", "unlicensed", "revoked", "expired", "blocked"} ) 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 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", "sourceVersion", "sourceStatus", "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, ["授权快照缺少过期或重验时间"]) 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}"]) source_version = str(_field(authorization, "sourceVersion", "source_version") or "") if not source_version: return _result(STATUS_BLOCKED_AUTHORIZATION, ["缺少 sourceVersion"]) 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, ["授权快照用途与运行用途不一致"]) 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): 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} 类型错误") 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())