Coverage for haystack/components/extractors/image/llm_document_content_extractor.py: 99%

156 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-21 13:53 +0000

1# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai> 

2# 

3# SPDX-License-Identifier: Apache-2.0 

4 

5import asyncio 

6import json 

7from concurrent.futures import ThreadPoolExecutor 

8from dataclasses import replace 

9from functools import partial 

10from typing import Any, Literal 

11 

12from jinja2 import meta 

13from jinja2.sandbox import SandboxedEnvironment 

14 

15from haystack import Document, component, default_from_dict, default_to_dict, logging, tracing 

16from haystack.components.converters.image.document_to_image import DocumentToImageContent 

17from haystack.components.generators.chat.types import ChatGenerator 

18from haystack.components.generators.utils import _trace_chat_generator_run 

19from haystack.core.serialization import component_to_dict 

20from haystack.dataclasses import ImageContent, TextContent 

21from haystack.dataclasses.chat_message import ChatMessage 

22from haystack.utils import deserialize_chatgenerator_inplace 

23from haystack.utils.async_utils import _execute_component_async 

24from haystack.utils.misc import _parse_dict_from_json 

25 

26logger = logging.getLogger(__name__) 

27 

28 

29# Reserved key in the LLM JSON response that holds the main document text. 

30DOCUMENT_CONTENT_KEY = "document_content" 

31 

32 

33DEFAULT_PROMPT_TEMPLATE = """ 

34You are part of an information extraction pipeline that extracts the content of image-based documents. 

35 

36Extract the content from the provided image. 

37You need to extract the content exactly. 

38Format everything as markdown. 

39Make sure to retain the reading order of the document. 

40 

41**Visual Elements** 

42Do not extract figures, drawings, maps, graphs or any other visual elements. 

43Instead, add a caption that describes briefly what you see in the visual element. 

44You must describe each visual element. 

45If you only see a visual element without other content, you must describe this visual element. 

46Enclose each image caption with [img-caption][/img-caption] 

47 

48**Tables** 

49Make sure to format the table in markdown. 

50Add a short caption below the table that describes the table's content. 

51Enclose each table caption with [table-caption][/table-caption]. 

52The caption must be placed below the extracted table. 

53 

54**Forms** 

55Reproduce checkbox selections with markdown. 

56 

57Return a single JSON object. It must contain the key "document_content" with the extracted text as value. 

58 

59No markdown, no code fence, only raw JSON. 

60 

61Document:""" 

62 

63 

64@component 

65class LLMDocumentContentExtractor: 

66 """ 

67 Extracts textual content and optionally metadata from image-based documents using a vision-enabled LLM. 

68 

69 One prompt and one LLM call per document. The component converts each document to an image via 

70 DocumentToImageContent and sends it to the ChatGenerator. The prompt must not contain Jinja variables. 

71 

72 Response handling: 

73 - If the LLM returns a **plain string** (non-JSON or not a JSON object), it is written to the document's content. 

74 - If the LLM returns a **JSON object with only the key** `document_content`, that value is written to content. 

75 - If the LLM returns a **JSON object with multiple keys**, the value of ``document_content`` (if present) is 

76 written to content and all other keys are merged into the document's metadata. 

77 

78 The ChatGenerator can be configured to return JSON (e.g. ``response_format={"type": "json_object"}`` 

79 in ``generation_kwargs``). 

80 

81 Documents that fail extraction are returned in ``failed_documents`` with ``content_extraction_error`` in metadata. 

82 

83 ### Usage example 

84 

85 ```python 

86 from haystack import Document 

87 from haystack.components.generators.chat import OpenAIChatGenerator 

88 from haystack.components.extractors.image import LLMDocumentContentExtractor 

89 

90 prompt = \"\"\" 

91 Extract the content from the provided image. 

92 Format everything as markdown. Return only the extracted content as a JSON object with the key 'document_content'. 

93 No markdown, no code fence, only raw JSON. 

94 

95 Extract metadata about the image like source of the image, date of creation, etc. if you can. 

96 Return this metadata as additional key-value pairs in the same JSON object. 

97 \"\"\" 

98 

99 chat_generator = OpenAIChatGenerator( 

100 generation_kwargs={ 

101 "response_format": { 

102 "type": "json_schema", 

103 "json_schema": { 

104 "name": "entity_extraction", 

105 "schema": { 

106 "type": "object", 

107 "properties": { 

108 "document_content": {"type": "string"}, 

109 "author": {"type": "string"}, 

110 "date": {"type": "string"}, 

111 "document_type": {"type": "string"}, 

112 "title": {"type": "string"}, 

113 }, 

114 "additionalProperties": False, 

115 }, 

116 }, 

117 } 

118 } 

119 ) 

120 

121 extractor = LLMDocumentContentExtractor( 

122 chat_generator=chat_generator, 

123 file_path_meta_field="file_path", 

124 raise_on_failure=False 

125 ) 

126 

127 documents = [ 

128 Document(content="", meta={"file_path": "test/test_files/images/image_metadata.png"}), 

129 Document(content="", meta={"file_path": "test/test_files/images/apple.jpg", "page_number": 1}) 

130 ] 

131 result = extractor.run(documents=documents) 

132 updated_documents = result["documents"] 

133 ``` 

134 """ 

135 

136 def __init__( 

137 self, 

138 *, 

139 chat_generator: ChatGenerator, 

140 prompt: str = DEFAULT_PROMPT_TEMPLATE, 

141 file_path_meta_field: str = "file_path", 

142 root_path: str | None = None, 

143 detail: Literal["auto", "high", "low"] | None = None, 

144 size: tuple[int, int] | None = None, 

145 raise_on_failure: bool = False, 

146 max_workers: int = 3, 

147 ) -> None: 

148 """ 

149 Initialize the LLMDocumentContentExtractor component. 

150 

151 :param chat_generator: A ChatGenerator that supports vision input. Optionally configured for JSON 

152 (e.g. ``response_format={"type": "json_object"}`` in ``generation_kwargs``). 

153 :param prompt: Prompt for extraction. Must not contain Jinja variables. 

154 :param file_path_meta_field: The metadata field in the Document that contains the file path to the image or PDF. 

155 :param root_path: The root directory path where document files are located. If provided, file paths in 

156 document metadata will be resolved relative to this path and are guaranteed to stay within it. If None, 

157 file paths are treated as absolute paths with no containment check. 

158 Security: this component reads the file referenced by `file_path_meta_field` from the host filesystem. If 

159 document metadata may be influenced by untrusted input, set `root_path` to a dedicated data directory so 

160 that path-traversal payloads (e.g. absolute paths or `../`) are rejected instead of read. 

161 :param detail: Optional detail level of the image (only supported by OpenAI). Can be "auto", "high", or "low". 

162 :param size: If provided, resizes the image to fit within (width, height) while keeping aspect ratio. 

163 :param raise_on_failure: If True, exceptions from the LLM are raised. If False, failed documents are returned. 

164 :param max_workers: Maximum number of threads for parallel LLM calls. 

165 """ 

166 self._chat_generator = chat_generator 

167 self.prompt = prompt 

168 self.file_path_meta_field = file_path_meta_field 

169 self.root_path = root_path or "" 

170 self.detail = detail 

171 self.size = size 

172 LLMDocumentContentExtractor._validate_prompt_no_variables(prompt) 

173 self.raise_on_failure = raise_on_failure 

174 self.max_workers = max_workers 

175 self._document_to_image_content = DocumentToImageContent( 

176 file_path_meta_field=file_path_meta_field, root_path=root_path, detail=detail, size=size 

177 ) 

178 

179 def warm_up(self) -> None: 

180 """ 

181 Warm up the underlying chat generator. 

182 """ 

183 if hasattr(self._chat_generator, "warm_up"): 

184 self._chat_generator.warm_up() 

185 

186 async def warm_up_async(self) -> None: 

187 """ 

188 Warm up the underlying chat generator on the serving event loop. 

189 """ 

190 if hasattr(self._chat_generator, "warm_up_async"): 

191 await self._chat_generator.warm_up_async() 

192 elif hasattr(self._chat_generator, "warm_up"): 

193 self._chat_generator.warm_up() 

194 

195 def close(self) -> None: 

196 """ 

197 Release the underlying chat generator's resources. 

198 """ 

199 if hasattr(self._chat_generator, "close"): 

200 self._chat_generator.close() 

201 

202 async def close_async(self) -> None: 

203 """ 

204 Release the underlying chat generator's async resources. 

205 """ 

206 if hasattr(self._chat_generator, "close_async"): 

207 await self._chat_generator.close_async() 

208 elif hasattr(self._chat_generator, "close"): 

209 self._chat_generator.close() 

210 

211 def to_dict(self) -> dict[str, Any]: 

212 """ 

213 Serializes the component to a dictionary. 

214 

215 :returns: 

216 Dictionary with serialized data. 

217 """ 

218 return default_to_dict( 

219 self, 

220 chat_generator=component_to_dict(obj=self._chat_generator, name="chat_generator"), 

221 prompt=self.prompt, 

222 file_path_meta_field=self.file_path_meta_field, 

223 root_path=self.root_path, 

224 detail=self.detail, 

225 size=self.size, 

226 raise_on_failure=self.raise_on_failure, 

227 max_workers=self.max_workers, 

228 ) 

229 

230 @classmethod 

231 def from_dict(cls, data: dict[str, Any]) -> "LLMDocumentContentExtractor": 

232 """ 

233 Deserializes the component from a dictionary. 

234 

235 :param data: 

236 Dictionary with serialized data. 

237 :returns: 

238 An instance of the component. 

239 """ 

240 init_params = data.get("init_parameters", {}) 

241 deserialize_chatgenerator_inplace(init_params, key="chat_generator") 

242 

243 return default_from_dict(cls, data) 

244 

245 @staticmethod 

246 def _validate_prompt_no_variables(prompt: str) -> None: 

247 ast = SandboxedEnvironment().parse(prompt) 

248 template_variables = meta.find_undeclared_variables(ast) 

249 variables = list(template_variables) 

250 if variables: 

251 raise ValueError( 

252 f"The prompt must not have any variables, only instructions on how to extract the content of the " 

253 f"the image-based document. Found {','.join(variables)} in the prompt." 

254 ) 

255 

256 @staticmethod 

257 def _process_response(response_text: str) -> tuple[str | None, dict[str, Any], str | None]: 

258 """ 

259 Parse LLM response. Returns (content, meta_updates, error). 

260 

261 - Plain string (non-JSON): use entire response as document content; 

262 - Valid JSON object: use key ``document_content`` for Document.content and all other keys for Document.metadata; 

263 - Valid JSON but not an object (e.g. array or primitive), report an error; 

264 """ 

265 try: 

266 parsed = _parse_dict_from_json(response_text, raise_on_failure=True) 

267 except json.JSONDecodeError: 

268 return response_text, {}, None 

269 except ValueError: 

270 return None, {}, "Response must be a JSON object, not an array or primitive." 

271 

272 content = parsed.get(DOCUMENT_CONTENT_KEY) 

273 meta_updates = {k: v for k, v in parsed.items() if k != DOCUMENT_CONTENT_KEY} 

274 return content, meta_updates, None 

275 

276 def _run_on_thread( 

277 self, image_content: ImageContent | None, parent_span: tracing.Span | None = None 

278 ) -> dict[str, Any]: 

279 """ 

280 Execute the LLM inference in a separate thread for each document. 

281 

282 :param image_content: The image content for one document, or None if conversion failed. 

283 :param parent_span: Span to nest the generator span under, captured on the calling thread. 

284 :returns: 

285 The LLM response if successful, or a dictionary with an "error" key on failure. 

286 """ 

287 if image_content is None: 

288 return {"error": "Document has no content, skipping LLM call."} 

289 

290 # the prompt is the same for all documents, so we can set it up once here for each document/thread 

291 message = ChatMessage.from_user(content_parts=[TextContent(text=self.prompt), image_content]) 

292 

293 try: 

294 with _trace_chat_generator_run( 

295 self._chat_generator, {"messages": [message]}, parent_span=parent_span 

296 ) as span: 

297 result = self._chat_generator.run(messages=[message]) 

298 span.set_content_tag("haystack.component.output", result) 

299 except Exception as e: 

300 if self.raise_on_failure: 

301 raise e 

302 logger.exception( 

303 "LLM {class_name} execution failed. Skipping metadata extraction. Failed with exception '{error}'.", 

304 class_name=self._chat_generator.__class__.__name__, 

305 error=e, 

306 ) 

307 result = {"error": "LLM failed with exception: " + str(e)} 

308 

309 return result 

310 

311 async def _run_async( 

312 self, image_content: ImageContent | None, parent_span: tracing.Span | None = None 

313 ) -> dict[str, Any]: 

314 """ 

315 Execute the LLM inference asynchronously for each document. 

316 

317 :param image_content: The image content for one document, or None if conversion failed. 

318 :param parent_span: Span to nest the generator span under, captured on the calling task. 

319 :returns: 

320 The LLM response if successful, or a dictionary with an "error" key on failure. 

321 """ 

322 if image_content is None: 

323 return {"error": "Document has no content, skipping LLM call."} 

324 

325 # the prompt is the same for all documents, so we can set it up once here for each document 

326 message = ChatMessage.from_user(content_parts=[TextContent(text=self.prompt), image_content]) 

327 

328 try: 

329 with _trace_chat_generator_run( 

330 self._chat_generator, {"messages": [message]}, parent_span=parent_span 

331 ) as span: 

332 result = await _execute_component_async(self._chat_generator, messages=[message]) 

333 span.set_content_tag("haystack.component.output", result) 

334 except Exception as e: 

335 if self.raise_on_failure: 

336 raise e 

337 logger.exception( 

338 "LLM {class_name} execution failed. Skipping metadata extraction. Failed with exception '{error}'.", 

339 class_name=self._chat_generator.__class__.__name__, 

340 error=e, 

341 ) 

342 result = {"error": "LLM failed with exception: " + str(e)} 

343 

344 return result 

345 

346 @staticmethod 

347 def _process_llm_results(document: Document, result: dict[str, Any]) -> tuple[Document, bool]: 

348 """ 

349 Process one document's LLM result using the unified response logic. 

350 

351 Returns (updated_document, True if success else False). 

352 """ 

353 if "error" in result: 

354 new_meta = {**document.meta, "extraction_error": result["error"]} 

355 return replace(document, meta=new_meta), False 

356 

357 # remove potentially existing error metadata from previous runs 

358 new_meta = {**document.meta} 

359 new_meta.pop("extraction_error", None) 

360 

361 # process the LLM response considering the possible response formats 

362 response_text = result["replies"][0].text 

363 content, meta_updates, error = LLMDocumentContentExtractor._process_response(response_text) 

364 

365 if error: 

366 new_meta["extraction_error"] = error 

367 return replace(document, meta=new_meta), False 

368 

369 new_meta.update(meta_updates) 

370 final_content = document.content if content is None else content 

371 return replace(document, content=final_content, meta=new_meta), True 

372 

373 @component.output_types(documents=list[Document], failed_documents=list[Document]) 

374 def run(self, documents: list[Document]) -> dict[str, list[Document]]: 

375 """ 

376 Run extraction on image-based documents. One LLM call per document. 

377 

378 :param documents: A list of image-based documents to process. Each must have a valid file path in its metadata. 

379 :returns: 

380 A dictionary with "documents" (successfully processed) and "failed_documents" (with failure metadata). 

381 """ 

382 if not documents: 

383 return {"documents": [], "failed_documents": []} 

384 

385 self.warm_up() 

386 

387 image_contents = self._document_to_image_content.run(documents=documents)["image_contents"] 

388 

389 # Capture the current span here so worker threads nest their generator spans under the component span. 

390 parent_span = tracing.tracer.current_span() 

391 

392 with ThreadPoolExecutor(max_workers=self.max_workers) as executor: 

393 results = executor.map(partial(self._run_on_thread, parent_span=parent_span), image_contents) 

394 

395 successful_documents = [] 

396 failed_documents = [] 

397 for document, result in zip(documents, results, strict=True): 

398 doc, success = self._process_llm_results(document, result) 

399 if success: 

400 successful_documents.append(doc) 

401 else: 

402 failed_documents.append(doc) 

403 

404 return {"documents": successful_documents, "failed_documents": failed_documents} 

405 

406 @component.output_types(documents=list[Document], failed_documents=list[Document]) 

407 async def run_async(self, documents: list[Document]) -> dict[str, list[Document]]: 

408 """ 

409 Asynchronously run extraction on image-based documents. One LLM call per document. 

410 

411 This is the asynchronous version of the `run` method. It has the same parameters and return values 

412 but can be used with `await` in an async code. LLM calls are made concurrently, bounded by `max_workers`. 

413 If the chat generator only implements a synchronous `run` method, it is executed in a thread to avoid 

414 blocking the event loop. 

415 

416 :param documents: A list of image-based documents to process. Each must have a valid file path in its metadata. 

417 :returns: 

418 A dictionary with "documents" (successfully processed) and "failed_documents" (with failure metadata). 

419 """ 

420 if not documents: 

421 return {"documents": [], "failed_documents": []} 

422 

423 await self.warm_up_async() 

424 

425 # Reading the files and rendering PDF pages is blocking work and `DocumentToImageContent` has no 

426 # `run_async`, so it runs in a thread instead of on the event loop. 

427 conversion_result = await _execute_component_async(self._document_to_image_content, documents=documents) 

428 image_contents = conversion_result["image_contents"] 

429 

430 # Capture the current span here so concurrent tasks nest their generator spans under the component span. 

431 parent_span = tracing.tracer.current_span() 

432 

433 # Run the LLM on each image content, bounding concurrency per task so max_workers is enforced. 

434 sem = asyncio.Semaphore(max(1, self.max_workers)) 

435 

436 async def _bounded_run(image_content: ImageContent | None) -> dict[str, Any]: 

437 async with sem: 

438 return await self._run_async(image_content, parent_span=parent_span) 

439 

440 results = await asyncio.gather(*[_bounded_run(image_content) for image_content in image_contents]) 

441 

442 successful_documents = [] 

443 failed_documents = [] 

444 for document, result in zip(documents, results, strict=True): 

445 doc, success = self._process_llm_results(document, result) 

446 if success: 

447 successful_documents.append(doc) 

448 else: 

449 failed_documents.append(doc) 

450 

451 return {"documents": successful_documents, "failed_documents": failed_documents}