mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-03 09:12:55 +00:00
Replace the sandbox Docling extractor with read_document, backed by the in-process backend parser (the same one ingestion uses) and offloaded to a dedicated 'parsing' Celery queue so it can run on GPU-capable workers with predictable RAM. The tool resolves the input ref under the run-scoped gate, enqueues the parse, and awaits it with a timeout (degrading to an error rather than hanging); the worker independently re-resolves the artifact through the same gate and never trusts a raw path. Untrusted files get the upload path's safeguards (extension whitelist, size cap, sanitized temp file, cleanup). Options: output (markdown/text/structured/chunks), ocr, pages, engine, max_chars, include_tables, persist, json_schema. The workflow native-file 'extract' fallback now uses the same worker path, so document parsing no longer needs the sandbox and works on every backend. Also fixes the branch's periodic-task test (the sandbox reaper made it 12) and points the dev and e2e Celery workers at the parsing queue.
358 lines
14 KiB
Python
358 lines
14 KiB
Python
"""In-process document parsing for the ``read_document`` tool, run on the Celery parsing worker.
|
|
|
|
``parse_document_bytes`` turns untrusted document bytes into a bounded, shaped
|
|
result (markdown/text/structured/chunks) using the BACKEND parsers (Docling by
|
|
default). It applies the same untrusted-content safeguards as uploads — an
|
|
extension whitelist, a byte cap, ``safe_filename`` staging into a temp file, and
|
|
temp cleanup — so a hostile filename or document is treated as inert data.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from application.core.settings import settings
|
|
from application.parser.file.bulk import get_default_file_extractor
|
|
from application.parser.file.constants import SUPPORTED_SOURCE_EXTENSIONS
|
|
from application.utils import safe_filename
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Cap the text returned to the LLM so a huge document can't flood context; the
|
|
# full result is still persisted as a ``data`` artifact. When the text exceeds
|
|
# the cap a head+tail window keeps both the document's beginning AND end (e.g.
|
|
# totals/signatures) within the byte budget.
|
|
_TEXT_MAX_BYTES = 8000
|
|
_MAX_TABLES_RETURNED = 20
|
|
_MAX_TABLE_ROWS = 50
|
|
_MAX_CELL_CHARS = 200
|
|
# Caps applied to the bounded view that rides back through the Redis result
|
|
# backend (the full result still lives in the persisted artifact).
|
|
_MAX_CHUNKS_RETURNED = 50
|
|
|
|
_VALID_OUTPUTS = ("markdown", "text", "structured", "chunks")
|
|
_VALID_OCR = ("auto", "on", "off")
|
|
_VALID_ENGINES = ("auto", "docling", "fast")
|
|
|
|
|
|
def truncate_text_head_tail(text: str, max_bytes: Optional[int] = None) -> str:
|
|
"""Bound text to a head+tail byte window so a large file can't flood context."""
|
|
cap = int(max_bytes or _TEXT_MAX_BYTES)
|
|
if cap <= 0:
|
|
return text
|
|
encoded = text.encode("utf-8")
|
|
if len(encoded) <= cap:
|
|
return text
|
|
head = cap // 2
|
|
tail = cap - head
|
|
dropped = len(encoded) - head - tail
|
|
head_text = encoded[:head].decode("utf-8", errors="ignore")
|
|
tail_text = encoded[-tail:].decode("utf-8", errors="ignore")
|
|
return f"{head_text}\n\n...[truncated {dropped} bytes]...\n\n{tail_text}"
|
|
|
|
|
|
def bound_parse_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Bound every shape of a parse payload so the Redis-backed result stays small.
|
|
|
|
``content`` is re-windowed and ``chunks`` is capped in count and per-chunk
|
|
length. ``structured`` is left as-is: it rides back so json_schema validation
|
|
in the tool can run against it, and it is already bounded by the input byte
|
|
cap plus the table caps (``_compact_table`` / ``summary``); the full result is
|
|
also persisted as a ``data`` artifact. The dict is mutated in place.
|
|
"""
|
|
content = payload.get("content")
|
|
if isinstance(content, str):
|
|
payload["content"] = truncate_text_head_tail(content)
|
|
|
|
chunks = payload.get("chunks")
|
|
if isinstance(chunks, list):
|
|
bounded = [
|
|
truncate_text_head_tail(chunk) if isinstance(chunk, str) else chunk
|
|
for chunk in chunks[:_MAX_CHUNKS_RETURNED]
|
|
]
|
|
if len(chunks) > _MAX_CHUNKS_RETURNED:
|
|
payload["chunks_truncated"] = True
|
|
payload["total_chunks"] = len(chunks)
|
|
payload["chunks"] = bounded
|
|
|
|
return payload
|
|
|
|
|
|
def _max_input_bytes() -> int:
|
|
"""Return the size cap for a parsed document (its own setting, else the sandbox cap)."""
|
|
explicit = int(getattr(settings, "DOCUMENT_PARSE_MAX_BYTES", 0) or 0)
|
|
if explicit > 0:
|
|
return explicit
|
|
return int(getattr(settings, "SANDBOX_MAX_INPUT_BYTES", 25 * 1024 * 1024))
|
|
|
|
|
|
def _resolve_ocr_enabled(ocr: str) -> bool:
|
|
"""Resolve the OCR flag from the ``ocr`` arg and the deployment setting."""
|
|
if ocr == "on":
|
|
return True
|
|
if ocr == "off":
|
|
return False
|
|
return bool(getattr(settings, "DOCLING_OCR_ENABLED", False))
|
|
|
|
|
|
def _pick_parser(suffix: str, *, ocr_enabled: bool, engine: str):
|
|
"""Select the parser for ``suffix`` honoring the requested engine; None when unsupported."""
|
|
if engine == "fast":
|
|
legacy = _legacy_parser_for(suffix)
|
|
if legacy is not None:
|
|
return legacy
|
|
extractor = get_default_file_extractor(ocr_enabled=ocr_enabled)
|
|
return extractor.get(suffix)
|
|
|
|
|
|
def _legacy_parser_for(suffix: str):
|
|
"""Return a non-Docling parser for ``suffix`` (the ``fast`` engine), or None."""
|
|
from application.parser.file.docs_parser import DocxParser, PDFParser
|
|
from application.parser.file.html_parser import HTMLParser
|
|
from application.parser.file.markdown_parser import MarkdownParser
|
|
from application.parser.file.tabular_parser import ExcelParser, PandasCSVParser
|
|
|
|
legacy = {
|
|
".pdf": PDFParser,
|
|
".docx": DocxParser,
|
|
".csv": PandasCSVParser,
|
|
".xlsx": ExcelParser,
|
|
".html": HTMLParser,
|
|
".md": MarkdownParser,
|
|
".mdx": MarkdownParser,
|
|
}
|
|
cls = legacy.get(suffix)
|
|
return cls() if cls is not None else None
|
|
|
|
|
|
def _parse_to_text(parser: Any, path: Path) -> str:
|
|
"""Run a parser and coerce its ``str | List[str]`` result to a single text blob."""
|
|
if not parser.parser_config_set:
|
|
parser.init_parser()
|
|
parsed = parser.parse_file(path, errors="ignore")
|
|
if isinstance(parsed, list):
|
|
return "\n\n".join(str(part) for part in parsed)
|
|
return str(parsed)
|
|
|
|
|
|
def _compact_table(table: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Bound a single table's rows and cell sizes so one giant table can't bloat context."""
|
|
|
|
def _cell(value: Any) -> Any:
|
|
if isinstance(value, str) and len(value) > _MAX_CELL_CHARS:
|
|
return value[:_MAX_CELL_CHARS] + "...[truncated]"
|
|
return value
|
|
|
|
rows = table.get("rows")
|
|
if not isinstance(rows, list):
|
|
return table
|
|
capped = [[_cell(c) for c in row] if isinstance(row, list) else _cell(row) for row in rows[:_MAX_TABLE_ROWS]]
|
|
compact = dict(table)
|
|
compact["rows"] = capped
|
|
if len(rows) > _MAX_TABLE_ROWS:
|
|
compact["rows_truncated"] = True
|
|
compact["total_rows"] = len(rows)
|
|
return compact
|
|
|
|
|
|
def _docling_structured(path: Path, *, ocr_enabled: bool, include_tables: bool) -> Dict[str, Any]:
|
|
"""Convert a document with Docling and return markdown + structured dict + bounded tables."""
|
|
from docling.document_converter import DocumentConverter
|
|
|
|
converter = DocumentConverter()
|
|
doc = converter.convert(str(path)).document
|
|
markdown = doc.export_to_markdown()
|
|
structured = doc.export_to_dict()
|
|
tables: List[Dict[str, Any]] = []
|
|
if include_tables:
|
|
for tbl in getattr(doc, "tables", []) or []:
|
|
try:
|
|
df = tbl.export_to_dataframe()
|
|
tables.append({"columns": [str(c) for c in df.columns], "rows": df.astype(str).values.tolist()})
|
|
except Exception:
|
|
try:
|
|
tables.append({"markdown": tbl.export_to_markdown()})
|
|
except Exception:
|
|
continue
|
|
if len(tables) >= _MAX_TABLES_RETURNED:
|
|
break
|
|
page_count = len(getattr(doc, "pages", {}) or {})
|
|
return {"markdown": markdown, "structured": structured, "tables": tables, "page_count": page_count}
|
|
|
|
|
|
def _structure_summary(structured: Any) -> Dict[str, int]:
|
|
"""Summarize the Docling structured dict by top-level element counts (keeps context compact)."""
|
|
if not isinstance(structured, dict):
|
|
return {}
|
|
counts: Dict[str, int] = {}
|
|
for key in ("texts", "tables", "pictures", "groups", "pages"):
|
|
value = structured.get(key)
|
|
if isinstance(value, (list, dict)):
|
|
counts[key] = len(value)
|
|
return counts
|
|
|
|
|
|
def _apply_pages(text: str, pages: Any) -> str:
|
|
"""Best-effort page-range slice on a page-delimited markdown blob (``\\f`` separated)."""
|
|
if not pages:
|
|
return text
|
|
parts = text.split("\f")
|
|
if len(parts) <= 1:
|
|
return text
|
|
selected = _selected_page_indices(pages, len(parts))
|
|
if not selected:
|
|
return text
|
|
return "\f".join(parts[i] for i in selected if 0 <= i < len(parts))
|
|
|
|
|
|
def _selected_page_indices(pages: Any, total: int) -> List[int]:
|
|
"""Parse ``pages`` ("1-3", "2", [1,2]) into 0-based indices bounded by ``total``."""
|
|
indices: List[int] = []
|
|
tokens = pages if isinstance(pages, list) else str(pages).split(",")
|
|
for token in tokens:
|
|
token = str(token).strip()
|
|
if "-" in token:
|
|
try:
|
|
lo, hi = (int(p) for p in token.split("-", 1))
|
|
except ValueError:
|
|
continue
|
|
indices.extend(range(lo - 1, hi))
|
|
else:
|
|
try:
|
|
indices.append(int(token) - 1)
|
|
except ValueError:
|
|
continue
|
|
return [i for i in indices if 0 <= i < total]
|
|
|
|
|
|
def _to_chunks(text: str, max_chars: Optional[int]) -> List[str]:
|
|
"""Chunk parsed text via the ingestion chunker; bounded and JSON-safe for the result."""
|
|
from application.parser.chunking_creator import ChunkerCreator
|
|
from application.parser.schema.base import Document
|
|
|
|
chunker = ChunkerCreator.create_chunker("classic_chunk")
|
|
chunks = chunker.chunk([Document(text=text)])
|
|
cap = int(max_chars or 0)
|
|
out: List[str] = []
|
|
for chunk in chunks:
|
|
body = getattr(chunk, "text", str(chunk))
|
|
out.append(body[:cap] if cap > 0 else body)
|
|
if len(out) >= 200:
|
|
break
|
|
return out
|
|
|
|
|
|
def parse_document_bytes(
|
|
data: bytes,
|
|
filename: str,
|
|
*,
|
|
output: str = "markdown",
|
|
ocr: str = "auto",
|
|
pages: Any = None,
|
|
engine: str = "auto",
|
|
max_chars: Optional[int] = None,
|
|
include_tables: bool = True,
|
|
) -> Dict[str, Any]:
|
|
"""Parse untrusted document bytes into a bounded shaped result; whitelist + size + cleanup guarded."""
|
|
if output not in _VALID_OUTPUTS:
|
|
return {"error": f"unsupported output '{output}'; expected one of {_VALID_OUTPUTS}."}
|
|
if ocr not in _VALID_OCR:
|
|
return {"error": f"unsupported ocr '{ocr}'; expected one of {_VALID_OCR}."}
|
|
if engine not in _VALID_ENGINES:
|
|
return {"error": f"unsupported engine '{engine}'; expected one of {_VALID_ENGINES}."}
|
|
|
|
safe_name = safe_filename(filename) or "document"
|
|
suffix = os.path.splitext(safe_name)[1].lower()
|
|
if suffix not in SUPPORTED_SOURCE_EXTENSIONS:
|
|
return {"error": f"unsupported file type '{suffix or filename}'."}
|
|
|
|
cap = _max_input_bytes()
|
|
if len(data) > cap:
|
|
return {"error": f"input document is too large: {len(data)} bytes exceeds the {cap}-byte cap."}
|
|
|
|
ocr_enabled = _resolve_ocr_enabled(ocr)
|
|
tmp_dir = tempfile.mkdtemp(prefix="docparse-")
|
|
tmp_path = Path(tmp_dir) / safe_name
|
|
try:
|
|
tmp_path.write_bytes(data)
|
|
return _shape(tmp_path, suffix, output, ocr_enabled, engine, pages, max_chars, include_tables)
|
|
except Exception as exc:
|
|
logger.exception("parse_document_bytes: parsing failed")
|
|
return {"error": f"parsing failed: {type(exc).__name__}: {exc}"}
|
|
finally:
|
|
try:
|
|
tmp_path.unlink(missing_ok=True)
|
|
os.rmdir(tmp_dir)
|
|
except OSError:
|
|
logger.warning("parse_document_bytes: temp cleanup failed for %s", tmp_dir, exc_info=True)
|
|
|
|
|
|
def _shape(
|
|
path: Path,
|
|
suffix: str,
|
|
output: str,
|
|
ocr_enabled: bool,
|
|
engine: str,
|
|
pages: Any,
|
|
max_chars: Optional[int],
|
|
include_tables: bool,
|
|
) -> Dict[str, Any]:
|
|
"""Run the selected parser/engine and shape the result per ``output``; bounded throughout.
|
|
|
|
``output='structured'`` always uses Docling regardless of ``engine`` — the ``fast``
|
|
engine is markdown/text only and cannot produce the structured dict.
|
|
"""
|
|
if output == "structured":
|
|
try:
|
|
extracted = _docling_structured(path, ocr_enabled=ocr_enabled, include_tables=include_tables)
|
|
except Exception as exc:
|
|
return {"error": f"structured parsing requires Docling: {type(exc).__name__}: {exc}"}
|
|
bounded, truncated = _bounded(extracted["markdown"], max_chars)
|
|
return {
|
|
"output": "structured",
|
|
"content": bounded,
|
|
"truncated": truncated,
|
|
"tables": [_compact_table(t) for t in extracted["tables"]],
|
|
"structured": extracted["structured"],
|
|
"summary": _structure_summary(extracted["structured"]),
|
|
"page_count": extracted["page_count"],
|
|
}
|
|
|
|
parser = _pick_parser(suffix, ocr_enabled=ocr_enabled, engine=engine)
|
|
if parser is None:
|
|
# A whitelisted extension with no dedicated parser (e.g. .txt) reads as plain
|
|
# text, matching SimpleDirectoryReader's standard-read fallback.
|
|
text = path.read_text(errors="ignore")
|
|
else:
|
|
text = _parse_to_text(parser, path)
|
|
text = _apply_pages(text, pages)
|
|
|
|
if output == "chunks":
|
|
return {"output": "chunks", "chunks": _to_chunks(text, max_chars), "truncated": False}
|
|
|
|
tables: List[Dict[str, Any]] = []
|
|
if include_tables and engine != "fast":
|
|
try:
|
|
tables = [_compact_table(t) for t in _docling_structured(
|
|
path, ocr_enabled=ocr_enabled, include_tables=True)["tables"]]
|
|
except Exception:
|
|
tables = []
|
|
bounded, truncated = _bounded(text, max_chars)
|
|
payload: Dict[str, Any] = {"output": output, "content": bounded, "truncated": truncated}
|
|
if tables:
|
|
payload["tables"] = tables
|
|
return payload
|
|
|
|
|
|
def _bounded(text: str, max_chars: Optional[int]) -> tuple[str, bool]:
|
|
"""Bound text to ``max_chars`` (chars) or the default byte window; flag truncation."""
|
|
if max_chars and int(max_chars) > 0:
|
|
capped = text[: int(max_chars)]
|
|
return capped, len(capped) < len(text)
|
|
bounded = truncate_text_head_tail(text)
|
|
return bounded, bounded != text
|