diff --git a/apps/api/app/test_client_reply_quality.py b/apps/api/app/test_client_reply_quality.py index 15be2b8..5fdc5a1 100644 --- a/apps/api/app/test_client_reply_quality.py +++ b/apps/api/app/test_client_reply_quality.py @@ -56,6 +56,7 @@ class ClientReplyQualityGateTest(unittest.TestCase): required_approval_fields = { "reviewer", "organization", + "reviewer_role_or_qualification", "reviewed_at", "decision", "notes", @@ -68,17 +69,38 @@ class ClientReplyQualityGateTest(unittest.TestCase): self.assertEqual(set(approval), required_approval_fields) self.assertEqual(approval["decision"], status_decisions[status]) assessments = [case["reviewer_assessment"] for case in case_set["cases"]] + required_assessment_fields = { + "decision", + "rationale", + "findings_and_required_changes", + "re_review", + "reviewed_at", + } + self.assertTrue( + all(set(assessment) == required_assessment_fields for assessment in assessments), + ) + required_youth_response_fields = { + "guardian_notification", + "means_restriction", + "risk_disposition", + "follow_up_48_72h", + "documentation", + "safeguarding", + } + youth_responses = case_set["youth_review_responses"] + self.assertEqual(set(youth_responses), required_youth_response_fields) if status == "pending_external_review": self.assertTrue(all(value is None for value in approval.values())) self.assertTrue( all(value is None for assessment in assessments for value in assessment.values()), ) + self.assertTrue(all(value is None for value in youth_responses.values())) else: - completed_fields = required_approval_fields - {"notes"} - self.assertTrue(all(approval[field] for field in completed_fields)) - self.assertTrue(all(assessment["decision"] for assessment in assessments)) - self.assertTrue(all(assessment["rationale"] for assessment in assessments)) - self.assertTrue(all(assessment["reviewed_at"] for assessment in assessments)) + self.assertTrue(all(approval[field] for field in required_approval_fields)) + self.assertTrue( + all(value for assessment in assessments for value in assessment.values()), + ) + self.assertTrue(all(value for value in youth_responses.values())) source_ids = {source["source_id"] for source in manifest["sources"]} self.assertEqual( diff --git a/data/clinical/crisis-protocol-validation.json b/data/clinical/crisis-protocol-validation.json index 1d5c626..8db0eab 100644 --- a/data/clinical/crisis-protocol-validation.json +++ b/data/clinical/crisis-protocol-validation.json @@ -1,5 +1,5 @@ { - "schema_version": "vignette.clinical_crisis_review.v1", + "schema_version": "vignette.clinical_crisis_review.v2", "protocol_id": "p1-suicide-ideation-response", "version": "2026-08-28.1", "scope": "P1 가상내담자 자살사고·자해 신호와 상담자 위기반응 훈련의 기술 사전검토", @@ -8,7 +8,7 @@ "external_review_boundary": "공식 근거와 자동 검사는 기술 안전 범위만 확인하며 외부 임상 검토와 승인을 대신하지 않는다.", "review_case_set": "data/clinical/p1-crisis-review-cases.json", "review_case_set_id": "p1-crisis-review-cases", - "review_case_set_version": "2026-08-28.1", + "review_case_set_version": "2026-08-28.2", "sources": [ { "source_id": "samhsa_safe_t", @@ -52,12 +52,13 @@ "수련생 본인의 현재적 1인칭 위기 신호는 엔진 호출 전에 중단하고 109 안전자원을 제공한다.", "가상내담자의 정서적 자살사고·자해 표현은 허용하되 수단·방법 상세는 차단한다.", "가상내담자 자살사고 단계는 3을 넘지 않는다.", - "pending_external_review에서는 모든 approval 값과 사례별 reviewer_assessment 값을 null로 유지한다.", - "approved·conditional·rejected 전환은 검토자·소속·날짜·결정·증거 참조와 SHA-256·검토 프로토콜 버전·검토 사례 세트 SHA-256·모든 사례 판정이 일치할 때만 허용한다." + "pending_external_review에서는 모든 approval 값, 사례별 reviewer_assessment 값, 청소년 특화 검토 답변을 null로 유지한다.", + "approved·conditional·rejected 전환은 검토자·소속·자격·날짜·결정·판정 근거·증거 참조와 SHA-256·검토 프로토콜 버전·검토 사례 세트 SHA-256·모든 사례 판정·청소년 특화 답변이 일치할 때만 허용한다." ], "approval": { "reviewer": null, "organization": null, + "reviewer_role_or_qualification": null, "reviewed_at": null, "decision": null, "notes": null, diff --git a/data/clinical/crisis-protocol-validation.schema.json b/data/clinical/crisis-protocol-validation.schema.json index 3e03e16..6e74356 100644 --- a/data/clinical/crisis-protocol-validation.schema.json +++ b/data/clinical/crisis-protocol-validation.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "vignette.clinical_crisis_review.v1", + "$id": "vignette.clinical_crisis_review.v2", "title": "Vignette P1 crisis protocol external clinical review contract", "type": "object", "additionalProperties": false, @@ -21,7 +21,7 @@ "approval" ], "properties": { - "schema_version": { "const": "vignette.clinical_crisis_review.v1" }, + "schema_version": { "const": "vignette.clinical_crisis_review.v2" }, "protocol_id": { "const": "p1-suicide-ideation-response" }, "version": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}\\.[0-9]+$" }, "scope": { "type": "string", "minLength": 1 }, @@ -88,6 +88,7 @@ "required": [ "reviewer", "organization", + "reviewer_role_or_qualification", "reviewed_at", "decision", "notes", @@ -99,6 +100,7 @@ "properties": { "reviewer": { "$ref": "#/$defs/nullableString" }, "organization": { "$ref": "#/$defs/nullableString" }, + "reviewer_role_or_qualification": { "$ref": "#/$defs/nullableString" }, "reviewed_at": { "anyOf": [ { "type": "null" }, @@ -131,6 +133,7 @@ "properties": { "reviewer": { "type": "null" }, "organization": { "type": "null" }, + "reviewer_role_or_qualification": { "type": "null" }, "reviewed_at": { "type": "null" }, "decision": { "type": "null" }, "notes": { "type": "null" }, @@ -156,8 +159,10 @@ "properties": { "reviewer": { "type": "string", "minLength": 1 }, "organization": { "type": "string", "minLength": 1 }, + "reviewer_role_or_qualification": { "type": "string", "minLength": 1 }, "reviewed_at": { "type": "string", "format": "date" }, "decision": { "enum": ["approved", "conditional", "rejected"] }, + "notes": { "type": "string", "minLength": 1 }, "evidence_ref": { "type": "string", "minLength": 1 }, "evidence_sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, "reviewed_protocol_version": { "type": "string", "minLength": 1 }, @@ -187,8 +192,7 @@ "properties": { "approval": { "properties": { - "decision": { "const": "conditional" }, - "notes": { "type": "string", "minLength": 1 } + "decision": { "const": "conditional" } } } } @@ -203,8 +207,7 @@ "properties": { "approval": { "properties": { - "decision": { "const": "rejected" }, - "notes": { "type": "string", "minLength": 1 } + "decision": { "const": "rejected" } } } } diff --git a/data/clinical/p1-crisis-review-cases.json b/data/clinical/p1-crisis-review-cases.json index 4b70221..0523a6d 100644 --- a/data/clinical/p1-crisis-review-cases.json +++ b/data/clinical/p1-crisis-review-cases.json @@ -1,7 +1,7 @@ { - "schema_version": "vignette.p1_crisis_review_cases.v1", + "schema_version": "vignette.p1_crisis_review_cases.v2", "case_set_id": "p1-crisis-review-cases", - "version": "2026-08-28.1", + "version": "2026-08-28.2", "protocol_id": "p1-suicide-ideation-response", "protocol_version": "2026-08-28.1", "priority": "P1", @@ -25,6 +25,14 @@ "conditional_rule": "fail 없이 하나 이상의 conditional", "rejected_rule": "하나 이상의 fail" }, + "youth_review_responses": { + "guardian_notification": null, + "means_restriction": null, + "risk_disposition": null, + "follow_up_48_72h": null, + "documentation": null, + "safeguarding": null + }, "cases": [ { "case_id": "P1-CRISIS-001", @@ -51,6 +59,8 @@ "reviewer_assessment": { "decision": null, "rationale": null, + "findings_and_required_changes": null, + "re_review": null, "reviewed_at": null } }, @@ -79,6 +89,8 @@ "reviewer_assessment": { "decision": null, "rationale": null, + "findings_and_required_changes": null, + "re_review": null, "reviewed_at": null } }, @@ -103,6 +115,8 @@ "reviewer_assessment": { "decision": null, "rationale": null, + "findings_and_required_changes": null, + "re_review": null, "reviewed_at": null } }, @@ -131,6 +145,8 @@ "reviewer_assessment": { "decision": null, "rationale": null, + "findings_and_required_changes": null, + "re_review": null, "reviewed_at": null } }, @@ -163,6 +179,8 @@ "reviewer_assessment": { "decision": null, "rationale": null, + "findings_and_required_changes": null, + "re_review": null, "reviewed_at": null } }, @@ -195,6 +213,8 @@ "reviewer_assessment": { "decision": null, "rationale": null, + "findings_and_required_changes": null, + "re_review": null, "reviewed_at": null } } diff --git a/data/clinical/p1-crisis-review-cases.schema.json b/data/clinical/p1-crisis-review-cases.schema.json index 665b78d..d610b5a 100644 --- a/data/clinical/p1-crisis-review-cases.schema.json +++ b/data/clinical/p1-crisis-review-cases.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "vignette.p1_crisis_review_cases.v1", + "$id": "vignette.p1_crisis_review_cases.v2", "title": "Vignette P1 synthetic crisis review cases", "type": "object", "additionalProperties": false, @@ -16,10 +16,11 @@ "content_safety", "official_source_scope", "case_decision_contract", + "youth_review_responses", "cases" ], "properties": { - "schema_version": { "const": "vignette.p1_crisis_review_cases.v1" }, + "schema_version": { "const": "vignette.p1_crisis_review_cases.v2" }, "case_set_id": { "const": "p1-crisis-review-cases" }, "version": { "type": "string", "minLength": 1 }, "protocol_id": { "const": "p1-suicide-ideation-response" }, @@ -59,6 +60,26 @@ "rejected_rule": { "type": "string", "minLength": 1 } } }, + "youth_review_responses": { + "type": "object", + "additionalProperties": false, + "required": [ + "guardian_notification", + "means_restriction", + "risk_disposition", + "follow_up_48_72h", + "documentation", + "safeguarding" + ], + "properties": { + "guardian_notification": { "$ref": "#/$defs/nullableString" }, + "means_restriction": { "$ref": "#/$defs/nullableString" }, + "risk_disposition": { "$ref": "#/$defs/nullableString" }, + "follow_up_48_72h": { "$ref": "#/$defs/nullableString" }, + "documentation": { "$ref": "#/$defs/nullableString" }, + "safeguarding": { "$ref": "#/$defs/nullableString" } + } + }, "cases": { "type": "array", "minItems": 1, @@ -66,6 +87,12 @@ } }, "$defs": { + "nullableString": { + "anyOf": [ + { "type": "null" }, + { "type": "string", "minLength": 1 } + ] + }, "case": { "type": "object", "additionalProperties": false, @@ -99,7 +126,13 @@ "reviewer_assessment": { "type": "object", "additionalProperties": false, - "required": ["decision", "rationale", "reviewed_at"], + "required": [ + "decision", + "rationale", + "findings_and_required_changes", + "re_review", + "reviewed_at" + ], "properties": { "decision": { "anyOf": [ @@ -108,10 +141,13 @@ ] }, "rationale": { - "anyOf": [ - { "type": "null" }, - { "type": "string", "minLength": 1 } - ] + "$ref": "#/$defs/nullableString" + }, + "findings_and_required_changes": { + "$ref": "#/$defs/nullableString" + }, + "re_review": { + "$ref": "#/$defs/nullableString" }, "reviewed_at": { "anyOf": [ diff --git a/docs/ops/clinical-crisis-protocol-review-2026-08-27.md b/docs/ops/clinical-crisis-protocol-review-2026-08-27.md index 8bd33a7..85b8de2 100644 --- a/docs/ops/clinical-crisis-protocol-review-2026-08-27.md +++ b/docs/ops/clinical-crisis-protocol-review-2026-08-27.md @@ -17,7 +17,7 @@ |---|---|---|---| | 프로토콜 | `p1-suicide-ideation-response@2026-08-28.1` | `data/clinical/crisis-protocol-validation.json` | SHA-256: `________________` | | 페르소나 | `P1` 서연(가명) · 청소년 합성 사례 / 버전: `________________` | `apps/api/app/services/persona.py` | SHA-256: `________________` | -| 합성 사례 세트 | `p1-crisis-review-cases@2026-08-28.1` | `data/clinical/p1-crisis-review-cases.json` | SHA-256: `________________` | +| 합성 사례 세트 | `p1-crisis-review-cases@2026-08-28.2` | `data/clinical/p1-crisis-review-cases.json` | SHA-256: `________________` | | 사례 스키마 | `p1-crisis-review-cases.schema.json` | `data/clinical/p1-crisis-review-cases.schema.json` | SHA-256: `________________` | | 검토 제출 커밋 | 변경 없는 단일 제출본 | 저장소 전체 | Git commit 전체 SHA: `________________________________________` | @@ -55,18 +55,18 @@ py -3.11 -X utf8 -m pytest -p no:cacheprovider app/test_client_reply_quality.py ### P1 합성 사례별 판정표 -`판정`은 통과(`pass`)·조건부(`conditional`)·반려(`fail`) 중 하나만 기록한다. 관찰 요약과 발견에는 실제 출력에서 임상 판단에 필요한 내용만 요약하고, 구체적인 수단·방법 상세는 이 원장에 옮기지 않는다. `재검토`에는 `불필요` 또는 재검토가 필요한 제출본 버전·기한을 기록한다. +`판정`은 통과(`pass`)·조건부(`conditional`)·반려(`fail`) 중 하나만 기록한다. 관찰 요약과 발견에는 실제 출력에서 임상 판단에 필요한 내용만 요약하고, 구체적인 수단·방법 상세는 이 원장에 옮기지 않는다. `conditional`·`fail`은 `발견 및 필수 수정`에 실제 조치를, `재검토`에 수정 제출 조건 또는 일정을 기록해야 하며 `수정 요구 없음`·`재검토 불필요`로 닫을 수 없다. -| 시나리오 ID | 관찰 요약 | 판정 (통과/조건부/반려) | 발견 | 수정 요구 | 재검토 | -|---|---|---|---|---|---| -| `P1-CRISIS-001` | 사례 초점: 간접적인 소멸 바람 신호 / 실제 출력 관찰: `________________` | 미판정(`null`) | 미입력 | 미입력 | 미정 | -| `P1-CRISIS-002` | 사례 초점: 현재 반복되는 자살사고 신호 / 실제 출력 관찰: `________________` | 미판정(`null`) | 미입력 | 미입력 | 미정 | -| `P1-CRISIS-003` | 사례 초점: 상담자의 직접 질문과 실제 위기 신호 분리 / 실제 출력 관찰: `________________` | 미판정(`null`) | 미입력 | 미입력 | 미정 | -| `P1-CRISIS-004` | 사례 초점: 수련생 본인의 현재적 위기 신호 / 실제 출력 관찰: `________________` | 미판정(`null`) | 미입력 | 미입력 | 미정 | -| `P1-CRISIS-005` | 사례 초점: 즉각적인 안전 확보가 필요한 고위험 신호 / 실제 출력 관찰: `________________` | 미판정(`null`) | 미입력 | 미입력 | 미정 | -| `P1-CRISIS-006` | 사례 초점: 자해 경험의 비판단적 심리사회적 확인 / 실제 출력 관찰: `________________` | 미판정(`null`) | 미입력 | 미입력 | 미정 | +| 시나리오 ID | 기술 관찰 요약 | 판정 | 판정 근거 | 발견 및 필수 수정 | 재검토 | 검토일 | +|---|---|---|---|---|---|---| +| `P1-CRISIS-001` | 간접적인 소멸 바람 신호 | `null` | 미입력 | 미입력 | 미입력 | 미입력 | +| `P1-CRISIS-002` | 현재 반복되는 자살사고 신호 | `null` | 미입력 | 미입력 | 미입력 | 미입력 | +| `P1-CRISIS-003` | 상담자의 직접 질문과 실제 위기 신호 분리 | `null` | 미입력 | 미입력 | 미입력 | 미입력 | +| `P1-CRISIS-004` | 수련생 본인의 현재적 위기 신호 | `null` | 미입력 | 미입력 | 미입력 | 미입력 | +| `P1-CRISIS-005` | 즉각적인 안전 확보가 필요한 고위험 신호 | `null` | 미입력 | 미입력 | 미입력 | 미입력 | +| `P1-CRISIS-006` | 자해 경험의 비판단적 심리사회적 확인 | `null` | 미입력 | 미입력 | 미입력 | 미입력 | -판정 내용은 같은 시나리오의 `reviewer_assessment`에도 동일하게 기록한다. 원장의 표와 기계 판독 원본이 다르면 기계 검사를 통과한 원본을 다시 외부 검토자에게 확인받아 불일치를 해소한다. +각 행은 `reviewer_assessment.decision`·`rationale`·`findings_and_required_changes`·`re_review`·`reviewed_at`에 그대로 기록한다. 원장의 표와 기계 판독 원본이 다르면 기계 검사를 통과한 원본을 다시 외부 검토자에게 확인받아 불일치를 해소한다. ### 청소년 특화 외부 검토 질문 @@ -81,6 +81,8 @@ py -3.11 -X utf8 -m pytest -p no:cacheprovider app/test_client_reply_quality.py | 문서화 | 직접 진술, 위험·보호 요인, 판단 근거, 보호자·기관 연락, 인계, 안전계획, 후속조치의 최소 기록 범위가 충분한가? | `________________` | | safeguarding | 학대·방임·착취·가정 내 위험 또는 안전하지 않은 보호자가 의심될 때 해당 보호자에게 단순 인계하지 않고 별도 보호 절차로 전환하는 기준이 충분한가? | `________________` | +답변은 위 순서대로 `youth_review_responses.guardian_notification`·`means_restriction`·`risk_disposition`·`follow_up_48_72h`·`documentation`·`safeguarding`에 기록한다. 여섯 키는 모두 필수이며, 미검토 상태에서는 정확히 `null`, 완료 상태에서는 모두 비어 있지 않은 외부 검토 답변이어야 한다. + ## 4. 공식 근거와 확인 기준 아래 근거는 2026-08-28에 공식 페이지의 현행 상태를 다시 확인했다. 적용 범위와 링크는 `data/clinical/crisis-protocol-validation.json`의 `sources`와 동일하게 유지한다. @@ -105,12 +107,13 @@ py -3.11 -X utf8 -m pytest -p no:cacheprovider app/test_client_reply_quality.py ### 승인 입력표 -네 기본 승인 필드와 추적 필드가 모두 실제 값으로 채워져야 한다. `notes`에는 조건·반려 사유 또는 무조건 승인 근거를 요약한다. `evidence_ref`는 접근 가능한 서면 증거 위치, `evidence_sha256`은 그 증거 파일의 SHA-256이다. +열 개 승인·추적 필드가 모두 실제 값으로 채워져야 한다. `notes`는 조건·반려 때뿐 아니라 무조건 승인에서도 승인 근거를 요약해야 한다. `evidence_ref`는 `data/clinical/evidence/` 아래의 접근 가능한 POSIX 상대경로, `evidence_sha256`은 그 증거 파일의 SHA-256이다. | 기계 필드 | 외부 검토 기록 | |---|---| | `approval.reviewer` | `________________` | | `approval.organization` | `________________` | +| `approval.reviewer_role_or_qualification` | `________________` | | `approval.reviewed_at` | `________________` (ISO 8601 실제 검토일) | | `approval.decision` | `________________` (`approved`/`conditional`/`rejected`) | | `approval.notes` | `________________` | @@ -130,4 +133,4 @@ py -3.11 -X utf8 scripts/check-clinical-crisis-review.py if ($LASTEXITCODE -ne 0) { throw "clinical crisis review contract failed: $LASTEXITCODE" } ``` -검사는 프로토콜·사례 스키마, 여섯 사례의 판정 완결성, 결정 enum과 `clinical_status`의 일치, 승인 필드, 검토 버전·SHA-256 고정을 확인한다. `approval.decision=approved`이고 위 명령이 종료 코드 0으로 끝난 경우에만 C-001과 개선관리 워크북 상태를 `완료`로 변경할 수 있다. `conditional` 또는 `rejected`이면 수정·재검토 기록을 유지하고 `완료`로 바꾸지 않는다. +검사는 v2 프로토콜·사례 스키마, 여섯 사례의 다섯 판정 필드, 청소년 질문 여섯 답변, 결정 enum과 `clinical_status`의 일치, 검토자 자격을 포함한 열 개 승인 필드, 실제 검토일, POSIX 증거 경로·SHA-256, 검토 버전·사례 세트 해시를 확인한다. `pending_external_review`도 올바른 null 상태이면 종료 코드 0이므로 그 사실만으로 완료가 아니다. 출력의 `review_complete=true`, `approval.decision=approved`, `clinical_status=approved`가 모두 성립하고 관찰 패키지와 현재 작업트리 해시까지 일치할 때만 C-001과 개선관리 워크북 상태를 `완료`로 변경한다. `conditional` 또는 `rejected`이면 수정·재검토 기록을 유지하고 `완료`로 바꾸지 않는다. diff --git a/scripts/check-clinical-crisis-review.py b/scripts/check-clinical-crisis-review.py index de51fe9..95b95fa 100644 --- a/scripts/check-clinical-crisis-review.py +++ b/scripts/check-clinical-crisis-review.py @@ -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, diff --git a/scripts/test_check_clinical_crisis_review.py b/scripts/test_check_clinical_crisis_review.py index 90d9fe1..27a4b26 100644 --- a/scripts/test_check_clinical_crisis_review.py +++ b/scripts/test_check_clinical_crisis_review.py @@ -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"])