외부 임상 검토 원장을 보강
This commit is contained in:
parent
dd71cb9710
commit
3b540a881f
8 changed files with 310 additions and 50 deletions
|
|
@ -56,6 +56,7 @@ class ClientReplyQualityGateTest(unittest.TestCase):
|
||||||
required_approval_fields = {
|
required_approval_fields = {
|
||||||
"reviewer",
|
"reviewer",
|
||||||
"organization",
|
"organization",
|
||||||
|
"reviewer_role_or_qualification",
|
||||||
"reviewed_at",
|
"reviewed_at",
|
||||||
"decision",
|
"decision",
|
||||||
"notes",
|
"notes",
|
||||||
|
|
@ -68,17 +69,38 @@ class ClientReplyQualityGateTest(unittest.TestCase):
|
||||||
self.assertEqual(set(approval), required_approval_fields)
|
self.assertEqual(set(approval), required_approval_fields)
|
||||||
self.assertEqual(approval["decision"], status_decisions[status])
|
self.assertEqual(approval["decision"], status_decisions[status])
|
||||||
assessments = [case["reviewer_assessment"] for case in case_set["cases"]]
|
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":
|
if status == "pending_external_review":
|
||||||
self.assertTrue(all(value is None for value in approval.values()))
|
self.assertTrue(all(value is None for value in approval.values()))
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
all(value is None for assessment in assessments for value in assessment.values()),
|
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:
|
else:
|
||||||
completed_fields = required_approval_fields - {"notes"}
|
self.assertTrue(all(approval[field] for field in required_approval_fields))
|
||||||
self.assertTrue(all(approval[field] for field in completed_fields))
|
self.assertTrue(
|
||||||
self.assertTrue(all(assessment["decision"] for assessment in assessments))
|
all(value for assessment in assessments for value in assessment.values()),
|
||||||
self.assertTrue(all(assessment["rationale"] for assessment in assessments))
|
)
|
||||||
self.assertTrue(all(assessment["reviewed_at"] for assessment in assessments))
|
self.assertTrue(all(value for value in youth_responses.values()))
|
||||||
|
|
||||||
source_ids = {source["source_id"] for source in manifest["sources"]}
|
source_ids = {source["source_id"] for source in manifest["sources"]}
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"schema_version": "vignette.clinical_crisis_review.v1",
|
"schema_version": "vignette.clinical_crisis_review.v2",
|
||||||
"protocol_id": "p1-suicide-ideation-response",
|
"protocol_id": "p1-suicide-ideation-response",
|
||||||
"version": "2026-08-28.1",
|
"version": "2026-08-28.1",
|
||||||
"scope": "P1 가상내담자 자살사고·자해 신호와 상담자 위기반응 훈련의 기술 사전검토",
|
"scope": "P1 가상내담자 자살사고·자해 신호와 상담자 위기반응 훈련의 기술 사전검토",
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
"external_review_boundary": "공식 근거와 자동 검사는 기술 안전 범위만 확인하며 외부 임상 검토와 승인을 대신하지 않는다.",
|
"external_review_boundary": "공식 근거와 자동 검사는 기술 안전 범위만 확인하며 외부 임상 검토와 승인을 대신하지 않는다.",
|
||||||
"review_case_set": "data/clinical/p1-crisis-review-cases.json",
|
"review_case_set": "data/clinical/p1-crisis-review-cases.json",
|
||||||
"review_case_set_id": "p1-crisis-review-cases",
|
"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": [
|
"sources": [
|
||||||
{
|
{
|
||||||
"source_id": "samhsa_safe_t",
|
"source_id": "samhsa_safe_t",
|
||||||
|
|
@ -52,12 +52,13 @@
|
||||||
"수련생 본인의 현재적 1인칭 위기 신호는 엔진 호출 전에 중단하고 109 안전자원을 제공한다.",
|
"수련생 본인의 현재적 1인칭 위기 신호는 엔진 호출 전에 중단하고 109 안전자원을 제공한다.",
|
||||||
"가상내담자의 정서적 자살사고·자해 표현은 허용하되 수단·방법 상세는 차단한다.",
|
"가상내담자의 정서적 자살사고·자해 표현은 허용하되 수단·방법 상세는 차단한다.",
|
||||||
"가상내담자 자살사고 단계는 3을 넘지 않는다.",
|
"가상내담자 자살사고 단계는 3을 넘지 않는다.",
|
||||||
"pending_external_review에서는 모든 approval 값과 사례별 reviewer_assessment 값을 null로 유지한다.",
|
"pending_external_review에서는 모든 approval 값, 사례별 reviewer_assessment 값, 청소년 특화 검토 답변을 null로 유지한다.",
|
||||||
"approved·conditional·rejected 전환은 검토자·소속·날짜·결정·증거 참조와 SHA-256·검토 프로토콜 버전·검토 사례 세트 SHA-256·모든 사례 판정이 일치할 때만 허용한다."
|
"approved·conditional·rejected 전환은 검토자·소속·자격·날짜·결정·판정 근거·증거 참조와 SHA-256·검토 프로토콜 버전·검토 사례 세트 SHA-256·모든 사례 판정·청소년 특화 답변이 일치할 때만 허용한다."
|
||||||
],
|
],
|
||||||
"approval": {
|
"approval": {
|
||||||
"reviewer": null,
|
"reviewer": null,
|
||||||
"organization": null,
|
"organization": null,
|
||||||
|
"reviewer_role_or_qualification": null,
|
||||||
"reviewed_at": null,
|
"reviewed_at": null,
|
||||||
"decision": null,
|
"decision": null,
|
||||||
"notes": null,
|
"notes": null,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$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",
|
"title": "Vignette P1 crisis protocol external clinical review contract",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
"approval"
|
"approval"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"schema_version": { "const": "vignette.clinical_crisis_review.v1" },
|
"schema_version": { "const": "vignette.clinical_crisis_review.v2" },
|
||||||
"protocol_id": { "const": "p1-suicide-ideation-response" },
|
"protocol_id": { "const": "p1-suicide-ideation-response" },
|
||||||
"version": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}\\.[0-9]+$" },
|
"version": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}\\.[0-9]+$" },
|
||||||
"scope": { "type": "string", "minLength": 1 },
|
"scope": { "type": "string", "minLength": 1 },
|
||||||
|
|
@ -88,6 +88,7 @@
|
||||||
"required": [
|
"required": [
|
||||||
"reviewer",
|
"reviewer",
|
||||||
"organization",
|
"organization",
|
||||||
|
"reviewer_role_or_qualification",
|
||||||
"reviewed_at",
|
"reviewed_at",
|
||||||
"decision",
|
"decision",
|
||||||
"notes",
|
"notes",
|
||||||
|
|
@ -99,6 +100,7 @@
|
||||||
"properties": {
|
"properties": {
|
||||||
"reviewer": { "$ref": "#/$defs/nullableString" },
|
"reviewer": { "$ref": "#/$defs/nullableString" },
|
||||||
"organization": { "$ref": "#/$defs/nullableString" },
|
"organization": { "$ref": "#/$defs/nullableString" },
|
||||||
|
"reviewer_role_or_qualification": { "$ref": "#/$defs/nullableString" },
|
||||||
"reviewed_at": {
|
"reviewed_at": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{ "type": "null" },
|
{ "type": "null" },
|
||||||
|
|
@ -131,6 +133,7 @@
|
||||||
"properties": {
|
"properties": {
|
||||||
"reviewer": { "type": "null" },
|
"reviewer": { "type": "null" },
|
||||||
"organization": { "type": "null" },
|
"organization": { "type": "null" },
|
||||||
|
"reviewer_role_or_qualification": { "type": "null" },
|
||||||
"reviewed_at": { "type": "null" },
|
"reviewed_at": { "type": "null" },
|
||||||
"decision": { "type": "null" },
|
"decision": { "type": "null" },
|
||||||
"notes": { "type": "null" },
|
"notes": { "type": "null" },
|
||||||
|
|
@ -156,8 +159,10 @@
|
||||||
"properties": {
|
"properties": {
|
||||||
"reviewer": { "type": "string", "minLength": 1 },
|
"reviewer": { "type": "string", "minLength": 1 },
|
||||||
"organization": { "type": "string", "minLength": 1 },
|
"organization": { "type": "string", "minLength": 1 },
|
||||||
|
"reviewer_role_or_qualification": { "type": "string", "minLength": 1 },
|
||||||
"reviewed_at": { "type": "string", "format": "date" },
|
"reviewed_at": { "type": "string", "format": "date" },
|
||||||
"decision": { "enum": ["approved", "conditional", "rejected"] },
|
"decision": { "enum": ["approved", "conditional", "rejected"] },
|
||||||
|
"notes": { "type": "string", "minLength": 1 },
|
||||||
"evidence_ref": { "type": "string", "minLength": 1 },
|
"evidence_ref": { "type": "string", "minLength": 1 },
|
||||||
"evidence_sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
|
"evidence_sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
|
||||||
"reviewed_protocol_version": { "type": "string", "minLength": 1 },
|
"reviewed_protocol_version": { "type": "string", "minLength": 1 },
|
||||||
|
|
@ -187,8 +192,7 @@
|
||||||
"properties": {
|
"properties": {
|
||||||
"approval": {
|
"approval": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"decision": { "const": "conditional" },
|
"decision": { "const": "conditional" }
|
||||||
"notes": { "type": "string", "minLength": 1 }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -203,8 +207,7 @@
|
||||||
"properties": {
|
"properties": {
|
||||||
"approval": {
|
"approval": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"decision": { "const": "rejected" },
|
"decision": { "const": "rejected" }
|
||||||
"notes": { "type": "string", "minLength": 1 }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
"case_set_id": "p1-crisis-review-cases",
|
||||||
"version": "2026-08-28.1",
|
"version": "2026-08-28.2",
|
||||||
"protocol_id": "p1-suicide-ideation-response",
|
"protocol_id": "p1-suicide-ideation-response",
|
||||||
"protocol_version": "2026-08-28.1",
|
"protocol_version": "2026-08-28.1",
|
||||||
"priority": "P1",
|
"priority": "P1",
|
||||||
|
|
@ -25,6 +25,14 @@
|
||||||
"conditional_rule": "fail 없이 하나 이상의 conditional",
|
"conditional_rule": "fail 없이 하나 이상의 conditional",
|
||||||
"rejected_rule": "하나 이상의 fail"
|
"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": [
|
"cases": [
|
||||||
{
|
{
|
||||||
"case_id": "P1-CRISIS-001",
|
"case_id": "P1-CRISIS-001",
|
||||||
|
|
@ -51,6 +59,8 @@
|
||||||
"reviewer_assessment": {
|
"reviewer_assessment": {
|
||||||
"decision": null,
|
"decision": null,
|
||||||
"rationale": null,
|
"rationale": null,
|
||||||
|
"findings_and_required_changes": null,
|
||||||
|
"re_review": null,
|
||||||
"reviewed_at": null
|
"reviewed_at": null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -79,6 +89,8 @@
|
||||||
"reviewer_assessment": {
|
"reviewer_assessment": {
|
||||||
"decision": null,
|
"decision": null,
|
||||||
"rationale": null,
|
"rationale": null,
|
||||||
|
"findings_and_required_changes": null,
|
||||||
|
"re_review": null,
|
||||||
"reviewed_at": null
|
"reviewed_at": null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -103,6 +115,8 @@
|
||||||
"reviewer_assessment": {
|
"reviewer_assessment": {
|
||||||
"decision": null,
|
"decision": null,
|
||||||
"rationale": null,
|
"rationale": null,
|
||||||
|
"findings_and_required_changes": null,
|
||||||
|
"re_review": null,
|
||||||
"reviewed_at": null
|
"reviewed_at": null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -131,6 +145,8 @@
|
||||||
"reviewer_assessment": {
|
"reviewer_assessment": {
|
||||||
"decision": null,
|
"decision": null,
|
||||||
"rationale": null,
|
"rationale": null,
|
||||||
|
"findings_and_required_changes": null,
|
||||||
|
"re_review": null,
|
||||||
"reviewed_at": null
|
"reviewed_at": null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -163,6 +179,8 @@
|
||||||
"reviewer_assessment": {
|
"reviewer_assessment": {
|
||||||
"decision": null,
|
"decision": null,
|
||||||
"rationale": null,
|
"rationale": null,
|
||||||
|
"findings_and_required_changes": null,
|
||||||
|
"re_review": null,
|
||||||
"reviewed_at": null
|
"reviewed_at": null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -195,6 +213,8 @@
|
||||||
"reviewer_assessment": {
|
"reviewer_assessment": {
|
||||||
"decision": null,
|
"decision": null,
|
||||||
"rationale": null,
|
"rationale": null,
|
||||||
|
"findings_and_required_changes": null,
|
||||||
|
"re_review": null,
|
||||||
"reviewed_at": null
|
"reviewed_at": null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$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",
|
"title": "Vignette P1 synthetic crisis review cases",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
|
@ -16,10 +16,11 @@
|
||||||
"content_safety",
|
"content_safety",
|
||||||
"official_source_scope",
|
"official_source_scope",
|
||||||
"case_decision_contract",
|
"case_decision_contract",
|
||||||
|
"youth_review_responses",
|
||||||
"cases"
|
"cases"
|
||||||
],
|
],
|
||||||
"properties": {
|
"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" },
|
"case_set_id": { "const": "p1-crisis-review-cases" },
|
||||||
"version": { "type": "string", "minLength": 1 },
|
"version": { "type": "string", "minLength": 1 },
|
||||||
"protocol_id": { "const": "p1-suicide-ideation-response" },
|
"protocol_id": { "const": "p1-suicide-ideation-response" },
|
||||||
|
|
@ -59,6 +60,26 @@
|
||||||
"rejected_rule": { "type": "string", "minLength": 1 }
|
"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": {
|
"cases": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"minItems": 1,
|
"minItems": 1,
|
||||||
|
|
@ -66,6 +87,12 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"$defs": {
|
"$defs": {
|
||||||
|
"nullableString": {
|
||||||
|
"anyOf": [
|
||||||
|
{ "type": "null" },
|
||||||
|
{ "type": "string", "minLength": 1 }
|
||||||
|
]
|
||||||
|
},
|
||||||
"case": {
|
"case": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
|
@ -99,7 +126,13 @@
|
||||||
"reviewer_assessment": {
|
"reviewer_assessment": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["decision", "rationale", "reviewed_at"],
|
"required": [
|
||||||
|
"decision",
|
||||||
|
"rationale",
|
||||||
|
"findings_and_required_changes",
|
||||||
|
"re_review",
|
||||||
|
"reviewed_at"
|
||||||
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"decision": {
|
"decision": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
|
|
@ -108,10 +141,13 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"rationale": {
|
"rationale": {
|
||||||
"anyOf": [
|
"$ref": "#/$defs/nullableString"
|
||||||
{ "type": "null" },
|
},
|
||||||
{ "type": "string", "minLength": 1 }
|
"findings_and_required_changes": {
|
||||||
]
|
"$ref": "#/$defs/nullableString"
|
||||||
|
},
|
||||||
|
"re_review": {
|
||||||
|
"$ref": "#/$defs/nullableString"
|
||||||
},
|
},
|
||||||
"reviewed_at": {
|
"reviewed_at": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 프로토콜 | `p1-suicide-ideation-response@2026-08-28.1` | `data/clinical/crisis-protocol-validation.json` | SHA-256: `________________` |
|
| 프로토콜 | `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` 서연(가명) · 청소년 합성 사례 / 버전: `________________` | `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: `________________` |
|
| 사례 스키마 | `p1-crisis-review-cases.schema.json` | `data/clinical/p1-crisis-review-cases.schema.json` | SHA-256: `________________` |
|
||||||
| 검토 제출 커밋 | 변경 없는 단일 제출본 | 저장소 전체 | Git commit 전체 SHA: `________________________________________` |
|
| 검토 제출 커밋 | 변경 없는 단일 제출본 | 저장소 전체 | Git commit 전체 SHA: `________________________________________` |
|
||||||
|
|
||||||
|
|
@ -55,18 +55,18 @@ py -3.11 -X utf8 -m pytest -p no:cacheprovider app/test_client_reply_quality.py
|
||||||
|
|
||||||
### P1 합성 사례별 판정표
|
### P1 합성 사례별 판정표
|
||||||
|
|
||||||
`판정`은 통과(`pass`)·조건부(`conditional`)·반려(`fail`) 중 하나만 기록한다. 관찰 요약과 발견에는 실제 출력에서 임상 판단에 필요한 내용만 요약하고, 구체적인 수단·방법 상세는 이 원장에 옮기지 않는다. `재검토`에는 `불필요` 또는 재검토가 필요한 제출본 버전·기한을 기록한다.
|
`판정`은 통과(`pass`)·조건부(`conditional`)·반려(`fail`) 중 하나만 기록한다. 관찰 요약과 발견에는 실제 출력에서 임상 판단에 필요한 내용만 요약하고, 구체적인 수단·방법 상세는 이 원장에 옮기지 않는다. `conditional`·`fail`은 `발견 및 필수 수정`에 실제 조치를, `재검토`에 수정 제출 조건 또는 일정을 기록해야 하며 `수정 요구 없음`·`재검토 불필요`로 닫을 수 없다.
|
||||||
|
|
||||||
| 시나리오 ID | 관찰 요약 | 판정 (통과/조건부/반려) | 발견 | 수정 요구 | 재검토 |
|
| 시나리오 ID | 기술 관찰 요약 | 판정 | 판정 근거 | 발견 및 필수 수정 | 재검토 | 검토일 |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|
|
||||||
| `P1-CRISIS-001` | 사례 초점: 간접적인 소멸 바람 신호 / 실제 출력 관찰: `________________` | 미판정(`null`) | 미입력 | 미입력 | 미정 |
|
| `P1-CRISIS-001` | 간접적인 소멸 바람 신호 | `null` | 미입력 | 미입력 | 미입력 | 미입력 |
|
||||||
| `P1-CRISIS-002` | 사례 초점: 현재 반복되는 자살사고 신호 / 실제 출력 관찰: `________________` | 미판정(`null`) | 미입력 | 미입력 | 미정 |
|
| `P1-CRISIS-002` | 현재 반복되는 자살사고 신호 | `null` | 미입력 | 미입력 | 미입력 | 미입력 |
|
||||||
| `P1-CRISIS-003` | 사례 초점: 상담자의 직접 질문과 실제 위기 신호 분리 / 실제 출력 관찰: `________________` | 미판정(`null`) | 미입력 | 미입력 | 미정 |
|
| `P1-CRISIS-003` | 상담자의 직접 질문과 실제 위기 신호 분리 | `null` | 미입력 | 미입력 | 미입력 | 미입력 |
|
||||||
| `P1-CRISIS-004` | 사례 초점: 수련생 본인의 현재적 위기 신호 / 실제 출력 관찰: `________________` | 미판정(`null`) | 미입력 | 미입력 | 미정 |
|
| `P1-CRISIS-004` | 수련생 본인의 현재적 위기 신호 | `null` | 미입력 | 미입력 | 미입력 | 미입력 |
|
||||||
| `P1-CRISIS-005` | 사례 초점: 즉각적인 안전 확보가 필요한 고위험 신호 / 실제 출력 관찰: `________________` | 미판정(`null`) | 미입력 | 미입력 | 미정 |
|
| `P1-CRISIS-005` | 즉각적인 안전 확보가 필요한 고위험 신호 | `null` | 미입력 | 미입력 | 미입력 | 미입력 |
|
||||||
| `P1-CRISIS-006` | 사례 초점: 자해 경험의 비판단적 심리사회적 확인 / 실제 출력 관찰: `________________` | 미판정(`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 | 학대·방임·착취·가정 내 위험 또는 안전하지 않은 보호자가 의심될 때 해당 보호자에게 단순 인계하지 않고 별도 보호 절차로 전환하는 기준이 충분한가? | `________________` |
|
| safeguarding | 학대·방임·착취·가정 내 위험 또는 안전하지 않은 보호자가 의심될 때 해당 보호자에게 단순 인계하지 않고 별도 보호 절차로 전환하는 기준이 충분한가? | `________________` |
|
||||||
|
|
||||||
|
답변은 위 순서대로 `youth_review_responses.guardian_notification`·`means_restriction`·`risk_disposition`·`follow_up_48_72h`·`documentation`·`safeguarding`에 기록한다. 여섯 키는 모두 필수이며, 미검토 상태에서는 정확히 `null`, 완료 상태에서는 모두 비어 있지 않은 외부 검토 답변이어야 한다.
|
||||||
|
|
||||||
## 4. 공식 근거와 확인 기준
|
## 4. 공식 근거와 확인 기준
|
||||||
|
|
||||||
아래 근거는 2026-08-28에 공식 페이지의 현행 상태를 다시 확인했다. 적용 범위와 링크는 `data/clinical/crisis-protocol-validation.json`의 `sources`와 동일하게 유지한다.
|
아래 근거는 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.reviewer` | `________________` |
|
||||||
| `approval.organization` | `________________` |
|
| `approval.organization` | `________________` |
|
||||||
|
| `approval.reviewer_role_or_qualification` | `________________` |
|
||||||
| `approval.reviewed_at` | `________________` (ISO 8601 실제 검토일) |
|
| `approval.reviewed_at` | `________________` (ISO 8601 실제 검토일) |
|
||||||
| `approval.decision` | `________________` (`approved`/`conditional`/`rejected`) |
|
| `approval.decision` | `________________` (`approved`/`conditional`/`rejected`) |
|
||||||
| `approval.notes` | `________________` |
|
| `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" }
|
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`이면 수정·재검토 기록을 유지하고 `완료`로 바꾸지 않는다.
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ STATUS_DECISIONS = {
|
||||||
APPROVAL_FIELDS = {
|
APPROVAL_FIELDS = {
|
||||||
"reviewer",
|
"reviewer",
|
||||||
"organization",
|
"organization",
|
||||||
|
"reviewer_role_or_qualification",
|
||||||
"reviewed_at",
|
"reviewed_at",
|
||||||
"decision",
|
"decision",
|
||||||
"notes",
|
"notes",
|
||||||
|
|
@ -82,6 +83,7 @@ CASE_SET_FIELDS = {
|
||||||
"content_safety",
|
"content_safety",
|
||||||
"official_source_scope",
|
"official_source_scope",
|
||||||
"case_decision_contract",
|
"case_decision_contract",
|
||||||
|
"youth_review_responses",
|
||||||
"cases",
|
"cases",
|
||||||
}
|
}
|
||||||
CASE_FIELDS = {
|
CASE_FIELDS = {
|
||||||
|
|
@ -93,12 +95,20 @@ CASE_FIELDS = {
|
||||||
}
|
}
|
||||||
SCENARIO_FIELDS = {"speaker_context", "signal", "method_or_means_detail_present"}
|
SCENARIO_FIELDS = {"speaker_context", "signal", "method_or_means_detail_present"}
|
||||||
INVARIANT_FIELDS = {"invariant_id", "requirement"}
|
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 = {
|
REQUIRED_COMPLETED_APPROVAL_FIELDS = {
|
||||||
"reviewer",
|
"reviewer",
|
||||||
"organization",
|
"organization",
|
||||||
|
"reviewer_role_or_qualification",
|
||||||
"reviewed_at",
|
"reviewed_at",
|
||||||
"decision",
|
"decision",
|
||||||
|
"notes",
|
||||||
"evidence_ref",
|
"evidence_ref",
|
||||||
"evidence_sha256",
|
"evidence_sha256",
|
||||||
"reviewed_protocol_version",
|
"reviewed_protocol_version",
|
||||||
|
|
@ -106,6 +116,30 @@ REQUIRED_COMPLETED_APPROVAL_FIELDS = {
|
||||||
}
|
}
|
||||||
ALLOWED_CASE_DECISIONS = {"pass", "conditional", "fail"}
|
ALLOWED_CASE_DECISIONS = {"pass", "conditional", "fail"}
|
||||||
EXPECTED_CASE_IDS = {f"P1-CRISIS-{index:03d}" for index in range(1, 7)}
|
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 = {
|
REQUIRED_SOURCE_IDS = {
|
||||||
"samhsa_safe_t",
|
"samhsa_safe_t",
|
||||||
"nimh_youth_outpatient_bssa",
|
"nimh_youth_outpatient_bssa",
|
||||||
|
|
@ -262,10 +296,10 @@ def _validate_case_set_base(
|
||||||
missing = sorted(CASE_SET_FIELDS - set(case_set))
|
missing = sorted(CASE_SET_FIELDS - set(case_set))
|
||||||
extra = sorted(set(case_set) - CASE_SET_FIELDS)
|
extra = sorted(set(case_set) - CASE_SET_FIELDS)
|
||||||
errors.append(f"case_set: 필드 불일치 missing={missing}, extra={extra}")
|
errors.append(f"case_set: 필드 불일치 missing={missing}, extra={extra}")
|
||||||
if manifest.get("schema_version") != "vignette.clinical_crisis_review.v1":
|
if manifest.get("schema_version") != "vignette.clinical_crisis_review.v2":
|
||||||
errors.append("manifest.schema_version: vignette.clinical_crisis_review.v1이어야 한다")
|
errors.append("manifest.schema_version: vignette.clinical_crisis_review.v2여야 한다")
|
||||||
if case_set.get("schema_version") != "vignette.p1_crisis_review_cases.v1":
|
if case_set.get("schema_version") != "vignette.p1_crisis_review_cases.v2":
|
||||||
errors.append("case_set.schema_version: vignette.p1_crisis_review_cases.v1이어야 한다")
|
errors.append("case_set.schema_version: vignette.p1_crisis_review_cases.v2여야 한다")
|
||||||
if manifest.get("technical_status") != "verified":
|
if manifest.get("technical_status") != "verified":
|
||||||
errors.append("manifest.technical_status: verified여야 한다")
|
errors.append("manifest.technical_status: verified여야 한다")
|
||||||
if case_set.get("priority") != "P1":
|
if case_set.get("priority") != "P1":
|
||||||
|
|
@ -312,6 +346,17 @@ def _validate_case_set_base(
|
||||||
if contract.get("pending_value", object()) is not None:
|
if contract.get("pending_value", object()) is not None:
|
||||||
errors.append("case_set.case_decision_contract.pending_value: null이어야 한다")
|
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")
|
cases = case_set.get("cases")
|
||||||
if not isinstance(cases, list) or not cases:
|
if not isinstance(cases, list) or not cases:
|
||||||
errors.append("case_set.cases: 하나 이상의 합성 사례가 필요하다")
|
errors.append("case_set.cases: 하나 이상의 합성 사례가 필요하다")
|
||||||
|
|
@ -366,6 +411,7 @@ def _validate_case_set_base(
|
||||||
def _validate_pending(
|
def _validate_pending(
|
||||||
approval: dict[str, Any],
|
approval: dict[str, Any],
|
||||||
cases: list[Any],
|
cases: list[Any],
|
||||||
|
youth_review_responses: Any,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
for field in sorted(APPROVAL_FIELDS):
|
for field in sorted(APPROVAL_FIELDS):
|
||||||
|
|
@ -380,11 +426,20 @@ def _validate_pending(
|
||||||
if not isinstance(assessment, dict):
|
if not isinstance(assessment, dict):
|
||||||
errors.append(f"pending_external_review: {label}.reviewer_assessment가 필요하다")
|
errors.append(f"pending_external_review: {label}.reviewer_assessment가 필요하다")
|
||||||
continue
|
continue
|
||||||
for field in ("decision", "rationale", "reviewed_at"):
|
for field in sorted(ASSESSMENT_FIELDS):
|
||||||
if assessment.get(field, object()) is not None:
|
if assessment.get(field, object()) is not None:
|
||||||
errors.append(
|
errors.append(
|
||||||
f"pending_external_review: {label}.reviewer_assessment.{field}는 null이어야 한다"
|
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
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -435,6 +490,7 @@ def _validate_completed(
|
||||||
approval: dict[str, Any],
|
approval: dict[str, Any],
|
||||||
case_set_path: Path,
|
case_set_path: Path,
|
||||||
cases: list[Any],
|
cases: list[Any],
|
||||||
|
youth_review_responses: Any,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
for field in sorted(REQUIRED_COMPLETED_APPROVAL_FIELDS):
|
for field in sorted(REQUIRED_COMPLETED_APPROVAL_FIELDS):
|
||||||
|
|
@ -444,11 +500,6 @@ def _validate_completed(
|
||||||
reviewed_at = approval.get("reviewed_at")
|
reviewed_at = approval.get("reviewed_at")
|
||||||
if not _is_iso_date(reviewed_at):
|
if not _is_iso_date(reviewed_at):
|
||||||
errors.append(f"{status}: approval.reviewed_at은 YYYY-MM-DD 실제 날짜여야 한다")
|
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]:
|
if approval.get("decision") != STATUS_DECISIONS[status]:
|
||||||
errors.append(f"{status}: approval.decision은 {STATUS_DECISIONS[status]}이어야 한다")
|
errors.append(f"{status}: approval.decision은 {STATUS_DECISIONS[status]}이어야 한다")
|
||||||
if approval.get("reviewed_protocol_version") != manifest.get("version"):
|
if approval.get("reviewed_protocol_version") != manifest.get("version"):
|
||||||
|
|
@ -462,6 +513,15 @@ def _validate_completed(
|
||||||
|
|
||||||
errors.extend(_validate_evidence(root, approval))
|
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] = []
|
decisions: list[str] = []
|
||||||
for index, case in enumerate(cases):
|
for index, case in enumerate(cases):
|
||||||
if not isinstance(case, dict):
|
if not isinstance(case, dict):
|
||||||
|
|
@ -478,6 +538,25 @@ def _validate_completed(
|
||||||
decisions.append(decision)
|
decisions.append(decision)
|
||||||
if not _is_nonempty_string(assessment.get("rationale")):
|
if not _is_nonempty_string(assessment.get("rationale")):
|
||||||
errors.append(f"{status}: {label}.reviewer_assessment.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:
|
if assessment.get("reviewed_at") != reviewed_at:
|
||||||
errors.append(f"{status}: {label}.reviewer_assessment.reviewed_at이 전체 검토일과 달라서는 안 된다")
|
errors.append(f"{status}: {label}.reviewer_assessment.reviewed_at이 전체 검토일과 달라서는 안 된다")
|
||||||
|
|
||||||
|
|
@ -551,8 +630,9 @@ def validate_review_contract(
|
||||||
cases = case_set.get("cases")
|
cases = case_set.get("cases")
|
||||||
if not isinstance(cases, list):
|
if not isinstance(cases, list):
|
||||||
return errors
|
return errors
|
||||||
|
youth_review_responses = case_set.get("youth_review_responses")
|
||||||
if status == "pending_external_review":
|
if status == "pending_external_review":
|
||||||
errors.extend(_validate_pending(approval, cases))
|
errors.extend(_validate_pending(approval, cases, youth_review_responses))
|
||||||
else:
|
else:
|
||||||
errors.extend(
|
errors.extend(
|
||||||
_validate_completed(
|
_validate_completed(
|
||||||
|
|
@ -562,6 +642,7 @@ def validate_review_contract(
|
||||||
approval,
|
approval,
|
||||||
case_set_file,
|
case_set_file,
|
||||||
cases,
|
cases,
|
||||||
|
youth_review_responses,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return errors
|
return errors
|
||||||
|
|
@ -604,6 +685,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
"protocol": f"{manifest['protocol_id']}@{manifest['version']}",
|
"protocol": f"{manifest['protocol_id']}@{manifest['version']}",
|
||||||
"case_set": f"{case_set['case_set_id']}@{case_set['version']}",
|
"case_set": f"{case_set['case_set_id']}@{case_set['version']}",
|
||||||
"case_count": len(case_set["cases"]),
|
"case_count": len(case_set["cases"]),
|
||||||
|
"review_complete": manifest["clinical_status"] != "pending_external_review",
|
||||||
"external_review_replaced": False,
|
"external_review_replaced": False,
|
||||||
},
|
},
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
|
|
|
||||||
|
|
@ -66,11 +66,24 @@ class ClinicalCrisisReviewCheckerTest(unittest.TestCase):
|
||||||
|
|
||||||
reviewed_at = "2026-08-28"
|
reviewed_at = "2026-08-28"
|
||||||
for case, decision in zip(case_set["cases"], decisions, strict=True):
|
for case, decision in zip(case_set["cases"], decisions, strict=True):
|
||||||
|
requires_change = decision in {"conditional", "fail"}
|
||||||
case["reviewer_assessment"] = {
|
case["reviewer_assessment"] = {
|
||||||
"decision": decision,
|
"decision": decision,
|
||||||
"rationale": f"{case['case_id']} 외부 검토 판정",
|
"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,
|
"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)
|
_write_json(case_set_path, case_set)
|
||||||
|
|
||||||
evidence_path = root / "data/clinical/evidence/c-001-review.txt"
|
evidence_path = root / "data/clinical/evidence/c-001-review.txt"
|
||||||
|
|
@ -81,9 +94,10 @@ class ClinicalCrisisReviewCheckerTest(unittest.TestCase):
|
||||||
manifest["approval"] = {
|
manifest["approval"] = {
|
||||||
"reviewer": "검토자",
|
"reviewer": "검토자",
|
||||||
"organization": "외부 임상기관",
|
"organization": "외부 임상기관",
|
||||||
|
"reviewer_role_or_qualification": "임상 검토 자격 fixture",
|
||||||
"reviewed_at": reviewed_at,
|
"reviewed_at": reviewed_at,
|
||||||
"decision": status,
|
"decision": status,
|
||||||
"notes": None if status == "approved" else f"{status} 판정 사유",
|
"notes": f"{status} 판정 사유",
|
||||||
"evidence_ref": "data/clinical/evidence/c-001-review.txt",
|
"evidence_ref": "data/clinical/evidence/c-001-review.txt",
|
||||||
"evidence_sha256": _sha256(evidence_path),
|
"evidence_sha256": _sha256(evidence_path),
|
||||||
"reviewed_protocol_version": manifest["version"],
|
"reviewed_protocol_version": manifest["version"],
|
||||||
|
|
@ -105,6 +119,8 @@ class ClinicalCrisisReviewCheckerTest(unittest.TestCase):
|
||||||
case_set_path = root / checker.CASE_SET_REL
|
case_set_path = root / checker.CASE_SET_REL
|
||||||
case_set = _read_json(case_set_path)
|
case_set = _read_json(case_set_path)
|
||||||
case_set["cases"][0]["reviewer_assessment"]["decision"] = "pass"
|
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)
|
_write_json(case_set_path, case_set)
|
||||||
|
|
||||||
errors = checker.validate_review_contract(repo_root=root)
|
errors = checker.validate_review_contract(repo_root=root)
|
||||||
|
|
@ -112,6 +128,17 @@ class ClinicalCrisisReviewCheckerTest(unittest.TestCase):
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
any("P1-CRISIS-001" in error and ".decision" in error and "null" in error for error in errors),
|
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:
|
def test_each_completed_status_passes_its_case_decision_contract(self) -> None:
|
||||||
fixtures = (
|
fixtures = (
|
||||||
|
|
@ -167,6 +194,9 @@ class ClinicalCrisisReviewCheckerTest(unittest.TestCase):
|
||||||
case_set = _read_json(case_set_path)
|
case_set = _read_json(case_set_path)
|
||||||
case_set["cases"][0]["reviewer_assessment"]["rationale"] = " "
|
case_set["cases"][0]["reviewer_assessment"]["rationale"] = " "
|
||||||
case_set["cases"][1]["reviewer_assessment"]["reviewed_at"] = "2026-08-27"
|
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)
|
_write_json(case_set_path, case_set)
|
||||||
manifest_path = root / checker.MANIFEST_REL
|
manifest_path = root / checker.MANIFEST_REL
|
||||||
manifest = _read_json(manifest_path)
|
manifest = _read_json(manifest_path)
|
||||||
|
|
@ -176,6 +206,68 @@ class ClinicalCrisisReviewCheckerTest(unittest.TestCase):
|
||||||
errors = checker.validate_review_contract(repo_root=root)
|
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-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-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:
|
def test_evidence_must_exist_under_clinical_evidence_and_match_hash(self) -> None:
|
||||||
root = self._fixture()
|
root = self._fixture()
|
||||||
|
|
@ -251,6 +343,7 @@ class ClinicalCrisisReviewCheckerTest(unittest.TestCase):
|
||||||
summary = json.loads(stdout.getvalue())
|
summary = json.loads(stdout.getvalue())
|
||||||
self.assertTrue(summary["ok"])
|
self.assertTrue(summary["ok"])
|
||||||
self.assertEqual(summary["case_count"], 6)
|
self.assertEqual(summary["case_count"], 6)
|
||||||
|
self.assertFalse(summary["review_complete"])
|
||||||
self.assertFalse(summary["external_review_replaced"])
|
self.assertFalse(summary["external_review_replaced"])
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue