Add LocalChunker adapter and DoclingDocument serialization
LocalChunker implements DocumentChunker port using docling-core chunkers. LocalConverter now serializes DoclingDocument to JSON for re-chunking support.
This commit is contained in:
parent
f5b31f809f
commit
4b1ec364f4
2 changed files with 105 additions and 5 deletions
84
document-parser/infra/local_chunker.py
Normal file
84
document-parser/infra/local_chunker.py
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
"""Local Docling chunker — runs chunking in-process using docling-core.
|
||||||
|
|
||||||
|
This adapter implements the DocumentChunker port. It deserializes a
|
||||||
|
DoclingDocument from JSON, applies the requested chunker, and returns
|
||||||
|
domain ChunkResult objects.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from docling_core.transforms.chunker import HierarchicalChunker
|
||||||
|
from docling_core.transforms.chunker.hybrid_chunker import HybridChunker
|
||||||
|
from docling_core.types.doc.document import DoclingDocument
|
||||||
|
|
||||||
|
from domain.value_objects import ChunkingOptions, ChunkResult
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _chunk_sync(document_json: str, options: ChunkingOptions) -> list[ChunkResult]:
|
||||||
|
if not document_json or not document_json.strip():
|
||||||
|
raise ValueError("Empty document JSON — nothing to chunk")
|
||||||
|
|
||||||
|
try:
|
||||||
|
doc_data = json.loads(document_json)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise ValueError(f"Malformed document JSON: {e}") from e
|
||||||
|
|
||||||
|
doc = DoclingDocument.model_validate(doc_data)
|
||||||
|
|
||||||
|
chunker = _build_chunker(options)
|
||||||
|
results: list[ChunkResult] = []
|
||||||
|
|
||||||
|
for chunk in chunker.chunk(doc):
|
||||||
|
source_page = None
|
||||||
|
token_count = 0
|
||||||
|
|
||||||
|
if hasattr(chunk, "meta") and chunk.meta and chunk.meta.doc_items:
|
||||||
|
for doc_item in chunk.meta.doc_items:
|
||||||
|
if hasattr(doc_item, "prov") and doc_item.prov:
|
||||||
|
source_page = doc_item.prov[0].page_no
|
||||||
|
break
|
||||||
|
|
||||||
|
if hasattr(chunker, "tokenizer") and chunker.tokenizer:
|
||||||
|
token_count = chunker.tokenizer.count_tokens(chunk.text)
|
||||||
|
|
||||||
|
headings = list(chunk.meta.headings) if chunk.meta and chunk.meta.headings else []
|
||||||
|
|
||||||
|
results.append(
|
||||||
|
ChunkResult(
|
||||||
|
text=chunk.text,
|
||||||
|
headings=headings,
|
||||||
|
source_page=source_page,
|
||||||
|
token_count=token_count,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Chunked document into %d chunks (chunker=%s)", len(results), options.chunker_type)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _build_chunker(options: ChunkingOptions) -> HierarchicalChunker | HybridChunker:
|
||||||
|
if options.chunker_type == "hierarchical":
|
||||||
|
return HierarchicalChunker()
|
||||||
|
|
||||||
|
return HybridChunker(
|
||||||
|
max_tokens=options.max_tokens,
|
||||||
|
merge_peers=options.merge_peers,
|
||||||
|
repeat_table_header=options.repeat_table_header,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LocalChunker:
|
||||||
|
"""Adapter that runs docling-core chunking locally."""
|
||||||
|
|
||||||
|
async def chunk(
|
||||||
|
self,
|
||||||
|
document_json: str,
|
||||||
|
options: ChunkingOptions,
|
||||||
|
) -> list[ChunkResult]:
|
||||||
|
return await asyncio.to_thread(_chunk_sync, document_json, options)
|
||||||
|
|
@ -9,6 +9,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
|
|
@ -83,6 +84,7 @@ def _get_element_type(item: DocItem) -> str:
|
||||||
# Pipeline factory
|
# Pipeline factory
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _build_docling_converter(options: ConversionOptions) -> DoclingConverter:
|
def _build_docling_converter(options: ConversionOptions) -> DoclingConverter:
|
||||||
table_options = TableStructureOptions(
|
table_options = TableStructureOptions(
|
||||||
do_cell_matching=True,
|
do_cell_matching=True,
|
||||||
|
|
@ -126,6 +128,7 @@ def _select_converter(options: ConversionOptions) -> DoclingConverter:
|
||||||
# Page extraction
|
# Page extraction
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _extract_pages_detail(doc_result) -> tuple[list[PageDetail], int]:
|
def _extract_pages_detail(doc_result) -> tuple[list[PageDetail], int]:
|
||||||
pages: dict[int, PageDetail] = {}
|
pages: dict[int, PageDetail] = {}
|
||||||
document = doc_result.document
|
document = doc_result.document
|
||||||
|
|
@ -149,7 +152,9 @@ def _extract_pages_detail(doc_result) -> tuple[list[PageDetail], int]:
|
||||||
|
|
||||||
|
|
||||||
def _process_content_item(
|
def _process_content_item(
|
||||||
item: DocItem | GroupItem, level: int, pages: dict[int, PageDetail],
|
item: DocItem | GroupItem,
|
||||||
|
level: int,
|
||||||
|
pages: dict[int, PageDetail],
|
||||||
) -> bool:
|
) -> bool:
|
||||||
if isinstance(item, GroupItem):
|
if isinstance(item, GroupItem):
|
||||||
return True
|
return True
|
||||||
|
|
@ -163,9 +168,13 @@ def _process_content_item(
|
||||||
if page_no not in pages:
|
if page_no not in pages:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Page %d not found in document metadata — using US Letter fallback (%sx%s pt)",
|
"Page %d not found in document metadata — using US Letter fallback (%sx%s pt)",
|
||||||
page_no, _DEFAULT_PAGE_WIDTH, _DEFAULT_PAGE_HEIGHT,
|
page_no,
|
||||||
|
_DEFAULT_PAGE_WIDTH,
|
||||||
|
_DEFAULT_PAGE_HEIGHT,
|
||||||
|
)
|
||||||
|
pages[page_no] = PageDetail(
|
||||||
|
page_number=page_no, width=_DEFAULT_PAGE_WIDTH, height=_DEFAULT_PAGE_HEIGHT
|
||||||
)
|
)
|
||||||
pages[page_no] = PageDetail(page_number=page_no, width=_DEFAULT_PAGE_WIDTH, height=_DEFAULT_PAGE_HEIGHT)
|
|
||||||
|
|
||||||
page_height = pages[page_no].height
|
page_height = pages[page_no].height
|
||||||
|
|
||||||
|
|
@ -199,6 +208,7 @@ def _process_content_item(
|
||||||
# Synchronous conversion (called via asyncio.to_thread)
|
# Synchronous conversion (called via asyncio.to_thread)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _convert_sync(file_path: str, options: ConversionOptions) -> ConversionResult:
|
def _convert_sync(file_path: str, options: ConversionOptions) -> ConversionResult:
|
||||||
with _converter_lock:
|
with _converter_lock:
|
||||||
conv = _select_converter(options)
|
conv = _select_converter(options)
|
||||||
|
|
@ -213,7 +223,9 @@ def _convert_sync(file_path: str, options: ConversionOptions) -> ConversionResul
|
||||||
PageDetail(
|
PageDetail(
|
||||||
page_number=i + 1,
|
page_number=i + 1,
|
||||||
width=doc.pages[i + 1].size.width if (i + 1) in doc.pages else _DEFAULT_PAGE_WIDTH,
|
width=doc.pages[i + 1].size.width if (i + 1) in doc.pages else _DEFAULT_PAGE_WIDTH,
|
||||||
height=doc.pages[i + 1].size.height if (i + 1) in doc.pages else _DEFAULT_PAGE_HEIGHT,
|
height=doc.pages[i + 1].size.height
|
||||||
|
if (i + 1) in doc.pages
|
||||||
|
else _DEFAULT_PAGE_HEIGHT,
|
||||||
)
|
)
|
||||||
for i in range(page_count)
|
for i in range(page_count)
|
||||||
]
|
]
|
||||||
|
|
@ -227,6 +239,7 @@ def _convert_sync(file_path: str, options: ConversionOptions) -> ConversionResul
|
||||||
content_html=doc.export_to_html(),
|
content_html=doc.export_to_html(),
|
||||||
pages=pages_detail,
|
pages=pages_detail,
|
||||||
skipped_items=skipped,
|
skipped_items=skipped,
|
||||||
|
document_json=json.dumps(doc.export_to_dict()),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -234,10 +247,13 @@ def _convert_sync(file_path: str, options: ConversionOptions) -> ConversionResul
|
||||||
# Public adapter class
|
# Public adapter class
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class LocalConverter:
|
class LocalConverter:
|
||||||
"""Adapter that runs Docling locally as a Python library."""
|
"""Adapter that runs Docling locally as a Python library."""
|
||||||
|
|
||||||
async def convert(
|
async def convert(
|
||||||
self, file_path: str, options: ConversionOptions,
|
self,
|
||||||
|
file_path: str,
|
||||||
|
options: ConversionOptions,
|
||||||
) -> ConversionResult:
|
) -> ConversionResult:
|
||||||
return await asyncio.to_thread(_convert_sync, file_path, options)
|
return await asyncio.to_thread(_convert_sync, file_path, options)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue