Coverage for src/qdrant_loader/core/chunking/docling/mapper.py: 100%
20 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-20 10:15 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-20 10:15 +0000
1"""Projects engine-neutral chunks into the qdrant-loader ``Document`` contract.
3:class:`ChunkDocumentMapper` is the docling-free half of the layer: it takes the
4:class:`~.outcome.Chunk` objects the chunker emits plus the parent ``Document`` and
5produces the chunk ``Document`` list the rest of the pipeline already consumes. Two
6contracts meet here:
8* the new ``metadata.structure`` block (engine-neutral provenance), plus
9 ``chunk_schema_version`` so a future re-index is detectable; and
10* the legacy ``metadata.*`` keys other subsystems still read
11 (``chunk_index``/``total_chunks``, ``section_breadcrumb``, ``parent_document_id``,
12 ``conversion_method``, …) — populated *from* the structured block for back-compat.
14Because it touches no docling type, this class and its tests run fast and offline.
15"""
17from __future__ import annotations
19import dataclasses
20from typing import TYPE_CHECKING, Any
22from qdrant_loader.core.document import Document
24from .config import ChunkingConfig
26if TYPE_CHECKING:
27 from .outcome import Chunk
28 from .structure import ChunkStructure
31class ChunkDocumentMapper:
32 """Chunk + parent Document -> chunk Documents (the stable payload contract)."""
34 def __init__(self, config: ChunkingConfig) -> None:
35 self._config = config
37 def to_documents(self, chunks: list[Chunk], parent: Document) -> list[Document]:
38 """Map chunks into chunk ``Document``s carrying the chunk payload contract.
40 Per chunk: a deterministic id via ``Document.generate_chunk_id``; the
41 ``metadata.structure`` block flattened from :class:`~.structure.ChunkStructure`
42 plus ``chunk_schema_version``; and the back-compat ``metadata.*`` keys derived
43 from it. ``content`` is the chunk body — contextual embedding is deferred, so
44 no heading prefix is folded into the embedded text yet.
45 """
46 total = len(chunks)
47 return [
48 self._to_document(chunk, parent, index, total)
49 for index, chunk in enumerate(chunks)
50 ]
52 def _to_document(
53 self, chunk: Chunk, parent: Document, index: int, total: int
54 ) -> Document:
55 structure = chunk.structure
56 metadata = dict(parent.metadata) # preserve parent scoping (project_id, ...)
57 metadata.update(
58 {
59 "chunk_index": index,
60 "total_chunks": total,
61 "chunking_strategy": "docling",
62 "chunk_schema_version": self._config.chunk_schema_version,
63 "parent_document_id": parent.id,
64 "parent_document_title": parent.title,
65 "parent_document_url": parent.url,
66 "structure": self._structure_block(structure),
67 # legacy-compat hierarchy keys, derived from the structured block
68 "section_breadcrumb": " > ".join(structure.heading_path),
69 "section_depth": structure.heading_level,
70 "section_title": (
71 structure.heading_path[-1] if structure.heading_path else None
72 ),
73 }
74 )
75 # Contextual embedding: when enabled, the contextualized embed text (heading
76 # breadcrumb + body) becomes the stored content, so it flows into both the payload
77 # and the vector (the embedder embeds Document.content). Off => the bare body.
78 content = (
79 chunk.embed_text if self._config.include_context_in_embed else chunk.text
80 )
81 return Document(
82 id=Document.generate_chunk_id(parent.id, index),
83 title=parent.title,
84 content_type=parent.content_type,
85 content=content,
86 source_type=parent.source_type,
87 source=parent.source,
88 url=parent.url,
89 metadata=metadata,
90 )
92 @staticmethod
93 def _structure_block(structure: ChunkStructure) -> dict[str, Any]:
94 """Flatten :class:`ChunkStructure` into a JSON-safe payload block.
96 Tuples become lists and the bbox dataclasses become dicts so the payload is
97 plain JSON — no docling objects, no dataclasses reach Qdrant.
98 """
99 return {
100 "heading_path": list(structure.heading_path),
101 "heading_level": structure.heading_level,
102 "doc_items": list(structure.doc_items),
103 "is_table": structure.is_table,
104 "is_picture": structure.is_picture,
105 "page_start": structure.page_start,
106 "page_end": structure.page_end,
107 "bbox": [dataclasses.asdict(box) for box in structure.bbox],
108 "charspan": list(structure.charspan) if structure.charspan else None,
109 "caption": structure.caption,
110 "dl_meta_version": structure.dl_meta_version,
111 }