mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-29 06:30:36 +00:00
* fix(uploads): keep document-conversion stat and write-back off the event loop convert_file_to_markdown offloads the conversion itself for files above 1 MB (documented in its docstring), but the two bookend filesystem calls still ran on the event loop: the size stat before the threshold check and the write of the converted markdown afterwards — the latter a potentially multi-megabyte write in the per-upload ingestion path. Route both through run_file_io (the dedicated file IO pool that also keeps the caller's context), matching the off-the-loop fixes already applied to the sibling upload/document paths. Add a tests/blocking_io anchor: with the converter patched to a 2 MB payload it fails on the previous head (Blockbuster catches the blocking os.stat first; the broad except swallows it into a None return) and passes with this change. * fix(uploads): address review — drain conversion write-back, make run_file_io re-entrant Review follow-up on the off-loop conversion stat/write PR: 1. The write-back now goes through await_drained(): a plain run_file_io await cancels immediately while the worker keeps writing, so the ingestion scope could remove its private directory over an in-flight write and leave a large hidden partial file. The drain delivers the cancellation only after the worker settled, and the handler removes the partial output before re-raising. 2. run_file_io is now re-entrant: a nested offload issued from code already running inside a file-io worker (project-document conversion runs asyncio.run(convert_file_to_markdown(...)) inside a run_file_io worker) executes inline instead of submitting to the same bounded executor, which deadlocked with a single configured worker. Both fixes carry deterministic regressions: the cancellation test pins the drain ordering (cancel issued before the worker is released) and the partial-file cleanup; the re-entrancy test pins inline execution by thread identity. Both fail on the previous head. Full blocking_io + project-document + conversion suites show an identical failure set to the previous head (pre-existing Windows platform failures only). * fix(uploads): keep cancellation authoritative when conversion cleanup fails If the post-cancellation unlink of the staging output raised, the new exception escaped the CancelledError handler, was swallowed by the broad except as an ordinary conversion failure (None return), and left both the caller's cancellation and the partial file behind. Catch and log cleanup failures inside the handler and always re-raise the captured cancellation; a second cancellation still passes through untouched (CancelledError is not an Exception). Red/green: tests/blocking_io/test_file_conversion_offloop.py gains a regression where unlink fails while the write-back is in flight — on the reviewed head the task settled on None (DID NOT RAISE); with the change the CancelledError propagates and the failure is logged. Signed-off-by: yetuge <2219677952@qq.com> --------- Signed-off-by: yetuge <2219677952@qq.com>
238 lines
9.1 KiB
Python
238 lines
9.1 KiB
Python
"""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).
|
|
|
|
Large files (> ASYNC_THRESHOLD_BYTES) are converted in a thread pool via
|
|
asyncio.to_thread() to avoid blocking the event loop (fixes #1569).
|
|
|
|
No FastAPI or HTTP dependencies — pure utility functions.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
from pathlib import Path
|
|
|
|
from deerflow.config.app_config import get_app_config
|
|
from deerflow.utils.file_io import await_drained, run_file_io
|
|
|
|
# Backward-compat re-exports — outline extraction moved to file_outline.py.
|
|
from deerflow.utils.file_outline import ( # noqa: F401
|
|
MAX_OUTLINE_ENTRIES,
|
|
extract_outline,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# File extensions that should be converted to markdown
|
|
CONVERTIBLE_EXTENSIONS = {
|
|
".pdf",
|
|
".ppt",
|
|
".pptx",
|
|
".xls",
|
|
".xlsx",
|
|
".doc",
|
|
".docx",
|
|
}
|
|
|
|
# Files larger than this threshold are converted in a background thread.
|
|
# Small files complete in < 1s synchronously; spawning a thread adds unnecessary
|
|
# scheduling overhead for them.
|
|
_ASYNC_THRESHOLD_BYTES = 1 * 1024 * 1024 # 1 MB
|
|
|
|
# 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 _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 _do_convert(file_path: Path, pdf_converter: str) -> str:
|
|
"""Synchronous conversion — called directly or via asyncio.to_thread.
|
|
|
|
Args:
|
|
file_path: Path to the file.
|
|
pdf_converter: "auto" | "pymupdf4llm" | "markitdown"
|
|
"""
|
|
is_pdf = file_path.suffix.lower() == ".pdf"
|
|
|
|
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)
|
|
|
|
|
|
async def convert_file_to_markdown(file_path: Path, output_path: Path | None = None) -> Path | None:
|
|
"""Convert a supported document file to Markdown.
|
|
|
|
PDF files are handled with a two-converter strategy (see module docstring).
|
|
Large files (> 1 MB) are offloaded to a thread pool to avoid blocking the
|
|
event loop.
|
|
|
|
Args:
|
|
file_path: Path to the file to convert.
|
|
output_path: Optional destination for the generated ``.md`` file.
|
|
When omitted, writes to ``file_path`` with a ``.md`` suffix.
|
|
Callers that track per-request filename uniqueness should pass a
|
|
pre-claimed path so companion markdown cannot clobber other uploads.
|
|
|
|
Returns:
|
|
Path to the generated .md file, or None if conversion failed.
|
|
"""
|
|
try:
|
|
pdf_converter = _get_pdf_converter()
|
|
# stat and the write-back are blocking filesystem calls on the upload
|
|
# ingestion event loop; offload them like the large-file conversion
|
|
# above (run_file_io keeps the caller's context in the worker).
|
|
file_size = (await run_file_io(file_path.stat)).st_size
|
|
|
|
if file_size > _ASYNC_THRESHOLD_BYTES:
|
|
text = await asyncio.to_thread(_do_convert, file_path, pdf_converter)
|
|
else:
|
|
text = _do_convert(file_path, pdf_converter)
|
|
|
|
md_path = output_path if output_path is not None else file_path.with_suffix(".md")
|
|
# Drain the write-back across caller cancellation: run_file_io awaits
|
|
# run_in_executor, so a plain await would unwind while the worker is
|
|
# still writing and leave the partial output behind for the caller's
|
|
# scope to trip over. await_drained delivers the cancellation only
|
|
# after the worker finished, so the cleanup below sees a settled file.
|
|
try:
|
|
await await_drained(run_file_io(md_path.write_text, text, encoding="utf-8"))
|
|
except asyncio.CancelledError:
|
|
try:
|
|
await await_drained(run_file_io(md_path.unlink, missing_ok=True))
|
|
except Exception:
|
|
# Cleanup must not shadow the cancellation: an unlink failure
|
|
# escaping this handler used to be swallowed by the broad
|
|
# handler below as an ordinary conversion failure, leaving
|
|
# both the caller's cancellation and the staging file behind.
|
|
logger.exception("Failed to remove partial conversion output %s after cancellation", md_path)
|
|
raise
|
|
|
|
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
|
|
|
|
|
|
# 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
|
|
|
|
_ALLOWED_PDF_CONVERTERS = {"auto", "pymupdf4llm", "markitdown"}
|
|
|
|
|
|
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"
|