Coverage for src / quber / core / fusion / fusion.py: 31%

83 statements  

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

1"""Fuse the document and table extractions into the two corrected outputs. 

2 

3The fusion step: given a `ParseResult` (the document extraction) and the 

4Set-of-Mark/Camelot tables (the table extraction), match the two by overlap, 

5split any region SoM merged, annotate charts and image tables, and graft the 

6Camelot bodies into the docling spine. The result carries both corrected 

7outputs: the SoM/Camelot tables (split, annotated) and the unified 

8`DoclingDocument`. 

9 

10`fuse_artifacts` is the standalone core: it takes already-produced data and the 

11source PDF and knows nothing about how the data was produced, so a cloud job can 

12run it against artifacts loaded from S3. `DocumentFusion` is the local control 

13flow: it runs the document and table extractions in-process (concurrently — they 

14are independent), then calls `fuse_artifacts`. 

15""" 

16 

17from __future__ import annotations 

18 

19import asyncio 

20from pathlib import Path 

21from typing import Dict, List, Optional, Tuple 

22 

23from docling_core.types.doc.base import CoordOrigin 

24from docling_core.types.doc.document import TableItem 

25from loguru import logger 

26 

27from quber.agents.llm_client import LLMClient, get_llm_client 

28from quber.core.extractors.base import ExtractedTable 

29from quber.core.extractors.camelot.correspondence.geometry import camelot_bbox_to_norm 

30from quber.core.extractors.set_of_mark import SetOfMarkExtractor 

31from quber.core.extractors.set_of_mark.split import split_table 

32from quber.core.fusion.graft import build_unified_document 

33from quber.core.fusion.heading_review import review_headings 

34from quber.core.fusion.matching import match_tables 

35from quber.core.fusion.models import FusionResult, RegionMatch 

36from quber.core.parsers import Parser, ParseResult 

37 

38NormBox = Tuple[float, float, float, float] 

39 

40 

41async def fuse_artifacts( 

42 parse: ParseResult, 

43 som_tables: List[ExtractedTable], 

44 source: Path, 

45 llm: LLMClient, 

46) -> FusionResult: 

47 """Fuse already-produced document and table extractions into both outputs. 

48 

49 Takes the document extraction (`parse`), the table extraction (`som_tables`), 

50 and the source PDF (needed to re-extract sub-regions when a merged region is 

51 split). Knows nothing about how the inputs were produced, so the same call 

52 serves the local flow and a cloud job loading artifacts from S3. 

53 """ 

54 page_dims = {p.page_no: (p.width, p.height) for p in parse.pages} 

55 tables_by_ref = {t.self_ref: t for t in parse.document.tables} 

56 

57 matches = match_tables(parse, som_tables) 

58 

59 # Split each region SoM merged (docling found more tables than SoM). The 

60 # merged SoM table is replaced in place by its splits, then the whole set is 

61 # re-matched so the region reads as a 1:1 replace. 

62 som_tables, split_any = await _split_merged_regions( 

63 matches, som_tables, source, tables_by_ref, page_dims, llm 

64 ) 

65 if split_any: 

66 matches = match_tables(parse, som_tables) 

67 

68 # Annotate charts and image tables on the SoM output. 

69 _annotate(matches, som_tables) 

70 

71 # Graft the Camelot bodies into a clone of the docling spine. 

72 unified, errors = build_unified_document(parse.document, som_tables, matches, page_dims) 

73 for err in errors: 

74 logger.error("fuse: {}", err) 

75 

76 # Demote page decoration the parser labeled as section headings, so no 

77 # consumer carries a repeated banner or a leaked column label as context. 

78 heading_flags = await review_headings(unified, llm, source=str(source)) 

79 

80 return FusionResult( 

81 document=unified, 

82 tables=som_tables, 

83 matches=matches, 

84 errors=errors, 

85 heading_flags=heading_flags, 

86 parse=parse, 

87 ) 

88 

89 

90class DocumentFusion: 

91 """Local control flow: run both extractions in-process, then fuse them. 

92 

93 Holds the parser, the table extractor, and the LLM client used by the split 

94 pass. `fuse` runs the whole flow; `fuse_sync` wraps it for the CLI. The cloud 

95 flow does not use this class — it runs the document and table extractions as 

96 separate jobs and calls `fuse_artifacts` directly on the loaded artifacts. 

97 """ 

98 

99 def __init__( 

100 self, 

101 parser: Optional[Parser] = None, 

102 extractor: Optional[SetOfMarkExtractor] = None, 

103 llm: Optional[LLMClient] = None, 

104 preset: str = "tuned-financial", 

105 ) -> None: 

106 if parser is None: 

107 # Engine import deferred: building a default parser needs the 

108 # full docling package, but artifact-fed fusion (the CPU cloud 

109 # job) always passes no engines and must not import it. 

110 from quber.core.parsers import parser_for_preset 

111 

112 parser = parser_for_preset(preset) 

113 self.parser = parser 

114 self.llm = llm or get_llm_client(None) 

115 self.extractor = extractor or SetOfMarkExtractor(llm=self.llm) 

116 

117 async def fuse(self, source: Path) -> FusionResult: 

118 # docling parse and SoM extraction are independent; run them together. 

119 parse_task = asyncio.to_thread(self.parser.parse, source) 

120 som_tables = await self.extractor.extract_tables(source) 

121 parse: ParseResult = await parse_task 

122 return await fuse_artifacts(parse, som_tables, source, self.llm) 

123 

124 def fuse_sync(self, source: Path) -> FusionResult: 

125 return asyncio.run(self.fuse(source)) 

126 

127 

128async def _split_merged_regions( 

129 matches: List[RegionMatch], 

130 som_tables: List[ExtractedTable], 

131 source: Path, 

132 tables_by_ref: Dict[str, TableItem], 

133 page_dims: Dict[int, Tuple[float, float]], 

134 llm: LLMClient, 

135) -> Tuple[List[ExtractedTable], bool]: 

136 """Replace each SoM-merged table with the tables `split_table` recovers. 

137 

138 Only the single-SoM-table case is handled (one fused region docling sees as 

139 several); a region with more than one SoM table is too ambiguous to split and 

140 is left for the graft pass to report. 

141 """ 

142 out = list(som_tables) 

143 split_any = False 

144 for match in matches: 

145 if match.kind != "som_merged" or len(match.som_indices) != 1: 

146 continue 

147 idx = match.som_indices[0] 

148 count = len(match.docling_table_refs) 

149 boundaries = [ 

150 _docling_norm_box(tables_by_ref[r], page_dims, match.page) 

151 for r in match.docling_table_refs 

152 if r in tables_by_ref 

153 ] 

154 boundaries = [b for b in boundaries if b is not None] 

155 if len(boundaries) != count: 

156 continue 

157 # split_table reaches the source PDF through the table's own `source` 

158 # field; keep it pointed at the resolved local path. 

159 out[idx].source = str(source) 

160 splits = await split_table(out[idx], count, boundaries, llm) 

161 if len(splits) > 1: 

162 out[idx] = splits[0] 

163 # Insert the remaining splits right after, preserving order. 

164 for offset, extra in enumerate(splits[1:], start=1): 

165 out.insert(idx + offset, extra) 

166 split_any = True 

167 logger.info( 

168 "fuse: split SoM-merged region on page {} into {} tables", 

169 match.page, 

170 len(splits), 

171 ) 

172 return out, split_any 

173 

174 

175def _annotate(matches: List[RegionMatch], som_tables: List[ExtractedTable]) -> None: 

176 """Set `ExtractedTable.kind` for image tables and charts from the matches.""" 

177 for match in matches: 

178 if match.kind == "image_table": 

179 for i in match.som_indices: 

180 som_tables[i].kind = "image_table" 

181 elif match.kind == "chart": 

182 for i in match.som_indices: 

183 som_tables[i].kind = "chart" 

184 

185 

186def _docling_norm_box( 

187 table: TableItem, page_dims: Dict[int, Tuple[float, float]], page: int 

188) -> Optional[NormBox]: 

189 """A docling table's box in the normalized top-left frame split_table expects.""" 

190 if not table.prov: 

191 return None 

192 width, height = page_dims.get(page, (612.0, 792.0)) 

193 bbox = table.prov[0].bbox 

194 if bbox.coord_origin == CoordOrigin.TOPLEFT: 

195 return (bbox.l / width, bbox.t / height, bbox.r / width, bbox.b / height) 

196 return camelot_bbox_to_norm((bbox.l, bbox.b, bbox.r, bbox.t), width, height)