Skill Tracer
Skills › Scientific Agent Skills › clinical-reports › validate_case_report.py

validate_case_report.py

278 lines · skills/clinical-reports/scripts/validate_case_report.py at 5f2bc03

1#!/usr/bin/env python3
2"""Validate a structured CARE coverage manifest without reading patient narrative."""
3
4from __future__ import annotations
5
6import argparse
7import sys
8from typing import Any
9
10sys.dont_write_bytecode = True
12from _common import ( # noqa: E402
13 ValidationError,
14 error_report,
15 load_json_object,
16 require_bool,
17 require_data_class,
18 require_exact_keys,
19 require_identifier,
20 require_string,
21 write_json_report,
24TOOL = "validate_case_report"
25CARE_ITEMS = (
26 "title",
27 "key_words",
28 "abstract",
29 "introduction",
30 "patient_information",
31 "clinical_findings",
32 "timeline",
33 "diagnostic_assessment",
34 "therapeutic_intervention",
35 "follow_up_and_outcomes",
36 "discussion",
37 "patient_perspective",
38 "informed_consent",
40ALLOWED_ITEM_STATUSES = {
41 "verified_present",
42 "not_applicable_with_rationale",
43 "missing",
44 "conflict",
46NA_ALLOWED = {"patient_perspective"}
47TOP_LEVEL_FIELDS = {
48 "schema_version",
49 "artifact_kind",
50 "draft_status",
51 "safety_notice",
52 "data_classification",
53 "authorized_purpose",
54 "authorization_verified",
55 "provenance_manifest",
56 "guidance",
57 "care_items",
58 "privacy",
59 "review",
61EXPECTED_DRAFT_STATUS = (
62 "BLOCKED_INCOMPLETE_DRAFT_NOT_FOR_CLINICAL_USE_OR_SUBMISSION"
66def _fact_ids(item: dict[str, Any], field: str) -> list[str]:
67 value = item.get("source_fact_ids")
68 if not isinstance(value, list):
69 raise ValidationError(f"{field}.source_fact_ids must be an array")
70 return [require_identifier(fact_id, f"{field}.source_fact_ids") for fact_id in value]
73def validate_case_manifest(data: dict[str, Any]) -> dict[str, Any]:
74 """Return fail-closed structural findings for a CARE manifest."""
75 errors: list[str] = []
76 warnings: list[str] = []
78 try:
79 require_exact_keys(data, TOP_LEVEL_FIELDS, "manifest")
80 if data.get("schema_version") != "2.0":
81 raise ValidationError("schema_version must be 2.0")
82 if data.get("artifact_kind") != "case_report_draft":
83 raise ValidationError("artifact_kind must be case_report_draft")
84 if data.get("draft_status") != EXPECTED_DRAFT_STATUS:
85 raise ValidationError(
86 "draft_status must preserve the blocked non-clinical-use warning"
87 )
88 require_string(data.get("safety_notice"), "safety_notice", max_length=1000)
89 require_data_class(data.get("data_classification"))
90 if not require_bool(data.get("authorization_verified"), "authorization_verified"):
91 errors.append("authorization_verified must be true")
92 require_string(data.get("authorized_purpose"), "authorized_purpose")
93 require_string(data.get("provenance_manifest"), "provenance_manifest")
94 except ValidationError as exc:
95 errors.append(str(exc))
97 guidance = data.get("guidance")
98 try:
99 guidance = require_exact_keys(
100 guidance,
101 {
102 "name",
103 "checklist_version",
104 "explanation_version",
105 "target_journal_instructions_checked",
106 },
107 "guidance",
108 )
109 if guidance.get("name") != "CARE":
110 errors.append("guidance.name must be CARE")
111 if guidance.get("checklist_version") != "2013":
112 errors.append("guidance.checklist_version must be 2013")
113 if guidance.get("explanation_version") != "2017":
114 errors.append("guidance.explanation_version must be 2017")
115 if guidance.get("target_journal_instructions_checked") is not True:
116 warnings.append("target journal instructions remain to be checked")
117 except ValidationError as exc:
118 errors.append(str(exc))
120 items = data.get("care_items")
121 if not isinstance(items, dict):
122 errors.append("care_items must be an object")
123 items = {}
124 missing_keys = sorted(set(CARE_ITEMS) - set(items))
125 extra_keys = sorted(set(items) - set(CARE_ITEMS))
126 if missing_keys:
127 errors.append(f"missing CARE item keys: {missing_keys}")
128 if extra_keys:
129 errors.append(f"unexpected CARE item keys: {extra_keys}")
131 coverage: dict[str, str] = {}
132 for key in CARE_ITEMS:
133 item = items.get(key)
134 if not isinstance(item, dict):
135 errors.append(f"care_items.{key} must be an object")
136 continue
137 try:
138 item = require_exact_keys(
139 item,
140 {"status", "source_fact_ids", "rationale"},
141 f"care_items.{key}",
142 )
143 except ValidationError as exc:
144 errors.append(str(exc))
145 continue
146 status = item.get("status")
147 coverage[key] = str(status)
148 if not isinstance(status, str) or status not in ALLOWED_ITEM_STATUSES:
149 errors.append(f"care_items.{key}.status is invalid")
150 continue
151 try:
152 facts = _fact_ids(item, f"care_items.{key}")
153 except ValidationError as exc:
154 errors.append(str(exc))
155 facts = []
156 if status == "verified_present" and not facts:
157 errors.append(f"care_items.{key} requires at least one verified source fact")
158 elif status == "not_applicable_with_rationale":
159 if key not in NA_ALLOWED:
160 errors.append(f"care_items.{key} cannot be marked not applicable")
161 try:
162 require_string(
163 item.get("rationale"),
164 f"care_items.{key}.rationale",
165 max_length=500,
166 )
167 except ValidationError as exc:
168 errors.append(str(exc))
169 elif status in {"missing", "conflict"}:
170 errors.append(f"care_items.{key} is {status}")
172 privacy = data.get("privacy")
173 try:
174 privacy = require_exact_keys(
175 privacy,
176 {
177 "deidentification_process_record",
178 "publication_consent_record",
179 "image_or_media_included",
180 "reidentification_risk_reviewed",
181 },
182 "privacy",
183 )
184 for field in ("deidentification_process_record", "publication_consent_record"):
185 try:
186 require_string(privacy.get(field), f"privacy.{field}")
187 except ValidationError as exc:
188 errors.append(str(exc))
189 try:
190 if not require_bool(
191 privacy.get("reidentification_risk_reviewed"),
192 "privacy.reidentification_risk_reviewed",
193 ):
194 errors.append("privacy.reidentification_risk_reviewed must be true")
195 except ValidationError as exc:
196 errors.append(str(exc))
197 try:
198 require_bool(privacy.get("image_or_media_included"), "privacy.image_or_media_included")
199 except ValidationError as exc:
200 errors.append(str(exc))
201 except ValidationError as exc:
202 errors.append(str(exc))
204 review = data.get("review")
205 try:
206 review = require_exact_keys(
207 review,
208 {
209 "qualified_clinical_review",
210 "privacy_legal_review",
211 "accountable_author_review",
212 "submission_authorized",
213 },
214 "review",
215 )
216 for field in (
217 "qualified_clinical_review",
218 "privacy_legal_review",
219 "accountable_author_review",
220 ):
221 if review.get(field) != "completed":
222 warnings.append(f"review.{field} remains required")
223 if review.get("submission_authorized") is not False:
224 errors.append("submission_authorized must remain false in this draft manifest")
225 except ValidationError as exc:
226 errors.append(str(exc))
228 status = "BLOCKED" if errors else "STRUCTURE_COMPLETE_REVIEW_REQUIRED"
229 return {
230 "tool": TOOL,
231 "status": status,
232 "guidance": "CARE 2013 with 2017 explanation",
233 "coverage": coverage,
234 "errors": errors,
235 "warnings": warnings,
236 "limitations": [
237 "Structure and fact-ID shape only; no cross-artifact linkage or clinical-content validation.",
238 "Does not establish consent, de-identification, CARE adherence, or journal readiness.",
239 ],
240 "review_required": True,
241 "authorizes_clinical_use_or_submission": False,
242 }
245def build_parser() -> argparse.ArgumentParser:
246 parser = argparse.ArgumentParser(
247 description=(
248 "Check a bounded JSON CARE coverage manifest. "
249 "Does not read patient narrative or claim compliance."
250 )
251 )
252 parser.add_argument("input_file", help="Local case-report manifest (.json)")
253 parser.add_argument("-o", "--output", help="Optional local JSON report path")
254 parser.add_argument(
255 "--overwrite",
256 action="store_true",
257 help="Allow replacing an existing output file",
258 )
259 return parser
262def main(argv: list[str] | None = None) -> int:
263 args = build_parser().parse_args(argv)
264 try:
265 _, data = load_json_object(args.input_file)
266 report = validate_case_manifest(data)
267 except (OSError, ValidationError) as exc:
268 report = error_report(TOOL, exc)
269 try:
270 write_json_report(report, args.output, overwrite=args.overwrite)
271 except (OSError, ValidationError) as exc:
272 print(str(exc), file=sys.stderr)
273 return 2
274 return 0 if report["status"] == "STRUCTURE_COMPLETE_REVIEW_REQUIRED" else 1
277if __name__ == "__main__":
278 raise SystemExit(main())