"""Dossier Reader. Reads every supported file in the user-supplied local folder and returns a list of extracted chunks. Original files never leave the machine and raw extracted text is never logged. Supported file types and their handlers: * ``.pdf`` -> ``pdfplumber`` (one chunk per page). * ``.docx`` -> ``python-docx`` (paragraphs + tables, single chunk per file). * ``.txt`` / ``.md`` -> read as utf-8 text. * ``.json`` -> parsed via ``json``. The structured payload is preserved. * ``.csv`` -> read as text plus a small ``json_data`` preview. * ``.png`` / ``.jpg`` / ``.jpeg`` -> metadata only (no OCR yet). Failures in a single file do not stop processing. The bad file is marked ``failed`` and reading continues. """ from __future__ import annotations import csv import hashlib import json from dataclasses import dataclass, field from pathlib import Path from typing import Iterable, Optional from app.models.schemas import ( SOURCE_PRIORITY, ChunkRecord, ExtractionStatus, SourceType, ) from app.services.folder_validator import ( IMAGE_EXTENSIONS, SUPPORTED_EXTENSIONS, TEXT_LIKE_EXTENSIONS, classify_source, ) from app.utils.file_utils import ( MAX_DOSSIER_FILE_BYTES, iter_contained_files, is_within_size_limit, ) _PDF_MAX_PAGES = 100 _WARNING_MAX_CHARS = 200 def _chunk_id(file_path: str, suffix: str) -> str: digest = hashlib.sha1(file_path.encode("utf-8")).hexdigest()[:10] return f"{digest}::{suffix}" def _sanitize_warning(message: Optional[str]) -> Optional[str]: """Collapse a warning to a single safe line. Reader warnings only ever carry exception class names and short operational notes — never raw dossier text — but we still flatten newlines and cap the length so nothing large or multi-line can leak into a log line or the debug panel. """ if not message: return None flat = " ".join(str(message).split()) if len(flat) > _WARNING_MAX_CHARS: flat = flat[: _WARNING_MAX_CHARS - 1].rstrip() + "…" return flat or None def _detect_section(text: str) -> Optional[str]: """Best-effort section name from a Markdown heading, if present.""" for line in text.splitlines(): stripped = line.strip() if stripped.startswith("#"): heading = stripped.lstrip("#").strip() if heading: return heading[:120] if stripped: # Only the first non-blank line can be the document heading. break return None def _load_json(path: Path) -> tuple[Optional[object], Optional[str]]: try: with open(path, "r", encoding="utf-8", errors="ignore") as fh: return json.load(fh), None except (OSError, ValueError) as exc: return None, f"{exc.__class__.__name__}: {exc}" def _read_text(path: Path) -> tuple[str, Optional[str], Optional[str]]: """Return ``(text, fatal_error, decode_warning)``. ``fatal_error`` is set only when the file could not be opened/read at all. ``decode_warning`` is set (but the text is still usable) when some non-UTF-8 bytes had to be dropped — so a mis-encoded file degrades visibly instead of producing quietly-corrupted evidence text. """ try: raw = path.read_bytes() except OSError as exc: return "", f"{exc.__class__.__name__}: {exc}", None text = raw.decode("utf-8", errors="ignore") try: raw.decode("utf-8") warning = None except UnicodeDecodeError: warning = "some non-UTF-8 bytes were dropped while reading" return text, None, warning def _read_csv_preview(path: Path, max_rows: int = 50) -> tuple[str, list[list[str]], Optional[str]]: try: with open(path, "r", encoding="utf-8", errors="ignore", newline="") as fh: reader = csv.reader(fh) rows: list[list[str]] = [] for i, row in enumerate(reader): if i >= max_rows: break rows.append(row) except OSError as exc: return "", [], f"{exc.__class__.__name__}: {exc}" text_lines = ["\t".join(cell for cell in row) for row in rows] return "\n".join(text_lines), rows, None def _pdf_chunks( path: Path, base_meta: dict, ) -> tuple[list[ChunkRecord], Optional[str]]: try: import pdfplumber # type: ignore except Exception as exc: # pragma: no cover - dependency must be present return [], f"pdfplumber unavailable: {exc}" records: list[ChunkRecord] = [] try: with pdfplumber.open(str(path)) as pdf: page_iter = pdf.pages[:_PDF_MAX_PAGES] for index, page in enumerate(page_iter, start=1): try: text = page.extract_text() or "" except Exception as exc: # noqa: BLE001 - per-page tolerance records.append( ChunkRecord( chunk_id=_chunk_id(base_meta["file_path"], f"p{index}"), page_number=index, extracted_text="", extraction_status="failed", extraction_warning=f"page extract failed: {exc.__class__.__name__}", **base_meta, ) ) continue records.append( ChunkRecord( chunk_id=_chunk_id(base_meta["file_path"], f"p{index}"), page_number=index, extracted_text=text.strip(), extraction_status="ok" if text.strip() else "empty", **base_meta, ) ) except Exception as exc: # noqa: BLE001 - one bad PDF must not crash run return records, f"pdfplumber failed: {exc.__class__.__name__}" return records, None def _docx_chunk( path: Path, base_meta: dict, ) -> tuple[list[ChunkRecord], Optional[str]]: try: from docx import Document # type: ignore except Exception as exc: # pragma: no cover return [], f"python-docx unavailable: {exc}" try: document = Document(str(path)) except Exception as exc: # noqa: BLE001 return [], f"docx open failed: {exc.__class__.__name__}" paragraphs = [p.text for p in document.paragraphs if p.text.strip()] table_texts: list[str] = [] for table in document.tables: for row in table.rows: cells = [cell.text.strip() for cell in row.cells if cell.text.strip()] if cells: table_texts.append(" | ".join(cells)) full_text = "\n".join(paragraphs + table_texts).strip() status: ExtractionStatus = "ok" if full_text else "empty" return ( [ ChunkRecord( chunk_id=_chunk_id(base_meta["file_path"], "docx"), extracted_text=full_text, extraction_status=status, **base_meta, ) ], None, ) def _failed_chunk(path: Path, base_meta: dict, message: str) -> ChunkRecord: return ChunkRecord( chunk_id=_chunk_id(base_meta["file_path"], "failed"), extracted_text="", extraction_status="failed", extraction_warning=_sanitize_warning(message) or "extraction failed", **base_meta, ) def _build_base_meta( path: Path, rel_path: Path, source_type: SourceType, ) -> dict: return { "file_name": path.name, "file_path": str(rel_path), "file_type": path.suffix.lower(), "source_type": source_type, "source_priority": SOURCE_PRIORITY[source_type], } def _fallback_base_meta(path: Path, rel_path: Path) -> dict: """Base metadata for a file whose classification itself failed.""" return _build_base_meta(path, rel_path, "unknown_supported_file") def read_dossier(folder_path: str | Path) -> list[ChunkRecord]: """Walk the dossier folder and return extracted chunks. One file may produce multiple chunks (e.g., one per PDF page). Files that fail to open produce a single ``failed`` chunk with the warning captured in ``extraction_warning``. Unsupported files are skipped. """ folder = Path(folder_path).expanduser() if not folder.exists() or not folder.is_dir(): return [] chunks: list[ChunkRecord] = [] try: # Symlink-safe, containment-checked, count-bounded walk. A symlink # whose target lives outside the folder is never read, so files the # user did not put in the dossier can never reach the LLM. candidates = list(iter_contained_files(folder)) except OSError: return [] for path in candidates: ext = path.suffix.lower() if ext not in SUPPORTED_EXTENSIONS: continue rel_path = path.relative_to(folder) # Skip files larger than the per-file cap rather than reading them # fully into memory; record a visible failed chunk so the UI can # show the file was intentionally skipped. if not is_within_size_limit(path): base_meta = _fallback_base_meta(path, rel_path) mb = MAX_DOSSIER_FILE_BYTES // (1024 * 1024) chunks.append( _failed_chunk( path, base_meta, f"file skipped: exceeds {mb}MB size limit", ) ) continue try: chunks.extend(_read_one_file(path, rel_path, ext)) except Exception as exc: # noqa: BLE001 - one bad file must not abort the run base_meta = _fallback_base_meta(path, rel_path) chunks.append( _failed_chunk( path, base_meta, f"unexpected reader error: {exc.__class__.__name__}", ) ) return chunks def _read_one_file(path: Path, rel_path: Path, ext: str) -> list[ChunkRecord]: """Extract one supported file into one or more chunks. Per-file failures are represented as ``failed`` chunks rather than raised exceptions; the caller wraps this in a final safety net so any truly unexpected error still degrades to a single failed chunk. """ # Classification: read a small sample for JSON / text-like files so # the classifier can use content as a signal. text_sample = "" json_payload: object = None if ext in TEXT_LIKE_EXTENSIONS: try: with open(path, "rb") as fh: text_sample = fh.read(8192).decode("utf-8", errors="ignore") except OSError: text_sample = "" if ext == ".json": json_payload, _ = _load_json(path) source_type, _note = classify_source( path, text_sample=text_sample, json_payload=json_payload ) base_meta = _build_base_meta(path, rel_path, source_type) if ext == ".pdf": pdf_chunks, warning = _pdf_chunks(path, base_meta) if pdf_chunks: if warning: pdf_chunks[-1].extraction_warning = _sanitize_warning(warning) return pdf_chunks return [_failed_chunk(path, base_meta, warning or "no pages")] if ext == ".docx": docx_chunks, warning = _docx_chunk(path, base_meta) if docx_chunks: return docx_chunks return [_failed_chunk(path, base_meta, warning or "no content")] if ext in {".txt", ".md"}: text, err, decode_warning = _read_text(path) status: ExtractionStatus = "ok" if text.strip() else "empty" section = _detect_section(text) if ext == ".md" else None return [ ChunkRecord( chunk_id=_chunk_id(base_meta["file_path"], "text"), section_name=section, extracted_text=text.strip(), extraction_status="failed" if err else status, extraction_warning=_sanitize_warning(err or decode_warning), **base_meta, ) ] if ext == ".json": payload, err = _load_json(path) if err: return [_failed_chunk(path, base_meta, err)] try: pretty = json.dumps(payload, indent=2, ensure_ascii=False) except Exception: # noqa: BLE001 pretty = "" return [ ChunkRecord( chunk_id=_chunk_id(base_meta["file_path"], "json"), extracted_text=pretty, json_data=payload, extraction_status="ok" if payload is not None else "empty", **base_meta, ) ] if ext == ".csv": text, rows, err = _read_csv_preview(path) return [ ChunkRecord( chunk_id=_chunk_id(base_meta["file_path"], "csv"), extracted_text=text, json_data={"rows": rows} if rows else None, extraction_status="failed" if err else ("ok" if rows else "empty"), extraction_warning=_sanitize_warning(err), **base_meta, ) ] if ext in IMAGE_EXTENSIONS: return [ ChunkRecord( chunk_id=_chunk_id(base_meta["file_path"], "image"), extracted_text="", extraction_status="metadata_only", extraction_warning="image stored as metadata only; OCR not run", **base_meta, ) ] return [] def chunks_by_file(chunks: Iterable[ChunkRecord]) -> dict[str, list[ChunkRecord]]: by_file: dict[str, list[ChunkRecord]] = {} for chunk in chunks: by_file.setdefault(chunk.file_path, []).append(chunk) return by_file # --------------------------------------------------------------------------- # Read summary (UI- and log-safe view over the extracted chunks) # --------------------------------------------------------------------------- @dataclass class FileReadSummary: """Per-file rollup used by the UI summary and debug panel. Carries metadata and a sanitized warning only — never raw text. """ file_name: str file_path: str file_type: str source_type: str chunk_count: int status: str warning: Optional[str] = None @dataclass class DossierReadSummary: """Aggregate, text-free summary of a dossier read. Safe to log and to render: it counts files and chunks and lists per-file status, but contains none of the extracted dossier text. """ files_processed: int = 0 chunks_extracted: int = 0 failed_files: int = 0 files: list[FileReadSummary] = field(default_factory=list) def _aggregate_status(statuses: list[str]) -> str: """Reduce a file's per-chunk statuses to one headline status.""" if not statuses: return "empty" if all(s == "failed" for s in statuses): return "failed" for preferred in ("ok", "partial", "metadata_only", "empty"): if preferred in statuses: return preferred return statuses[0] def summarize_chunks(chunks: Iterable[ChunkRecord]) -> DossierReadSummary: """Build a text-free summary of an extracted dossier. ``files_processed`` counts the distinct files that produced at least one chunk, ``chunks_extracted`` is the total chunk count, and ``failed_files`` counts files whose every chunk failed to extract. """ by_file = chunks_by_file(chunks) file_summaries: list[FileReadSummary] = [] failed_files = 0 total_chunks = 0 for file_path, file_chunks in by_file.items(): total_chunks += len(file_chunks) statuses = [c.extraction_status for c in file_chunks] status = _aggregate_status(statuses) if status == "failed": failed_files += 1 warning = next( (c.extraction_warning for c in file_chunks if c.extraction_warning), None, ) first = file_chunks[0] file_summaries.append( FileReadSummary( file_name=first.file_name, file_path=file_path, file_type=first.file_type, source_type=first.source_type, chunk_count=len(file_chunks), status=status, warning=_sanitize_warning(warning), ) ) return DossierReadSummary( files_processed=len(by_file), chunks_extracted=total_chunks, failed_files=failed_files, files=file_summaries, )