외부 임상 검토 원장을 보강

This commit is contained in:
Yun Chan 2026-08-28 21:01:57 +09:00
parent dd71cb9710
commit 3b540a881f
8 changed files with 310 additions and 50 deletions

View file

@ -46,6 +46,7 @@ STATUS_DECISIONS = {
APPROVAL_FIELDS = {
"reviewer",
"organization",
"reviewer_role_or_qualification",
"reviewed_at",
"decision",
"notes",
@ -82,6 +83,7 @@ CASE_SET_FIELDS = {
"content_safety",
"official_source_scope",
"case_decision_contract",
"youth_review_responses",
"cases",
}
CASE_FIELDS = {
@ -93,12 +95,20 @@ CASE_FIELDS = {
}
SCENARIO_FIELDS = {"speaker_context", "signal", "method_or_means_detail_present"}
INVARIANT_FIELDS = {"invariant_id", "requirement"}
ASSESSMENT_FIELDS = {"decision", "rationale", "reviewed_at"}
ASSESSMENT_FIELDS = {
"decision",
"rationale",
"findings_and_required_changes",
"re_review",
"reviewed_at",
}
REQUIRED_COMPLETED_APPROVAL_FIELDS = {
"reviewer",
"organization",
"reviewer_role_or_qualification",
"reviewed_at",
"decision",
"notes",
"evidence_ref",
"evidence_sha256",
"reviewed_protocol_version",
@ -106,6 +116,30 @@ REQUIRED_COMPLETED_APPROVAL_FIELDS = {
}
ALLOWED_CASE_DECISIONS = {"pass", "conditional", "fail"}
EXPECTED_CASE_IDS = {f"P1-CRISIS-{index:03d}" for index in range(1, 7)}
NO_REQUIRED_CHANGE_VALUES = {
"없음",
"수정 요구 없음",
"해당 없음",
"none",
"n/a",
"na",
}
NO_RE_REVIEW_VALUES = {
"없음",
"재검토 불필요",
"해당 없음",
"none",
"n/a",
"na",
}
YOUTH_REVIEW_RESPONSE_FIELDS = {
"guardian_notification",
"means_restriction",
"risk_disposition",
"follow_up_48_72h",
"documentation",
"safeguarding",
}
REQUIRED_SOURCE_IDS = {
"samhsa_safe_t",
"nimh_youth_outpatient_bssa",
@ -262,10 +296,10 @@ def _validate_case_set_base(
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("schema_version") != "vignette.clinical_crisis_review.v2":
errors.append("manifest.schema_version: vignette.clinical_crisis_review.v2여야 한다")
if case_set.get("schema_version") != "vignette.p1_crisis_review_cases.v2":
errors.append("case_set.schema_version: vignette.p1_crisis_review_cases.v2여야 한다")
if manifest.get("technical_status") != "verified":
errors.append("manifest.technical_status: verified여야 한다")
if case_set.get("priority") != "P1":
@ -312,6 +346,17 @@ def _validate_case_set_base(
if contract.get("pending_value", object()) is not None:
errors.append("case_set.case_decision_contract.pending_value: null이어야 한다")
youth_responses = case_set.get("youth_review_responses")
if not isinstance(youth_responses, dict):
errors.append("case_set.youth_review_responses: 객체여야 한다")
elif set(youth_responses) != YOUTH_REVIEW_RESPONSE_FIELDS:
missing = sorted(YOUTH_REVIEW_RESPONSE_FIELDS - set(youth_responses))
extra = sorted(set(youth_responses) - YOUTH_REVIEW_RESPONSE_FIELDS)
errors.append(
"case_set.youth_review_responses: 필드 불일치 "
f"missing={missing}, extra={extra}"
)
cases = case_set.get("cases")
if not isinstance(cases, list) or not cases:
errors.append("case_set.cases: 하나 이상의 합성 사례가 필요하다")
@ -366,6 +411,7 @@ def _validate_case_set_base(
def _validate_pending(
approval: dict[str, Any],
cases: list[Any],
youth_review_responses: Any,
) -> list[str]:
errors: list[str] = []
for field in sorted(APPROVAL_FIELDS):
@ -380,11 +426,20 @@ def _validate_pending(
if not isinstance(assessment, dict):
errors.append(f"pending_external_review: {label}.reviewer_assessment가 필요하다")
continue
for field in ("decision", "rationale", "reviewed_at"):
for field in sorted(ASSESSMENT_FIELDS):
if assessment.get(field, object()) is not None:
errors.append(
f"pending_external_review: {label}.reviewer_assessment.{field}는 null이어야 한다"
)
if not isinstance(youth_review_responses, dict):
errors.append("pending_external_review: youth_review_responses 객체가 필요하다")
else:
for field in sorted(YOUTH_REVIEW_RESPONSE_FIELDS):
if youth_review_responses.get(field, object()) is not None:
errors.append(
"pending_external_review: "
f"youth_review_responses.{field}는 null이어야 한다"
)
return errors
@ -435,6 +490,7 @@ def _validate_completed(
approval: dict[str, Any],
case_set_path: Path,
cases: list[Any],
youth_review_responses: Any,
) -> list[str]:
errors: list[str] = []
for field in sorted(REQUIRED_COMPLETED_APPROVAL_FIELDS):
@ -444,11 +500,6 @@ def _validate_completed(
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"):
@ -462,6 +513,15 @@ def _validate_completed(
errors.extend(_validate_evidence(root, approval))
if not isinstance(youth_review_responses, dict):
errors.append(f"{status}: youth_review_responses 객체가 필요하다")
else:
for field in sorted(YOUTH_REVIEW_RESPONSE_FIELDS):
if not _is_nonempty_string(youth_review_responses.get(field)):
errors.append(
f"{status}: youth_review_responses.{field} 답변이 필요하다"
)
decisions: list[str] = []
for index, case in enumerate(cases):
if not isinstance(case, dict):
@ -478,6 +538,25 @@ def _validate_completed(
decisions.append(decision)
if not _is_nonempty_string(assessment.get("rationale")):
errors.append(f"{status}: {label}.reviewer_assessment.rationale이 필요하다")
findings = assessment.get("findings_and_required_changes")
re_review = assessment.get("re_review")
if not _is_nonempty_string(findings):
errors.append(
f"{status}: {label}.reviewer_assessment.findings_and_required_changes가 필요하다"
)
if not _is_nonempty_string(re_review):
errors.append(f"{status}: {label}.reviewer_assessment.re_review가 필요하다")
if decision in {"conditional", "fail"}:
if isinstance(findings, str) and findings.strip().casefold() in NO_REQUIRED_CHANGE_VALUES:
errors.append(
f"{status}: {label}.reviewer_assessment.findings_and_required_changes에 "
"실제 수정 요구를 기록해야 한다"
)
if isinstance(re_review, str) and re_review.strip().casefold() in NO_RE_REVIEW_VALUES:
errors.append(
f"{status}: {label}.reviewer_assessment.re_review에 "
"재검토 제출 조건 또는 일정을 기록해야 한다"
)
if assessment.get("reviewed_at") != reviewed_at:
errors.append(f"{status}: {label}.reviewer_assessment.reviewed_at이 전체 검토일과 달라서는 안 된다")
@ -551,8 +630,9 @@ def validate_review_contract(
cases = case_set.get("cases")
if not isinstance(cases, list):
return errors
youth_review_responses = case_set.get("youth_review_responses")
if status == "pending_external_review":
errors.extend(_validate_pending(approval, cases))
errors.extend(_validate_pending(approval, cases, youth_review_responses))
else:
errors.extend(
_validate_completed(
@ -562,6 +642,7 @@ def validate_review_contract(
approval,
case_set_file,
cases,
youth_review_responses,
)
)
return errors
@ -604,6 +685,7 @@ def main(argv: list[str] | None = None) -> int:
"protocol": f"{manifest['protocol_id']}@{manifest['version']}",
"case_set": f"{case_set['case_set_id']}@{case_set['version']}",
"case_count": len(case_set["cases"]),
"review_complete": manifest["clinical_status"] != "pending_external_review",
"external_review_replaced": False,
},
ensure_ascii=False,

View file

@ -66,11 +66,24 @@ class ClinicalCrisisReviewCheckerTest(unittest.TestCase):
reviewed_at = "2026-08-28"
for case, decision in zip(case_set["cases"], decisions, strict=True):
requires_change = decision in {"conditional", "fail"}
case["reviewer_assessment"] = {
"decision": decision,
"rationale": f"{case['case_id']} 외부 검토 판정",
"findings_and_required_changes": (
"안전 전환 문구를 보강해야 함" if requires_change else "수정 요구 없음"
),
"re_review": (
"2026-09-04 수정본 제출 후 재검토"
if requires_change
else "재검토 불필요"
),
"reviewed_at": reviewed_at,
}
case_set["youth_review_responses"] = {
field: f"{field} 외부 검토 답변"
for field in checker.YOUTH_REVIEW_RESPONSE_FIELDS
}
_write_json(case_set_path, case_set)
evidence_path = root / "data/clinical/evidence/c-001-review.txt"
@ -81,9 +94,10 @@ class ClinicalCrisisReviewCheckerTest(unittest.TestCase):
manifest["approval"] = {
"reviewer": "검토자",
"organization": "외부 임상기관",
"reviewer_role_or_qualification": "임상 검토 자격 fixture",
"reviewed_at": reviewed_at,
"decision": status,
"notes": None if status == "approved" else f"{status} 판정 사유",
"notes": f"{status} 판정 사유",
"evidence_ref": "data/clinical/evidence/c-001-review.txt",
"evidence_sha256": _sha256(evidence_path),
"reviewed_protocol_version": manifest["version"],
@ -105,6 +119,8 @@ class ClinicalCrisisReviewCheckerTest(unittest.TestCase):
case_set_path = root / checker.CASE_SET_REL
case_set = _read_json(case_set_path)
case_set["cases"][0]["reviewer_assessment"]["decision"] = "pass"
case_set["cases"][0]["reviewer_assessment"]["findings_and_required_changes"] = "없음"
case_set["youth_review_responses"]["guardian_notification"] = "답변"
_write_json(case_set_path, case_set)
errors = checker.validate_review_contract(repo_root=root)
@ -112,6 +128,17 @@ class ClinicalCrisisReviewCheckerTest(unittest.TestCase):
self.assertTrue(
any("P1-CRISIS-001" in error and ".decision" in error and "null" in error for error in errors),
)
self.assertTrue(
any(
"P1-CRISIS-001" in error
and ".findings_and_required_changes" in error
and "null" in error
for error in errors
),
)
self.assertTrue(
any("youth_review_responses.guardian_notification" in error for error in errors),
)
def test_each_completed_status_passes_its_case_decision_contract(self) -> None:
fixtures = (
@ -167,6 +194,9 @@ class ClinicalCrisisReviewCheckerTest(unittest.TestCase):
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"
case_set["cases"][2]["reviewer_assessment"]["findings_and_required_changes"] = None
case_set["cases"][3]["reviewer_assessment"]["re_review"] = " "
case_set["youth_review_responses"]["safeguarding"] = None
_write_json(case_set_path, case_set)
manifest_path = root / checker.MANIFEST_REL
manifest = _read_json(manifest_path)
@ -176,6 +206,68 @@ class ClinicalCrisisReviewCheckerTest(unittest.TestCase):
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))
self.assertTrue(
any("P1-CRISIS-003" in error and "findings_and_required_changes" in error for error in errors),
)
self.assertTrue(any("P1-CRISIS-004" in error and "re_review" in error for error in errors))
self.assertTrue(any("youth_review_responses.safeguarding" in error for error in errors))
def test_completed_state_requires_reviewer_qualification_and_notes(self) -> None:
root = self._fixture()
self._complete(root, "approved", ["pass"] * 6)
manifest_path = root / checker.MANIFEST_REL
manifest = _read_json(manifest_path)
manifest["approval"]["reviewer_role_or_qualification"] = " "
manifest["approval"]["notes"] = None
_write_json(manifest_path, manifest)
errors = checker.validate_review_contract(repo_root=root)
self.assertTrue(any("reviewer_role_or_qualification" in error for error in errors))
self.assertTrue(any("approval.notes" in error for error in errors))
def test_conditional_or_fail_case_rejects_no_change_and_no_re_review_claims(self) -> None:
fixtures = (
("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)
case_set_path = root / checker.CASE_SET_REL
case_set = _read_json(case_set_path)
case_set["cases"][0]["reviewer_assessment"][
"findings_and_required_changes"
] = "수정 요구 없음"
case_set["cases"][0]["reviewer_assessment"]["re_review"] = "재검토 불필요"
_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("실제 수정 요구" in error for error in errors))
self.assertTrue(any("재검토 제출 조건" in error for error in errors))
def test_youth_review_response_keys_must_match_exactly_without_jsonschema(self) -> None:
root = self._fixture()
case_set_path = root / checker.CASE_SET_REL
case_set = _read_json(case_set_path)
del case_set["youth_review_responses"]["documentation"]
case_set["youth_review_responses"]["unexpected"] = None
_write_json(case_set_path, case_set)
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("youth_review_responses: 필드 불일치" in error for error in errors))
def test_evidence_must_exist_under_clinical_evidence_and_match_hash(self) -> None:
root = self._fixture()
@ -251,6 +343,7 @@ class ClinicalCrisisReviewCheckerTest(unittest.TestCase):
summary = json.loads(stdout.getvalue())
self.assertTrue(summary["ok"])
self.assertEqual(summary["case_count"], 6)
self.assertFalse(summary["review_complete"])
self.assertFalse(summary["external_review_replaced"])