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

153 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-12 09:42 +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 typing import Any, Literal 

10 

11from jinja2 import meta 

12from jinja2.sandbox import SandboxedEnvironment 

13 

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

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

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

17from haystack.components.generators.utils import _trace_chat_generator_run 

18from haystack.core.serialization import component_to_dict 

19from haystack.dataclasses import ImageContent, TextContent 

20from haystack.dataclasses.chat_message import ChatMessage 

21from haystack.utils import deserialize_chatgenerator_inplace 

22from haystack.utils.async_utils import _execute_component_async 

23from haystack.utils.misc import _parse_dict_from_json 

24 

25logger = logging.getLogger(__name__) 

26 

27 

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

29DOCUMENT_CONTENT_KEY = "document_content" 

30 

31 

32DEFAULT_PROMPT_TEMPLATE = """ 

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

34 

35Extract the content from the provided image. 

36You need to extract the content exactly. 

37Format everything as markdown. 

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

39 

40**Visual Elements** 

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

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

43You must describe each visual element. 

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

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

46 

47**Tables** 

48Make sure to format the table in markdown. 

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

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

51The caption must be placed below the extracted table. 

52 

53**Forms** 

54Reproduce checkbox selections with markdown. 

55 

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

57 

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

59 

60Document:""" 

61 

62 

63@component 

64class LLMDocumentContentExtractor: 

65 """ 

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

67 

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

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

70 

71 Response handling: 

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

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

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

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

76 

77 The ChatGenerator can be configured to return JSON. For example, with the OpenAIResponsesChatGenerator, pass 

78 `{"text": {"format": {"type": "json_schema", ...}}}` in `generation_kwargs` as shown in the usage example below. 

79 

80 Documents that fail extraction are returned in `failed_documents` with the error in their `extraction_error` 

81 metadata field. 

82 

83 ### Usage example 

84 

85 ```python 

86 from haystack import Document 

87 from haystack.components.generators.chat import OpenAIResponsesChatGenerator 

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 = OpenAIResponsesChatGenerator( 

100 generation_kwargs={ 

101 "text": { 

102 "format": { 

103 "type": "json_schema", 

104 "name": "entity_extraction", 

105 "strict": False, 

106 "schema": { 

107 "type": "object", 

108 "properties": { 

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

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

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

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

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

114 }, 

115 "additionalProperties": False, 

116 }, 

117 }, 

118 } 

119 } 

120 ) 

121 

122 extractor = LLMDocumentContentExtractor( 

123 chat_generator=chat_generator, 

124 file_path_meta_field="file_path", 

125 raise_on_failure=False 

126 ) 

127 

128 documents = [ 

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

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

131 ] 

132 result = extractor.run(documents=documents) 

133 updated_documents = result["documents"] 

134 ``` 

135 """ 

136 

137 def __init__( 

138 self, 

139 *, 

140 chat_generator: ChatGenerator, 

141 prompt: str = DEFAULT_PROMPT_TEMPLATE, 

142 file_path_meta_field: str = "file_path", 

143 root_path: str | None = None, 

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

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

146 raise_on_failure: bool = False, 

147 max_workers: int = 3, 

148 ) -> None: 

149 """ 

150 Initialize the LLMDocumentContentExtractor component. 

151 

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

153 (e.g. `{"text": {"format": {"type": "json_schema", ...}}}` in `generation_kwargs` when using the 

154 OpenAIResponsesChatGenerator). 

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

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

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

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

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

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

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

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

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

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

165 :param raise_on_failure: If True, exceptions from the LLM are raised. If False, documents that fail extraction 

166 are returned in `failed_documents` with the error in their `extraction_error` metadata field. 

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

168 """ 

169 self._chat_generator = chat_generator 

170 self.prompt = prompt 

171 self.file_path_meta_field = file_path_meta_field 

172 self.root_path = root_path or "" 

173 self.detail = detail 

174 self.size = size 

175 LLMDocumentContentExtractor._validate_prompt_no_variables(prompt) 

176 self.raise_on_failure = raise_on_failure 

177 self.max_workers = max_workers 

178 self._document_to_image_content = DocumentToImageContent( 

179 file_path_meta_field=file_path_meta_field, root_path=root_path, detail=detail, size=size 

180 ) 

181 

182 def warm_up(self) -> None: 

183 """ 

184 Warm up the underlying chat generator. 

185 """ 

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

187 self._chat_generator.warm_up() 

188 

189 async def warm_up_async(self) -> None: 

190 """ 

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

192 """ 

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

194 await self._chat_generator.warm_up_async() 

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

196 self._chat_generator.warm_up() 

197 

198 def close(self) -> None: 

199 """ 

200 Release the underlying chat generator's resources. 

201 """ 

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

203 self._chat_generator.close() 

204 

205 async def close_async(self) -> None: 

206 """ 

207 Release the underlying chat generator's async resources. 

208 """ 

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

210 await self._chat_generator.close_async() 

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

212 self._chat_generator.close() 

213 

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

215 """ 

216 Serializes the component to a dictionary. 

217 

218 :returns: 

219 Dictionary with serialized data. 

220 """ 

221 return default_to_dict( 

222 self, 

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

224 prompt=self.prompt, 

225 file_path_meta_field=self.file_path_meta_field, 

226 root_path=self.root_path, 

227 detail=self.detail, 

228 size=self.size, 

229 raise_on_failure=self.raise_on_failure, 

230 max_workers=self.max_workers, 

231 ) 

232 

233 @classmethod 

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

235 """ 

236 Deserializes the component from a dictionary. 

237 

238 :param data: 

239 Dictionary with serialized data. 

240 :returns: 

241 An instance of the component. 

242 """ 

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

244 deserialize_chatgenerator_inplace(init_params, key="chat_generator") 

245 

246 return default_from_dict(cls, data) 

247 

248 @staticmethod 

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

250 ast = SandboxedEnvironment().parse(prompt) 

251 template_variables = meta.find_undeclared_variables(ast) 

252 variables = list(template_variables) 

253 if variables: 

254 raise ValueError( 

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

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

257 ) 

258 

259 @staticmethod 

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

261 """ 

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

263 

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

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

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

267 """ 

268 try: 

269 parsed = _parse_dict_from_json(response_text, raise_on_failure=True) 

270 except json.JSONDecodeError: 

271 return response_text, {}, None 

272 except ValueError: 

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

274 

275 content = parsed.get(DOCUMENT_CONTENT_KEY) 

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

277 return content, meta_updates, None 

278 

279 @staticmethod 

280 def _fail(document: Document, error: str) -> tuple[Document, bool]: 

281 """Return a copy of `document` with `extraction_error` set, flagged as failed.""" 

282 return replace(document, meta={**document.meta, "extraction_error": error}), False 

283 

284 def _run_on_thread( 

285 self, document: Document, image_content: ImageContent | None, parent_span: tracing.Span | None = None 

286 ) -> tuple[Document, bool]: 

287 """ 

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

289 

290 :param document: The document to extract content for. 

291 :param image_content: The image content for the document, or None if conversion failed. 

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

293 :returns: 

294 The updated document and True on success, or the document with failure metadata and False. 

295 """ 

296 if image_content is None: 

297 return self._fail(document, "Document could not be converted to an image, skipping LLM call.") 

298 

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

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

301 

302 try: 

303 with _trace_chat_generator_run( 

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

305 ) as span: 

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

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

308 except Exception as e: 

309 if self.raise_on_failure: 

310 raise e 

311 logger.exception( 

312 "LLMDocumentContentExtractor failed during chat generation. Returning the document in " 

313 "failed_documents. Error: {error}", 

314 error=e, 

315 ) 

316 return self._fail(document, "LLM failed with exception: " + str(e)) 

317 

318 return self._process_llm_results(document, result["replies"][0]) 

319 

320 async def _run_async( 

321 self, document: Document, image_content: ImageContent | None, parent_span: tracing.Span | None = None 

322 ) -> tuple[Document, bool]: 

323 """ 

324 Execute the LLM inference asynchronously for each document. 

325 

326 :param document: The document to extract content for. 

327 :param image_content: The image content for the document, or None if conversion failed. 

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

329 :returns: 

330 The updated document and True on success, or the document with failure metadata and False. 

331 """ 

332 if image_content is None: 

333 return self._fail(document, "Document could not be converted to an image, skipping LLM call.") 

334 

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

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

337 

338 try: 

339 with _trace_chat_generator_run( 

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

341 ) as span: 

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

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

344 except Exception as e: 

345 if self.raise_on_failure: 

346 raise e 

347 logger.exception( 

348 "LLMDocumentContentExtractor failed during chat generation. Returning the document in " 

349 "failed_documents. Error: {error}", 

350 error=e, 

351 ) 

352 return self._fail(document, "LLM failed with exception: " + str(e)) 

353 

354 return self._process_llm_results(document, result["replies"][0]) 

355 

356 @staticmethod 

357 def _process_llm_results(document: Document, reply: ChatMessage) -> tuple[Document, bool]: 

358 """ 

359 Process one document's LLM reply using the unified response logic. 

360 

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

362 """ 

363 # remove potentially existing error metadata from previous runs 

364 new_meta = {**document.meta} 

365 new_meta.pop("extraction_error", None) 

366 

367 # process the LLM response considering the possible response formats 

368 content, meta_updates, error = LLMDocumentContentExtractor._process_response(reply.text or "") 

369 

370 if error: 

371 logger.warning( 

372 "LLMDocumentContentExtractor couldn't use the ChatGenerator response for document {document_id}. " 

373 "Returning the document in failed_documents. Error: {error}", 

374 document_id=document.id, 

375 error=error, 

376 ) 

377 new_meta["extraction_error"] = error 

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

379 

380 new_meta.update(meta_updates) 

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

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

383 

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

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

386 """ 

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

388 

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

390 :returns: 

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

392 """ 

393 if not documents: 

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

395 

396 self.warm_up() 

397 

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

399 

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

401 parent_span = tracing.tracer.current_span() 

402 

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

404 results = executor.map( 

405 lambda document, image_content: self._run_on_thread(document, image_content, parent_span=parent_span), 

406 documents, 

407 image_contents, 

408 ) 

409 

410 successful_documents = [] 

411 failed_documents = [] 

412 for doc, success in results: 

413 if success: 

414 successful_documents.append(doc) 

415 else: 

416 failed_documents.append(doc) 

417 

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

419 

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

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

422 """ 

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

424 

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

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

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

428 blocking the event loop. 

429 

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

431 :returns: 

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

433 """ 

434 if not documents: 

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

436 

437 await self.warm_up_async() 

438 

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

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

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

442 image_contents = conversion_result["image_contents"] 

443 

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

445 parent_span = tracing.tracer.current_span() 

446 

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

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

449 

450 async def _bounded_run(document: Document, image_content: ImageContent | None) -> tuple[Document, bool]: 

451 async with sem: 

452 return await self._run_async(document, image_content, parent_span=parent_span) 

453 

454 results = await asyncio.gather( 

455 *[ 

456 _bounded_run(document, image_content) 

457 for document, image_content in zip(documents, image_contents, strict=True) 

458 ] 

459 ) 

460 

461 successful_documents = [] 

462 failed_documents = [] 

463 for doc, success in results: 

464 if success: 

465 successful_documents.append(doc) 

466 else: 

467 failed_documents.append(doc) 

468 

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