Badal023's picture
Upload 74 files
4918630 verified
Raw History Blame Contribute Delete
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",
]