"""Author RAG Chatbot SaaS — Document Ingestion Validator. This module is the single entry point for all pre-ingestion file validation. It wraps the low-level checks in file_utils and raises typed ingestion exceptions. Validation order (MUST run BEFORE any processing starts): 1. File existence check 2. Empty file check 3. Size limit check (UPLOAD_MAX_FILE_SIZE_MB) 4. MIME type check by magic bytes (never trust file extension alone) RULE: Call validate_file() before any parsing, chunking, or embedding. RULE: Raise typed exceptions — never return False or None on failure. """ import os from pathlib import Path import structlog from app.config import get_settings from app.exceptions.ingestion import ( EmptyFileError, FileTooLargeError, ParseError, UnsupportedFormatError, ) from app.utils.file_utils import ( compute_sha256, detect_mime_type, get_file_extension_from_mime, validate_upload, ) logger = structlog.get_logger(__name__) cfg = get_settings() def validate_file(file_path: str) -> str: """Run all pre-ingestion validation checks on an uploaded file. This is the primary validation entry point used by the ingestion pipeline. Delegates to file_utils.validate_upload for the actual checks. Args: file_path: Absolute path to the uploaded/saved file. Returns: Detected extension label ('pdf', 'epub', 'docx', 'txt'). Raises: EmptyFileError: File has no content. FileTooLargeError: File exceeds UPLOAD_MAX_FILE_SIZE_MB. UnsupportedFormatError: File MIME type is not supported. ParseError: File does not exist or cannot be read. """ if not os.path.exists(file_path): raise ParseError(filename=Path(file_path).name, reason="File not found on disk") size_bytes = os.path.getsize(file_path) logger.debug("Validating upload", path=file_path, size_bytes=size_bytes) # Delegates all checks to the centralized file_utils implementation extension = validate_upload(file_path, size_bytes) logger.info("File validation passed", path=file_path, extension=extension) return extension def check_for_corruption(file_path: str, extension: str) -> None: """Attempt a lightweight read of the file to detect obvious corruption. This is a best-effort check — does not guarantee the file is fully valid. Real corruption is caught during the parse stage with a ParseError. Args: file_path: Absolute path to the file. extension: Validated extension label. Raises: ParseError: If the file cannot be opened or is obviously corrupted. """ try: with open(file_path, "rb") as f: header = f.read(512) if len(header) == 0: raise EmptyFileError() except EmptyFileError: raise except Exception as e: raise ParseError(filename=Path(file_path).name, reason=f"File unreadable: {e}") # Re-export core utilities for callers that import directly from this module __all__ = [ "validate_file", "check_for_corruption", "compute_sha256", "detect_mime_type", ]