C-001 임상 검토 게이트 보강
This commit is contained in:
parent
391fb9f4d0
commit
6988280b30
18 changed files with 1896 additions and 67 deletions
616
scripts/check-clinical-crisis-review.py
Normal file
616
scripts/check-clinical-crisis-review.py
Normal file
|
|
@ -0,0 +1,616 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Fail-closed validator for the C-001 external clinical review contract.
|
||||
|
||||
The validator proves approval provenance and internal consistency. It does not
|
||||
make a clinical judgment and it never converts technical checks into clinical
|
||||
approval.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
from datetime import date
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from jsonschema import Draft202012Validator, FormatChecker
|
||||
from jsonschema.exceptions import SchemaError
|
||||
except ModuleNotFoundError: # Runtime images need no dev-only jsonschema package.
|
||||
Draft202012Validator = None
|
||||
FormatChecker = None
|
||||
SchemaError = Exception
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
MANIFEST_REL = Path("data/clinical/crisis-protocol-validation.json")
|
||||
MANIFEST_SCHEMA_REL = Path("data/clinical/crisis-protocol-validation.schema.json")
|
||||
CASE_SET_REL = Path("data/clinical/p1-crisis-review-cases.json")
|
||||
CASE_SCHEMA_REL = Path("data/clinical/p1-crisis-review-cases.schema.json")
|
||||
|
||||
ALLOWED_STATUSES = {
|
||||
"pending_external_review",
|
||||
"approved",
|
||||
"conditional",
|
||||
"rejected",
|
||||
}
|
||||
STATUS_DECISIONS = {
|
||||
"pending_external_review": None,
|
||||
"approved": "approved",
|
||||
"conditional": "conditional",
|
||||
"rejected": "rejected",
|
||||
}
|
||||
APPROVAL_FIELDS = {
|
||||
"reviewer",
|
||||
"organization",
|
||||
"reviewed_at",
|
||||
"decision",
|
||||
"notes",
|
||||
"evidence_ref",
|
||||
"evidence_sha256",
|
||||
"reviewed_protocol_version",
|
||||
"reviewed_case_set_sha256",
|
||||
}
|
||||
MANIFEST_FIELDS = {
|
||||
"schema_version",
|
||||
"protocol_id",
|
||||
"version",
|
||||
"scope",
|
||||
"technical_status",
|
||||
"clinical_status",
|
||||
"external_review_boundary",
|
||||
"review_case_set",
|
||||
"review_case_set_id",
|
||||
"review_case_set_version",
|
||||
"sources",
|
||||
"review_sequence",
|
||||
"technical_gates",
|
||||
"approval",
|
||||
}
|
||||
CASE_SET_FIELDS = {
|
||||
"schema_version",
|
||||
"case_set_id",
|
||||
"version",
|
||||
"protocol_id",
|
||||
"protocol_version",
|
||||
"priority",
|
||||
"scope",
|
||||
"external_review_boundary",
|
||||
"content_safety",
|
||||
"official_source_scope",
|
||||
"case_decision_contract",
|
||||
"cases",
|
||||
}
|
||||
CASE_FIELDS = {
|
||||
"case_id",
|
||||
"title",
|
||||
"synthetic_scenario",
|
||||
"technical_invariants",
|
||||
"reviewer_assessment",
|
||||
}
|
||||
SCENARIO_FIELDS = {"speaker_context", "signal", "method_or_means_detail_present"}
|
||||
INVARIANT_FIELDS = {"invariant_id", "requirement"}
|
||||
ASSESSMENT_FIELDS = {"decision", "rationale", "reviewed_at"}
|
||||
REQUIRED_COMPLETED_APPROVAL_FIELDS = {
|
||||
"reviewer",
|
||||
"organization",
|
||||
"reviewed_at",
|
||||
"decision",
|
||||
"evidence_ref",
|
||||
"evidence_sha256",
|
||||
"reviewed_protocol_version",
|
||||
"reviewed_case_set_sha256",
|
||||
}
|
||||
ALLOWED_CASE_DECISIONS = {"pass", "conditional", "fail"}
|
||||
EXPECTED_CASE_IDS = {f"P1-CRISIS-{index:03d}" for index in range(1, 7)}
|
||||
REQUIRED_SOURCE_IDS = {
|
||||
"samhsa_safe_t",
|
||||
"nimh_youth_outpatient_bssa",
|
||||
"mohw_109",
|
||||
"nice_ng225",
|
||||
}
|
||||
REQUIRED_SOURCE_PROVENANCE = {
|
||||
"samhsa_safe_t": (
|
||||
"SAMHSA",
|
||||
"SAFE-T Suicide Assessment Five-Step Evaluation and Triage",
|
||||
"https://www.samhsa.gov/resource/dbhis/safe-t-pocket-card-suicide-assessment-five-step-evaluation-triage-safe-t-clinicians",
|
||||
),
|
||||
"nimh_youth_outpatient_bssa": (
|
||||
"NIMH",
|
||||
"Youth Outpatient Brief Suicide Safety Assessment Guide",
|
||||
"https://www.nimh.nih.gov/research/research-conducted-at-nimh/asq-toolkit-materials/youth-outpatient/youth-outpatient-brief-suicide-safety-assessment-guide",
|
||||
),
|
||||
"mohw_109": (
|
||||
"대한민국 보건복지부",
|
||||
"자살예방상담전화 109",
|
||||
"https://www.mohw.go.kr/menu.es?mid=a10716040000",
|
||||
),
|
||||
"nice_ng225": (
|
||||
"NICE",
|
||||
"NG225 Self-harm: assessment, management and preventing recurrence",
|
||||
"https://www.nice.org.uk/guidance/ng225",
|
||||
),
|
||||
}
|
||||
MANIFEST_BOUNDARY = (
|
||||
"공식 근거와 자동 검사는 기술 안전 범위만 확인하며 외부 임상 검토와 승인을 대신하지 않는다."
|
||||
)
|
||||
CASE_SET_BOUNDARY = (
|
||||
"이 사례에는 임상 정답이 없으며 자동 검사는 외부 임상 검토와 승인을 대신하지 않는다."
|
||||
)
|
||||
SHA256_LENGTH = 64
|
||||
|
||||
|
||||
def _path(root: Path, value: Path | str) -> Path:
|
||||
candidate = Path(value)
|
||||
return candidate if candidate.is_absolute() else root / candidate
|
||||
|
||||
|
||||
def _load_json(path: Path, label: str, errors: list[str]) -> Any | None:
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
errors.append(f"{label}: 파일이 없다: {path}")
|
||||
except json.JSONDecodeError as exc:
|
||||
errors.append(f"{label}: JSON 파싱 실패: {exc}")
|
||||
except OSError as exc:
|
||||
errors.append(f"{label}: 읽기 실패: {exc}")
|
||||
return None
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _is_nonempty_string(value: Any) -> bool:
|
||||
return isinstance(value, str) and bool(value.strip())
|
||||
|
||||
|
||||
def _is_sha256(value: Any) -> bool:
|
||||
if not isinstance(value, str) or len(value) != SHA256_LENGTH:
|
||||
return False
|
||||
return all(character in "0123456789abcdef" for character in value)
|
||||
|
||||
|
||||
def _is_iso_date(value: Any) -> bool:
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
try:
|
||||
return date.fromisoformat(value).isoformat() == value
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _schema_errors(
|
||||
instance: Any,
|
||||
schema: Any,
|
||||
label: str,
|
||||
) -> list[str]:
|
||||
if not isinstance(schema, dict):
|
||||
return [f"{label} schema: 최상위 값은 객체여야 한다"]
|
||||
if Draft202012Validator is None or FormatChecker is None:
|
||||
return []
|
||||
try:
|
||||
Draft202012Validator.check_schema(schema)
|
||||
except SchemaError as exc:
|
||||
return [f"{label} schema: 스키마 자체가 유효하지 않다: {exc.message}"]
|
||||
|
||||
validator = Draft202012Validator(schema, format_checker=FormatChecker())
|
||||
failures: list[str] = []
|
||||
for error in sorted(
|
||||
validator.iter_errors(instance),
|
||||
key=lambda item: tuple(str(part) for part in item.absolute_path),
|
||||
):
|
||||
location = ".".join(str(part) for part in error.absolute_path) or "$"
|
||||
failures.append(f"{label} schema: {location}: {error.message}")
|
||||
return failures
|
||||
|
||||
|
||||
def _validate_sources(manifest: dict[str, Any], case_set: dict[str, Any]) -> list[str]:
|
||||
errors: list[str] = []
|
||||
sources = manifest.get("sources")
|
||||
if not isinstance(sources, list):
|
||||
errors.append("manifest.sources: 배열이어야 한다")
|
||||
source_ids: set[Any] = set()
|
||||
else:
|
||||
source_ids = {
|
||||
source.get("source_id")
|
||||
for source in sources
|
||||
if isinstance(source, dict)
|
||||
}
|
||||
if len(source_ids) != len(sources):
|
||||
errors.append("manifest.sources: source_id가 없거나 중복됐다")
|
||||
if source_ids != REQUIRED_SOURCE_IDS:
|
||||
errors.append(
|
||||
"manifest.sources: 공식 범위는 SAMHSA SAFE-T, NIMH Youth Outpatient "
|
||||
"BSSA, 보건복지부 109, NICE NG225 네 항목과 정확히 일치해야 한다"
|
||||
)
|
||||
if isinstance(sources, list):
|
||||
for source in sources:
|
||||
if not isinstance(source, dict):
|
||||
continue
|
||||
source_id = source.get("source_id")
|
||||
expected = REQUIRED_SOURCE_PROVENANCE.get(source_id)
|
||||
if expected is None:
|
||||
continue
|
||||
actual = (source.get("authority"), source.get("title"), source.get("url"))
|
||||
if actual != expected:
|
||||
errors.append(f"manifest.sources[{source_id}]: 공식 authority/title/url과 다르다")
|
||||
|
||||
case_sources = case_set.get("official_source_scope")
|
||||
if not isinstance(case_sources, list) or set(case_sources) != REQUIRED_SOURCE_IDS:
|
||||
errors.append("case_set.official_source_scope: manifest의 공식 근거 네 항목과 일치해야 한다")
|
||||
return errors
|
||||
|
||||
|
||||
def _validate_case_set_base(
|
||||
manifest: dict[str, Any],
|
||||
case_set: dict[str, Any],
|
||||
) -> list[str]:
|
||||
errors: list[str] = []
|
||||
if set(manifest) != MANIFEST_FIELDS:
|
||||
missing = sorted(MANIFEST_FIELDS - set(manifest))
|
||||
extra = sorted(set(manifest) - MANIFEST_FIELDS)
|
||||
errors.append(f"manifest: 필드 불일치 missing={missing}, extra={extra}")
|
||||
if set(case_set) != CASE_SET_FIELDS:
|
||||
missing = sorted(CASE_SET_FIELDS - set(case_set))
|
||||
extra = sorted(set(case_set) - CASE_SET_FIELDS)
|
||||
errors.append(f"case_set: 필드 불일치 missing={missing}, extra={extra}")
|
||||
if manifest.get("schema_version") != "vignette.clinical_crisis_review.v1":
|
||||
errors.append("manifest.schema_version: vignette.clinical_crisis_review.v1이어야 한다")
|
||||
if case_set.get("schema_version") != "vignette.p1_crisis_review_cases.v1":
|
||||
errors.append("case_set.schema_version: vignette.p1_crisis_review_cases.v1이어야 한다")
|
||||
if manifest.get("technical_status") != "verified":
|
||||
errors.append("manifest.technical_status: verified여야 한다")
|
||||
if case_set.get("priority") != "P1":
|
||||
errors.append("case_set.priority: P1이어야 한다")
|
||||
if manifest.get("external_review_boundary") != MANIFEST_BOUNDARY:
|
||||
errors.append("manifest.external_review_boundary: 외부 임상 승인 비대체 경계를 변경할 수 없다")
|
||||
if case_set.get("external_review_boundary") != CASE_SET_BOUNDARY:
|
||||
errors.append("case_set.external_review_boundary: 임상 정답 및 외부 승인 비대체 경계를 변경할 수 없다")
|
||||
|
||||
safety = case_set.get("content_safety")
|
||||
expected_safety = {
|
||||
"synthetic_only": True,
|
||||
"method_or_means_detail": "forbidden",
|
||||
"clinical_answer_included": False,
|
||||
}
|
||||
if safety != expected_safety:
|
||||
errors.append("case_set.content_safety: 합성 전용·방법 상세 금지·임상 정답 미포함 계약과 다르다")
|
||||
|
||||
comparisons = (
|
||||
(case_set.get("protocol_id"), manifest.get("protocol_id"), "protocol_id"),
|
||||
(case_set.get("protocol_version"), manifest.get("version"), "protocol_version"),
|
||||
(case_set.get("case_set_id"), manifest.get("review_case_set_id"), "case_set_id"),
|
||||
(case_set.get("version"), manifest.get("review_case_set_version"), "case_set_version"),
|
||||
)
|
||||
for actual, expected, label in comparisons:
|
||||
if actual != expected:
|
||||
errors.append(f"case_set.{label}: manifest와 일치하지 않는다")
|
||||
|
||||
contract = case_set.get("case_decision_contract")
|
||||
if not isinstance(contract, dict):
|
||||
errors.append("case_set.case_decision_contract: 객체여야 한다")
|
||||
else:
|
||||
expected_contract_fields = {
|
||||
"allowed",
|
||||
"pending_value",
|
||||
"approved_rule",
|
||||
"conditional_rule",
|
||||
"rejected_rule",
|
||||
}
|
||||
if set(contract) != expected_contract_fields:
|
||||
errors.append("case_set.case_decision_contract: 필드 계약과 정확히 일치해야 한다")
|
||||
if contract.get("allowed") != ["pass", "conditional", "fail"]:
|
||||
errors.append("case_set.case_decision_contract.allowed: pass/conditional/fail 순서와 일치해야 한다")
|
||||
if contract.get("pending_value", object()) is not None:
|
||||
errors.append("case_set.case_decision_contract.pending_value: null이어야 한다")
|
||||
|
||||
cases = case_set.get("cases")
|
||||
if not isinstance(cases, list) or not cases:
|
||||
errors.append("case_set.cases: 하나 이상의 합성 사례가 필요하다")
|
||||
return errors
|
||||
|
||||
case_ids = {
|
||||
case.get("case_id")
|
||||
for case in cases
|
||||
if isinstance(case, dict)
|
||||
}
|
||||
if len(case_ids) != len(cases):
|
||||
errors.append("case_set.cases: case_id가 없거나 중복됐다")
|
||||
if case_ids != EXPECTED_CASE_IDS:
|
||||
errors.append("case_set.cases: P1-CRISIS-001..006을 정확히 포함해야 한다")
|
||||
|
||||
for index, case in enumerate(cases):
|
||||
label = case.get("case_id", f"cases[{index}]") if isinstance(case, dict) else f"cases[{index}]"
|
||||
if not isinstance(case, dict):
|
||||
errors.append(f"{label}: 객체여야 한다")
|
||||
continue
|
||||
if set(case) != CASE_FIELDS:
|
||||
errors.append(f"{label}: 사례 필드 계약과 정확히 일치해야 한다")
|
||||
scenario = case.get("synthetic_scenario")
|
||||
if not isinstance(scenario, dict):
|
||||
errors.append(f"{label}.synthetic_scenario: 객체여야 한다")
|
||||
else:
|
||||
if set(scenario) != SCENARIO_FIELDS:
|
||||
errors.append(f"{label}.synthetic_scenario: 시나리오 필드 계약과 정확히 일치해야 한다")
|
||||
if scenario.get("method_or_means_detail_present") is not False:
|
||||
errors.append(f"{label}.synthetic_scenario: 수단·방법 상세는 절대 포함할 수 없다")
|
||||
invariants = case.get("technical_invariants")
|
||||
if not isinstance(invariants, list) or not invariants:
|
||||
errors.append(f"{label}.technical_invariants: 하나 이상의 기술 불변조건이 필요하다")
|
||||
else:
|
||||
invariant_ids = {
|
||||
invariant.get("invariant_id")
|
||||
for invariant in invariants
|
||||
if isinstance(invariant, dict)
|
||||
}
|
||||
if len(invariant_ids) != len(invariants):
|
||||
errors.append(f"{label}.technical_invariants: invariant_id가 없거나 중복됐다")
|
||||
for invariant in invariants:
|
||||
if not isinstance(invariant, dict) or set(invariant) != INVARIANT_FIELDS:
|
||||
errors.append(f"{label}.technical_invariants: 불변조건 필드 계약과 정확히 일치해야 한다")
|
||||
break
|
||||
assessment = case.get("reviewer_assessment")
|
||||
if not isinstance(assessment, dict) or set(assessment) != ASSESSMENT_FIELDS:
|
||||
errors.append(f"{label}.reviewer_assessment: 판정 필드 계약과 정확히 일치해야 한다")
|
||||
return errors
|
||||
|
||||
|
||||
def _validate_pending(
|
||||
approval: dict[str, Any],
|
||||
cases: list[Any],
|
||||
) -> list[str]:
|
||||
errors: list[str] = []
|
||||
for field in sorted(APPROVAL_FIELDS):
|
||||
if approval.get(field, object()) is not None:
|
||||
errors.append(f"pending_external_review: approval.{field}는 null이어야 한다")
|
||||
|
||||
for index, case in enumerate(cases):
|
||||
if not isinstance(case, dict):
|
||||
continue
|
||||
label = case.get("case_id", f"cases[{index}]")
|
||||
assessment = case.get("reviewer_assessment")
|
||||
if not isinstance(assessment, dict):
|
||||
errors.append(f"pending_external_review: {label}.reviewer_assessment가 필요하다")
|
||||
continue
|
||||
for field in ("decision", "rationale", "reviewed_at"):
|
||||
if assessment.get(field, object()) is not None:
|
||||
errors.append(
|
||||
f"pending_external_review: {label}.reviewer_assessment.{field}는 null이어야 한다"
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
def _validate_evidence(
|
||||
root: Path,
|
||||
approval: dict[str, Any],
|
||||
) -> list[str]:
|
||||
errors: list[str] = []
|
||||
evidence_ref = approval.get("evidence_ref")
|
||||
if not _is_nonempty_string(evidence_ref):
|
||||
return ["approval.evidence_ref: 비어 있지 않은 저장소 상대 경로가 필요하다"]
|
||||
|
||||
if "\\" in evidence_ref:
|
||||
errors.append("approval.evidence_ref: 플랫폼 독립적인 / 구분자를 사용해야 한다")
|
||||
return errors
|
||||
pure = PurePosixPath(evidence_ref)
|
||||
if pure.is_absolute() or ".." in pure.parts:
|
||||
errors.append("approval.evidence_ref: 절대 경로와 상위 경로 이동은 허용하지 않는다")
|
||||
return errors
|
||||
if pure.parts[:3] != ("data", "clinical", "evidence") or len(pure.parts) < 4:
|
||||
errors.append("approval.evidence_ref: data/clinical/evidence/ 아래 파일이어야 한다")
|
||||
return errors
|
||||
|
||||
root_resolved = root.resolve()
|
||||
evidence_root = (root_resolved / "data/clinical/evidence").resolve()
|
||||
evidence_path = (root_resolved / Path(*pure.parts)).resolve()
|
||||
try:
|
||||
evidence_path.relative_to(evidence_root)
|
||||
except ValueError:
|
||||
errors.append("approval.evidence_ref: evidence 디렉터리 밖을 가리킨다")
|
||||
return errors
|
||||
if not evidence_path.is_file():
|
||||
errors.append(f"approval.evidence_ref: 증거 파일이 없다: {evidence_ref}")
|
||||
return errors
|
||||
|
||||
declared_hash = approval.get("evidence_sha256")
|
||||
if not _is_sha256(declared_hash):
|
||||
errors.append("approval.evidence_sha256: 소문자 64자리 SHA-256이어야 한다")
|
||||
elif _sha256(evidence_path) != declared_hash:
|
||||
errors.append("approval.evidence_sha256: 실제 증거 파일 해시와 일치하지 않는다")
|
||||
return errors
|
||||
|
||||
|
||||
def _validate_completed(
|
||||
root: Path,
|
||||
status: str,
|
||||
manifest: dict[str, Any],
|
||||
approval: dict[str, Any],
|
||||
case_set_path: Path,
|
||||
cases: list[Any],
|
||||
) -> list[str]:
|
||||
errors: list[str] = []
|
||||
for field in sorted(REQUIRED_COMPLETED_APPROVAL_FIELDS):
|
||||
if not _is_nonempty_string(approval.get(field)):
|
||||
errors.append(f"{status}: approval.{field} 값이 필요하다")
|
||||
|
||||
reviewed_at = approval.get("reviewed_at")
|
||||
if not _is_iso_date(reviewed_at):
|
||||
errors.append(f"{status}: approval.reviewed_at은 YYYY-MM-DD 실제 날짜여야 한다")
|
||||
if status in {"conditional", "rejected"} and not _is_nonempty_string(approval.get("notes")):
|
||||
errors.append(f"{status}: approval.notes에 조건 또는 반려 사유가 필요하다")
|
||||
elif approval.get("notes") is not None and not _is_nonempty_string(approval.get("notes")):
|
||||
errors.append(f"{status}: approval.notes는 null 또는 비어 있지 않은 문자열이어야 한다")
|
||||
|
||||
if approval.get("decision") != STATUS_DECISIONS[status]:
|
||||
errors.append(f"{status}: approval.decision은 {STATUS_DECISIONS[status]}이어야 한다")
|
||||
if approval.get("reviewed_protocol_version") != manifest.get("version"):
|
||||
errors.append(f"{status}: approval.reviewed_protocol_version이 현재 protocol version과 다르다")
|
||||
|
||||
case_hash = approval.get("reviewed_case_set_sha256")
|
||||
if not _is_sha256(case_hash):
|
||||
errors.append(f"{status}: approval.reviewed_case_set_sha256은 소문자 64자리 SHA-256이어야 한다")
|
||||
elif _sha256(case_set_path) != case_hash:
|
||||
errors.append(f"{status}: approval.reviewed_case_set_sha256이 실제 사례 세트 해시와 다르다")
|
||||
|
||||
errors.extend(_validate_evidence(root, approval))
|
||||
|
||||
decisions: list[str] = []
|
||||
for index, case in enumerate(cases):
|
||||
if not isinstance(case, dict):
|
||||
continue
|
||||
label = case.get("case_id", f"cases[{index}]")
|
||||
assessment = case.get("reviewer_assessment")
|
||||
if not isinstance(assessment, dict):
|
||||
errors.append(f"{status}: {label}.reviewer_assessment가 필요하다")
|
||||
continue
|
||||
decision = assessment.get("decision")
|
||||
if decision not in ALLOWED_CASE_DECISIONS:
|
||||
errors.append(f"{status}: {label}.reviewer_assessment.decision 판정이 필요하다")
|
||||
continue
|
||||
decisions.append(decision)
|
||||
if not _is_nonempty_string(assessment.get("rationale")):
|
||||
errors.append(f"{status}: {label}.reviewer_assessment.rationale이 필요하다")
|
||||
if assessment.get("reviewed_at") != reviewed_at:
|
||||
errors.append(f"{status}: {label}.reviewer_assessment.reviewed_at이 전체 검토일과 달라서는 안 된다")
|
||||
|
||||
if len(decisions) == len(cases):
|
||||
if status == "approved" and any(decision != "pass" for decision in decisions):
|
||||
errors.append("approved: 모든 사례 판정이 pass여야 한다")
|
||||
elif status == "conditional":
|
||||
if "fail" in decisions:
|
||||
errors.append("conditional: fail 사례가 있으면 rejected여야 한다")
|
||||
if "conditional" not in decisions:
|
||||
errors.append("conditional: 하나 이상의 conditional 사례가 필요하다")
|
||||
elif status == "rejected" and "fail" not in decisions:
|
||||
errors.append("rejected: 하나 이상의 fail 사례가 필요하다")
|
||||
return errors
|
||||
|
||||
|
||||
def validate_review_contract(
|
||||
*,
|
||||
repo_root: Path = REPO_ROOT,
|
||||
manifest_path: Path | str = MANIFEST_REL,
|
||||
manifest_schema_path: Path | str = MANIFEST_SCHEMA_REL,
|
||||
case_set_path: Path | str = CASE_SET_REL,
|
||||
case_schema_path: Path | str = CASE_SCHEMA_REL,
|
||||
) -> list[str]:
|
||||
"""Return every contract violation; an empty list means the gate is green."""
|
||||
|
||||
root = repo_root.resolve()
|
||||
manifest_file = _path(root, manifest_path)
|
||||
manifest_schema_file = _path(root, manifest_schema_path)
|
||||
case_set_file = _path(root, case_set_path)
|
||||
case_schema_file = _path(root, case_schema_path)
|
||||
errors: list[str] = []
|
||||
|
||||
manifest = _load_json(manifest_file, "manifest", errors)
|
||||
manifest_schema = _load_json(manifest_schema_file, "manifest schema", errors)
|
||||
case_set = _load_json(case_set_file, "case set", errors)
|
||||
case_schema = _load_json(case_schema_file, "case schema", errors)
|
||||
if any(value is None for value in (manifest, manifest_schema, case_set, case_schema)):
|
||||
return errors
|
||||
if not isinstance(manifest, dict) or not isinstance(case_set, dict):
|
||||
errors.append("manifest와 case set의 최상위 값은 객체여야 한다")
|
||||
return errors
|
||||
|
||||
errors.extend(_schema_errors(manifest, manifest_schema, "manifest"))
|
||||
errors.extend(_schema_errors(case_set, case_schema, "case set"))
|
||||
errors.extend(_validate_sources(manifest, case_set))
|
||||
errors.extend(_validate_case_set_base(manifest, case_set))
|
||||
|
||||
expected_case_ref = CASE_SET_REL.as_posix()
|
||||
if manifest.get("review_case_set") != expected_case_ref:
|
||||
errors.append(f"manifest.review_case_set: {expected_case_ref}여야 한다")
|
||||
|
||||
status = manifest.get("clinical_status")
|
||||
if status not in ALLOWED_STATUSES:
|
||||
errors.append(f"manifest.clinical_status: 허용되지 않은 상태: {status!r}")
|
||||
return errors
|
||||
approval = manifest.get("approval")
|
||||
if not isinstance(approval, dict):
|
||||
errors.append("manifest.approval: 객체여야 한다")
|
||||
return errors
|
||||
if set(approval) != APPROVAL_FIELDS:
|
||||
missing = sorted(APPROVAL_FIELDS - set(approval))
|
||||
extra = sorted(set(approval) - APPROVAL_FIELDS)
|
||||
errors.append(f"manifest.approval: 필드 불일치 missing={missing}, extra={extra}")
|
||||
|
||||
if approval.get("decision") != STATUS_DECISIONS[status]:
|
||||
errors.append(
|
||||
f"manifest: clinical_status={status}와 approval.decision={approval.get('decision')!r}가 일치하지 않는다"
|
||||
)
|
||||
|
||||
cases = case_set.get("cases")
|
||||
if not isinstance(cases, list):
|
||||
return errors
|
||||
if status == "pending_external_review":
|
||||
errors.extend(_validate_pending(approval, cases))
|
||||
else:
|
||||
errors.extend(
|
||||
_validate_completed(
|
||||
root,
|
||||
status,
|
||||
manifest,
|
||||
approval,
|
||||
case_set_file,
|
||||
cases,
|
||||
)
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
def _parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="C-001 외부 임상 검토 상태와 증거를 fail-closed 검증한다.",
|
||||
)
|
||||
parser.add_argument("--repo-root", type=Path, default=REPO_ROOT)
|
||||
parser.add_argument("--manifest", type=Path, default=MANIFEST_REL)
|
||||
parser.add_argument("--manifest-schema", type=Path, default=MANIFEST_SCHEMA_REL)
|
||||
parser.add_argument("--case-set", type=Path, default=CASE_SET_REL)
|
||||
parser.add_argument("--case-schema", type=Path, default=CASE_SCHEMA_REL)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = _parser().parse_args(argv)
|
||||
errors = validate_review_contract(
|
||||
repo_root=args.repo_root,
|
||||
manifest_path=args.manifest,
|
||||
manifest_schema_path=args.manifest_schema,
|
||||
case_set_path=args.case_set,
|
||||
case_schema_path=args.case_schema,
|
||||
)
|
||||
if errors:
|
||||
print("C-001 임상 검토 계약: FAIL", file=sys.stderr)
|
||||
for error in errors:
|
||||
print(f"- {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
manifest = json.loads(_path(args.repo_root.resolve(), args.manifest).read_text(encoding="utf-8"))
|
||||
case_set = json.loads(_path(args.repo_root.resolve(), args.case_set).read_text(encoding="utf-8"))
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"ok": True,
|
||||
"clinical_status": manifest["clinical_status"],
|
||||
"protocol": f"{manifest['protocol_id']}@{manifest['version']}",
|
||||
"case_set": f"{case_set['case_set_id']}@{case_set['version']}",
|
||||
"case_count": len(case_set["cases"]),
|
||||
"external_review_replaced": False,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue