Claude commited on
Commit
63e236b
·
unverified ·
1 Parent(s): 6d406c5

feat: corpus triplet, post-correction LLM et modèles dynamiques avec capacités

Browse files

Corpus triplet (image + .ocr.txt + .gt.txt):
- Document.ocr_text: nouveau champ optionnel pour le texte OCR bruité
- load_corpus_from_directory: détecte automatiquement les .ocr.txt
- Corpus.has_ocr_text / ocr_text_count: propriétés de détection
- Backward-compatible: les corpus sans .ocr.txt fonctionnent comme avant

Pipeline post-correction sans OCR engine:
- _run_llm_step(): extraction du code LLM commun de _run_ocr()
- run_with_ocr_text(): nouvelle méthode qui court-circuite l'OCR engine
et utilise le texte OCR du corpus comme entrée directe
- Supporte text_only (LLM textuel), text_and_image (LLM multimodal),
et zero_shot (VLM, ignore l'OCR)

Runner intelligent:
- _io_doc_worker détecte doc.ocr_text + pipeline et route vers
run_with_ocr_text() automatiquement
- Metadata "ocr_source": "corpus" vs "live" pour traçabilité

Modèles dynamiques avec capacités:
- /api/models/{provider} retourne {id, capabilities: ["text","vision"]}
- Filtrage par ?capability=vision pour l'UI
- Heuristiques par provider: Mistral (TEXT_ONLY_MODELS), OpenAI (gpt-4o),
Anthropic (tous vision), Ollama (familles connues)
- Backward-compatible: model_ids[] en plus de models[]

Web backend post-correction:
- CompetitorConfig.ocr_engine peut être "corpus" ou "" pour post-correction
- _engine_from_competitor: construit pipeline sans OCR engine quand corpus
- Upload ZIP et analyse corpus: acceptent .ocr.txt
- _analyze_corpus_dir: retourne has_ocr_text et ocr_text_count

Tests: 890 passed, 0 failed

https://claude.ai/code/session_01UtY7QGAcj2M7pAyU2nvzvn

picarones/core/corpus.py CHANGED
@@ -1,10 +1,16 @@
1
  """Chargement et gestion des corpus de documents.
2
 
3
- Format supporté (Sprint 1) : dossier local avec paires image / .gt.txt
 
 
4
 
5
  Convention :
6
- mon_document.jpg ←→ mon_document.gt.txt
7
- page_001.png ←→ page_001.gt.txt
 
 
 
 
8
 
9
  Extensions d'images acceptées : .jpg, .jpeg, .png, .tif, .tiff, .bmp, .webp
10
  """
@@ -24,11 +30,17 @@ IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".bmp", ".webp"}
24
 
25
  @dataclass
26
  class Document:
27
- """Une paire (image, texte de vérité terrain)."""
 
 
 
 
28
 
29
  image_path: Path
30
  ground_truth: str
31
  doc_id: str = ""
 
 
32
  metadata: dict = field(default_factory=dict)
33
 
34
  def __post_init__(self) -> None:
@@ -54,6 +66,16 @@ class Corpus:
54
  def __repr__(self) -> str:
55
  return f"Corpus(name={self.name!r}, documents={len(self.documents)})"
56
 
 
 
 
 
 
 
 
 
 
 
57
  @property
58
  def stats(self) -> dict:
59
  gt_lengths = [len(doc.ground_truth) for doc in self.documents]
@@ -61,38 +83,52 @@ class Corpus:
61
  return {"document_count": 0}
62
  import statistics
63
 
64
- return {
65
  "document_count": len(self.documents),
66
  "gt_length_mean": round(statistics.mean(gt_lengths), 1),
67
  "gt_length_median": round(statistics.median(gt_lengths), 1),
68
  "gt_length_min": min(gt_lengths),
69
  "gt_length_max": max(gt_lengths),
 
 
70
  }
 
71
 
72
 
73
  def load_corpus_from_directory(
74
  directory: str | Path,
75
  name: Optional[str] = None,
76
  gt_suffix: str = ".gt.txt",
 
77
  encoding: str = "utf-8",
78
  ) -> Corpus:
79
- """Charge un corpus depuis un dossier local de paires image / GT.
 
 
 
 
 
 
 
 
 
80
 
81
  Parameters
82
  ----------
83
  directory:
84
- Chemin vers le dossier contenant les paires image + fichier GT.
85
  name:
86
  Nom du corpus (par défaut : nom du dossier).
87
  gt_suffix:
88
  Suffixe des fichiers vérité terrain (par défaut : ``.gt.txt``).
 
 
89
  encoding:
90
  Encodage des fichiers texte (par défaut : utf-8).
91
 
92
  Returns
93
  -------
94
  Corpus
95
- Objet Corpus prêt à être utilisé dans le pipeline.
96
 
97
  Raises
98
  ------
@@ -115,6 +151,8 @@ def load_corpus_from_directory(
115
  if p.suffix.lower() in IMAGE_EXTENSIONS and not p.name.startswith(".")
116
  )
117
 
 
 
118
  for image_path in image_paths:
119
  gt_path = image_path.with_name(image_path.stem + gt_suffix)
120
  if not gt_path.exists():
@@ -129,10 +167,21 @@ def load_corpus_from_directory(
129
  skipped += 1
130
  continue
131
 
 
 
 
 
 
 
 
 
 
 
132
  documents.append(
133
  Document(
134
  image_path=image_path,
135
  ground_truth=ground_truth,
 
136
  )
137
  )
138
 
@@ -145,7 +194,13 @@ def load_corpus_from_directory(
145
  if skipped:
146
  logger.info("%d image(s) ignorée(s) faute de fichier GT.", skipped)
147
 
148
- logger.info("Corpus '%s' chargé : %d documents.", corpus_name, len(documents))
 
 
 
 
 
 
149
  return Corpus(
150
  name=corpus_name,
151
  documents=documents,
 
1
  """Chargement et gestion des corpus de documents.
2
 
3
+ Format supporté :
4
+ - Paires classiques : image + .gt.txt
5
+ - Triplets post-correction : image + .gt.txt + .ocr.txt
6
 
7
  Convention :
8
+ mon_document.jpg ←→ mon_document.gt.txt (paire)
9
+ mon_document.jpg ←→ mon_document.gt.txt + mon_document.ocr.txt (triplet)
10
+
11
+ Le fichier ``.ocr.txt`` contient le texte OCR bruité (sortie d'un moteur OCR)
12
+ qui sera utilisé comme entrée pour les benchmarks de post-correction LLM.
13
+ Il est optionnel — un corpus sans ``.ocr.txt`` reste un corpus classique.
14
 
15
  Extensions d'images acceptées : .jpg, .jpeg, .png, .tif, .tiff, .bmp, .webp
16
  """
 
30
 
31
  @dataclass
32
  class Document:
33
+ """Un document du corpus : image + vérité terrain + (optionnel) OCR bruité.
34
+
35
+ Quand ``ocr_text`` est renseigné (corpus triplet), le benchmark de
36
+ post-correction LLM peut utiliser ce texte au lieu de lancer un moteur OCR.
37
+ """
38
 
39
  image_path: Path
40
  ground_truth: str
41
  doc_id: str = ""
42
+ ocr_text: Optional[str] = None
43
+ """Texte OCR bruité pré-calculé (``None`` pour les corpus classiques sans ``.ocr.txt``)."""
44
  metadata: dict = field(default_factory=dict)
45
 
46
  def __post_init__(self) -> None:
 
66
  def __repr__(self) -> str:
67
  return f"Corpus(name={self.name!r}, documents={len(self.documents)})"
68
 
69
+ @property
70
+ def has_ocr_text(self) -> bool:
71
+ """True si au moins un document possède un texte OCR pré-calculé."""
72
+ return any(doc.ocr_text is not None for doc in self.documents)
73
+
74
+ @property
75
+ def ocr_text_count(self) -> int:
76
+ """Nombre de documents avec un texte OCR pré-calculé."""
77
+ return sum(1 for doc in self.documents if doc.ocr_text is not None)
78
+
79
  @property
80
  def stats(self) -> dict:
81
  gt_lengths = [len(doc.ground_truth) for doc in self.documents]
 
83
  return {"document_count": 0}
84
  import statistics
85
 
86
+ s = {
87
  "document_count": len(self.documents),
88
  "gt_length_mean": round(statistics.mean(gt_lengths), 1),
89
  "gt_length_median": round(statistics.median(gt_lengths), 1),
90
  "gt_length_min": min(gt_lengths),
91
  "gt_length_max": max(gt_lengths),
92
+ "has_ocr_text": self.has_ocr_text,
93
+ "ocr_text_count": self.ocr_text_count,
94
  }
95
+ return s
96
 
97
 
98
  def load_corpus_from_directory(
99
  directory: str | Path,
100
  name: Optional[str] = None,
101
  gt_suffix: str = ".gt.txt",
102
+ ocr_suffix: str = ".ocr.txt",
103
  encoding: str = "utf-8",
104
  ) -> Corpus:
105
+ """Charge un corpus depuis un dossier local.
106
+
107
+ Supporte deux formats :
108
+ - **Paires** : ``image + .gt.txt``
109
+ - **Triplets** : ``image + .gt.txt + .ocr.txt`` (post-correction LLM)
110
+
111
+ Le fichier ``.ocr.txt`` est optionnel. Quand il est présent, le champ
112
+ ``Document.ocr_text`` est renseigné et le benchmark peut l'utiliser
113
+ comme entrée OCR bruitée pour tester la post-correction LLM sans
114
+ relancer un moteur OCR.
115
 
116
  Parameters
117
  ----------
118
  directory:
119
+ Chemin vers le dossier contenant les paires/triplets.
120
  name:
121
  Nom du corpus (par défaut : nom du dossier).
122
  gt_suffix:
123
  Suffixe des fichiers vérité terrain (par défaut : ``.gt.txt``).
124
+ ocr_suffix:
125
+ Suffixe des fichiers OCR bruité (par défaut : ``.ocr.txt``).
126
  encoding:
127
  Encodage des fichiers texte (par défaut : utf-8).
128
 
129
  Returns
130
  -------
131
  Corpus
 
132
 
133
  Raises
134
  ------
 
151
  if p.suffix.lower() in IMAGE_EXTENSIONS and not p.name.startswith(".")
152
  )
153
 
154
+ ocr_text_loaded = 0
155
+
156
  for image_path in image_paths:
157
  gt_path = image_path.with_name(image_path.stem + gt_suffix)
158
  if not gt_path.exists():
 
167
  skipped += 1
168
  continue
169
 
170
+ # OCR bruité optionnel (.ocr.txt)
171
+ ocr_text: Optional[str] = None
172
+ ocr_path = image_path.with_name(image_path.stem + ocr_suffix)
173
+ if ocr_path.exists():
174
+ try:
175
+ ocr_text = ocr_path.read_text(encoding=encoding).strip()
176
+ ocr_text_loaded += 1
177
+ except OSError as exc:
178
+ logger.warning("Impossible de lire %s : %s — OCR bruité ignoré.", ocr_path, exc)
179
+
180
  documents.append(
181
  Document(
182
  image_path=image_path,
183
  ground_truth=ground_truth,
184
+ ocr_text=ocr_text,
185
  )
186
  )
187
 
 
194
  if skipped:
195
  logger.info("%d image(s) ignorée(s) faute de fichier GT.", skipped)
196
 
197
+ if ocr_text_loaded:
198
+ logger.info(
199
+ "Corpus '%s' chargé : %d documents (%d avec OCR bruité — post-correction disponible).",
200
+ corpus_name, len(documents), ocr_text_loaded,
201
+ )
202
+ else:
203
+ logger.info("Corpus '%s' chargé : %d documents.", corpus_name, len(documents))
204
  return Corpus(
205
  name=corpus_name,
206
  documents=documents,
picarones/core/runner.py CHANGED
@@ -71,8 +71,23 @@ def _io_doc_worker(
71
  Exécute l'OCR et calcule les métriques dans un thread. L'instance du
72
  moteur est partagée entre les threads — les adaptateurs HTTP sont
73
  généralement sans état mutable entre les appels.
 
 
 
 
74
  """
75
- ocr_result = engine.run(doc.image_path) # type: ignore[attr-defined]
 
 
 
 
 
 
 
 
 
 
 
76
  return _compute_document_result(
77
  doc_id=doc.doc_id, # type: ignore[attr-defined]
78
  image_path=str(doc.image_path), # type: ignore[attr-defined]
 
71
  Exécute l'OCR et calcule les métriques dans un thread. L'instance du
72
  moteur est partagée entre les threads — les adaptateurs HTTP sont
73
  généralement sans état mutable entre les appels.
74
+
75
+ Si le document possède un texte OCR pré-calculé (corpus triplet) et que
76
+ le moteur est un pipeline OCR+LLM, utilise ``run_with_ocr_text()`` pour
77
+ court-circuiter l'étape OCR et tester directement la post-correction LLM.
78
  """
79
+ doc_ocr_text = getattr(doc, "ocr_text", None)
80
+ if doc_ocr_text is not None:
81
+ # Corpus triplet — vérifier si le moteur supporte run_with_ocr_text
82
+ run_with = getattr(engine, "run_with_ocr_text", None)
83
+ if run_with is not None:
84
+ ocr_result = run_with(doc.image_path, doc_ocr_text) # type: ignore[attr-defined]
85
+ else:
86
+ # Moteur OCR classique — ignorer le texte OCR pré-calculé
87
+ ocr_result = engine.run(doc.image_path) # type: ignore[attr-defined]
88
+ else:
89
+ ocr_result = engine.run(doc.image_path) # type: ignore[attr-defined]
90
+
91
  return _compute_document_result(
92
  doc_id=doc.doc_id, # type: ignore[attr-defined]
93
  image_path=str(doc.image_path), # type: ignore[attr-defined]
picarones/pipelines/base.py CHANGED
@@ -139,72 +139,53 @@ class OCRLLMPipeline(BaseOCREngine):
139
  ocr_v = self.ocr_engine._safe_version() if self.ocr_engine else "—"
140
  return f"ocr={ocr_v}; llm={self.llm_adapter.model}"
141
 
142
- def _run_ocr(self, image_path: Path) -> tuple[str, Optional[str]]:
143
- """Logique interne du pipeline — appelée par ``run()``.
 
 
144
 
145
- Returns
146
- -------
147
- tuple[str, Optional[str]]
148
- (llm_text, ocr_intermediate) — ocr_intermediate est None en mode zero_shot.
149
  """
150
- ocr_text = ""
151
-
152
  if self.mode == PipelineMode.ZERO_SHOT:
153
  image_b64 = _image_to_b64(image_path)
154
  prompt = self._build_prompt(image_b64=image_b64)
155
- logger.debug(
156
- "[%s] zero-shot — longueur prompt : %d car.", self._name, len(prompt)
157
- )
158
  logger.info("[Pipeline] appel LLM pour doc %s (zero-shot)", image_path.name)
159
  result = self.llm_adapter.complete(prompt, image_b64=image_b64)
160
- logger.info("[Pipeline] LLM retourné pour doc %s", image_path.name)
161
 
162
  elif self.mode == PipelineMode.TEXT_ONLY:
163
- if self.ocr_engine is None:
164
- raise ValueError("ocr_engine est requis pour le mode text_only")
165
- ocr_result = self.ocr_engine.run(image_path)
166
- ocr_text = ocr_result.text
167
- logger.debug(
168
- "[%s] texte OCR : %d car. → envoi au LLM.",
169
- self._name, len(ocr_text),
170
- )
171
  if not ocr_text.strip():
172
  logger.warning(
173
- "[%s] le moteur OCR a produit un texte vide pour '%s'. "
174
- "Le LLM recevra un prompt sans texte OCR ({ocr_output} vide).",
175
  self._name, image_path.name,
176
  )
177
  prompt = self._build_prompt(ocr_text=ocr_text)
178
- logger.info("[Pipeline] appel LLM pour doc %s (text_only, ocr=%d chars)", image_path.name, len(ocr_text))
 
 
 
179
  result = self.llm_adapter.complete(prompt)
180
- logger.info("[Pipeline] LLM retourné pour doc %s", image_path.name)
181
 
182
  else: # TEXT_AND_IMAGE
183
- if self.ocr_engine is None:
184
- raise ValueError("ocr_engine est requis pour le mode text_and_image")
185
- ocr_result = self.ocr_engine.run(image_path)
186
- ocr_text = ocr_result.text
187
- logger.debug(
188
- "[%s] texte OCR : %d car. + image → envoi au LLM.",
189
- self._name, len(ocr_text),
190
- )
191
  if not ocr_text.strip():
192
  logger.warning(
193
- "[%s] le moteur OCR a produit un texte vide pour '%s'. "
194
- "Le LLM recevra un prompt sans texte OCR ({ocr_output} vide).",
195
  self._name, image_path.name,
196
  )
197
  image_b64 = _image_to_b64(image_path)
198
  prompt = self._build_prompt(ocr_text=ocr_text, image_b64=image_b64)
199
- logger.info("[Pipeline] appel LLM pour doc %s (text_and_image, ocr=%d chars)", image_path.name, len(ocr_text))
 
 
 
200
  result = self.llm_adapter.complete(prompt, image_b64=image_b64)
201
- logger.info("[Pipeline] LLM retourné pour doc %s", image_path.name)
 
202
 
203
  if not result.success:
204
  raise RuntimeError(f"Erreur LLM ({self.llm_adapter.model}): {result.error}")
205
 
206
  llm_text = result.text
207
- # INFO — bilan OCR→LLM visible sur HuggingFace (niveau INFO)
208
  logger.info(
209
  "[Pipeline] %s — OCR: %d chars → LLM: %d chars",
210
  image_path.name, len(ocr_text), len(llm_text),
@@ -227,6 +208,26 @@ class OCRLLMPipeline(BaseOCREngine):
227
  ocr_intermediate = ocr_text if self.mode != PipelineMode.ZERO_SHOT else None
228
  return llm_text, ocr_intermediate
229
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
  # ------------------------------------------------------------------
231
  # Override run() pour injecter les métadonnées pipeline
232
  # ------------------------------------------------------------------
@@ -272,6 +273,69 @@ class OCRLLMPipeline(BaseOCREngine):
272
  metadata=metadata,
273
  )
274
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
275
  # ------------------------------------------------------------------
276
  # Helpers
277
  # ------------------------------------------------------------------
 
139
  ocr_v = self.ocr_engine._safe_version() if self.ocr_engine else "—"
140
  return f"ocr={ocr_v}; llm={self.llm_adapter.model}"
141
 
142
+ def _run_llm_step(
143
+ self, image_path: Path, ocr_text: str,
144
+ ) -> tuple[str, Optional[str]]:
145
+ """Étape LLM du pipeline (commune à run() et run_with_ocr_text()).
146
 
147
+ Construit le prompt, appelle le LLM, retourne ``(llm_text, ocr_intermediate)``.
148
+ ``ocr_intermediate`` est ``None`` en mode zero_shot.
 
 
149
  """
 
 
150
  if self.mode == PipelineMode.ZERO_SHOT:
151
  image_b64 = _image_to_b64(image_path)
152
  prompt = self._build_prompt(image_b64=image_b64)
 
 
 
153
  logger.info("[Pipeline] appel LLM pour doc %s (zero-shot)", image_path.name)
154
  result = self.llm_adapter.complete(prompt, image_b64=image_b64)
 
155
 
156
  elif self.mode == PipelineMode.TEXT_ONLY:
 
 
 
 
 
 
 
 
157
  if not ocr_text.strip():
158
  logger.warning(
159
+ "[%s] texte OCR vide pour '%s' — le LLM recevra {ocr_output} vide.",
 
160
  self._name, image_path.name,
161
  )
162
  prompt = self._build_prompt(ocr_text=ocr_text)
163
+ logger.info(
164
+ "[Pipeline] appel LLM pour doc %s (text_only, ocr=%d chars)",
165
+ image_path.name, len(ocr_text),
166
+ )
167
  result = self.llm_adapter.complete(prompt)
 
168
 
169
  else: # TEXT_AND_IMAGE
 
 
 
 
 
 
 
 
170
  if not ocr_text.strip():
171
  logger.warning(
172
+ "[%s] texte OCR vide pour '%s' — le LLM recevra {ocr_output} vide.",
 
173
  self._name, image_path.name,
174
  )
175
  image_b64 = _image_to_b64(image_path)
176
  prompt = self._build_prompt(ocr_text=ocr_text, image_b64=image_b64)
177
+ logger.info(
178
+ "[Pipeline] appel LLM pour doc %s (text_and_image, ocr=%d chars)",
179
+ image_path.name, len(ocr_text),
180
+ )
181
  result = self.llm_adapter.complete(prompt, image_b64=image_b64)
182
+
183
+ logger.info("[Pipeline] LLM retourné pour doc %s", image_path.name)
184
 
185
  if not result.success:
186
  raise RuntimeError(f"Erreur LLM ({self.llm_adapter.model}): {result.error}")
187
 
188
  llm_text = result.text
 
189
  logger.info(
190
  "[Pipeline] %s — OCR: %d chars → LLM: %d chars",
191
  image_path.name, len(ocr_text), len(llm_text),
 
208
  ocr_intermediate = ocr_text if self.mode != PipelineMode.ZERO_SHOT else None
209
  return llm_text, ocr_intermediate
210
 
211
+ def _run_ocr(self, image_path: Path) -> tuple[str, Optional[str]]:
212
+ """Logique interne du pipeline — lance l'OCR engine puis le LLM.
213
+
214
+ Returns
215
+ -------
216
+ tuple[str, Optional[str]]
217
+ (llm_text, ocr_intermediate) — ocr_intermediate est None en mode zero_shot.
218
+ """
219
+ ocr_text = ""
220
+ if self.mode != PipelineMode.ZERO_SHOT:
221
+ if self.ocr_engine is None:
222
+ raise ValueError(
223
+ f"ocr_engine est requis pour le mode {self.mode.value} "
224
+ "(utilisez run_with_ocr_text() pour la post-correction sans OCR engine)"
225
+ )
226
+ ocr_result = self.ocr_engine.run(image_path)
227
+ ocr_text = ocr_result.text
228
+
229
+ return self._run_llm_step(image_path, ocr_text)
230
+
231
  # ------------------------------------------------------------------
232
  # Override run() pour injecter les métadonnées pipeline
233
  # ------------------------------------------------------------------
 
273
  metadata=metadata,
274
  )
275
 
276
+ # ------------------------------------------------------------------
277
+ # Post-correction avec OCR pré-calculé
278
+ # ------------------------------------------------------------------
279
+
280
+ def run_with_ocr_text(
281
+ self, image_path: str | Path, ocr_text: str,
282
+ ) -> EngineResult:
283
+ """Exécute le pipeline avec un texte OCR pré-fourni (corpus triplet).
284
+
285
+ Utilisé quand le corpus contient des fichiers ``.ocr.txt`` : le
286
+ texte OCR bruité est fourni directement, sans lancer de moteur OCR.
287
+
288
+ Parameters
289
+ ----------
290
+ image_path:
291
+ Chemin de l'image (utilisée en mode multimodal, ignorée en text_only).
292
+ ocr_text:
293
+ Texte OCR bruité pré-calculé.
294
+
295
+ Returns
296
+ -------
297
+ EngineResult
298
+ """
299
+ image_path = Path(image_path)
300
+ start = time.perf_counter()
301
+
302
+ ocr_intermediate: Optional[str] = ocr_text
303
+ try:
304
+ text, _ = self._run_llm_step(image_path, ocr_text)
305
+ error = None
306
+ except Exception as exc: # noqa: BLE001
307
+ text = ""
308
+ error = str(exc)
309
+ logger.warning(
310
+ "[%s] erreur pipeline (post-correction) pour '%s' : %s",
311
+ self._name, image_path.name, exc,
312
+ )
313
+
314
+ duration = time.perf_counter() - start
315
+
316
+ metadata: dict = {
317
+ "engine_version": self._safe_version(),
318
+ "pipeline_mode": self.mode.value,
319
+ "prompt_file": self.prompt_path,
320
+ "prompt_template": self._prompt_template,
321
+ "llm_model": self.llm_adapter.model,
322
+ "llm_provider": self.llm_adapter.name,
323
+ "pipeline_steps": self._build_steps_info(),
324
+ "is_pipeline": True,
325
+ "ocr_source": "corpus", # distingue de "live"
326
+ }
327
+ if ocr_intermediate is not None:
328
+ metadata["ocr_intermediate"] = ocr_intermediate
329
+
330
+ return EngineResult(
331
+ engine_name=self.name,
332
+ image_path=str(image_path),
333
+ text=text,
334
+ duration_seconds=round(duration, 4),
335
+ error=error,
336
+ metadata=metadata,
337
+ )
338
+
339
  # ------------------------------------------------------------------
340
  # Helpers
341
  # ------------------------------------------------------------------
picarones/web/app.py CHANGED
@@ -173,7 +173,8 @@ class HuggingFaceImportRequest(BaseModel):
173
 
174
  class CompetitorConfig(BaseModel):
175
  name: str = ""
176
- ocr_engine: str
 
177
  ocr_model: str = ""
178
  llm_provider: str = ""
179
  llm_model: str = ""
@@ -418,12 +419,66 @@ def _get_tesseract_langs() -> list[str]:
418
 
419
 
420
  # ---------------------------------------------------------------------------
421
- # API — models (dynamic per provider)
422
  # ---------------------------------------------------------------------------
423
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
424
  @app.get("/api/models/{provider}")
425
- async def api_models(provider: str) -> dict:
426
- """Retourne la liste des modèles disponibles pour un provider, en temps réel."""
 
 
 
 
 
 
 
 
 
 
 
427
  import urllib.error
428
  import urllib.request as _urlreq
429
 
@@ -432,98 +487,128 @@ async def api_models(provider: str) -> dict:
432
  with _urlreq.urlopen(req, timeout=10) as resp:
433
  return json.loads(resp.read().decode())
434
 
 
 
 
 
 
 
 
 
 
435
  if provider == "tesseract":
436
- return {"provider": provider, "models": _get_tesseract_langs()}
 
437
 
438
  if provider == "mistral_ocr":
439
  api_key = os.environ.get("MISTRAL_API_KEY")
440
  if not api_key:
441
- return {"provider": provider, "models": [], "error": "MISTRAL_API_KEY non définie"}
442
  try:
443
  data = _fetch_json(
444
  "https://api.mistral.ai/v1/models",
445
  {"Authorization": f"Bearer {api_key}"},
446
  )
447
- models = sorted(
448
- m["id"] for m in data.get("data", [])
 
449
  if "pixtral" in m["id"].lower() or "mistral-ocr" in m["id"].lower()
450
- )
451
- return {"provider": provider, "models": models}
452
  except Exception as exc:
453
- return {
454
- "provider": provider,
455
- "models": ["pixtral-12b-2409", "pixtral-large-latest", "mistral-ocr-latest"],
456
- "error": str(exc),
457
- }
 
458
 
459
  if provider == "openai":
460
  api_key = os.environ.get("OPENAI_API_KEY")
461
  if not api_key:
462
- return {"provider": provider, "models": [], "error": "OPENAI_API_KEY non définie"}
463
  try:
464
  data = _fetch_json(
465
  "https://api.openai.com/v1/models",
466
  {"Authorization": f"Bearer {api_key}"},
467
  )
468
- models = sorted(
469
- (m["id"] for m in data.get("data", []) if "gpt-4" in m["id"].lower()),
470
- reverse=True,
471
- )
472
- return {"provider": provider, "models": models}
 
473
  except Exception as exc:
474
- return {
475
- "provider": provider,
476
- "models": ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo"],
477
- "error": str(exc),
478
- }
 
479
 
480
  if provider == "anthropic":
481
  api_key = os.environ.get("ANTHROPIC_API_KEY")
482
  if not api_key:
483
- return {"provider": provider, "models": [], "error": "ANTHROPIC_API_KEY non définie"}
484
  try:
485
  data = _fetch_json(
486
  "https://api.anthropic.com/v1/models",
487
  {"x-api-key": api_key, "anthropic-version": "2023-06-01"},
488
  )
489
- models = [m["id"] for m in data.get("data", [])]
490
- return {"provider": provider, "models": models}
 
491
  except Exception as exc:
492
- return {
493
- "provider": provider,
494
- "models": ["claude-sonnet-4-6", "claude-haiku-4-5-20251001", "claude-opus-4-6"],
495
- "error": str(exc),
496
- }
 
497
 
498
  if provider == "mistral":
499
  api_key = os.environ.get("MISTRAL_API_KEY")
500
  if not api_key:
501
- return {"provider": provider, "models": [], "error": "MISTRAL_API_KEY non définie"}
502
  try:
503
  data = _fetch_json(
504
  "https://api.mistral.ai/v1/models",
505
  {"Authorization": f"Bearer {api_key}"},
506
  )
507
- models = sorted(
508
- m["id"] for m in data.get("data", [])
 
509
  if "pixtral" not in m["id"].lower() and "mistral-ocr" not in m["id"].lower()
510
- )
511
- return {"provider": provider, "models": models}
512
  except Exception as exc:
513
- return {
514
- "provider": provider,
515
- "models": ["mistral-large-latest", "mistral-small-latest"],
516
- "error": str(exc),
517
- }
518
 
519
  if provider == "ollama":
520
- return {"provider": provider, "models": _list_ollama_models()}
 
 
 
 
 
521
 
522
  if provider == "google_vision":
523
- return {"provider": provider, "models": ["document_text_detection", "text_detection"]}
 
 
 
 
524
 
525
  if provider == "azure_doc_intel":
526
- return {"provider": provider, "models": ["prebuilt-document", "prebuilt-read"]}
 
 
 
 
527
 
528
  if provider == "prompts":
529
  prompts_dir = Path(__file__).parent.parent / "prompts"
@@ -531,7 +616,7 @@ async def api_models(provider: str) -> dict:
531
  prompts = sorted(f.name for f in prompts_dir.glob("*.txt"))
532
  else:
533
  prompts = []
534
- return {"provider": provider, "models": prompts}
535
 
536
  raise HTTPException(status_code=404, detail=f"Provider inconnu : {provider}")
537
 
@@ -700,6 +785,12 @@ def _analyze_corpus_dir(path: Path) -> dict:
700
  else:
701
  dominant_format = "texte brut"
702
 
 
 
 
 
 
 
703
  return {
704
  "doc_count": len(pairs),
705
  "pairs": pairs[:20],
@@ -709,6 +800,8 @@ def _analyze_corpus_dir(path: Path) -> dict:
709
  "warnings": [f"GT manquant : {img}" for img in missing_gt[:5]],
710
  "usable": len(pairs) > 0,
711
  "gt_format": dominant_format,
 
 
712
  }
713
 
714
 
@@ -729,8 +822,8 @@ def _flatten_zip_to_dir(zf: zipfile.ZipFile, dest: Path) -> None:
729
  # Ignorer les fichiers cachés macOS (._* créés par AppleDouble dans les ZIPs)
730
  if name.startswith("."):
731
  continue
732
- # Accepter images, .gt.txt et .xml (ALTO/PAGE)
733
- if p.suffix.lower() in _IMAGE_EXTS or name.endswith(".gt.txt") or p.suffix.lower() == ".xml":
734
  # Protection ZIP bomb : vérifier la taille décompressée
735
  total_size += member.file_size
736
  if total_size > _MAX_ZIP_TOTAL_SIZE:
@@ -762,7 +855,7 @@ async def api_corpus_upload(files: list[UploadFile] = File(...)) -> dict:
762
  import io
763
  with zipfile.ZipFile(io.BytesIO(data)) as zf:
764
  _flatten_zip_to_dir(zf, corpus_dir)
765
- elif suffix in _IMAGE_EXTS or filename.endswith(".gt.txt") or suffix in (".txt", ".xml"):
766
  (corpus_dir / filename).write_bytes(data)
767
  # Ignorer les autres types
768
 
@@ -1114,36 +1207,73 @@ async def api_benchmark_run(req: BenchmarkRunRequest) -> dict:
1114
  return {"job_id": job_id, "status": "pending"}
1115
 
1116
 
1117
- def _engine_from_competitor(comp: CompetitorConfig) -> Any:
1118
- """Instancie un moteur OCR (ou pipeline OCR+LLM) depuis une CompetitorConfig."""
1119
- from picarones.engines.tesseract import TesseractEngine
1120
- from picarones.engines.mistral_ocr import MistralOCREngine
 
 
 
 
 
 
 
 
 
 
 
 
1121
 
 
 
 
 
 
 
 
 
 
 
 
1122
  engine_id = comp.ocr_engine
1123
 
1124
- if engine_id == "tesseract":
1125
- ocr = TesseractEngine(config={"lang": comp.ocr_model or "fra", "psm": 6})
1126
- elif engine_id == "mistral_ocr":
1127
- ocr = MistralOCREngine(config={"model": comp.ocr_model or "mistral-ocr-latest"})
1128
- elif engine_id == "google_vision":
1129
- try:
1130
- from picarones.engines.google_vision import GoogleVisionEngine
1131
- ocr = GoogleVisionEngine(config={"detection_type": comp.ocr_model or "document_text_detection"})
1132
- except ImportError as exc:
1133
- raise RuntimeError("Google Vision non disponible (google-cloud-vision non installé).") from exc
1134
- elif engine_id == "azure_doc_intel":
1135
- try:
1136
- from picarones.engines.azure_doc_intel import AzureDocIntelEngine
1137
- ocr = AzureDocIntelEngine(config={"model": comp.ocr_model or "prebuilt-document"})
1138
- except ImportError as exc:
1139
- raise RuntimeError("Azure Document Intelligence non disponible.") from exc
1140
- else:
1141
- raise ValueError(f"Moteur OCR inconnu : {engine_id}")
1142
 
1143
- if not comp.llm_provider:
1144
- return ocr
 
 
 
 
 
 
 
 
1145
 
1146
- # Pipeline OCR+LLM
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1147
  _mode_map = {
1148
  "text_only": "text_only",
1149
  "post_correction_text": "text_only",
@@ -1153,24 +1283,16 @@ def _engine_from_competitor(comp: CompetitorConfig) -> Any:
1153
  }
1154
  mode = _mode_map.get(comp.pipeline_mode, "text_only")
1155
 
1156
- if comp.llm_provider == "openai":
1157
- from picarones.llm.openai_adapter import OpenAIAdapter
1158
- llm = OpenAIAdapter(model=comp.llm_model or None)
1159
- elif comp.llm_provider == "anthropic":
1160
- from picarones.llm.anthropic_adapter import AnthropicAdapter
1161
- llm = AnthropicAdapter(model=comp.llm_model or None)
1162
- elif comp.llm_provider == "mistral":
1163
- from picarones.llm.mistral_adapter import MistralAdapter
1164
- llm = MistralAdapter(model=comp.llm_model or None)
1165
- elif comp.llm_provider == "ollama":
1166
- from picarones.llm.ollama_adapter import OllamaAdapter
1167
- llm = OllamaAdapter(model=comp.llm_model or None)
1168
- else:
1169
- raise ValueError(f"Provider LLM inconnu : {comp.llm_provider}")
1170
 
1171
  from picarones.pipelines.base import OCRLLMPipeline
1172
  prompt = comp.prompt_file or "correction_medieval_french.txt"
1173
- pipeline_name = comp.name or f"{engine_id}→{comp.llm_model or comp.llm_provider}"
 
 
 
 
 
1174
  return OCRLLMPipeline(
1175
  ocr_engine=ocr,
1176
  llm_adapter=llm,
 
173
 
174
  class CompetitorConfig(BaseModel):
175
  name: str = ""
176
+ ocr_engine: str = ""
177
+ """Moteur OCR : 'tesseract', 'mistral_ocr', ... ou 'corpus' pour utiliser l'OCR pré-calculé."""
178
  ocr_model: str = ""
179
  llm_provider: str = ""
180
  llm_model: str = ""
 
419
 
420
 
421
  # ---------------------------------------------------------------------------
422
+ # API — models (dynamic per provider, with capability metadata)
423
  # ---------------------------------------------------------------------------
424
 
425
+ # Modèles Mistral text-only (pas de support vision)
426
+ _MISTRAL_TEXT_ONLY = frozenset({
427
+ "ministral-3b-latest", "ministral-8b-latest", "mistral-tiny",
428
+ "mistral-tiny-latest", "open-mistral-7b", "open-mixtral-8x7b",
429
+ "mistral-small-latest", "mistral-small-2409",
430
+ })
431
+
432
+ # Familles Ollama multimodales connues
433
+ _OLLAMA_VISION_FAMILIES = frozenset({
434
+ "llava", "bakllava", "moondream", "minicpm-v", "llama3.2-vision",
435
+ "llava-llama3", "llava-phi3", "nanollava",
436
+ })
437
+
438
+
439
+ def _model_entry(model_id: str, capabilities: list[str]) -> dict:
440
+ """Crée une entrée modèle avec son ID et ses capacités."""
441
+ return {"id": model_id, "capabilities": capabilities}
442
+
443
+
444
+ def _infer_mistral_capabilities(model_id: str) -> list[str]:
445
+ mid = model_id.lower()
446
+ if mid in _MISTRAL_TEXT_ONLY or any(mid.startswith(p) for p in ("ministral", "open-mistral", "open-mixtral")):
447
+ return ["text"]
448
+ if "pixtral" in mid or "mistral-ocr" in mid:
449
+ return ["text", "vision"]
450
+ # Mistral Large et autres modèles récents supportent la vision
451
+ return ["text", "vision"]
452
+
453
+
454
+ def _infer_openai_capabilities(model_id: str) -> list[str]:
455
+ mid = model_id.lower()
456
+ if "gpt-4o" in mid or "gpt-4-turbo" in mid or "gpt-4.1" in mid or "o1" in mid or "o3" in mid:
457
+ return ["text", "vision"]
458
+ return ["text"]
459
+
460
+
461
+ def _infer_ollama_capabilities(model_name: str) -> list[str]:
462
+ base = model_name.split(":")[0].lower()
463
+ if any(base.startswith(family) for family in _OLLAMA_VISION_FAMILIES):
464
+ return ["text", "vision"]
465
+ return ["text"]
466
+
467
+
468
  @app.get("/api/models/{provider}")
469
+ async def api_models(
470
+ provider: str,
471
+ capability: str = Query(default="", description="Filtre par capacité : 'text', 'vision', ou vide pour tout"),
472
+ ) -> dict:
473
+ """Retourne les modèles disponibles avec leurs capacités (text, vision).
474
+
475
+ Interroge l'API du provider en temps réel. Les capacités sont déterminées
476
+ par heuristique sur le nom du modèle quand l'API ne fournit pas cette
477
+ information directement.
478
+
479
+ Le paramètre ``capability`` filtre les résultats (ex : ``?capability=vision``
480
+ ne retourne que les modèles supportant la vision).
481
+ """
482
  import urllib.error
483
  import urllib.request as _urlreq
484
 
 
487
  with _urlreq.urlopen(req, timeout=10) as resp:
488
  return json.loads(resp.read().decode())
489
 
490
+ def _filter_and_format(models: list[dict]) -> dict:
491
+ if capability:
492
+ models = [m for m in models if capability in m["capabilities"]]
493
+ return {
494
+ "provider": provider,
495
+ "models": models,
496
+ "model_ids": [m["id"] for m in models],
497
+ }
498
+
499
  if provider == "tesseract":
500
+ langs = _get_tesseract_langs()
501
+ return {"provider": provider, "models": langs, "model_ids": langs}
502
 
503
  if provider == "mistral_ocr":
504
  api_key = os.environ.get("MISTRAL_API_KEY")
505
  if not api_key:
506
+ return {"provider": provider, "models": [], "model_ids": [], "error": "MISTRAL_API_KEY non définie"}
507
  try:
508
  data = _fetch_json(
509
  "https://api.mistral.ai/v1/models",
510
  {"Authorization": f"Bearer {api_key}"},
511
  )
512
+ models = [
513
+ _model_entry(m["id"], _infer_mistral_capabilities(m["id"]))
514
+ for m in data.get("data", [])
515
  if "pixtral" in m["id"].lower() or "mistral-ocr" in m["id"].lower()
516
+ ]
517
+ return _filter_and_format(sorted(models, key=lambda m: m["id"]))
518
  except Exception as exc:
519
+ fallback = [
520
+ _model_entry("pixtral-12b-2409", ["text", "vision"]),
521
+ _model_entry("pixtral-large-latest", ["text", "vision"]),
522
+ _model_entry("mistral-ocr-latest", ["text", "vision"]),
523
+ ]
524
+ return {**_filter_and_format(fallback), "error": str(exc)}
525
 
526
  if provider == "openai":
527
  api_key = os.environ.get("OPENAI_API_KEY")
528
  if not api_key:
529
+ return {"provider": provider, "models": [], "model_ids": [], "error": "OPENAI_API_KEY non définie"}
530
  try:
531
  data = _fetch_json(
532
  "https://api.openai.com/v1/models",
533
  {"Authorization": f"Bearer {api_key}"},
534
  )
535
+ models = [
536
+ _model_entry(m["id"], _infer_openai_capabilities(m["id"]))
537
+ for m in data.get("data", [])
538
+ if "gpt-4" in m["id"].lower() or "o1" in m["id"].lower() or "o3" in m["id"].lower()
539
+ ]
540
+ return _filter_and_format(sorted(models, key=lambda m: m["id"], reverse=True))
541
  except Exception as exc:
542
+ fallback = [
543
+ _model_entry("gpt-4o", ["text", "vision"]),
544
+ _model_entry("gpt-4o-mini", ["text", "vision"]),
545
+ _model_entry("gpt-4-turbo", ["text", "vision"]),
546
+ ]
547
+ return {**_filter_and_format(fallback), "error": str(exc)}
548
 
549
  if provider == "anthropic":
550
  api_key = os.environ.get("ANTHROPIC_API_KEY")
551
  if not api_key:
552
+ return {"provider": provider, "models": [], "model_ids": [], "error": "ANTHROPIC_API_KEY non définie"}
553
  try:
554
  data = _fetch_json(
555
  "https://api.anthropic.com/v1/models",
556
  {"x-api-key": api_key, "anthropic-version": "2023-06-01"},
557
  )
558
+ # Tous les modèles Claude 3+ supportent la vision
559
+ models = [_model_entry(m["id"], ["text", "vision"]) for m in data.get("data", [])]
560
+ return _filter_and_format(models)
561
  except Exception as exc:
562
+ fallback = [
563
+ _model_entry("claude-sonnet-4-6", ["text", "vision"]),
564
+ _model_entry("claude-haiku-4-5-20251001", ["text", "vision"]),
565
+ _model_entry("claude-opus-4-6", ["text", "vision"]),
566
+ ]
567
+ return {**_filter_and_format(fallback), "error": str(exc)}
568
 
569
  if provider == "mistral":
570
  api_key = os.environ.get("MISTRAL_API_KEY")
571
  if not api_key:
572
+ return {"provider": provider, "models": [], "model_ids": [], "error": "MISTRAL_API_KEY non définie"}
573
  try:
574
  data = _fetch_json(
575
  "https://api.mistral.ai/v1/models",
576
  {"Authorization": f"Bearer {api_key}"},
577
  )
578
+ models = [
579
+ _model_entry(m["id"], _infer_mistral_capabilities(m["id"]))
580
+ for m in data.get("data", [])
581
  if "pixtral" not in m["id"].lower() and "mistral-ocr" not in m["id"].lower()
582
+ ]
583
+ return _filter_and_format(sorted(models, key=lambda m: m["id"]))
584
  except Exception as exc:
585
+ fallback = [
586
+ _model_entry("mistral-large-latest", ["text", "vision"]),
587
+ _model_entry("mistral-small-latest", ["text"]),
588
+ ]
589
+ return {**_filter_and_format(fallback), "error": str(exc)}
590
 
591
  if provider == "ollama":
592
+ _, model_names = _fetch_ollama_info()
593
+ models = [
594
+ _model_entry(name, _infer_ollama_capabilities(name))
595
+ for name in model_names
596
+ ]
597
+ return _filter_and_format(models)
598
 
599
  if provider == "google_vision":
600
+ models = [
601
+ _model_entry("document_text_detection", ["vision"]),
602
+ _model_entry("text_detection", ["vision"]),
603
+ ]
604
+ return _filter_and_format(models)
605
 
606
  if provider == "azure_doc_intel":
607
+ models = [
608
+ _model_entry("prebuilt-document", ["vision"]),
609
+ _model_entry("prebuilt-read", ["vision"]),
610
+ ]
611
+ return _filter_and_format(models)
612
 
613
  if provider == "prompts":
614
  prompts_dir = Path(__file__).parent.parent / "prompts"
 
616
  prompts = sorted(f.name for f in prompts_dir.glob("*.txt"))
617
  else:
618
  prompts = []
619
+ return {"provider": provider, "models": prompts, "model_ids": prompts}
620
 
621
  raise HTTPException(status_code=404, detail=f"Provider inconnu : {provider}")
622
 
 
785
  else:
786
  dominant_format = "texte brut"
787
 
788
+ # Détecter les fichiers OCR bruité (.ocr.txt) pour les corpus triplets
789
+ ocr_text_count = sum(
790
+ 1 for p in pairs
791
+ if (path / (Path(p["image"]).stem + ".ocr.txt")).exists()
792
+ )
793
+
794
  return {
795
  "doc_count": len(pairs),
796
  "pairs": pairs[:20],
 
800
  "warnings": [f"GT manquant : {img}" for img in missing_gt[:5]],
801
  "usable": len(pairs) > 0,
802
  "gt_format": dominant_format,
803
+ "has_ocr_text": ocr_text_count > 0,
804
+ "ocr_text_count": ocr_text_count,
805
  }
806
 
807
 
 
822
  # Ignorer les fichiers cachés macOS (._* créés par AppleDouble dans les ZIPs)
823
  if name.startswith("."):
824
  continue
825
+ # Accepter images, .gt.txt, .ocr.txt et .xml (ALTO/PAGE)
826
+ if p.suffix.lower() in _IMAGE_EXTS or name.endswith(".gt.txt") or name.endswith(".ocr.txt") or p.suffix.lower() == ".xml":
827
  # Protection ZIP bomb : vérifier la taille décompressée
828
  total_size += member.file_size
829
  if total_size > _MAX_ZIP_TOTAL_SIZE:
 
855
  import io
856
  with zipfile.ZipFile(io.BytesIO(data)) as zf:
857
  _flatten_zip_to_dir(zf, corpus_dir)
858
+ elif suffix in _IMAGE_EXTS or filename.endswith(".gt.txt") or filename.endswith(".ocr.txt") or suffix in (".txt", ".xml"):
859
  (corpus_dir / filename).write_bytes(data)
860
  # Ignorer les autres types
861
 
 
1207
  return {"job_id": job_id, "status": "pending"}
1208
 
1209
 
1210
+ def _build_llm_adapter(comp: CompetitorConfig) -> Any:
1211
+ """Instancie un adaptateur LLM depuis la config d'un concurrent."""
1212
+ if comp.llm_provider == "openai":
1213
+ from picarones.llm.openai_adapter import OpenAIAdapter
1214
+ return OpenAIAdapter(model=comp.llm_model or None)
1215
+ elif comp.llm_provider == "anthropic":
1216
+ from picarones.llm.anthropic_adapter import AnthropicAdapter
1217
+ return AnthropicAdapter(model=comp.llm_model or None)
1218
+ elif comp.llm_provider == "mistral":
1219
+ from picarones.llm.mistral_adapter import MistralAdapter
1220
+ return MistralAdapter(model=comp.llm_model or None)
1221
+ elif comp.llm_provider == "ollama":
1222
+ from picarones.llm.ollama_adapter import OllamaAdapter
1223
+ return OllamaAdapter(model=comp.llm_model or None)
1224
+ else:
1225
+ raise ValueError(f"Provider LLM inconnu : {comp.llm_provider}")
1226
 
1227
+
1228
+ def _engine_from_competitor(comp: CompetitorConfig) -> Any:
1229
+ """Instancie un moteur OCR (ou pipeline OCR+LLM) depuis une CompetitorConfig.
1230
+
1231
+ Modes supportés :
1232
+ - ``ocr_engine`` = 'tesseract', 'mistral_ocr', etc. → moteur OCR seul
1233
+ - ``ocr_engine`` + ``llm_provider`` → pipeline OCR live + LLM
1234
+ - ``ocr_engine`` = 'corpus' + ``llm_provider`` → post-correction LLM
1235
+ avec OCR pré-calculé (fichiers .ocr.txt du corpus triplet)
1236
+ - ``ocr_engine`` = '' + ``llm_provider`` → LLM seul (zero-shot ou post-correction)
1237
+ """
1238
  engine_id = comp.ocr_engine
1239
 
1240
+ # Pipeline post-correction avec OCR pré-calculé (corpus triplet)
1241
+ is_corpus_ocr = engine_id in ("corpus", "")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1242
 
1243
+ if is_corpus_ocr and not comp.llm_provider:
1244
+ raise ValueError(
1245
+ "ocr_engine='corpus' nécessite un llm_provider "
1246
+ "(pour la post-correction ou le zero-shot)"
1247
+ )
1248
+
1249
+ ocr = None
1250
+ if not is_corpus_ocr:
1251
+ from picarones.engines.tesseract import TesseractEngine
1252
+ from picarones.engines.mistral_ocr import MistralOCREngine
1253
 
1254
+ if engine_id == "tesseract":
1255
+ ocr = TesseractEngine(config={"lang": comp.ocr_model or "fra", "psm": 6})
1256
+ elif engine_id == "mistral_ocr":
1257
+ ocr = MistralOCREngine(config={"model": comp.ocr_model or "mistral-ocr-latest"})
1258
+ elif engine_id == "google_vision":
1259
+ try:
1260
+ from picarones.engines.google_vision import GoogleVisionEngine
1261
+ ocr = GoogleVisionEngine(config={"detection_type": comp.ocr_model or "document_text_detection"})
1262
+ except ImportError as exc:
1263
+ raise RuntimeError("Google Vision non disponible.") from exc
1264
+ elif engine_id == "azure_doc_intel":
1265
+ try:
1266
+ from picarones.engines.azure_doc_intel import AzureDocIntelEngine
1267
+ ocr = AzureDocIntelEngine(config={"model": comp.ocr_model or "prebuilt-document"})
1268
+ except ImportError as exc:
1269
+ raise RuntimeError("Azure Document Intelligence non disponible.") from exc
1270
+ else:
1271
+ raise ValueError(f"Moteur OCR inconnu : {engine_id}")
1272
+
1273
+ if not comp.llm_provider:
1274
+ return ocr
1275
+
1276
+ # Pipeline OCR+LLM (live ou post-correction)
1277
  _mode_map = {
1278
  "text_only": "text_only",
1279
  "post_correction_text": "text_only",
 
1283
  }
1284
  mode = _mode_map.get(comp.pipeline_mode, "text_only")
1285
 
1286
+ llm = _build_llm_adapter(comp)
 
 
 
 
 
 
 
 
 
 
 
 
 
1287
 
1288
  from picarones.pipelines.base import OCRLLMPipeline
1289
  prompt = comp.prompt_file or "correction_medieval_french.txt"
1290
+
1291
+ if is_corpus_ocr:
1292
+ pipeline_name = comp.name or f"corpus_ocr → {comp.llm_model or comp.llm_provider}"
1293
+ else:
1294
+ pipeline_name = comp.name or f"{engine_id} → {comp.llm_model or comp.llm_provider}"
1295
+
1296
  return OCRLLMPipeline(
1297
  ocr_engine=ocr,
1298
  llm_adapter=llm,