Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 40 additions & 3 deletions deepdoc/parser/docling_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,22 @@ def check_installation(self, docling_server_url: Optional[str] = None) -> bool:
self.logger.error(f"[Docling] init DocumentConverter failed: {e}")
return False

@staticmethod
def _resolve_page_range(page_from: int, page_to: int) -> Optional[tuple[int, int]]:
"""Translate RAGFlow's task page range into Docling's ``page_range``.

RAGFlow is 0-based with ``page_to`` exclusive (Python slice stop); Docling
is 1-based with both bounds inclusive and rejects a range whose end
precedes its start. Returns ``None`` when the caller did not narrow the
range, so a document that was never split sends exactly the request it
sent before page ranges were supported.
"""
if page_from <= 0 and page_to >= MAXIMUM_PAGE_NUMBER:
return None
if page_to <= page_from:
return None
return (page_from + 1, page_to)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

def __images__(self, fnm, zoomin: int = 1, page_from=0, page_to=MAXIMUM_PAGE_NUMBER, callback=None):
self.page_from = page_from
self.page_to = page_to
Expand Down Expand Up @@ -396,6 +412,7 @@ def _parse_pdf_remote(
parse_method: str = "raw",
docling_server_url: Optional[str] = None,
request_timeout: Optional[int] = None,
page_range: Optional[tuple[int, int]] = None,
):
"""
Parses a PDF document using a remote Docling server.
Expand Down Expand Up @@ -431,14 +448,22 @@ def _parse_pdf_remote(
filename = Path(filepath).name or "input.pdf"
b64 = base64.b64encode(pdf_bytes).decode("ascii")

# docling-serve's ConvertDocumentsOptions.page_range is Docling's own field
# (docling.datamodel.service.options): 1-based, both bounds inclusive.
# Docling still numbers the pages it returns from the start of the
# document, so nothing downstream needs rebasing. Empty for a task that
# covers the whole document, leaving those requests byte-for-byte as they
# were.
range_opt = {"page_range": list(page_range)} if page_range else {}

# Standard payloads
# Standard fallback payloads (no chunking)
v1_payload_standard = {
"options": {"from_formats": ["pdf"], "to_formats": ["json", "md", "text"]},
"options": {"from_formats": ["pdf"], "to_formats": ["json", "md", "text"], **range_opt},
"sources": [{"kind": "file", "filename": filename, "base64_string": b64}],
}
v1alpha_payload_standard = {
"options": {"from_formats": ["pdf"], "to_formats": ["json", "md", "text"]},
"options": {"from_formats": ["pdf"], "to_formats": ["json", "md", "text"], **range_opt},
"file_sources": [{"filename": filename, "base64_string": b64}],
}

Expand All @@ -452,6 +477,7 @@ def _parse_pdf_remote(
"overlap": 50,
"tokenizer": "sentencepiece", # Required by Docling contract
},
**range_opt,
}
v1_payload_chunked = {
"options": chunking_opts,
Expand Down Expand Up @@ -569,12 +595,22 @@ def parse_pdf(
parse_method: str = "raw",
docling_server_url: Optional[str] = None,
request_timeout: Optional[int] = None,
page_from: int = 0,
page_to: int = MAXIMUM_PAGE_NUMBER,
):
self.outlines = extract_pdf_outlines(binary if binary is not None else filepath)

if not self.check_installation(docling_server_url=docling_server_url):
raise RuntimeError("Docling not available, please install `docling`")

# RAGFlow splits a large PDF into several page-range tasks and calls this
# once per range. Without forwarding the range every task converts the
# whole document, so the work is repeated N times and each request is as
# slow and as memory-hungry as the entire file.
page_range = self._resolve_page_range(page_from, page_to)
if page_range:
self.logger.info(f"[Docling] resolved page range {page_range} (from page_from={page_from} page_to={page_to})")

server_url = self._effective_server_url(docling_server_url)
if server_url:
return self._parse_pdf_remote(
Expand All @@ -584,6 +620,7 @@ def parse_pdf(
parse_method=parse_method,
docling_server_url=server_url,
request_timeout=request_timeout,
page_range=page_range,
)

if binary is not None:
Expand Down Expand Up @@ -615,7 +652,7 @@ def parse_pdf(
pipeline_options = PdfPipelineOptions()
pipeline_options.do_formula_enrichment = do_formula_enrichment
conv = DocumentConverter(format_options={InputFormat.PDF: PdfFormatOption(pipeline_options=pipeline_options)})
conv_res = conv.convert(str(src_path))
conv_res = conv.convert(str(src_path), page_range=page_range) if page_range else conv.convert(str(src_path))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
doc = conv_res.document
if callback:
callback(0.7, f"[Docling] Parsed doc: {getattr(doc, 'num_pages', 'n/a')} pages")
Expand Down
2 changes: 2 additions & 0 deletions rag/app/naive.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,8 @@ def by_docling(filename, binary=None, from_page=0, to_page=MAXIMUM_PAGE_NUMBER,
delete_output=bool(int(os.environ.get("DOCLING_DELETE_OUTPUT", 1))),
docling_server_url=os.environ.get("DOCLING_SERVER_URL", ""),
parse_method=parse_method,
page_from=from_page,
page_to=min(to_page, MAXIMUM_PAGE_NUMBER),
)
return sections, tables, pdf_parser

Expand Down
62 changes: 62 additions & 0 deletions test/unit_test/deepdoc/parser/test_docling_parser_remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,68 @@ def fake_post(_url, json, timeout):
assert "Server ignored chunking request" in flat


def _capture_remote_payloads(monkeypatch, module, reject_chunking: bool = False):
"""Fake out the remote server and the installation probe so ``parse_pdf`` can
be driven end to end. Returns the list of payloads it posts; with
``reject_chunking`` the chunked attempts 422 so the standard fallback
payloads are exercised too."""
payloads = []

def fake_post(_url, json, timeout):
payloads.append(json)
if reject_chunking and json["options"].get("do_chunking"):
return _Response(None, status_code=422)
return _Response({"document": {"md_content": "body"}})

monkeypatch.setattr(module.requests, "post", fake_post)
monkeypatch.setattr(module.DoclingParser, "check_installation", lambda _self, **_kw: True)
return payloads


@pytest.mark.p2
def test_resolve_page_range_translates_ragflow_convention(monkeypatch):
"""RAGFlow's 0-based/exclusive task range becomes Docling's 1-based/inclusive
one; an un-narrowed or empty range asks for the whole document instead."""
module = _load_docling_parser(monkeypatch)
resolve = module.DoclingParser._resolve_page_range

assert resolve(0, 13) == (1, 13)
assert resolve(144, 157) == (145, 157)
assert resolve(0, module.MAXIMUM_PAGE_NUMBER) is None
# Docling rejects end < start, so a degenerate range must not be sent.
assert resolve(12, 12) is None


@pytest.mark.p2
def test_page_range_is_threaded_into_remote_payload(monkeypatch):
"""A task that owns pages 145-157 must ask Docling Serve for exactly that
window instead of converting the whole document — on every payload variant
the fallback chain can reach, not just the first one."""
module = _load_docling_parser(monkeypatch)
payloads = _capture_remote_payloads(monkeypatch, module, reject_chunking=True)

parser = module.DoclingParser(docling_server_url="http://docling.local")
parser.parse_pdf("sample.pdf", binary=b"%PDF", page_from=144, page_to=157)

# two rejected chunked attempts, then the standard payload that succeeds
assert len(payloads) == 3
assert all(payload["options"]["page_range"] == [145, 157] for payload in payloads)


@pytest.mark.p2
def test_full_document_request_omits_page_range(monkeypatch):
"""An unsplit document sends the request it sent before page ranges were
supported, so the whole-document path is untouched."""
module = _load_docling_parser(monkeypatch)
payloads = _capture_remote_payloads(monkeypatch, module, reject_chunking=True)

parser = module.DoclingParser(docling_server_url="http://docling.local")
parser.parse_pdf("sample.pdf", binary=b"%PDF")

assert len(payloads) == 3
assert all("page_range" not in payload["options"] for payload in payloads)


@pytest.mark.p2
def test_crop_without_page_images_returns_none(monkeypatch):
"""Position tags are emitted even when page rendering failed and left
Expand Down