11"""Mistral AI adapter for OCR processing."""
22
33import base64
4+ import json
45from pathlib import Path
5- from typing import List , Optional , Set
6+
67from mistralai import Mistral , OCRResponse
78from mistralai .extra import response_format_from_pydantic_model
89from pydantic import BaseModel , Field
9- import json
1010
11- from ..protocols .ocr_service import OCRService
1211from ..models .settings import Settings
12+ from ..protocols .ocr_service import OCRService
1313from ..utils .page_parser import PagePatternParser
1414
1515
1616class ImageDescription (BaseModel ):
1717 """Model for image descriptions from bbox annotations."""
18+
1819 description : str = Field (..., description = "Detailed description of what is visible in the image" )
1920
2021
2122class MistralOCRAdapter (OCRService ):
2223 """Mistral AI OCR service adapter."""
2324
24- def __init__ (self , settings : Settings , client : Optional [ Mistral ] = None ):
25+ def __init__ (self , settings : Settings , client : Mistral | None = None ):
2526 """Initialize the Mistral OCR adapter.
2627
2728 Args:
@@ -30,54 +31,54 @@ def __init__(self, settings: Settings, client: Optional[Mistral] = None):
3031 """
3132 self .client = client or Mistral (api_key = settings .mistral_api_key .get_secret_value ())
3233 self .settings = settings
33-
34+
3435 def _validate_image_file (self , file_path : Path ) -> bool :
3536 """Validate that the file is a valid image file."""
3637 try :
3738 with open (file_path , "rb" ) as file :
3839 header = file .read (10 ) # Read first 10 bytes
39-
40+
4041 ext = file_path .suffix .lower ()
41- if ext in [' .jpg' , ' .jpeg' ]:
42+ if ext in [" .jpg" , " .jpeg" ]:
4243 # Check for JPEG header: FF D8 FF
43- return header .startswith (b' \xff \xd8 \xff ' )
44- elif ext == ' .png' :
44+ return header .startswith (b" \xff \xd8 \xff " )
45+ elif ext == " .png" :
4546 # Check for PNG header: 89 50 4E 47 0D 0A 1A 0A
46- return header .startswith (b' \x89 PNG\r \n \x1a \n ' )
47- elif ext == ' .avif' :
47+ return header .startswith (b" \x89 PNG\r \n \x1a \n " )
48+ elif ext == " .avif" :
4849 # Check for AVIF header: 00 00 00 20 66 74 79 70 61 76 69 66
49- return header .startswith (b' \x00 \x00 \x00 ftypavif' )
50+ return header .startswith (b" \x00 \x00 \x00 ftypavif" )
5051 else :
5152 # For other formats, assume valid
5253 return True
5354 except Exception :
5455 return False
55-
56+
5657 def _encode_file (self , file_path : Path ) -> str :
5758 """Encode file to base64."""
5859 try :
5960 # Validate image files before encoding
60- if file_path .suffix .lower () in [' .jpg' , ' .jpeg' , ' .png' , ' .avif' ]:
61+ if file_path .suffix .lower () in [" .jpg" , " .jpeg" , " .png" , " .avif" ]:
6162 if not self ._validate_image_file (file_path ):
6263 raise ValueError (f"Invalid or corrupted image file: { file_path } " )
63-
64+
6465 with open (file_path , "rb" ) as file :
65- return base64 .b64encode (file .read ()).decode (' utf-8' )
66+ return base64 .b64encode (file .read ()).decode (" utf-8" )
6667 except FileNotFoundError :
67- raise FileNotFoundError (f"File not found: { file_path } " )
68+ raise FileNotFoundError (f"File not found: { file_path } " ) from None
6869 except Exception as e :
69- raise Exception (f"Error encoding file { file_path } : { e } " )
70-
70+ raise Exception (f"Error encoding file { file_path } : { e } " ) from e
71+
7172 def _get_document_type (self , file_path : Path ) -> str :
7273 """Determine document type based on file extension."""
7374 ext = file_path .suffix .lower ()
74- if ext in [' .pdf' , ' .pptx' , ' .docx' ]:
75+ if ext in [" .pdf" , " .pptx" , " .docx" ]:
7576 return "document_url"
76- elif ext in [' .png' , ' .jpg' , ' .jpeg' , ' .avif' ]:
77+ elif ext in [" .png" , " .jpg" , " .jpeg" , " .avif" ]:
7778 return "image_url"
7879 else :
7980 raise ValueError (f"Unsupported file type: { ext } " )
80-
81+
8182 def _get_url_field_name (self , file_path : Path ) -> str :
8283 """Get the correct URL field name based on document type."""
8384 doc_type = self ._get_document_type (file_path )
@@ -87,35 +88,31 @@ def _get_url_field_name(self, file_path: Path) -> str:
8788 return "image_url"
8889 else :
8990 raise ValueError (f"Unsupported document type: { doc_type } " )
90-
91+
9192 def _get_mime_type (self , file_path : Path ) -> str :
9293 """Get MIME type based on file extension."""
9394 ext = file_path .suffix .lower ()
9495 mime_types = {
95- ' .pdf' : ' application/pdf' ,
96- ' .png' : ' image/png' ,
97- ' .jpg' : ' image/jpeg' ,
98- ' .jpeg' : ' image/jpeg' ,
99- ' .avif' : ' image/avif' ,
100- ' .pptx' : ' application/vnd.openxmlformats-officedocument.presentationml.presentation' ,
101- ' .docx' : ' application/vnd.openxmlformats-officedocument.wordprocessingml.document'
96+ " .pdf" : " application/pdf" ,
97+ " .png" : " image/png" ,
98+ " .jpg" : " image/jpeg" ,
99+ " .jpeg" : " image/jpeg" ,
100+ " .avif" : " image/avif" ,
101+ " .pptx" : " application/vnd.openxmlformats-officedocument.presentationml.presentation" ,
102+ " .docx" : " application/vnd.openxmlformats-officedocument.wordprocessingml.document" ,
102103 }
103- return mime_types .get (ext , ' application/octet-stream' )
104-
105- def _filter_pages_by_pattern (self , response : OCRResponse , page_pattern : Optional [ str ] = None ) -> OCRResponse :
104+ return mime_types .get (ext , " application/octet-stream" )
105+
106+ def _filter_pages_by_pattern (self , response : OCRResponse , page_pattern : str | None = None ) -> OCRResponse :
106107 """Filter OCR response pages based on pattern."""
107108 if not page_pattern :
108109 return response
109-
110- # Parse the page pattern
111- total_pages = len (response .pages )
112- selected_pages = PagePatternParser .parse_pattern (page_pattern , total_pages )
113-
114- # Create a new response with only selected pages
115- # Note: We can't modify the original response, so we'll return the filtered pages
116- # The actual filtering will be done in the markdown generation
110+
111+ # Parse the page pattern (actual filtering done in _generate_markdown)
112+ # Just validate the pattern is parseable here
113+ _ = PagePatternParser .parse_pattern (page_pattern , len (response .pages ))
117114 return response
118-
115+
119116 def _collect_images_map (self , response : OCRResponse , pages_to_process : list [int ]) -> tuple [dict , int ]:
120117 """Collect a mapping of image filename -> {mime, base64} from the OCR response pages."""
121118 images_map = {}
@@ -131,12 +128,12 @@ def _collect_images_map(self, response: OCRResponse, pages_to_process: list[int]
131128 b64 = getattr (img , "image_base64" , None ) or getattr (img , "base64" , None )
132129 mime = getattr (img , "mime" , None ) or getattr (img , "content_type" , None )
133130 # Extract base64 data from data URL if present
134- if b64 and b64 .startswith (' data:' ):
135- if ' ;base64,' in b64 :
136- b64 = b64 .split (' ;base64,' , 1 )[1 ]
131+ if b64 and b64 .startswith (" data:" ):
132+ if " ;base64," in b64 :
133+ b64 = b64 .split (" ;base64," , 1 )[1 ]
137134 else :
138135 continue
139-
136+
140137 if not b64 :
141138 continue
142139 # Generate filename if not present
@@ -167,8 +164,7 @@ def _collect_images_map(self, response: OCRResponse, pages_to_process: list[int]
167164 images_map [filename ] = {"mime" : mime , "base64" : b64 }
168165 return images_map , total_images
169166
170- def _generate_markdown (self , response : OCRResponse , page_pattern : Optional [str ] = None ,
171- include_page_headlines : bool = False ) -> tuple [str , int , int ]:
167+ def _generate_markdown (self , response : OCRResponse , page_pattern : str | None = None , include_page_headlines : bool = False ) -> tuple [str , int , int ]:
172168 """Generate markdown from OCR response with optional filtering and headlines.
173169
174170 Returns:
@@ -213,10 +209,7 @@ def _generate_markdown(self, response: OCRResponse, page_pattern: Optional[str]
213209 # Replace image reference with image + description
214210 image_pattern = f""
215211 if image_pattern in page_content :
216- page_content = page_content .replace (
217- image_pattern ,
218- f"{ image_pattern } \n \n **Image Description:** { description } \n "
219- )
212+ page_content = page_content .replace (image_pattern , f"{ image_pattern } \n \n **Image Description:** { description } \n " )
220213
221214 if include_page_headlines :
222215 markdown_parts .append (f"### Page { page_num } \n { page_content } " )
@@ -232,10 +225,8 @@ def _generate_markdown(self, response: OCRResponse, page_pattern: Optional[str]
232225 header = f"<!--IMAGES_MAP\n { json .dumps (images_map )} \n -->\n \n "
233226 return header + markdown_body , total_images , pages_count
234227 return markdown_body , total_images , pages_count
235-
236- async def process_file (self , file_path : Path , page_pattern : Optional [str ] = None ,
237- include_page_headlines : bool = False ,
238- include_images : bool = True ) -> tuple [str , int , int ]:
228+
229+ async def process_file (self , file_path : Path , page_pattern : str | None = None , include_page_headlines : bool = False , include_images : bool = True ) -> tuple [str , int , int ]:
239230 """Process a single file and return extracted text.
240231
241232 Returns:
@@ -262,11 +253,7 @@ async def process_file(self, file_path: Path, page_pattern: Optional[str] = None
262253 document_dict [url_field ] = data_url
263254
264255 # Add bbox_annotation_format if image descriptions are enabled
265- ocr_kwargs = {
266- "model" : "mistral-ocr-latest" ,
267- "document" : document_dict ,
268- "include_image_base64" : include_images
269- }
256+ ocr_kwargs = {"model" : "mistral-ocr-latest" , "document" : document_dict , "include_image_base64" : include_images }
270257
271258 if self .settings .include_image_descriptions :
272259 ocr_kwargs ["bbox_annotation_format" ] = response_format_from_pydantic_model (ImageDescription )
@@ -278,22 +265,17 @@ async def process_file(self, file_path: Path, page_pattern: Optional[str] = None
278265
279266 return markdown , total_images , pages_count
280267
281- async def process_first_page (self , file_path : Path ,
282- include_page_headlines : bool = False ,
283- include_images : bool = True ) -> tuple [str , int ]:
268+ async def process_first_page (self , file_path : Path , include_page_headlines : bool = False , include_images : bool = True ) -> tuple [str , int ]:
284269 """Process only first page for filename generation analysis.
285270
286271 Returns:
287272 Tuple of (markdown_content, total_images)
288273 Note: pages_processed is always 1 for this method, so not returned
289274 """
290- markdown , total_images , _ = await self .process_file (file_path , page_pattern = "1" ,
291- include_page_headlines = include_page_headlines ,
292- include_images = include_images )
275+ markdown , total_images , _ = await self .process_file (file_path , page_pattern = "1" , include_page_headlines = include_page_headlines , include_images = include_images )
293276 return markdown , total_images
294277
295- async def process_files (self , file_paths : List [Path ], page_pattern : Optional [str ] = None ,
296- include_page_headlines : bool = False ) -> List [tuple [str , int , int ]]:
278+ async def process_files (self , file_paths : list [Path ], page_pattern : str | None = None , include_page_headlines : bool = False ) -> list [tuple [str , int , int ]]:
297279 """Process multiple files and return extracted text for each.
298280
299281 Returns:
@@ -309,9 +291,8 @@ async def process_files(self, file_paths: List[Path], page_pattern: Optional[str
309291 print (f"Error processing { file_path } : { e } " )
310292 results .append ((f"Error processing { file_path } : { e } " , 0 , 0 ))
311293 return results
312-
313- async def process_folder (self , folder_path : Path , page_pattern : Optional [str ] = None ,
314- include_page_headlines : bool = False ) -> List [tuple [str , int , int ]]:
294+
295+ async def process_folder (self , folder_path : Path , page_pattern : str | None = None , include_page_headlines : bool = False ) -> list [tuple [str , int , int ]]:
315296 """Process all supported files in a folder and return extracted text.
316297
317298 Returns:
@@ -324,12 +305,9 @@ async def process_folder(self, folder_path: Path, page_pattern: Optional[str] =
324305 raise ValueError (f"Path is not a directory: { folder_path } " )
325306
326307 # Supported file extensions
327- supported_extensions = {' .pdf' , ' .png' , ' .jpg' , ' .jpeg' , ' .avif' , ' .pptx' , ' .docx' }
308+ supported_extensions = {" .pdf" , " .png" , " .jpg" , " .jpeg" , " .avif" , " .pptx" , " .docx" }
328309
329310 # Find all supported files
330- files = [
331- f for f in folder_path .iterdir ()
332- if f .is_file () and f .suffix .lower () in supported_extensions
333- ]
311+ files = [f for f in folder_path .iterdir () if f .is_file () and f .suffix .lower () in supported_extensions ]
334312
335313 return await self .process_files (files , page_pattern , include_page_headlines )
0 commit comments