C-001 임상 검토 게이트 보강
This commit is contained in:
parent
391fb9f4d0
commit
6988280b30
18 changed files with 1896 additions and 67 deletions
258
scripts/test_check_clinical_crisis_review.py
Normal file
258
scripts/test_check_clinical_crisis_review.py
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
CHECKER_PATH = REPO_ROOT / "scripts" / "check-clinical-crisis-review.py"
|
||||
SPEC = importlib.util.spec_from_file_location("clinical_crisis_review_checker", CHECKER_PATH)
|
||||
assert SPEC is not None and SPEC.loader is not None
|
||||
checker = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(checker)
|
||||
|
||||
CONTRACT_FILES = (
|
||||
Path("data/clinical/crisis-protocol-validation.json"),
|
||||
Path("data/clinical/crisis-protocol-validation.schema.json"),
|
||||
Path("data/clinical/p1-crisis-review-cases.json"),
|
||||
Path("data/clinical/p1-crisis-review-cases.schema.json"),
|
||||
)
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _write_json(path: Path, payload: dict) -> None:
|
||||
path.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
class ClinicalCrisisReviewCheckerTest(unittest.TestCase):
|
||||
def _fixture(self) -> Path:
|
||||
temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temp.cleanup)
|
||||
root = Path(temp.name)
|
||||
for relative in CONTRACT_FILES:
|
||||
target = root / relative
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(REPO_ROOT / relative, target)
|
||||
return root
|
||||
|
||||
def _complete(
|
||||
self,
|
||||
root: Path,
|
||||
status: str,
|
||||
decisions: list[str],
|
||||
) -> tuple[dict, dict]:
|
||||
manifest_path = root / checker.MANIFEST_REL
|
||||
case_set_path = root / checker.CASE_SET_REL
|
||||
manifest = _read_json(manifest_path)
|
||||
case_set = _read_json(case_set_path)
|
||||
self.assertEqual(len(decisions), len(case_set["cases"]))
|
||||
|
||||
reviewed_at = "2026-08-28"
|
||||
for case, decision in zip(case_set["cases"], decisions, strict=True):
|
||||
case["reviewer_assessment"] = {
|
||||
"decision": decision,
|
||||
"rationale": f"{case['case_id']} 외부 검토 판정",
|
||||
"reviewed_at": reviewed_at,
|
||||
}
|
||||
_write_json(case_set_path, case_set)
|
||||
|
||||
evidence_path = root / "data/clinical/evidence/c-001-review.txt"
|
||||
evidence_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
evidence_path.write_text("외부 검토 서면 증거 fixture\n", encoding="utf-8")
|
||||
|
||||
manifest["clinical_status"] = status
|
||||
manifest["approval"] = {
|
||||
"reviewer": "검토자",
|
||||
"organization": "외부 임상기관",
|
||||
"reviewed_at": reviewed_at,
|
||||
"decision": status,
|
||||
"notes": None if status == "approved" else f"{status} 판정 사유",
|
||||
"evidence_ref": "data/clinical/evidence/c-001-review.txt",
|
||||
"evidence_sha256": _sha256(evidence_path),
|
||||
"reviewed_protocol_version": manifest["version"],
|
||||
"reviewed_case_set_sha256": _sha256(case_set_path),
|
||||
}
|
||||
_write_json(manifest_path, manifest)
|
||||
return manifest, case_set
|
||||
|
||||
def test_repository_pending_contract_is_green(self) -> None:
|
||||
self.assertEqual(checker.validate_review_contract(repo_root=REPO_ROOT), [])
|
||||
|
||||
def test_pending_requires_every_approval_and_case_assessment_value_to_be_null(self) -> None:
|
||||
root = self._fixture()
|
||||
manifest_path = root / checker.MANIFEST_REL
|
||||
manifest = _read_json(manifest_path)
|
||||
manifest["approval"]["reviewer"] = "임의 검토자"
|
||||
_write_json(manifest_path, manifest)
|
||||
|
||||
case_set_path = root / checker.CASE_SET_REL
|
||||
case_set = _read_json(case_set_path)
|
||||
case_set["cases"][0]["reviewer_assessment"]["decision"] = "pass"
|
||||
_write_json(case_set_path, case_set)
|
||||
|
||||
errors = checker.validate_review_contract(repo_root=root)
|
||||
self.assertTrue(any("approval.reviewer는 null" in error for error in errors))
|
||||
self.assertTrue(
|
||||
any("P1-CRISIS-001" in error and ".decision" in error and "null" in error for error in errors),
|
||||
)
|
||||
|
||||
def test_each_completed_status_passes_its_case_decision_contract(self) -> None:
|
||||
fixtures = (
|
||||
("approved", ["pass"] * 6),
|
||||
("conditional", ["conditional", "pass", "pass", "pass", "pass", "pass"]),
|
||||
("rejected", ["fail", "pass", "pass", "pass", "pass", "pass"]),
|
||||
)
|
||||
for status, decisions in fixtures:
|
||||
with self.subTest(status=status):
|
||||
root = self._fixture()
|
||||
self._complete(root, status, decisions)
|
||||
self.assertEqual(checker.validate_review_contract(repo_root=root), [])
|
||||
|
||||
def test_status_and_overall_decision_must_match(self) -> None:
|
||||
root = self._fixture()
|
||||
self._complete(root, "approved", ["pass"] * 6)
|
||||
manifest_path = root / checker.MANIFEST_REL
|
||||
manifest = _read_json(manifest_path)
|
||||
manifest["approval"]["decision"] = "conditional"
|
||||
_write_json(manifest_path, manifest)
|
||||
|
||||
errors = checker.validate_review_contract(repo_root=root)
|
||||
self.assertTrue(any("clinical_status=approved" in error for error in errors))
|
||||
self.assertTrue(any("approval.decision은 approved" in error for error in errors))
|
||||
|
||||
def test_approved_rejects_any_non_pass_case(self) -> None:
|
||||
root = self._fixture()
|
||||
self._complete(root, "approved", ["conditional", "pass", "pass", "pass", "pass", "pass"])
|
||||
errors = checker.validate_review_contract(repo_root=root)
|
||||
self.assertTrue(any("모든 사례 판정이 pass" in error for error in errors))
|
||||
|
||||
def test_conditional_requires_conditional_and_forbids_fail(self) -> None:
|
||||
root_without_condition = self._fixture()
|
||||
self._complete(root_without_condition, "conditional", ["pass"] * 6)
|
||||
errors = checker.validate_review_contract(repo_root=root_without_condition)
|
||||
self.assertTrue(any("하나 이상의 conditional" in error for error in errors))
|
||||
|
||||
root_with_fail = self._fixture()
|
||||
self._complete(root_with_fail, "conditional", ["fail", "conditional", "pass", "pass", "pass", "pass"])
|
||||
errors = checker.validate_review_contract(repo_root=root_with_fail)
|
||||
self.assertTrue(any("fail 사례가 있으면 rejected" in error for error in errors))
|
||||
|
||||
def test_rejected_requires_at_least_one_fail(self) -> None:
|
||||
root = self._fixture()
|
||||
self._complete(root, "rejected", ["conditional", "pass", "pass", "pass", "pass", "pass"])
|
||||
errors = checker.validate_review_contract(repo_root=root)
|
||||
self.assertTrue(any("하나 이상의 fail" in error for error in errors))
|
||||
|
||||
def test_completed_state_requires_each_case_rationale_and_matching_review_date(self) -> None:
|
||||
root = self._fixture()
|
||||
self._complete(root, "approved", ["pass"] * 6)
|
||||
case_set_path = root / checker.CASE_SET_REL
|
||||
case_set = _read_json(case_set_path)
|
||||
case_set["cases"][0]["reviewer_assessment"]["rationale"] = " "
|
||||
case_set["cases"][1]["reviewer_assessment"]["reviewed_at"] = "2026-08-27"
|
||||
_write_json(case_set_path, case_set)
|
||||
manifest_path = root / checker.MANIFEST_REL
|
||||
manifest = _read_json(manifest_path)
|
||||
manifest["approval"]["reviewed_case_set_sha256"] = _sha256(case_set_path)
|
||||
_write_json(manifest_path, manifest)
|
||||
|
||||
errors = checker.validate_review_contract(repo_root=root)
|
||||
self.assertTrue(any("P1-CRISIS-001" in error and "rationale" in error for error in errors))
|
||||
self.assertTrue(any("P1-CRISIS-002" in error and "전체 검토일" in error for error in errors))
|
||||
|
||||
def test_evidence_must_exist_under_clinical_evidence_and_match_hash(self) -> None:
|
||||
root = self._fixture()
|
||||
self._complete(root, "approved", ["pass"] * 6)
|
||||
manifest_path = root / checker.MANIFEST_REL
|
||||
manifest = _read_json(manifest_path)
|
||||
|
||||
manifest["approval"]["evidence_ref"] = "../outside.txt"
|
||||
_write_json(manifest_path, manifest)
|
||||
errors = checker.validate_review_contract(repo_root=root)
|
||||
self.assertTrue(any("상위 경로 이동" in error for error in errors))
|
||||
|
||||
manifest["approval"]["evidence_ref"] = "data/clinical/evidence/missing.txt"
|
||||
_write_json(manifest_path, manifest)
|
||||
errors = checker.validate_review_contract(repo_root=root)
|
||||
self.assertTrue(any("증거 파일이 없다" in error for error in errors))
|
||||
|
||||
manifest["approval"]["evidence_ref"] = "data/clinical/evidence/c-001-review.txt"
|
||||
manifest["approval"]["evidence_sha256"] = "0" * 64
|
||||
_write_json(manifest_path, manifest)
|
||||
errors = checker.validate_review_contract(repo_root=root)
|
||||
self.assertTrue(any("실제 증거 파일 해시" in error for error in errors))
|
||||
|
||||
def test_protocol_version_and_case_set_hash_are_pinned(self) -> None:
|
||||
root = self._fixture()
|
||||
self._complete(root, "approved", ["pass"] * 6)
|
||||
manifest_path = root / checker.MANIFEST_REL
|
||||
manifest = _read_json(manifest_path)
|
||||
manifest["approval"]["reviewed_protocol_version"] = "outdated"
|
||||
manifest["approval"]["reviewed_case_set_sha256"] = "0" * 64
|
||||
_write_json(manifest_path, manifest)
|
||||
|
||||
errors = checker.validate_review_contract(repo_root=root)
|
||||
self.assertTrue(any("reviewed_protocol_version" in error for error in errors))
|
||||
self.assertTrue(any("reviewed_case_set_sha256" in error for error in errors))
|
||||
|
||||
def test_case_set_cannot_claim_clinical_answer_or_method_detail(self) -> None:
|
||||
root = self._fixture()
|
||||
case_set_path = root / checker.CASE_SET_REL
|
||||
case_set = _read_json(case_set_path)
|
||||
case_set["content_safety"]["clinical_answer_included"] = True
|
||||
case_set["cases"][0]["synthetic_scenario"]["method_or_means_detail_present"] = True
|
||||
_write_json(case_set_path, case_set)
|
||||
|
||||
errors = checker.validate_review_contract(repo_root=root)
|
||||
self.assertTrue(any("임상 정답 미포함" in error for error in errors))
|
||||
self.assertTrue(any("수단·방법 상세" in error for error in errors))
|
||||
|
||||
def test_direct_validator_rejects_unknown_fields_without_jsonschema(self) -> None:
|
||||
root = self._fixture()
|
||||
manifest_path = root / checker.MANIFEST_REL
|
||||
manifest = _read_json(manifest_path)
|
||||
manifest["clinical_approval_override"] = True
|
||||
_write_json(manifest_path, manifest)
|
||||
|
||||
original_validator = checker.Draft202012Validator
|
||||
original_format_checker = checker.FormatChecker
|
||||
checker.Draft202012Validator = None
|
||||
checker.FormatChecker = None
|
||||
try:
|
||||
errors = checker.validate_review_contract(repo_root=root)
|
||||
finally:
|
||||
checker.Draft202012Validator = original_validator
|
||||
checker.FormatChecker = original_format_checker
|
||||
self.assertTrue(any("manifest: 필드 불일치" in error for error in errors))
|
||||
|
||||
def test_cli_returns_zero_for_current_contract_and_json_summary(self) -> None:
|
||||
stdout = StringIO()
|
||||
stderr = StringIO()
|
||||
with redirect_stdout(stdout), redirect_stderr(stderr):
|
||||
result = checker.main(["--repo-root", str(REPO_ROOT)])
|
||||
self.assertEqual(result, 0, stderr.getvalue())
|
||||
summary = json.loads(stdout.getvalue())
|
||||
self.assertTrue(summary["ok"])
|
||||
self.assertEqual(summary["case_count"], 6)
|
||||
self.assertFalse(summary["external_review_replaced"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue