Coverage for src / quber / core / parsers / docling_parser.py: 77%

83 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-09-23 22:14 -0400

1""" 

2DoclingParser — docling pipeline machinery behind the `Parser` ABC. 

3 

4`DoclingParser` is abstract: it owns the converter construction, the run 

5fingerprint, and the `parse` template, but defers the pipeline-option bundle 

6to a concrete subclass. The configuration is a *type*, not a runtime flag: 

7 

8- `TunedFinancialParser` — the canonical configuration selected by QUE-217 and 

9 adopted by QUE-218 (TableFormerMode.ACCURATE, DoclingParse backend, OCR, 

10 picture classification, CUDA, page_batch_size=32). The default everywhere. 

11- `LegacyDoclingParser` — the pre-QUE-218 behaviour (PdfPipelineOptions with 

12 only do_ocr / do_table_structure / do_cell_matching set). Kept for 

13 regression comparison and as a fallback; select it with `--preset legacy`. 

14 

15Use `parser_for_preset(preset)` to map a preset string to the right class. 

16 

17The header-consolidation post-processor that builds `TableMetadata` lives in 

18`core.consolidation` and operates on the returned DoclingDocument. That keeps 

19these classes focused on a single job: source -> DoclingDocument. 

20""" 

21 

22import os 

23from abc import abstractmethod 

24from pathlib import Path 

25from typing import Any, ClassVar 

26 

27import logfire 

28from docling.backend.docling_parse_backend import DoclingParseDocumentBackend 

29from docling.datamodel.accelerator_options import AcceleratorDevice, AcceleratorOptions 

30from docling.datamodel.base_models import InputFormat 

31from docling.datamodel.pipeline_options import ( 

32 PdfPipelineOptions, 

33 RapidOcrOptions, 

34 TableFormerMode, 

35) 

36from docling.datamodel.settings import settings 

37from docling.document_converter import DocumentConverter, PdfFormatOption 

38from docling.utils.accelerator_utils import decide_device 

39 

40from quber.core.parsers.base import Parser 

41from quber.core.parsers.result import ParseResult 

42 

43CANONICAL_PAGE_BATCH_SIZE = 32 

44 

45 

46class DoclingParser(Parser): 

47 """Abstract docling parser. Subclasses define the pipeline-option bundle. 

48 

49 Subclasses set the `preset` and `backend_label` class attributes and 

50 implement `build_pipeline_options`. Everything else (converter, perf 

51 settings, fingerprint, the `parse` template) is shared. 

52 """ 

53 

54 #: Stable name of the configuration, surfaced in the run fingerprint. 

55 preset: ClassVar[str] 

56 #: How the PDF backend is reported in the fingerprint. 

57 backend_label: ClassVar[str] 

58 

59 def __init__( 

60 self, 

61 do_ocr: bool = False, 

62 do_table_structure: bool = True, 

63 do_cell_matching: bool = True, 

64 page_batch_size: int = CANONICAL_PAGE_BATCH_SIZE, 

65 accelerator_device: AcceleratorDevice = AcceleratorDevice.AUTO, 

66 num_threads: int | None = None, 

67 ) -> None: 

68 self.do_ocr = do_ocr 

69 self.do_table_structure = do_table_structure 

70 self.do_cell_matching = do_cell_matching 

71 self.page_batch_size = page_batch_size 

72 self.accelerator_device = accelerator_device 

73 self.num_threads = num_threads if num_threads is not None else (os.cpu_count() or 4) 

74 

75 @abstractmethod 

76 def build_pipeline_options(self) -> PdfPipelineOptions: 

77 """Return the docling pipeline options for this configuration.""" 

78 ... 

79 

80 def build_converter(self, pipeline_options: PdfPipelineOptions) -> DocumentConverter: 

81 """Default converter (no explicit backend). Overridden by presets that pin one.""" 

82 return DocumentConverter( 

83 format_options={InputFormat.PDF: PdfFormatOption(pipeline_options=pipeline_options)} 

84 ) 

85 

86 def apply_perf_settings(self) -> None: 

87 """Hook for process-wide perf tweaks. No-op by default.""" 

88 

89 def fingerprint(self, pipeline_options: PdfPipelineOptions) -> dict[str, Any]: 

90 device: Any = pipeline_options.accelerator_options.device 

91 device_name = device.name if hasattr(device, "name") else str(device) 

92 mode: Any = pipeline_options.table_structure_options.mode # type: ignore[union-attr] 

93 mode_name = mode.name if hasattr(mode, "name") else str(mode) 

94 ocr_engine = pipeline_options.ocr_options.__class__.__name__ 

95 ocr_cuda = isinstance(pipeline_options.ocr_options, RapidOcrOptions) and bool( 

96 (pipeline_options.ocr_options.rapidocr_params or {}).get( 

97 "EngineConfig.onnxruntime.use_cuda", False 

98 ) 

99 ) 

100 resolved_device = decide_device(self.accelerator_device.value) 

101 return { 

102 "preset": self.preset, 

103 "table_mode": mode_name, 

104 "do_cell_matching": pipeline_options.table_structure_options.do_cell_matching, # type: ignore[union-attr] 

105 "do_ocr": pipeline_options.do_ocr, 

106 "do_picture_classification": pipeline_options.do_picture_classification, 

107 "generate_picture_images": pipeline_options.generate_picture_images, 

108 "backend": self.backend_label, 

109 "accelerator_device_requested": device_name, 

110 "accelerator_device_resolved": resolved_device, 

111 "num_threads": pipeline_options.accelerator_options.num_threads, 

112 "page_batch_size": self.page_batch_size, 

113 "ocr_engine": ocr_engine, 

114 "ocr_cuda": ocr_cuda, 

115 } 

116 

117 def parse(self, source: Path) -> ParseResult: 

118 pipeline_options = self.build_pipeline_options() 

119 # Keep docling's parsed-page cells (text, box, from_ocr, confidence) alive 

120 # past document assembly. Off by default, so docling frees them; they are 

121 # the OCR-vs-native signal the reconciliation needs, so capture them for 

122 # every preset. 

123 pipeline_options.generate_parsed_pages = True 

124 converter = self.build_converter(pipeline_options) 

125 self.apply_perf_settings() 

126 

127 fingerprint = self.fingerprint(pipeline_options) 

128 with logfire.span("docling_parser.parse", source=str(source), **fingerprint): 

129 result = converter.convert(str(source)) 

130 return ParseResult.from_conversion(result) 

131 

132 

133class LegacyDoclingParser(DoclingParser): 

134 """Pre-QUE-218 docling defaults: bare PdfPipelineOptions, default backend.""" 

135 

136 preset = "legacy" 

137 backend_label = "default" 

138 

139 def build_pipeline_options(self) -> PdfPipelineOptions: 

140 pipeline_options = PdfPipelineOptions() 

141 pipeline_options.do_table_structure = self.do_table_structure 

142 pipeline_options.table_structure_options.do_cell_matching = self.do_cell_matching # type: ignore[misc] 

143 pipeline_options.do_ocr = self.do_ocr 

144 return pipeline_options 

145 

146 

147class TunedFinancialParser(DoclingParser): 

148 """Canonical QUE-218 configuration for dense SEC financial documents.""" 

149 

150 preset = "tuned-financial" 

151 backend_label = DoclingParseDocumentBackend.__name__ 

152 

153 def build_pipeline_options(self) -> PdfPipelineOptions: 

154 pipeline_options = PdfPipelineOptions() 

155 pipeline_options.do_table_structure = self.do_table_structure 

156 pipeline_options.table_structure_options.do_cell_matching = self.do_cell_matching # type: ignore[misc] 

157 pipeline_options.do_ocr = True 

158 pipeline_options.table_structure_options.mode = TableFormerMode.ACCURATE # type: ignore[misc] 

159 pipeline_options.do_picture_classification = True 

160 pipeline_options.generate_picture_images = True 

161 pipeline_options.accelerator_options = AcceleratorOptions( 

162 device=self.accelerator_device, 

163 num_threads=self.num_threads, 

164 ) 

165 # QUE-219: docling propagates accelerator_options.device=CUDA to RapidOCR's 

166 # paddle/torch engines but not the onnxruntime engine (which is the default). 

167 # Flip EngineConfig.onnxruntime.use_cuda explicitly so the OCR pass actually 

168 # runs on GPU. CUDA 12 runtime libs preloaded in quber/__init__.py. 

169 # Gate on the *resolved* device (decide_device handles AUTO -> cuda:0/cpu/mps) 

170 # so non-GPU hosts (e.g., Intel MacBook) skip the override and run OCR on CPU. 

171 resolved_device = decide_device(self.accelerator_device.value) 

172 if resolved_device.startswith("cuda"): 

173 pipeline_options.ocr_options = RapidOcrOptions( 

174 rapidocr_params={ 

175 "EngineConfig.onnxruntime.use_cuda": True, 

176 "EngineConfig.onnxruntime.cuda_ep_cfg.device_id": 0, 

177 }, 

178 ) 

179 return pipeline_options 

180 

181 def build_converter(self, pipeline_options: PdfPipelineOptions) -> DocumentConverter: 

182 return DocumentConverter( 

183 format_options={ 

184 InputFormat.PDF: PdfFormatOption( 

185 pipeline_options=pipeline_options, 

186 backend=DoclingParseDocumentBackend, 

187 ) 

188 } 

189 ) 

190 

191 def apply_perf_settings(self) -> None: 

192 settings.perf.page_batch_size = self.page_batch_size 

193 

194 

195#: Preset name -> concrete parser class. The CLI `--preset` choices mirror these keys. 

196PARSERS_BY_PRESET: dict[str, type[DoclingParser]] = { 

197 "tuned-financial": TunedFinancialParser, 

198 "legacy": LegacyDoclingParser, 

199} 

200 

201 

202def parser_for_preset(preset: str, **kwargs: Any) -> DoclingParser: 

203 """Instantiate the parser for a preset name (forwards kwargs to the class).""" 

204 try: 

205 cls = PARSERS_BY_PRESET[preset] 

206 except KeyError: 

207 valid = ", ".join(sorted(PARSERS_BY_PRESET)) 

208 raise ValueError(f"Unknown parser preset {preset!r}; valid presets: {valid}") from None 

209 return cls(**kwargs)