"""File conversion utilities. Converts document files (PDF, PPT, Excel, Word) to Markdown. PDF conversion strategy (auto mode): 1. Try pymupdf4llm if installed — better heading detection, faster on most files. 2. If output is suspiciously short (< _MIN_CHARS_PER_PAGE chars/page, or < 200 chars total when page count is unavailable), treat as image-based and fall back to MarkItDown. 3. If pymupdf4llm is not installed, use MarkItDown directly (existing behaviour). Parsing runs in a bounded pool of spawned processes so CPU-heavy document readers cannot hold the gateway's GIL. Async callers wait and write sidecars in a dedicated thread pool, leaving the event loop and default executor available. No FastAPI or HTTP dependencies — pure utility functions. """ import atexit import logging import multiprocessing import os import re import shutil import subprocess import tempfile import threading from concurrent.futures import ProcessPoolExecutor from concurrent.futures.process import BrokenProcessPool from pathlib import Path from deerflow.config.app_config import get_app_config from deerflow.utils.file_io import FileIOPool logger = logging.getLogger(__name__) # File extensions that should be converted to markdown CONVERTIBLE_EXTENSIONS = { ".pdf", ".ppt", ".pptx", ".xls", ".xlsx", ".doc", ".docx", ".odt", ".ods", ".odp", } # MarkItDown reads modern OOXML files directly. LibreOffice is used as a # compatibility bridge for legacy binary Office files and OpenDocument files, # which are common in offline/intranet deployments but are not handled # consistently by the pure-Python readers. _LIBREOFFICE_TARGETS = { ".doc": ".docx", ".ppt": ".pptx", ".xls": ".xlsx", ".odt": ".docx", ".odp": ".pptx", ".ods": ".xlsx", } _LIBREOFFICE_TIMEOUT_SECONDS = 120 MAX_CONVERTED_MARKDOWN_CHARS = 2_000_000 _CONVERSION_CONCURRENCY = 2 _CONVERSION_SEMAPHORE = threading.BoundedSemaphore(_CONVERSION_CONCURRENCY) _CONVERSION_IO = FileIOPool(_CONVERSION_CONCURRENCY, _CONVERSION_CONCURRENCY, "document-conversion") _PROCESS_POOL_LOCK = threading.Lock() _PROCESS_POOL: ProcessPoolExecutor | None = None def _get_conversion_process_pool() -> ProcessPoolExecutor: """Lazily start isolated readers without forking a multithreaded gateway.""" global _PROCESS_POOL with _PROCESS_POOL_LOCK: if _PROCESS_POOL is None: _PROCESS_POOL = ProcessPoolExecutor( max_workers=_CONVERSION_CONCURRENCY, mp_context=multiprocessing.get_context("spawn"), ) return _PROCESS_POOL def shutdown_conversion_workers(*, wait: bool = True) -> None: """Release document reader processes; a later request can start a new pool.""" global _PROCESS_POOL with _PROCESS_POOL_LOCK: pool, _PROCESS_POOL = _PROCESS_POOL, None if pool is not None: pool.shutdown(wait=wait, cancel_futures=True) def _discard_broken_process_pool(pool: ProcessPoolExecutor) -> None: global _PROCESS_POOL with _PROCESS_POOL_LOCK: if _PROCESS_POOL is not pool: return _PROCESS_POOL = None pool.shutdown(wait=False, cancel_futures=True) def _convert_document_worker(file_path: str, pdf_converter: str) -> str: """Child entry point: only the path and converter cross the process boundary.""" return _bound_converted_markdown(_do_convert_unbounded(Path(file_path), pdf_converter)) def _run_conversion_in_process(file_path: Path, pdf_converter: str) -> str: pool = _get_conversion_process_pool() try: return pool.submit(_convert_document_worker, str(file_path), pdf_converter).result() except BrokenProcessPool: # Do not retry a document that may have crashed a native parser. The # existing public API reports conversion failure; the next request gets # fresh workers instead of inheriting a permanently unusable executor. _discard_broken_process_pool(pool) raise atexit.register(shutdown_conversion_workers) # If pymupdf4llm produces fewer characters *per page* than this threshold, # the PDF is likely image-based or encrypted — fall back to MarkItDown. # Rationale: normal text PDFs yield 200-2000 chars/page; image-based PDFs # yield close to 0. 50 chars/page gives a wide safety margin. # Falls back to absolute 200-char check when page count is unavailable. _MIN_CHARS_PER_PAGE = 50 def markdown_sidecar_path(file_path: Path) -> Path: """Return the collision-free Markdown companion path for a document. Keeping the original extension (``report.docx.md``) avoids the historical collision where ``report.docx`` and ``report.pdf`` both overwrote ``report.md``. """ return file_path.with_name(f"{file_path.name}.md") def find_markdown_sidecar(file_path: Path) -> Path | None: """Find a new-style sidecar, falling back to the legacy ``.md``.""" # Only formats handled by the conversion pipeline can own a generated # Markdown companion. Without this guard, unrelated uploads such as # ``report.txt`` or ``report.jpg`` incorrectly claimed a real user upload # named ``report.md`` as their legacy sidecar. if file_path.suffix.lower() not in CONVERTIBLE_EXTENSIONS: return None current = markdown_sidecar_path(file_path) if current.is_file(): return current legacy = file_path.with_suffix(".md") if legacy != file_path and legacy.is_file(): return legacy return None def _pymupdf_output_too_sparse(text: str, file_path: Path) -> bool: """Return True if pymupdf4llm output is suspiciously short (image-based PDF). Uses chars-per-page rather than an absolute threshold so that both short documents (few pages, few chars) and long documents (many pages, many chars) are handled correctly. """ chars = len(text.strip()) doc = None pages: int | None = None try: import pymupdf doc = pymupdf.open(str(file_path)) pages = len(doc) except Exception: pass finally: if doc is not None: try: doc.close() except Exception: pass if pages is not None and pages > 0: return (chars / pages) < _MIN_CHARS_PER_PAGE # Fallback: absolute threshold when page count is unavailable return chars < 200 def _convert_pdf_with_pymupdf4llm(file_path: Path) -> str | None: """Attempt PDF conversion with pymupdf4llm. Returns the markdown text, or None if pymupdf4llm is not installed or if conversion fails (e.g. encrypted/corrupt PDF). """ try: import pymupdf4llm except ImportError: return None try: return pymupdf4llm.to_markdown(str(file_path)) except Exception: logger.exception("pymupdf4llm failed to convert %s; falling back to MarkItDown", file_path.name) return None def _convert_with_markitdown(file_path: Path) -> str: """Convert any supported file to markdown text using MarkItDown.""" from markitdown import MarkItDown md = MarkItDown() return md.convert(str(file_path)).text_content def _convert_with_libreoffice_then_markitdown(file_path: Path) -> str | None: """Normalize a legacy Office/OpenDocument file, then extract its text. Each conversion gets an isolated LibreOffice user profile. This avoids the profile-lock conflict that otherwise appears when several uploads are converted concurrently in the same sandbox container. """ target_suffix = _LIBREOFFICE_TARGETS.get(file_path.suffix.lower()) if target_suffix is None: return None executable = shutil.which("soffice") or shutil.which("libreoffice") if executable is None: logger.warning( "LibreOffice is unavailable; trying MarkItDown directly for %s", file_path.name, ) return None with tempfile.TemporaryDirectory(prefix="deerflow-office-") as temporary: temp_root = Path(temporary).resolve() output_dir = temp_root / "output" profile_dir = temp_root / "profile" output_dir.mkdir() profile_dir.mkdir() command = [ executable, "--headless", "--nologo", "--nodefault", "--nolockcheck", "--nofirststartwizard", f"-env:UserInstallation={profile_dir.as_uri()}", "--convert-to", target_suffix.lstrip("."), "--outdir", str(output_dir), str(file_path.resolve()), ] try: completed = subprocess.run( command, check=False, capture_output=True, text=True, timeout=_LIBREOFFICE_TIMEOUT_SECONDS, env={**os.environ, "HOME": str(temp_root)}, ) except (OSError, subprocess.TimeoutExpired): logger.exception("LibreOffice failed to normalize %s", file_path.name) return None converted_path = output_dir / f"{file_path.stem}{target_suffix}" if completed.returncode != 0 or not converted_path.is_file(): logger.error( "LibreOffice could not normalize %s (exit=%s, stderr=%s)", file_path.name, completed.returncode, completed.stderr.strip()[-1000:], ) return None return _convert_with_markitdown(converted_path) def _bound_converted_markdown(text: str) -> str: """Apply a non-disableable cap before converted text is persisted or returned.""" if len(text) <= MAX_CONVERTED_MARKDOWN_CHARS: return text marker = ( "\n\n\n" ) return text[: MAX_CONVERTED_MARKDOWN_CHARS - len(marker)] + marker def _do_convert(file_path: Path, pdf_converter: str) -> str: """Synchronous conversion with a shared limit for sync and async callers. Args: file_path: Path to the file. pdf_converter: "auto" | "pymupdf4llm" | "markitdown" """ with _CONVERSION_SEMAPHORE: return _run_conversion_in_process(file_path, pdf_converter) def _do_convert_unbounded(file_path: Path, pdf_converter: str) -> str: """Parser implementation; callers must use :func:`_do_convert`.""" is_pdf = file_path.suffix.lower() == ".pdf" normalized_office_text = _convert_with_libreoffice_then_markitdown(file_path) if normalized_office_text is not None: return normalized_office_text if is_pdf and pdf_converter != "markitdown": # Try pymupdf4llm first (auto or explicit) pymupdf_text = _convert_pdf_with_pymupdf4llm(file_path) if pymupdf_text is not None: # pymupdf4llm is installed if pdf_converter == "pymupdf4llm": # Explicit — use as-is regardless of output length return pymupdf_text # auto mode: fall back if output looks like a failed parse. # Use chars-per-page to distinguish image-based PDFs (near 0) from # legitimately short documents. if not _pymupdf_output_too_sparse(pymupdf_text, file_path): return pymupdf_text logger.warning( "pymupdf4llm produced only %d chars for %s (likely image-based PDF); falling back to MarkItDown", len(pymupdf_text.strip()), file_path.name, ) # pymupdf4llm not installed or fallback triggered → use MarkItDown return _convert_with_markitdown(file_path) def convert_file_to_markdown_sync(file_path: Path) -> Path | None: """Convert and write a sidecar inside an existing file worker. Callers must keep this synchronous parsing and disk work off an event loop. The shared conversion gate also applies to upload workers calling directly. """ try: pdf_converter = _get_pdf_converter() text = _do_convert(file_path, pdf_converter) md_path = markdown_sidecar_path(file_path) md_path.write_text(text, encoding="utf-8") logger.info("Converted %s to markdown: %s (%d chars)", file_path.name, md_path.name, len(text)) return md_path except Exception as e: logger.error("Failed to convert %s to markdown: %s", file_path.name, e) return None async def convert_file_to_markdown(file_path: Path) -> Path | None: """Convert and persist Markdown without blocking other requests. Admission waits on the event loop, never inside the default executor. Cancellation waits for the worker so callers can safely remove its input. """ return await _CONVERSION_IO.run_until_complete(convert_file_to_markdown_sync, file_path) def _extract_file_to_markdown_sync(file_path: Path) -> str: return _do_convert(file_path, _get_pdf_converter()) async def extract_file_to_markdown(file_path: Path) -> str | None: """Extract a supported document to Markdown without creating a sidecar file. Knowledge-ingest scanners use this variant because running a read-only distillation task must never add generated ``.md`` files to a live skill. Conversion always runs off the event loop; Office/PDF parsers can be CPU intensive even for deceptively small inputs. """ if file_path.suffix.lower() not in CONVERTIBLE_EXTENSIONS: return None try: return await _CONVERSION_IO.run_until_complete(_extract_file_to_markdown_sync, file_path) except Exception as exc: logger.error("Failed to extract %s as markdown: %s", file_path.name, exc) return None # Regex for bold-only lines that look like section headings. # Targets SEC filing structural headings that pymupdf4llm renders as **bold** # rather than # Markdown headings (because they use same font size as body text, # distinguished only by bold+caps formatting). # # Pattern requires ALL of: # 1. Entire line is a single **...** block (no surrounding prose) # 2. Starts with a recognised structural keyword: # - ITEM / PART / SECTION (with optional number/letter after) # - SCHEDULE, EXHIBIT, APPENDIX, ANNEX, CHAPTER # All-caps addresses, boilerplate ("CURRENT REPORT", "SIGNATURES", # "WASHINGTON, DC 20549") do NOT start with these keywords and are excluded. # # Chinese headings (第三节...) are already captured as standard # headings # by pymupdf4llm, so they don't need this pattern. _BOLD_HEADING_RE = re.compile(r"^\*\*((ITEM|PART|SECTION|SCHEDULE|EXHIBIT|APPENDIX|ANNEX|CHAPTER)\b[A-Z0-9 .,\-]*)\*\*\s*$") # Regex for split-bold headings produced by pymupdf4llm when a heading spans # multiple text spans in the PDF (e.g. section number and title are separate spans). # Matches lines like: **1** **Introduction** or **3.2** **Multi-Head Attention** # Requirements: # 1. Entire line consists only of **...** blocks separated by whitespace (no prose) # 2. First block is a section number (digits and dots, e.g. "1", "3.2", "A.1") # 3. Second block must not be purely numeric/punctuation — excludes financial table # headers like **2023** **2022** **2021** while allowing non-ASCII titles such as # **1** **概述** or accented words (negative lookahead instead of [A-Za-z]) # 4. At most two additional blocks (four total) with [^*]+ (no * inside) to keep # the regex linear and avoid ReDoS on attacker-controlled content _SPLIT_BOLD_HEADING_RE = re.compile(r"^\*\*[\dA-Z][\d\.]*\*\*\s+\*\*(?!\d[\d\s.,\-–—/:()%]*\*\*)[^*]+\*\*(?:\s+\*\*[^*]+\*\*){0,2}\s*$") # Maximum number of outline entries injected into the agent context. # Keeps prompt size bounded even for very long documents. MAX_OUTLINE_ENTRIES = 50 _ALLOWED_PDF_CONVERTERS = {"auto", "pymupdf4llm", "markitdown"} def _clean_bold_title(raw: str) -> str: """Normalise a title string that may contain pymupdf4llm bold artefacts. pymupdf4llm sometimes emits adjacent bold spans as ``**A** **B**`` instead of a single ``**A B**`` block. This helper merges those fragments and then strips the outermost ``**...**`` wrapper so the caller gets plain text. Examples:: "**Overview**" → "Overview" "**UNITED STATES** **SECURITIES**" → "UNITED STATES SECURITIES" "plain text" → "plain text" (unchanged) """ # Merge adjacent bold spans: "** **" → " " merged = re.sub(r"\*\*\s*\*\*", " ", raw).strip() # Strip outermost **...** if the whole string is wrapped if m := re.fullmatch(r"\*\*(.+?)\*\*", merged, re.DOTALL): return m.group(1).strip() return merged def extract_outline(md_path: Path) -> list[dict]: """Extract document outline (headings) from a Markdown file. Recognises three heading styles produced by pymupdf4llm: 1. Standard Markdown headings: lines starting with one or more '#'. Inline ``**...**`` wrappers and adjacent bold spans (``** **``) are cleaned so the title is plain text. 2. Bold-only structural headings: ``**ITEM 1. BUSINESS**``, ``**PART II**``, etc. SEC filings use bold+caps for section headings with the same font size as body text, so pymupdf4llm cannot promote them to # headings. 3. Split-bold headings: ``**1** **Introduction**``, ``**3.2** **Attention**``. pymupdf4llm emits these when the section number and title text are separate spans in the underlying PDF (common in academic papers). Args: md_path: Path to the .md file. Returns: List of dicts with keys: title (str), line (int, 1-based). When the outline is truncated at MAX_OUTLINE_ENTRIES, a sentinel entry ``{"truncated": True}`` is appended as the last element so callers can render a "showing first N headings" hint without re-scanning the file. Returns an empty list if the file cannot be read or has no headings. """ outline: list[dict] = [] try: with md_path.open(encoding="utf-8") as f: for lineno, line in enumerate(f, 1): stripped = line.strip() if not stripped: continue # Style 1: standard Markdown heading if stripped.startswith("#"): title = _clean_bold_title(stripped.lstrip("#").strip()) if title: outline.append({"title": title, "line": lineno}) # Style 2: single bold block with SEC structural keyword elif m := _BOLD_HEADING_RE.match(stripped): title = m.group(1).strip() if title: outline.append({"title": title, "line": lineno}) # Style 3: split-bold heading — **** **** # Regex already enforces max 4 blocks and non-numeric second block. elif _SPLIT_BOLD_HEADING_RE.match(stripped): title = " ".join(re.findall(r"\*\*([^*]+)\*\*", stripped)) if title: outline.append({"title": title, "line": lineno}) if len(outline) >= MAX_OUTLINE_ENTRIES: outline.append({"truncated": True}) break except Exception: return [] return outline def _get_uploads_config_value(key: str, default: object) -> object: """Read a value from the uploads config, supporting dict and attribute access.""" cfg = get_app_config() uploads_cfg = getattr(cfg, "uploads", None) if isinstance(uploads_cfg, dict): return uploads_cfg.get(key, default) return getattr(uploads_cfg, key, default) def _get_pdf_converter() -> str: """Read pdf_converter setting from app config, defaulting to 'auto'. Normalizes the value to lowercase and validates it against the allowed set so that values like 'AUTO' or 'MarkItDown' from config.yaml don't silently fall through to unexpected behaviour. """ try: raw = str(_get_uploads_config_value("pdf_converter", "auto")).strip().lower() if raw not in _ALLOWED_PDF_CONVERTERS: logger.warning("Invalid pdf_converter value %r; falling back to 'auto'", raw) return "auto" return raw except Exception: pass return "auto"