외부 임상 검토 원장을 보강
This commit is contained in:
parent
dd71cb9710
commit
3b540a881f
8 changed files with 310 additions and 50 deletions
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue