Spaces:
Sleeping
Sleeping
Download src/ehr_intel/documents/validators.py from Badal023/EHR-Intelligence: direct link, hf CLI and curl.
- Browser
- Download file 10.4 kB
-
https://huggingface.co/spaces/Badal023/EHR-Intelligence/resolve/main/src/ehr_intel/documents/validators.py
- Command line
-
hf download hf://spaces/Badal023/EHR-Intelligence/src/ehr_intel/documents/validators.py
-
curl -L -o validators.py https://huggingface.co/spaces/Badal023/EHR-Intelligence/resolve/main/src/ehr_intel/documents/validators.py
10.4 kB
| """Document validators β MIME detection and file safety checks. | |
| Validates uploaded documents before any processing: | |
| 1. File size limit | |
| 2. MIME type detection via magic bytes (content-first, not extension-first) | |
| 3. Supported MIME type check | |
| 4. Basic structural sanity | |
| Never calls AI models. | |
| Never crashes on corrupt input β returns structured errors. | |
| """ | |
| from __future__ import annotations | |
| import hashlib | |
| import json | |
| import logging | |
| from ehr_intel.models import DocumentTypeEnum | |
| log = logging.getLogger(__name__) | |
| # --------------------------------------------------------------------------- | |
| # Supported MIME types | |
| # --------------------------------------------------------------------------- | |
| SUPPORTED_MIME_TYPES: frozenset[str] = frozenset({ | |
| "application/pdf", | |
| "image/png", | |
| "image/jpeg", | |
| "image/webp", | |
| "text/plain", | |
| "application/json", | |
| "application/fhir+json", | |
| }) | |
| # Magic byte signatures for content-based MIME detection | |
| _MAGIC_SIGNATURES: list[tuple[bytes, str]] = [ | |
| (b"%PDF", "application/pdf"), | |
| (b"\x89PNG\r\n\x1a\n", "image/png"), | |
| (b"\xff\xd8\xff", "image/jpeg"), | |
| (b"RIFF", "image/webp"), # bytes 8-11 must also be b"WEBP" | |
| (b"\xff\xd8", "image/jpeg"), # looser JPEG prefix fallback | |
| ] | |
| _EXTENSION_MIME_MAP: dict[str, str] = { | |
| ".pdf": "application/pdf", | |
| ".png": "image/png", | |
| ".jpg": "image/jpeg", | |
| ".jpeg": "image/jpeg", | |
| ".webp": "image/webp", | |
| ".txt": "text/plain", | |
| ".json": "application/json", | |
| } | |
| class ValidationError(Exception): | |
| """Raised when a document fails validation.""" | |
| def __init__(self, message: str, code: str = "VALIDATION_ERROR") -> None: | |
| super().__init__(message) | |
| self.code = code | |
| self.message = message | |
| class ValidationResult: | |
| """Result of document validation.""" | |
| def __init__( | |
| self, | |
| *, | |
| valid: bool, | |
| mime_type: str, | |
| document_type: DocumentTypeEnum, | |
| file_size_bytes: int, | |
| sha256: str, | |
| error: str | None = None, | |
| error_code: str | None = None, | |
| ) -> None: | |
| self.valid = valid | |
| self.mime_type = mime_type | |
| self.document_type = document_type | |
| self.file_size_bytes = file_size_bytes | |
| self.sha256 = sha256 | |
| self.error = error | |
| self.error_code = error_code | |
| def detect_mime_type( | |
| raw_bytes: bytes, | |
| filename: str | None = None, | |
| claimed_mime: str | None = None, | |
| ) -> str: | |
| """Detects MIME type using magic bytes first. | |
| Falls back to claimed MIME and then file extension. | |
| Content always wins over extension claim. | |
| Args: | |
| raw_bytes: Raw file bytes. May be empty. | |
| filename: Original filename. Used only as final fallback. | |
| claimed_mime: MIME type claimed by the caller. Not trusted blindly. | |
| Returns: | |
| Detected MIME type string. | |
| """ | |
| if raw_bytes: | |
| # --- Primary: magic byte sniffing --- | |
| for signature, mime in _MAGIC_SIGNATURES: | |
| if raw_bytes.startswith(signature): | |
| if signature == b"RIFF": | |
| # WebP: bytes 8-12 must be b"WEBP" | |
| if len(raw_bytes) >= 12 and raw_bytes[8:12] == b"WEBP": | |
| return "image/webp" | |
| continue | |
| return mime | |
| # --- Check for JSON / FHIR Bundle --- | |
| try: | |
| snippet = raw_bytes[:1024].decode("utf-8", errors="ignore").strip() | |
| if snippet.startswith("{") or snippet.startswith("["): | |
| parsed = json.loads(raw_bytes.decode("utf-8", errors="replace")) | |
| if isinstance(parsed, dict) and parsed.get("resourceType") == "Bundle": | |
| return "application/fhir+json" | |
| return "application/json" | |
| except (json.JSONDecodeError, UnicodeDecodeError): | |
| pass | |
| # --- Check for plain text (UTF-8 decodable, no binary nulls) --- | |
| try: | |
| text = raw_bytes[:512].decode("utf-8") | |
| if "\x00" not in text: | |
| # Looks like plain text | |
| if claimed_mime == "text/plain" or ( | |
| filename and filename.lower().endswith(".txt") | |
| ): | |
| return "text/plain" | |
| except UnicodeDecodeError: | |
| pass | |
| # --- Secondary: claimed MIME --- | |
| if claimed_mime: | |
| normalized = claimed_mime.lower().strip() | |
| # Normalize "image/jpg" β "image/jpeg" | |
| if normalized == "image/jpg": | |
| normalized = "image/jpeg" | |
| if normalized in SUPPORTED_MIME_TYPES or normalized == "text/plain": | |
| return normalized | |
| # --- Tertiary: extension --- | |
| if filename: | |
| fn_lower = filename.lower().strip() | |
| for ext, mime in _EXTENSION_MIME_MAP.items(): | |
| if fn_lower.endswith(ext): | |
| return mime | |
| return "application/octet-stream" | |
| def classify_document_type( | |
| *, | |
| mime_type: str, | |
| filename: str | None = None, | |
| text_content: str | None = None, | |
| ) -> DocumentTypeEnum: | |
| """Classifies clinical document type from available signals. | |
| This is a lightweight heuristic classification. Gemini will refine it later. | |
| Args: | |
| mime_type: Detected MIME type. | |
| filename: Original filename. | |
| text_content: Short text sample, if available (e.g. from PDF extraction). | |
| Returns: | |
| DocumentTypeEnum best guess. | |
| """ | |
| if mime_type == "application/fhir+json": | |
| return DocumentTypeEnum.FHIR_BUNDLE | |
| probe = " ".join(filter(None, [filename or "", text_content or ""])).lower() | |
| # FHIR Bundle text indicator | |
| if "resourcetype" in probe or '"bundle"' in probe: | |
| return DocumentTypeEnum.FHIR_BUNDLE | |
| # Prescription indicators | |
| rx_kws = ["prescription", "rx:", "sig:", "disp:", "dispense", "refill"] | |
| rx_soft = ["tab", "cap", "od", "bd", "tid", "qid", "po", "mg", "tablet", "capsule"] | |
| if any(kw in probe for kw in rx_kws) or sum(1 for kw in rx_soft if kw in probe) >= 3: | |
| if "discharge summary" not in probe: | |
| return DocumentTypeEnum.PRESCRIPTION | |
| # Doctor note indicators | |
| note_kws = ["progress note", "soap note", "subjective:", "objective:", "assessment", "consultation note"] | |
| if any(kw in probe for kw in note_kws): | |
| return DocumentTypeEnum.DOCTOR_NOTE | |
| # Discharge summary indicators | |
| ds_kws = ["discharge summary", "hospital course", "admission date", "discharge date", "discharge diagnosis"] | |
| if any(kw in probe for kw in ds_kws): | |
| return DocumentTypeEnum.DISCHARGE_SUMMARY | |
| # Lab report indicators | |
| lab_kws = ["lab report", "laboratory", "reference range", "specimen", "hemoglobin", "creatinine"] | |
| if any(kw in probe for kw in lab_kws): | |
| return DocumentTypeEnum.LAB_REPORT | |
| # Filename-based hints | |
| if filename: | |
| fn = filename.lower() | |
| if any(kw in fn for kw in ["presc", "rx_", "_rx"]): | |
| return DocumentTypeEnum.PRESCRIPTION | |
| if any(kw in fn for kw in ["discharge", "disch", "summary"]): | |
| return DocumentTypeEnum.DISCHARGE_SUMMARY | |
| if any(kw in fn for kw in ["note", "soap", "doctor"]): | |
| return DocumentTypeEnum.DOCTOR_NOTE | |
| if "lab" in fn or "report" in fn: | |
| return DocumentTypeEnum.LAB_REPORT | |
| if mime_type in ("image/png", "image/jpeg", "image/webp"): | |
| return DocumentTypeEnum.GENERAL_CLINICAL | |
| return DocumentTypeEnum.UNKNOWN | |
| def validate_document( | |
| raw_bytes: bytes, | |
| *, | |
| filename: str | None = None, | |
| claimed_mime: str | None = None, | |
| max_file_mb: int = 20, | |
| ) -> ValidationResult: | |
| """Full document validation pipeline. | |
| Args: | |
| raw_bytes: Document bytes (may be empty β will fail gracefully). | |
| filename: Original filename. | |
| claimed_mime: MIME type claimed by caller. | |
| max_file_mb: Maximum allowed file size in MB. | |
| Returns: | |
| ValidationResult β always returns (never raises), callers check .valid. | |
| """ | |
| # Compute hash immediately (needed for logging even on failure) | |
| sha256 = hashlib.sha256(raw_bytes).hexdigest() if raw_bytes else "empty" | |
| size = len(raw_bytes) | |
| # --- Empty file check --- | |
| if not raw_bytes or size == 0: | |
| return ValidationResult( | |
| valid=False, | |
| mime_type="application/octet-stream", | |
| document_type=DocumentTypeEnum.UNKNOWN, | |
| file_size_bytes=0, | |
| sha256=sha256, | |
| error="Empty file received.", | |
| error_code="EMPTY_FILE", | |
| ) | |
| # --- Size limit check --- | |
| max_bytes = max_file_mb * 1024 * 1024 | |
| if size > max_bytes: | |
| return ValidationResult( | |
| valid=False, | |
| mime_type="application/octet-stream", | |
| document_type=DocumentTypeEnum.UNKNOWN, | |
| file_size_bytes=size, | |
| sha256=sha256, | |
| error=f"File size {size / 1024 / 1024:.1f} MB exceeds limit of {max_file_mb} MB.", | |
| error_code="FILE_TOO_LARGE", | |
| ) | |
| # --- MIME detection --- | |
| mime = detect_mime_type(raw_bytes, filename=filename, claimed_mime=claimed_mime) | |
| # --- Supported MIME check --- | |
| if mime not in SUPPORTED_MIME_TYPES and mime != "application/octet-stream": | |
| # Allow some unlisted mimes through with a warning | |
| pass | |
| if mime == "application/octet-stream": | |
| return ValidationResult( | |
| valid=False, | |
| mime_type=mime, | |
| document_type=DocumentTypeEnum.UNKNOWN, | |
| file_size_bytes=size, | |
| sha256=sha256, | |
| error=( | |
| f"Unsupported or unrecognized file type. " | |
| f"Accepted: {sorted(SUPPORTED_MIME_TYPES)}." | |
| ), | |
| error_code="UNSUPPORTED_MIME", | |
| ) | |
| # --- Document type classification --- | |
| doc_type = classify_document_type(mime_type=mime, filename=filename) | |
| log.info( | |
| "Document validated: sha256=%s... size=%d mime=%s type=%s", | |
| sha256[:8], | |
| size, | |
| mime, | |
| doc_type, | |
| ) | |
| return ValidationResult( | |
| valid=True, | |
| mime_type=mime, | |
| document_type=doc_type, | |
| file_size_bytes=size, | |
| sha256=sha256, | |
| ) | |
| __all__ = [ | |
| "SUPPORTED_MIME_TYPES", | |
| "ValidationError", | |
| "ValidationResult", | |
| "classify_document_type", | |
| "detect_mime_type", | |
| "validate_document", | |
| ] | |