chore: import upstream snapshot with attribution
Backend release / release (push) Waiting to run
Bandit Security Scan / bandit_scan (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / manifest (push) Blocked by required conditions
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / manifest (push) Blocked by required conditions
Python linting / ruff (push) Waiting to run
Run python tests with pytest / Run tests and count coverage (3.12) (push) Waiting to run
React Widget Build / build (push) Waiting to run
Backend release / release (push) Waiting to run
Bandit Security Scan / bandit_scan (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / manifest (push) Blocked by required conditions
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / manifest (push) Blocked by required conditions
Python linting / ruff (push) Waiting to run
Run python tests with pytest / Run tests and count coverage (3.12) (push) Waiting to run
React Widget Build / build (push) Waiting to run
This commit is contained in:
@@ -0,0 +1,508 @@
|
||||
"""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 io
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterator, 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__)
|
||||
|
||||
# Default cap for the LLM-facing VIEW of the text (applied in ``bound_parse_payload``,
|
||||
# NOT during parsing) 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
|
||||
_MAX_PAGE_SELECTIONS = 10_000
|
||||
_MAX_PAGE_SELECTOR_TOKENS = 20_000
|
||||
|
||||
_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], max_chars: Optional[int] = None) -> Dict[str, Any]:
|
||||
"""Bound every shape of a parse payload so the Redis-backed result stays small.
|
||||
|
||||
This is where ALL view-bounding happens: parsing now returns the FULL content and the
|
||||
persisted ``data`` artifact keeps it, while the view ridden back through the Redis result
|
||||
backend is bounded here. ``content`` is capped to ``max_chars`` when given, else re-windowed
|
||||
to a head+tail byte window; ``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``). ``payload['truncated']`` is set when the content view actually cut. The dict is
|
||||
mutated in place.
|
||||
"""
|
||||
content = payload.get("content")
|
||||
if isinstance(content, str):
|
||||
if max_chars and int(max_chars) > 0:
|
||||
capped = content[: int(max_chars)]
|
||||
else:
|
||||
capped = truncate_text_head_tail(content)
|
||||
payload["content"] = capped
|
||||
payload["truncated"] = capped != 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))
|
||||
|
||||
|
||||
_ZIP_CONTAINER_EXTENSIONS = frozenset({".docx", ".xlsx", ".pptx", ".epub"})
|
||||
|
||||
|
||||
def _reject_zip_bomb(data: bytes, suffix: str) -> Optional[str]:
|
||||
"""Return an error string if a zip-based document declares an implausible expansion, else None."""
|
||||
if suffix not in _ZIP_CONTAINER_EXTENSIONS:
|
||||
return None
|
||||
max_entries = int(getattr(settings, "DOCUMENT_MAX_ARCHIVE_ENTRIES", 10000))
|
||||
cap = int(getattr(settings, "DOCUMENT_MAX_DECOMPRESSED_BYTES", 300 * 1024 * 1024))
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||
infos = zf.infolist()
|
||||
if len(infos) > max_entries:
|
||||
return f"document archive has too many entries ({len(infos)} > {max_entries})."
|
||||
total = 0
|
||||
for info in infos:
|
||||
total += info.file_size
|
||||
if total > cap:
|
||||
return f"document decompresses to too much data: exceeds the {cap}-byte cap."
|
||||
except zipfile.BadZipFile:
|
||||
# Not a readable zip; the format-specific parser will surface a clean error.
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
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 _is_docling_parser(parser: Any) -> bool:
|
||||
"""True when ``parser`` is Docling-backed (collecting tables would otherwise re-convert)."""
|
||||
if parser is None:
|
||||
return False
|
||||
try:
|
||||
from application.parser.file.docling_parser import DoclingParser
|
||||
except Exception:
|
||||
return False
|
||||
return isinstance(parser, DoclingParser)
|
||||
|
||||
|
||||
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, parser: Any = None) -> Dict[str, Any]:
|
||||
"""Convert a document with Docling and return markdown + structured dict + bounded tables.
|
||||
|
||||
When ``parser`` is the configured ``DoclingParser`` (the collapse-the-double-convert
|
||||
path), reuse ITS converter + export so OCR/pipeline options and the export fallback
|
||||
are honored and the content matches the legacy single-parse output exactly; otherwise
|
||||
fall back to a vanilla converter.
|
||||
"""
|
||||
if _is_docling_parser(parser):
|
||||
if getattr(parser, "_converter", None) is None:
|
||||
parser._init_parser()
|
||||
doc = parser._converter.convert(str(path)).document
|
||||
markdown = parser._export_content(doc)
|
||||
else:
|
||||
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 or "\f" not in text:
|
||||
return text
|
||||
total = text.count("\f") + 1
|
||||
selected = _selected_page_indices(pages, total)
|
||||
if not selected:
|
||||
return text
|
||||
|
||||
# Splitting a hostile form-feed blob could allocate millions of strings.
|
||||
# Walk boundaries and retain only the bounded set of requested pages.
|
||||
wanted = set(selected)
|
||||
found: Dict[int, tuple[int, int]] = {}
|
||||
page_index = 0
|
||||
start = 0
|
||||
while wanted:
|
||||
end = text.find("\f", start)
|
||||
if end < 0:
|
||||
end = len(text)
|
||||
if page_index in wanted:
|
||||
found[page_index] = (start, end)
|
||||
wanted.remove(page_index)
|
||||
if end == len(text):
|
||||
break
|
||||
page_index += 1
|
||||
start = end + 1
|
||||
|
||||
# Preserve requested order and ordinary duplicates, but never let repeated
|
||||
# selectors amplify the output beyond the source text's resident size.
|
||||
output = io.StringIO()
|
||||
output_chars = 0
|
||||
wrote_page = False
|
||||
for index in selected:
|
||||
span = found.get(index)
|
||||
if span is None:
|
||||
continue
|
||||
page_start, page_end = span
|
||||
added = (page_end - page_start) + (1 if wrote_page else 0)
|
||||
if output_chars + added > len(text):
|
||||
break
|
||||
if wrote_page:
|
||||
output.write("\f")
|
||||
output.write(text[page_start:page_end])
|
||||
output_chars += added
|
||||
wrote_page = True
|
||||
return output.getvalue() if wrote_page else text
|
||||
|
||||
|
||||
def _iter_page_tokens(pages: Any) -> Iterator[Any]:
|
||||
"""Yield bounded selector tokens without materializing a comma-split list."""
|
||||
if isinstance(pages, list):
|
||||
for position, token in enumerate(pages):
|
||||
if position >= _MAX_PAGE_SELECTOR_TOKENS:
|
||||
break
|
||||
yield token
|
||||
return
|
||||
|
||||
raw = str(pages)
|
||||
start = 0
|
||||
emitted = 0
|
||||
while emitted < _MAX_PAGE_SELECTOR_TOKENS:
|
||||
end = raw.find(",", start)
|
||||
if end < 0:
|
||||
yield raw[start:]
|
||||
return
|
||||
yield raw[start:end]
|
||||
emitted += 1
|
||||
start = end + 1
|
||||
|
||||
|
||||
def _selected_page_indices(pages: Any, total: int) -> List[int]:
|
||||
"""Parse ``pages`` into a bounded list of valid 0-based page occurrences."""
|
||||
if total <= 0:
|
||||
return []
|
||||
|
||||
indices: List[int] = []
|
||||
for token in _iter_page_tokens(pages):
|
||||
if len(indices) >= _MAX_PAGE_SELECTIONS:
|
||||
break
|
||||
token = str(token).strip()
|
||||
if "-" in token:
|
||||
try:
|
||||
lo, hi = (int(p) for p in token.split("-", 1))
|
||||
except ValueError:
|
||||
continue
|
||||
start = max(lo - 1, 0)
|
||||
stop = min(hi, total, start + (_MAX_PAGE_SELECTIONS - len(indices)))
|
||||
indices.extend(range(start, stop))
|
||||
else:
|
||||
try:
|
||||
index = int(token) - 1
|
||||
except ValueError:
|
||||
continue
|
||||
if 0 <= index < total:
|
||||
indices.append(index)
|
||||
return indices
|
||||
|
||||
|
||||
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."}
|
||||
|
||||
bomb = _reject_zip_bomb(data, suffix)
|
||||
if bomb is not None:
|
||||
return {"error": bomb}
|
||||
|
||||
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"])
|
||||
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)
|
||||
wants_tables = include_tables and engine != "fast" and output != "chunks"
|
||||
|
||||
# A Docling-backed parser already converts the whole document to produce its text.
|
||||
# When tables are also requested, reuse that single conversion for both the markdown
|
||||
# content and the tables instead of converting a second time just to collect tables
|
||||
# (Docling/torch conversion dominates the cost, so a re-convert ~doubles latency).
|
||||
if wants_tables and _is_docling_parser(parser):
|
||||
try:
|
||||
extracted = _docling_structured(path, ocr_enabled=ocr_enabled, include_tables=True, parser=parser)
|
||||
text = extracted["markdown"]
|
||||
tables: List[Dict[str, Any]] = [_compact_table(t) for t in extracted["tables"]]
|
||||
except Exception:
|
||||
text, tables = _parse_to_text(parser, path), []
|
||||
text = _apply_pages(text, pages)
|
||||
bounded, truncated = _bounded(text)
|
||||
payload: Dict[str, Any] = {"output": output, "content": bounded, "truncated": truncated}
|
||||
if tables:
|
||||
payload["tables"] = tables
|
||||
return payload
|
||||
|
||||
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 wants_tables:
|
||||
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)
|
||||
payload: Dict[str, Any] = {"output": output, "content": bounded, "truncated": truncated}
|
||||
if tables:
|
||||
payload["tables"] = tables
|
||||
return payload
|
||||
|
||||
|
||||
def _bounded(text: str) -> tuple[str, bool]:
|
||||
"""Return the FULL extracted text (never truncated here); the view is bounded in ``bound_parse_payload``.
|
||||
|
||||
Parsing keeps the complete text so the persisted ``data`` artifact is the full parse;
|
||||
``max_chars`` and the default head+tail window now bound only the LLM-facing view.
|
||||
"""
|
||||
return text, False
|
||||
Reference in New Issue
Block a user