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

1"""Projects engine-neutral chunks into the qdrant-loader ``Document`` contract. 

2 

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: 

7 

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. 

13 

14Because it touches no docling type, this class and its tests run fast and offline. 

15""" 

16 

17from __future__ import annotations 

18 

19import dataclasses 

20from typing import TYPE_CHECKING, Any 

21 

22from qdrant_loader.core.document import Document 

23 

24from .config import ChunkingConfig 

25 

26if TYPE_CHECKING: 

27 from .outcome import Chunk 

28 from .structure import ChunkStructure 

29 

30 

31class ChunkDocumentMapper: 

32 """Chunk + parent Document -> chunk Documents (the stable payload contract).""" 

33 

34 def __init__(self, config: ChunkingConfig) -> None: 

35 self._config = config 

36 

37 def to_documents(self, chunks: list[Chunk], parent: Document) -> list[Document]: 

38 """Map chunks into chunk ``Document``s carrying the chunk payload contract. 

39 

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 ] 

51 

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 ) 

91 

92 @staticmethod 

93 def _structure_block(structure: ChunkStructure) -> dict[str, Any]: 

94 """Flatten :class:`ChunkStructure` into a JSON-safe payload block. 

95 

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 }