Skip to content

Commit 347781a

Browse files
Hansehartanakin87
andauthored
feat: Metadata as input for MistalOCRDocumentConverter (#2457)
* Revise MCPTool usage example for Streamable HTTP Updated example usage in MCPTool documentation to reflect Streamable HTTP usage and mentioned deprecated SSE. * Clarify connection types in MCPToolset documentation Updated documentation to reflect changes in connection types for MCPToolset. * fix: Align with hatch run fmt requirements * add: MistralOCRDocumentConverter * add: project files * fix: example lib usage * move: ocr document converter into child /mistral * add: example usage with annotations * add: hatch run fmt * add: mistralai * fix: python3.9 compatibility with using Union, List, Optional * add: new comments and their position * add: moved schemas from init into run to bypass problems with serializing * add: docstring convention * add: process mutliple documents * add: robust api handling with catching mistral errors * add: Union[str, Path, ByteStream] as input * add: comment for new inputs * add: pipeline example * fix: example ocr component * fix: mistral file upload and pydantic v2 models * add: pipeline example * add: hint on document annotation page limit * add: mistralai as project dependency * fix: hatch run fmt * fix: hatch run docs * add: exlcuse mistral from compliance workflow (its apache 2.0) * add: 3 initialization tests * add: 4 se test * add: test w/ proper mocking * add: real api test when env is set * add: delete files by default from mistral if uploaded * fix: mock file deletion * fix: hatch run fmt * Apply suggestion from @anakin87 Co-authored-by: Stefano Fiorucci <stefanofiorucci@gmail.com> * Update integrations/mistral/src/haystack_integrations/components/converters/mistral/ocr_document_converter.py Co-authored-by: Stefano Fiorucci <stefanofiorucci@gmail.com> * fix: nested try excepts * add: mention file upload * Update integrations/mistral/tests/test_ocr_document_converter.py Co-authored-by: Stefano Fiorucci <stefanofiorucci@gmail.com> * Update integrations/mistral/tests/test_ocr_document_converter.py Co-authored-by: Stefano Fiorucci <stefanofiorucci@gmail.com> * add: less test code due to pytest.mark..parametrize * add: less tests and const class type * fix: format * add: ocr document converter to docusaurus * add: converter to mistral * add: metadata handling in run method following other converters * fix(mistral): hatch run fmt * Update integrations/mistral/src/haystack_integrations/components/converters/mistral/ocr_document_converter.py Co-authored-by: Stefano Fiorucci <stefanofiorucci@gmail.com> --------- Co-authored-by: Stefano Fiorucci <stefanofiorucci@gmail.com>
1 parent 70d0b7e commit 347781a

2 files changed

Lines changed: 100 additions & 6 deletions

File tree

integrations/mistral/src/haystack_integrations/components/converters/mistral/ocr_document_converter.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44
from typing import Any, Dict, List, Optional, Type, Union
55

66
from haystack import Document, component, default_from_dict, default_to_dict, logging
7-
from haystack.components.converters.utils import get_bytestream_from_source
7+
from haystack.components.converters.utils import (
8+
get_bytestream_from_source,
9+
normalize_metadata,
10+
)
811
from haystack.dataclasses import ByteStream
912
from haystack.utils import Secret, deserialize_secrets_inplace
1013
from mistralai import Mistral
@@ -173,6 +176,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "MistralOCRDocumentConverter":
173176
def run(
174177
self,
175178
sources: List[Union[str, Path, ByteStream, DocumentURLChunk, FileChunk, ImageURLChunk]],
179+
meta: Optional[Union[Dict[str, Any], List[Dict[str, Any]]]] = None,
176180
bbox_annotation_schema: Optional[Type[BaseModel]] = None,
177181
document_annotation_schema: Optional[Type[BaseModel]] = None,
178182
) -> Dict[str, Any]:
@@ -187,6 +191,11 @@ def run(
187191
- DocumentURLChunk: Mistral chunk for document URLs (signed or public URLs to PDFs, etc.)
188192
- ImageURLChunk: Mistral chunk for image URLs (signed or public URLs to images)
189193
- FileChunk: Mistral chunk for file IDs (files previously uploaded to Mistral)
194+
:param meta:
195+
Optional metadata to attach to the Documents.
196+
This value can be either a list of dictionaries or a single dictionary.
197+
If it's a single dictionary, its content is added to the metadata of all produced Documents.
198+
If it's a list, the length of the list must match the number of sources, because they will be zipped.
190199
:param bbox_annotation_schema:
191200
Optional Pydantic model for structured annotations per bounding box.
192201
When provided, a Vision LLM analyzes each image region and returns structured data.
@@ -217,14 +226,18 @@ def run(
217226
response_format_from_pydantic_model(document_annotation_schema) if document_annotation_schema else None
218227
)
219228

229+
# Normalize metadata
230+
meta_list = normalize_metadata(meta, sources_count=len(sources))
231+
220232
# Process each source
221233
documents = []
222234
raw_responses = []
223235
uploaded_file_ids = []
224236

225-
for source in sources:
237+
for source, user_metadata in zip(sources, meta_list):
226238
document, raw_response, uploaded_file_id = self._process_single_source(
227239
source,
240+
user_metadata,
228241
bbox_annotation_format,
229242
document_annotation_format,
230243
document_annotation_schema,
@@ -247,6 +260,7 @@ def run(
247260
def _process_single_source(
248261
self,
249262
source: Union[str, Path, ByteStream, DocumentURLChunk, FileChunk, ImageURLChunk],
263+
user_metadata: Dict[str, Any],
250264
bbox_annotation_format: Optional[Any],
251265
document_annotation_format: Optional[Any],
252266
document_annotation_schema: Optional[Type[BaseModel]],
@@ -256,6 +270,8 @@ def _process_single_source(
256270
257271
:param source:
258272
The source to process.
273+
:param user_metadata:
274+
User-provided metadata to attach to the document.
259275
:param bbox_annotation_format:
260276
Optional response format for bounding box annotations.
261277
:param document_annotation_format:
@@ -286,7 +302,7 @@ def _process_single_source(
286302
document_annotation_format=document_annotation_format,
287303
)
288304

289-
document = self._process_ocr_response(ocr_response, document_annotation_schema)
305+
document = self._process_ocr_response(ocr_response, user_metadata, document_annotation_schema)
290306
return (document, ocr_response.model_dump(), uploaded_file_id)
291307
except Exception as e:
292308
logger.warning(
@@ -354,13 +370,16 @@ def _convert_source_to_chunk(
354370
def _process_ocr_response(
355371
self,
356372
ocr_response: OCRResponse,
373+
user_metadata: Dict[str, Any],
357374
document_annotation_schema: Optional[Type[BaseModel]],
358375
) -> Document:
359376
"""
360377
Convert an OCR response from Mistral API into a single Haystack Document.
361378
362379
:param ocr_response:
363380
The OCR response object from Mistral API.
381+
:param user_metadata:
382+
User-provided metadata to attach to the document.
364383
:param document_annotation_schema:
365384
Optional Pydantic model for document-level annotations.
366385
@@ -402,9 +421,12 @@ def _process_ocr_response(
402421
document = Document(
403422
content=all_content,
404423
meta={
424+
# User metadata (lowest priority - can be overridden)
425+
**user_metadata,
426+
# Automatic metadata (medium priority)
405427
"source_page_count": len(ocr_response.pages),
406428
"source_total_images": total_images,
407-
# Unpack document annotation
429+
# Document annotation (highest priority - overrides all)
408430
**doc_annotation_meta,
409431
},
410432
)

integrations/mistral/tests/test_ocr_document_converter.py

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -419,12 +419,84 @@ def test_run_handles_api_error(self, mock_ocr_response):
419419
assert len(result["documents"]) == 2
420420
assert len(result["raw_mistral_response"]) == 2
421421

422+
def test_run_with_meta_single_dict(self, mock_ocr_response):
423+
"""Test that meta parameter with single dict is applied to all documents"""
424+
converter = MistralOCRDocumentConverter(api_key=Secret.from_token("test-api-key"))
425+
426+
with patch.object(converter.client.ocr, "process", return_value=mock_ocr_response):
427+
sources = [
428+
DocumentURLChunk(document_url="https://example.com/doc1.pdf"),
429+
DocumentURLChunk(document_url="https://example.com/doc2.pdf"),
430+
]
431+
result = converter.run(sources=sources, meta={"department": "engineering", "year": 2024})
432+
433+
assert len(result["documents"]) == 2
434+
# Both documents should have the same metadata
435+
for doc in result["documents"]:
436+
assert doc.meta["department"] == "engineering"
437+
assert doc.meta["year"] == 2024
438+
# Automatic metadata should still be present
439+
assert "source_page_count" in doc.meta
440+
assert "source_total_images" in doc.meta
441+
442+
def test_run_with_meta_list_of_dicts(self, mock_ocr_response):
443+
"""Test that meta parameter with list of dicts applies each dict to corresponding document"""
444+
converter = MistralOCRDocumentConverter(api_key=Secret.from_token("test-api-key"))
445+
446+
with patch.object(converter.client.ocr, "process", return_value=mock_ocr_response):
447+
sources = [
448+
DocumentURLChunk(document_url="https://example.com/doc1.pdf"),
449+
DocumentURLChunk(document_url="https://example.com/doc2.pdf"),
450+
]
451+
result = converter.run(
452+
sources=sources,
453+
meta=[
454+
{"author": "Alice", "category": "report"},
455+
{"author": "Bob", "category": "invoice"},
456+
],
457+
)
458+
459+
assert len(result["documents"]) == 2
460+
# First document
461+
assert result["documents"][0].meta["author"] == "Alice"
462+
assert result["documents"][0].meta["category"] == "report"
463+
# Second document
464+
assert result["documents"][1].meta["author"] == "Bob"
465+
assert result["documents"][1].meta["category"] == "invoice"
466+
# Automatic metadata should still be present in both
467+
assert "source_page_count" in result["documents"][0].meta
468+
assert "source_page_count" in result["documents"][1].meta
469+
470+
def test_run_with_meta_none(self, mock_ocr_response):
471+
"""Test that meta parameter with None works correctly"""
472+
converter = MistralOCRDocumentConverter(api_key=Secret.from_token("test-api-key"))
473+
474+
with patch.object(converter.client.ocr, "process", return_value=mock_ocr_response):
475+
sources = [DocumentURLChunk(document_url="https://example.com/doc.pdf")]
476+
result = converter.run(sources=sources, meta=None)
477+
478+
assert len(result["documents"]) == 1
479+
# Only automatic metadata should be present
480+
assert "source_page_count" in result["documents"][0].meta
481+
assert "source_total_images" in result["documents"][0].meta
482+
483+
def test_run_with_meta_list_length_mismatch(self):
484+
"""Test that meta parameter with list length mismatch raises ValueError"""
485+
converter = MistralOCRDocumentConverter(api_key=Secret.from_token("test-api-key"))
486+
487+
with pytest.raises(ValueError, match="length of the metadata list must match"):
488+
sources = [
489+
DocumentURLChunk(document_url="https://example.com/doc1.pdf"),
490+
DocumentURLChunk(document_url="https://example.com/doc2.pdf"),
491+
]
492+
converter.run(sources=sources, meta=[{"author": "Alice"}]) # Only 1 dict for 2 sources
493+
422494
def test_process_ocr_response_multiple_pages(self, mock_ocr_response_with_multiple_pages):
423495
"""Test multi-page document with form feed separator"""
424496
converter = MistralOCRDocumentConverter(api_key=Secret.from_token("test-api-key"))
425497

426498
document = converter._process_ocr_response(
427-
mock_ocr_response_with_multiple_pages, document_annotation_schema=None
499+
mock_ocr_response_with_multiple_pages, user_metadata={}, document_annotation_schema=None
428500
)
429501

430502
assert isinstance(document, Document)
@@ -454,7 +526,7 @@ def test_process_ocr_response_with_images(self):
454526
mock_response.pages = [mock_page]
455527
mock_response.document_annotation = None
456528

457-
document = converter._process_ocr_response(mock_response, document_annotation_schema=None)
529+
document = converter._process_ocr_response(mock_response, user_metadata={}, document_annotation_schema=None)
458530

459531
assert document.meta["source_page_count"] == 1
460532
assert document.meta["source_total_images"] == 2

0 commit comments

Comments
 (0)