Cyril Dupland commited on
Commit
b1df3b7
·
1 Parent(s): 227f51c

Add batch processing service for structured data extraction from OCR results. Include metrics for carbon impact, latency, and pricing in API examples. Update default OCR model in Mistral service for consistency.

Browse files
docs/API_EXAMPLES.md CHANGED
@@ -110,6 +110,67 @@ data: {"content": " une", "done": false, "metadata": {"model": "gpt-3.5-turbo",
110
  data: {"content": "", "done": true, "metadata": {"model": "gpt-3.5-turbo", "agent_type": "simple"}}
111
  ```
112
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  ### Completion avec historique de conversation
114
 
115
  **Requête:**
 
110
  data: {"content": "", "done": true, "metadata": {"model": "gpt-3.5-turbo", "agent_type": "simple"}}
111
  ```
112
 
113
+ #### Champs d'empreinte carbone, latence, pricing et équivalences
114
+
115
+ Les réponses incluent désormais des métriques d'impact carbone calculées avec ecologits.
116
+
117
+ - Non-stream (champ `metadata`):
118
+ ```json
119
+ {
120
+ "metadata": {
121
+ "message_count": 4,
122
+ "latency_s": 1.23,
123
+ "emissions_kgCO2eq": 0.00042,
124
+ "emissions_gCO2eq": 0.42,
125
+ "pricing": {
126
+ "currency": "EUR",
127
+ "total_cost": 0.0031,
128
+ "by_model": {
129
+ "mistral-large-latest": {"input": 0.0005, "output": 0.0026, "total": 0.0031}
130
+ }
131
+ },
132
+ "equivalences": {
133
+ "water_liters": 0.3,
134
+ "car_km": 0.002,
135
+ "tgv_km": 0.01,
136
+ "smartphone_charges": 0.04
137
+ }
138
+ }
139
+ }
140
+ ```
141
+
142
+ - Stream (dernier event, champ `metadata`):
143
+ ```json
144
+ {
145
+ "content": "",
146
+ "done": true,
147
+ "metadata": {
148
+ "model": "mistral-large-latest",
149
+ "agent_type": "simple",
150
+ "usage": {"input_tokens":123, "output_tokens":456, "total_tokens":579},
151
+ "usage_by_model": {
152
+ "mistral-large-latest": {"input_tokens":123, "output_tokens":456, "total_tokens":579}
153
+ },
154
+ "latency_s": 1.23,
155
+ "emissions_kgCO2eq": 0.00042,
156
+ "emissions_gCO2eq": 0.42,
157
+ "pricing": {
158
+ "currency": "EUR",
159
+ "total_cost": 0.0031,
160
+ "by_model": {
161
+ "mistral-large-latest": {"input": 0.0005, "output": 0.0026, "total": 0.0031}
162
+ }
163
+ },
164
+ "equivalences": {
165
+ "water_liters": 0.3,
166
+ "car_km": 0.002,
167
+ "tgv_km": 0.01,
168
+ "smartphone_charges": 0.04
169
+ }
170
+ }
171
+ }
172
+ ```
173
+
174
  ### Completion avec historique de conversation
175
 
176
  **Requête:**
services/batch_extractor_service.py ADDED
@@ -0,0 +1,374 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Service de traitement par lots pour l'extraction de données structurées depuis des documents OCR.
3
+
4
+ Ce module fournit une classe réutilisable `BatchExtractor` qui permet d'extraire des données
5
+ structurées à partir de résultats OCR en traitant le document par lots de pages.
6
+
7
+ Fonctionnalités:
8
+ - Traitement par lots configurable (taille, pauses, retries)
9
+ - Support des plages de pages (start_page, end_page)
10
+ - Gestion des erreurs avec retry automatique
11
+ - Callbacks de progression
12
+ - Statistiques d'extraction
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import time
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+ from typing import Any, Callable, Dict, List, Optional, Union
22
+
23
+
24
+ @dataclass
25
+ class BatchConfig:
26
+ """Configuration pour le traitement par lots."""
27
+
28
+ batch_size: int = 10
29
+ """Nombre de pages par batch."""
30
+
31
+ pause_seconds: float = 2.0
32
+ """Pause entre les batchs (rate limiting)."""
33
+
34
+ max_retries: int = 3
35
+ """Tentatives max par batch en cas d'erreur."""
36
+
37
+ retry_delay: float = 5.0
38
+ """Délai entre les tentatives (secondes)."""
39
+
40
+ model: str = "mistral-large-latest"
41
+ """Modèle Mistral à utiliser."""
42
+
43
+ start_page: Optional[int] = None
44
+ """Page de début (1-indexed, None = début du document)."""
45
+
46
+ end_page: Optional[int] = None
47
+ """Page de fin (1-indexed, inclusive, None = fin du document)."""
48
+
49
+
50
+ @dataclass
51
+ class BatchResult:
52
+ """Résultat d'un batch."""
53
+
54
+ batch_index: int
55
+ """Index du batch (0-indexed)."""
56
+
57
+ start_page: int
58
+ """Numéro de la première page du batch (1-indexed)."""
59
+
60
+ end_page: int
61
+ """Numéro de la dernière page du batch (1-indexed)."""
62
+
63
+ items: List[Dict[str, Any]] = field(default_factory=list)
64
+ """Liste des éléments extraits."""
65
+
66
+ success: bool = True
67
+ """True si l'extraction a réussi."""
68
+
69
+ error: Optional[str] = None
70
+ """Message d'erreur si échec."""
71
+
72
+
73
+ @dataclass
74
+ class ExtractionResult:
75
+ """Résultat complet d'une extraction."""
76
+
77
+ items: List[Dict[str, Any]]
78
+ """Liste de tous les éléments extraits."""
79
+
80
+ stats: Dict[str, Any]
81
+ """Statistiques d'extraction."""
82
+
83
+ errors: List[Dict[str, Any]]
84
+ """Liste des erreurs rencontrées."""
85
+
86
+
87
+ # Type alias pour le callback de progression
88
+ ProgressCallback = Callable[[int, int, BatchResult], None]
89
+
90
+
91
+ class BatchExtractor:
92
+ """
93
+ Extracteur de données par lots depuis des résultats OCR.
94
+
95
+ Traite un résultat OCR en lots de pages pour éviter les timeouts API.
96
+ Utilise le service Mistral pour les appels LLM.
97
+
98
+ Exemple d'utilisation:
99
+ ```python
100
+ extractor = BatchExtractor(
101
+ ocr_result=ocr_data,
102
+ system_prompt=SYSTEM_PROMPT,
103
+ json_schema=JSON_SCHEMA,
104
+ items_key="formations", # Clé dans la réponse JSON
105
+ config=BatchConfig(batch_size=10, start_page=15, end_page=50)
106
+ )
107
+ result = extractor.extract_all()
108
+ ```
109
+ """
110
+
111
+ def __init__(
112
+ self,
113
+ ocr_result: Dict[str, Any],
114
+ system_prompt: str,
115
+ json_schema: Dict[str, Any],
116
+ items_key: str = "items",
117
+ user_prompt: str = "Extrait les données de ces pages du document.",
118
+ config: Optional[BatchConfig] = None,
119
+ verbose: bool = True,
120
+ ):
121
+ """
122
+ Initialise l'extracteur.
123
+
124
+ Args:
125
+ ocr_result: Résultat OCR contenant les pages (dict avec clé 'pages')
126
+ system_prompt: Prompt système pour guider l'extraction
127
+ json_schema: Schéma JSON pour structurer la sortie
128
+ items_key: Clé dans la réponse JSON contenant la liste des éléments
129
+ user_prompt: Prompt utilisateur de base
130
+ config: Configuration du batch processing
131
+ verbose: Afficher les messages de progression
132
+ """
133
+ self.pages = ocr_result.get('pages', [])
134
+ self.system_prompt = system_prompt
135
+ self.json_schema = json_schema
136
+ self.items_key = items_key
137
+ self.user_prompt = user_prompt
138
+ self.config = config or BatchConfig()
139
+ self.verbose = verbose
140
+
141
+ # Accès au client Mistral via le service singleton
142
+ from services.mistral_service import mistral_service
143
+ self.client = mistral_service.client
144
+
145
+ def _log(self, message: str, end: str = "\n"):
146
+ """Affiche un message si verbose est activé."""
147
+ if self.verbose:
148
+ print(message, end=end)
149
+
150
+ def _pages_to_markdown(self, page_list: List[Dict]) -> str:
151
+ """Convertit une liste de pages OCR en markdown."""
152
+ parts = []
153
+ for page in page_list:
154
+ idx = page.get('index', 0)
155
+ md = page.get('markdown', '')
156
+ parts.append(f"\n--- PAGE {idx + 1} ---\n{md}")
157
+ return "\n".join(parts)
158
+
159
+ def _build_messages(self, batch_markdown: str) -> List[Dict[str, Any]]:
160
+ """Construit les messages pour l'API chat."""
161
+ schema_str = json.dumps(self.json_schema, indent=2)
162
+ system_content = (
163
+ f"{self.system_prompt}\n\n"
164
+ f"**JSON Schema to follow:**\n```json\n{schema_str}\n```"
165
+ )
166
+
167
+ user_content = (
168
+ f"{self.user_prompt}\n\n"
169
+ f"=== DOCUMENT CONTENT (OCR) ===\n{batch_markdown}"
170
+ )
171
+
172
+ return [
173
+ {"role": "system", "content": system_content},
174
+ {"role": "user", "content": user_content}
175
+ ]
176
+
177
+ def _extract_batch(self, batch_pages: List[Dict], batch_idx: int) -> BatchResult:
178
+ """Extrait les données d'un batch de pages."""
179
+ start_page = batch_pages[0].get('index', 0) + 1
180
+ end_page = batch_pages[-1].get('index', 0) + 1
181
+
182
+ result = BatchResult(
183
+ batch_index=batch_idx,
184
+ start_page=start_page,
185
+ end_page=end_page
186
+ )
187
+
188
+ batch_markdown = self._pages_to_markdown(batch_pages)
189
+ messages = self._build_messages(batch_markdown)
190
+
191
+ for attempt in range(self.config.max_retries):
192
+ try:
193
+ response = self.client.chat.complete(
194
+ model=self.config.model,
195
+ messages=messages,
196
+ response_format={"type": "json_object"},
197
+ )
198
+
199
+ content = response.choices[0].message.content
200
+ parsed = json.loads(content) if isinstance(content, str) else content
201
+ result.items = parsed.get(self.items_key, [])
202
+ result.success = True
203
+ return result
204
+
205
+ except Exception as e:
206
+ error_msg = str(e)
207
+ if attempt < self.config.max_retries - 1:
208
+ self._log(f" ⚠️ Tentative {attempt + 1} échouée, retry dans {self.config.retry_delay}s...")
209
+ time.sleep(self.config.retry_delay)
210
+ else:
211
+ result.success = False
212
+ result.error = error_msg
213
+
214
+ return result
215
+
216
+ def extract_all(self, progress_callback: Optional[ProgressCallback] = None) -> ExtractionResult:
217
+ """
218
+ Extrait toutes les données par lots.
219
+
220
+ Args:
221
+ progress_callback: Fonction optionnelle appelée après chaque batch
222
+ signature: callback(batch_idx, total_batches, batch_result)
223
+
224
+ Returns:
225
+ ExtractionResult avec 'items' (liste), 'stats' et 'errors'
226
+ """
227
+ # Filtrer les pages selon la plage configurée (pages 1-indexed)
228
+ all_pages = self.pages
229
+ start_idx = 0
230
+ end_idx = len(all_pages)
231
+
232
+ if self.config.start_page is not None:
233
+ start_idx = max(0, self.config.start_page - 1)
234
+ if self.config.end_page is not None:
235
+ end_idx = min(len(all_pages), self.config.end_page)
236
+
237
+ pages_to_process = all_pages[start_idx:end_idx]
238
+ total_pages = len(pages_to_process)
239
+
240
+ if total_pages == 0:
241
+ self._log("⚠️ Aucune page à traiter dans la plage spécifiée.")
242
+ return ExtractionResult(items=[], stats={}, errors=[])
243
+
244
+ num_batches = (total_pages + self.config.batch_size - 1) // self.config.batch_size
245
+
246
+ # Afficher la plage de pages
247
+ actual_start = start_idx + 1
248
+ actual_end = start_idx + total_pages
249
+ self._log(f"📄 Pages à traiter: {actual_start} à {actual_end} ({total_pages} pages sur {len(all_pages)} total)")
250
+ self._log(f"🔄 Traitement en {num_batches} batchs de {self.config.batch_size} pages max...")
251
+ self._log(f"⏱️ Pause de {self.config.pause_seconds}s entre chaque batch\n")
252
+
253
+ all_items: List[Dict[str, Any]] = []
254
+ errors: List[BatchResult] = []
255
+
256
+ for batch_idx in range(num_batches):
257
+ batch_start = batch_idx * self.config.batch_size
258
+ batch_end = min(batch_start + self.config.batch_size, total_pages)
259
+ batch_pages = pages_to_process[batch_start:batch_end]
260
+
261
+ # Calculer les numéros de pages réels (1-indexed)
262
+ real_start_page = batch_pages[0].get('index', 0) + 1
263
+ real_end_page = batch_pages[-1].get('index', 0) + 1
264
+ self._log(f"📦 Batch {batch_idx + 1}/{num_batches} - Pages {real_start_page} à {real_end_page}...", end=" ")
265
+
266
+ batch_result = self._extract_batch(batch_pages, batch_idx)
267
+
268
+ if batch_result.success:
269
+ all_items.extend(batch_result.items)
270
+ self._log(f"✅ {len(batch_result.items)} éléments extraits")
271
+ else:
272
+ errors.append(batch_result)
273
+ error_preview = batch_result.error[:60] if batch_result.error else "Unknown"
274
+ self._log(f"❌ Erreur: {error_preview}...")
275
+
276
+ if progress_callback:
277
+ progress_callback(batch_idx, num_batches, batch_result)
278
+
279
+ # Pause entre les batchs (sauf le dernier)
280
+ if batch_idx < num_batches - 1:
281
+ time.sleep(self.config.pause_seconds)
282
+
283
+ # Résumé
284
+ self._log(f"\n{'='*60}")
285
+ self._log(f"📊 RÉSULTAT FINAL")
286
+ self._log(f"{'='*60}")
287
+ self._log(f"✅ Total éléments extraits: {len(all_items)}")
288
+ if errors:
289
+ self._log(f"⚠️ Batchs en erreur: {len(errors)}")
290
+ for err in errors:
291
+ self._log(f" - Batch {err.batch_index + 1}: Pages {err.start_page}-{err.end_page}")
292
+
293
+ stats = {
294
+ "total_pages_in_document": len(all_pages),
295
+ "pages_processed": total_pages,
296
+ "page_range": f"{actual_start}-{actual_end}",
297
+ "total_batches": num_batches,
298
+ "successful_batches": num_batches - len(errors),
299
+ "failed_batches": len(errors),
300
+ "total_items": len(all_items)
301
+ }
302
+
303
+ error_dicts = [
304
+ {
305
+ "batch": e.batch_index,
306
+ "pages": f"{e.start_page}-{e.end_page}",
307
+ "error": e.error
308
+ }
309
+ for e in errors
310
+ ]
311
+
312
+ return ExtractionResult(
313
+ items=all_items,
314
+ stats=stats,
315
+ errors=error_dicts
316
+ )
317
+
318
+ def extract_to_file(
319
+ self,
320
+ output_path: Union[str, Path],
321
+ items_key: Optional[str] = None,
322
+ progress_callback: Optional[ProgressCallback] = None
323
+ ) -> ExtractionResult:
324
+ """
325
+ Extrait les données et les sauvegarde dans un fichier JSON.
326
+
327
+ Args:
328
+ output_path: Chemin du fichier de sortie
329
+ items_key: Clé pour les éléments dans le fichier de sortie (défaut: self.items_key)
330
+ progress_callback: Callback de progression optionnel
331
+
332
+ Returns:
333
+ ExtractionResult
334
+ """
335
+ result = self.extract_all(progress_callback)
336
+
337
+ output_path = Path(output_path)
338
+ output_key = items_key or self.items_key
339
+
340
+ output_data = {
341
+ output_key: result.items,
342
+ "stats": result.stats,
343
+ "errors": result.errors
344
+ }
345
+
346
+ with open(output_path, 'w', encoding='utf-8') as f:
347
+ json.dump(output_data, f, ensure_ascii=False, indent=2)
348
+
349
+ self._log(f"\n💾 Résultat sauvegardé dans: {output_path}")
350
+
351
+ return result
352
+
353
+
354
+ # Fonction utilitaire pour charger un fichier OCR
355
+ def load_ocr_result(path: Union[str, Path]) -> Dict[str, Any]:
356
+ """
357
+ Charge un fichier JSON contenant le résultat OCR.
358
+
359
+ Args:
360
+ path: Chemin vers le fichier JSON OCR
361
+
362
+ Returns:
363
+ Dict contenant le résultat OCR
364
+
365
+ Raises:
366
+ FileNotFoundError: Si le fichier n'existe pas
367
+ """
368
+ path = Path(path)
369
+ if not path.exists():
370
+ raise FileNotFoundError(f"Fichier OCR introuvable: {path}")
371
+
372
+ with open(path, 'r', encoding='utf-8') as f:
373
+ return json.load(f)
374
+
services/mistral_service.py CHANGED
@@ -33,7 +33,7 @@ class MistralService:
33
  def __init__(
34
  self,
35
  api_key: Optional[str] = None,
36
- ocr_model: str = "mistral-ocr-latest",
37
  chat_model: str = "mistral-large-latest",
38
  ) -> None:
39
  self._api_key = api_key or settings.mistralai_api_key
 
33
  def __init__(
34
  self,
35
  api_key: Optional[str] = None,
36
+ ocr_model: str = "mistral-ocr-2503",
37
  chat_model: str = "mistral-large-latest",
38
  ) -> None:
39
  self._api_key = api_key or settings.mistralai_api_key