Claude commited on
Commit
c0b5d70
·
unverified ·
1 Parent(s): d03b796

fix(sprint-f2): fiabilité — error handling, async I/O, pagination, cache, prompts

Browse files

Sprint F2 — corrections de fiabilité et d'architecture :

- Error handling providers IA : try/catch autour de generate_content()
dans Google AI, Vertex SA et Mistral (log structuré + RuntimeError)
- Error handling analyzer.py : vérification existence image avant lecture,
catch OSError sur read_bytes()
- Error handling master_writer.py : try/catch sur mkdir + write_text
avec log d'erreur avant re-raise
- Async I/O : search.py, profiles.py, export.py — toutes les lectures
fichiers bloquantes wrappées dans asyncio.to_thread()
- Transactions ingest : cleanup des fichiers orphelins si commit BDD
échoue (written_files track + unlink on exception)
- Pagination : skip/limit sur list_corpora (défaut 100, max 1000),
limit sur search_pages (défaut 200, max 2000)
- Cache providers : singleton _cached_providers dans model_registry.py
(évite de recréer 4 instances à chaque requête)
- Prompt loader : validation des variables non résolues {{...}}
conformément à CLAUDE.md §8

477 tests passants, 0 échecs.

https://claude.ai/code/session_015Lht7wNQRzhUaLw94dE9z9

backend/app/api/v1/corpora.py CHANGED
@@ -14,7 +14,7 @@ import uuid
14
  from datetime import datetime, timezone
15
 
16
  # 2. third-party
17
- from fastapi import APIRouter, Depends, HTTPException
18
  from pydantic import BaseModel, ConfigDict, Field
19
  from sqlalchemy import select
20
  from sqlalchemy.ext.asyncio import AsyncSession
@@ -59,9 +59,13 @@ class ManuscriptResponse(BaseModel):
59
  # ── Endpoints ────────────────────────────────────────────────────────────────
60
 
61
  @router.get("", response_model=list[CorpusResponse])
62
- async def list_corpora(db: AsyncSession = Depends(get_db)) -> list[CorpusModel]:
63
- """Retourne tous les corpus enregistrés."""
64
- result = await db.execute(select(CorpusModel))
 
 
 
 
65
  return list(result.scalars().all())
66
 
67
 
 
14
  from datetime import datetime, timezone
15
 
16
  # 2. third-party
17
+ from fastapi import APIRouter, Depends, HTTPException, Query
18
  from pydantic import BaseModel, ConfigDict, Field
19
  from sqlalchemy import select
20
  from sqlalchemy.ext.asyncio import AsyncSession
 
59
  # ── Endpoints ────────────────────────────────────────────────────────────────
60
 
61
  @router.get("", response_model=list[CorpusResponse])
62
+ async def list_corpora(
63
+ db: AsyncSession = Depends(get_db),
64
+ skip: int = Query(0, ge=0, description="Nombre d'éléments à sauter"),
65
+ limit: int = Query(100, ge=1, le=1000, description="Nombre maximum d'éléments"),
66
+ ) -> list[CorpusModel]:
67
+ """Retourne les corpus enregistrés (paginé)."""
68
+ result = await db.execute(select(CorpusModel).offset(skip).limit(limit))
69
  return list(result.scalars().all())
70
 
71
 
backend/app/api/v1/export.py CHANGED
@@ -10,6 +10,7 @@ Règle (R02) : toutes les sorties sont générées depuis les PageMasters
10
  (master.json), jamais depuis les réponses brutes de l'IA.
11
  """
12
  # 1. stdlib
 
13
  import io
14
  import json
15
  import logging
@@ -66,7 +67,7 @@ async def _load_manuscript_with_masters(
66
 
67
  masters: list[PageMaster] = []
68
  for page in pages:
69
- master = _read_master_json(corpus.slug, page.id)
70
  if master is not None:
71
  masters.append(master)
72
 
@@ -79,8 +80,8 @@ async def _load_manuscript_with_masters(
79
  return manuscript, corpus, masters
80
 
81
 
82
- def _read_master_json(corpus_slug: str, page_id: str) -> PageMaster | None:
83
- """Lit le master.json d'une page depuis data/. Retourne None si absent."""
84
  path = (
85
  _config_module.settings.data_dir
86
  / "corpora"
@@ -95,6 +96,11 @@ def _read_master_json(corpus_slug: str, page_id: str) -> PageMaster | None:
95
  return PageMaster.model_validate(raw)
96
 
97
 
 
 
 
 
 
98
  def _build_manuscript_meta(
99
  manuscript: ManuscriptModel, corpus: CorpusModel
100
  ) -> dict:
@@ -154,7 +160,7 @@ async def get_alto(page_id: str, db: AsyncSession = Depends(get_db)) -> Response
154
  manuscript = await db.get(ManuscriptModel, page.manuscript_id)
155
  corpus = await db.get(CorpusModel, manuscript.corpus_id)
156
 
157
- master = _read_master_json(corpus.slug, page_id)
158
  if master is None:
159
  raise HTTPException(
160
  status_code=404,
 
10
  (master.json), jamais depuis les réponses brutes de l'IA.
11
  """
12
  # 1. stdlib
13
+ import asyncio
14
  import io
15
  import json
16
  import logging
 
67
 
68
  masters: list[PageMaster] = []
69
  for page in pages:
70
+ master = await _read_master_json(corpus.slug, page.id)
71
  if master is not None:
72
  masters.append(master)
73
 
 
80
  return manuscript, corpus, masters
81
 
82
 
83
+ def _read_master_json_sync(corpus_slug: str, page_id: str) -> PageMaster | None:
84
+ """Lit le master.json d'une page depuis data/. Retourne None si absent (bloquant)."""
85
  path = (
86
  _config_module.settings.data_dir
87
  / "corpora"
 
96
  return PageMaster.model_validate(raw)
97
 
98
 
99
+ async def _read_master_json(corpus_slug: str, page_id: str) -> PageMaster | None:
100
+ """Version async — délègue la lecture au threadpool."""
101
+ return await asyncio.to_thread(_read_master_json_sync, corpus_slug, page_id)
102
+
103
+
104
  def _build_manuscript_meta(
105
  manuscript: ManuscriptModel, corpus: CorpusModel
106
  ) -> dict:
 
160
  manuscript = await db.get(ManuscriptModel, page.manuscript_id)
161
  corpus = await db.get(CorpusModel, manuscript.corpus_id)
162
 
163
+ master = await _read_master_json(corpus.slug, page_id)
164
  if master is None:
165
  raise HTTPException(
166
  status_code=404,
backend/app/api/v1/ingest.py CHANGED
@@ -249,6 +249,7 @@ async def ingest_files(
249
  dupes = _find_duplicate_labels(labels)
250
 
251
  created: list[PageModel] = []
 
252
  skipped = 0
253
  for i, upload in enumerate(files):
254
  # Validation MIME type
@@ -281,6 +282,7 @@ async def ingest_files(
281
  master_dir.mkdir(parents=True, exist_ok=True)
282
  master_path = master_dir / filename
283
  master_path.write_bytes(content)
 
284
 
285
  page = await _create_page(
286
  db, ms.id, page_id, folio_label, seq + i,
@@ -292,7 +294,13 @@ async def ingest_files(
292
  created.append(page)
293
 
294
  ms.total_pages = (ms.total_pages or 0) + len(created)
295
- await db.commit()
 
 
 
 
 
 
296
 
297
  logger.info(
298
  "Fichiers ingérés",
 
249
  dupes = _find_duplicate_labels(labels)
250
 
251
  created: list[PageModel] = []
252
+ written_files: list[Path] = []
253
  skipped = 0
254
  for i, upload in enumerate(files):
255
  # Validation MIME type
 
282
  master_dir.mkdir(parents=True, exist_ok=True)
283
  master_path = master_dir / filename
284
  master_path.write_bytes(content)
285
+ written_files.append(master_path)
286
 
287
  page = await _create_page(
288
  db, ms.id, page_id, folio_label, seq + i,
 
294
  created.append(page)
295
 
296
  ms.total_pages = (ms.total_pages or 0) + len(created)
297
+ try:
298
+ await db.commit()
299
+ except Exception:
300
+ # Nettoyage des fichiers orphelins si le commit BDD échoue
301
+ for f in written_files:
302
+ f.unlink(missing_ok=True)
303
+ raise
304
 
305
  logger.info(
306
  "Fichiers ingérés",
backend/app/api/v1/profiles.py CHANGED
@@ -8,6 +8,7 @@ Les profils sont des fichiers JSON dans profiles/ (racine du dépôt).
8
  Ils sont validés par CorpusProfile avant d'être retournés.
9
  """
10
  # 1. stdlib
 
11
  import json
12
  import logging
13
  import re
@@ -50,12 +51,16 @@ async def list_profiles() -> list[dict]:
50
  if not settings.profiles_dir.is_dir():
51
  logger.warning("profiles_dir introuvable : %s", settings.profiles_dir)
52
  return []
53
- profiles = []
54
- for path in sorted(settings.profiles_dir.glob("*.json")):
55
- profile = _load_profile(path)
56
- if profile is not None:
57
- profiles.append(profile.model_dump())
58
- return profiles
 
 
 
 
59
 
60
 
61
  _SAFE_ID_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
@@ -67,9 +72,15 @@ async def get_profile(profile_id: str) -> dict:
67
  if not _SAFE_ID_RE.match(profile_id):
68
  raise HTTPException(status_code=400, detail="profile_id invalide")
69
  path = settings.profiles_dir / f"{profile_id}.json"
70
- if not path.exists():
 
 
 
 
 
 
 
71
  raise HTTPException(status_code=404, detail="Profil introuvable")
72
- profile = _load_profile(path)
73
  if profile is None:
74
  raise HTTPException(status_code=422, detail="Profil invalide")
75
  return profile.model_dump()
 
8
  Ils sont validés par CorpusProfile avant d'être retournés.
9
  """
10
  # 1. stdlib
11
+ import asyncio
12
  import json
13
  import logging
14
  import re
 
51
  if not settings.profiles_dir.is_dir():
52
  logger.warning("profiles_dir introuvable : %s", settings.profiles_dir)
53
  return []
54
+
55
+ def _scan_profiles() -> list[dict]:
56
+ result = []
57
+ for path in sorted(settings.profiles_dir.glob("*.json")):
58
+ profile = _load_profile(path)
59
+ if profile is not None:
60
+ result.append(profile.model_dump())
61
+ return result
62
+
63
+ return await asyncio.to_thread(_scan_profiles)
64
 
65
 
66
  _SAFE_ID_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
 
72
  if not _SAFE_ID_RE.match(profile_id):
73
  raise HTTPException(status_code=400, detail="profile_id invalide")
74
  path = settings.profiles_dir / f"{profile_id}.json"
75
+
76
+ def _read() -> CorpusProfile | None:
77
+ if not path.exists():
78
+ return None
79
+ return _load_profile(path)
80
+
81
+ profile = await asyncio.to_thread(_read)
82
+ if profile is None and not path.exists():
83
  raise HTTPException(status_code=404, detail="Profil introuvable")
 
84
  if profile is None:
85
  raise HTTPException(status_code=422, detail="Profil invalide")
86
  return profile.model_dump()
backend/app/api/v1/search.py CHANGED
@@ -7,6 +7,7 @@ Implémentation MVP : scan des fichiers master.json (pas d'index externe).
7
  Insensible à la casse et aux accents (unicodedata NFD + ASCII).
8
  """
9
  # 1. stdlib
 
10
  import json
11
  import logging
12
  import unicodedata
@@ -96,6 +97,7 @@ def _score_master(data: dict, query_normalized: str) -> tuple[int, str]:
96
  @router.get("/search", response_model=list[SearchResult])
97
  async def search_pages(
98
  q: str = Query(..., min_length=2, max_length=500, description="Requête de recherche (2–500 caractères)"),
 
99
  ) -> list[SearchResult]:
100
  """Recherche plein texte dans les master.json de tous les corpus.
101
 
@@ -106,29 +108,32 @@ async def search_pages(
106
  query_normalized = _normalize(q.strip())
107
  data_dir = _config_module.settings.data_dir
108
 
109
- results: list[SearchResult] = []
110
-
111
- for master_path in data_dir.glob("corpora/*/pages/*/master.json"):
112
- try:
113
- raw: dict = json.loads(master_path.read_text(encoding="utf-8"))
114
- except (json.JSONDecodeError, OSError):
115
- continue
116
-
117
- score, excerpt = _score_master(raw, query_normalized)
118
- if score == 0:
119
- continue
120
-
121
- results.append(
122
- SearchResult(
123
- page_id=raw.get("page_id", ""),
124
- folio_label=raw.get("folio_label", ""),
125
- manuscript_id=raw.get("manuscript_id", ""),
126
- excerpt=excerpt,
127
- score=score,
128
- corpus_profile=raw.get("corpus_profile", ""),
 
 
129
  )
130
- )
 
131
 
132
- results.sort(key=lambda r: r.score, reverse=True)
133
  logger.info("Recherche exécutée", extra={"q": q, "results": len(results)})
134
- return results
 
7
  Insensible à la casse et aux accents (unicodedata NFD + ASCII).
8
  """
9
  # 1. stdlib
10
+ import asyncio
11
  import json
12
  import logging
13
  import unicodedata
 
97
  @router.get("/search", response_model=list[SearchResult])
98
  async def search_pages(
99
  q: str = Query(..., min_length=2, max_length=500, description="Requête de recherche (2–500 caractères)"),
100
+ limit: int = Query(200, ge=1, le=2000, description="Nombre maximum de résultats"),
101
  ) -> list[SearchResult]:
102
  """Recherche plein texte dans les master.json de tous les corpus.
103
 
 
108
  query_normalized = _normalize(q.strip())
109
  data_dir = _config_module.settings.data_dir
110
 
111
+ def _scan() -> list[SearchResult]:
112
+ """Scan bloquant exécuté dans un thread dédié."""
113
+ hits: list[SearchResult] = []
114
+ for master_path in data_dir.glob("corpora/*/pages/*/master.json"):
115
+ try:
116
+ raw: dict = json.loads(master_path.read_text(encoding="utf-8"))
117
+ except (json.JSONDecodeError, OSError):
118
+ continue
119
+
120
+ score, excerpt = _score_master(raw, query_normalized)
121
+ if score == 0:
122
+ continue
123
+
124
+ hits.append(
125
+ SearchResult(
126
+ page_id=raw.get("page_id", ""),
127
+ folio_label=raw.get("folio_label", ""),
128
+ manuscript_id=raw.get("manuscript_id", ""),
129
+ excerpt=excerpt,
130
+ score=score,
131
+ corpus_profile=raw.get("corpus_profile", ""),
132
+ )
133
  )
134
+ hits.sort(key=lambda r: r.score, reverse=True)
135
+ return hits
136
 
137
+ results = await asyncio.to_thread(_scan)
138
  logger.info("Recherche exécutée", extra={"q": q, "results": len(results)})
139
+ return results[:limit]
backend/app/services/ai/analyzer.py CHANGED
@@ -85,7 +85,12 @@ def run_primary_analysis(
85
  )
86
 
87
  # ── 2. Chargement de l'image dérivée ────────────────────────────────────
88
- jpeg_bytes = derivative_image_path.read_bytes()
 
 
 
 
 
89
 
90
  # ── 3. Appel IA via le provider sélectionné ─────────────────────────────
91
  provider = get_provider(model_config.provider)
 
85
  )
86
 
87
  # ── 2. Chargement de l'image dérivée ────────────────────────────────────
88
+ if not derivative_image_path.exists():
89
+ raise FileNotFoundError(f"Image dérivée introuvable : {derivative_image_path}")
90
+ try:
91
+ jpeg_bytes = derivative_image_path.read_bytes()
92
+ except OSError as exc:
93
+ raise RuntimeError(f"Erreur lecture image {derivative_image_path} : {exc}") from exc
94
 
95
  # ── 3. Appel IA via le provider sélectionné ─────────────────────────────
96
  provider = get_provider(model_config.provider)
backend/app/services/ai/master_writer.py CHANGED
@@ -22,17 +22,17 @@ def write_gemini_raw(raw_text: str, output_path: Path) -> None:
22
  Toujours appelé AVANT toute tentative de parsing.
23
  Le contenu est enveloppé dans un objet JSON pour garantir un fichier valide,
24
  même si la réponse IA n'est pas du JSON.
25
-
26
- Args:
27
- raw_text: texte brut retourné par l'API Google AI.
28
- output_path: chemin complet du fichier de sortie (gemini_raw.json).
29
  """
30
- output_path.parent.mkdir(parents=True, exist_ok=True)
31
- payload = {"response_text": raw_text}
32
- output_path.write_text(
33
- json.dumps(payload, ensure_ascii=False, indent=2),
34
- encoding="utf-8",
35
- )
 
 
 
 
36
  logger.info("gemini_raw.json écrit", extra={"path": str(output_path)})
37
 
38
 
@@ -41,14 +41,14 @@ def write_master_json(page_master: PageMaster, output_path: Path) -> None:
41
 
42
  N'est appelé QUE si le parsing et la validation Pydantic ont réussi.
43
  Crée les dossiers parents si nécessaire.
44
-
45
- Args:
46
- page_master: instance PageMaster validée par Pydantic.
47
- output_path: chemin complet du fichier de sortie (master.json).
48
  """
49
- output_path.parent.mkdir(parents=True, exist_ok=True)
50
- output_path.write_text(
51
- page_master.model_dump_json(indent=2),
52
- encoding="utf-8",
53
- )
 
 
 
 
54
  logger.info("master.json écrit", extra={"path": str(output_path)})
 
22
  Toujours appelé AVANT toute tentative de parsing.
23
  Le contenu est enveloppé dans un objet JSON pour garantir un fichier valide,
24
  même si la réponse IA n'est pas du JSON.
 
 
 
 
25
  """
26
+ try:
27
+ output_path.parent.mkdir(parents=True, exist_ok=True)
28
+ payload = {"response_text": raw_text}
29
+ output_path.write_text(
30
+ json.dumps(payload, ensure_ascii=False, indent=2),
31
+ encoding="utf-8",
32
+ )
33
+ except OSError as exc:
34
+ logger.error("Écriture gemini_raw.json échouée", extra={"path": str(output_path), "error": str(exc)})
35
+ raise
36
  logger.info("gemini_raw.json écrit", extra={"path": str(output_path)})
37
 
38
 
 
41
 
42
  N'est appelé QUE si le parsing et la validation Pydantic ont réussi.
43
  Crée les dossiers parents si nécessaire.
 
 
 
 
44
  """
45
+ try:
46
+ output_path.parent.mkdir(parents=True, exist_ok=True)
47
+ output_path.write_text(
48
+ page_master.model_dump_json(indent=2),
49
+ encoding="utf-8",
50
+ )
51
+ except OSError as exc:
52
+ logger.error("Écriture master.json échouée", extra={"path": str(output_path), "error": str(exc)})
53
+ raise
54
  logger.info("master.json écrit", extra={"path": str(output_path)})
backend/app/services/ai/model_registry.py CHANGED
@@ -23,19 +23,27 @@ _PROVIDER_DISPLAY_NAMES: dict[ProviderType, str] = {
23
  }
24
 
25
 
 
 
 
26
  def _build_providers() -> list[AIProvider]:
27
- """Construit la liste des providers — imports différés."""
 
 
 
 
28
  from app.services.ai.provider_google_ai import GoogleAIProvider
29
  from app.services.ai.provider_mistral import MistralProvider
30
  from app.services.ai.provider_vertex_key import VertexAPIKeyProvider
31
  from app.services.ai.provider_vertex_sa import VertexServiceAccountProvider
32
 
33
- return [
34
  GoogleAIProvider(),
35
  VertexAPIKeyProvider(),
36
  VertexServiceAccountProvider(),
37
  MistralProvider(),
38
  ]
 
39
 
40
 
41
  def get_available_providers() -> list[dict]:
 
23
  }
24
 
25
 
26
+ _cached_providers: list[AIProvider] | None = None
27
+
28
+
29
  def _build_providers() -> list[AIProvider]:
30
+ """Construit la liste des providers — imports différés, résultat mis en cache."""
31
+ global _cached_providers
32
+ if _cached_providers is not None:
33
+ return _cached_providers
34
+
35
  from app.services.ai.provider_google_ai import GoogleAIProvider
36
  from app.services.ai.provider_mistral import MistralProvider
37
  from app.services.ai.provider_vertex_key import VertexAPIKeyProvider
38
  from app.services.ai.provider_vertex_sa import VertexServiceAccountProvider
39
 
40
+ _cached_providers = [
41
  GoogleAIProvider(),
42
  VertexAPIKeyProvider(),
43
  VertexServiceAccountProvider(),
44
  MistralProvider(),
45
  ]
46
+ return _cached_providers
47
 
48
 
49
  def get_available_providers() -> list[dict]:
backend/app/services/ai/prompt_loader.py CHANGED
@@ -6,6 +6,7 @@ Le code charge le fichier, substitue les variables {{nom}}, envoie à l'API.
6
  """
7
  # 1. stdlib
8
  import logging
 
9
  from pathlib import Path
10
 
11
  logger = logging.getLogger(__name__)
@@ -38,6 +39,11 @@ def load_and_render_prompt(template_path: str | Path, context: dict[str, str]) -
38
  for key, value in context.items():
39
  rendered = rendered.replace("{{" + key + "}}", value)
40
 
 
 
 
 
 
41
  logger.debug(
42
  "Prompt chargé et rendu",
43
  extra={"template": str(path), "variables": list(context.keys())},
 
6
  """
7
  # 1. stdlib
8
  import logging
9
+ import re
10
  from pathlib import Path
11
 
12
  logger = logging.getLogger(__name__)
 
39
  for key, value in context.items():
40
  rendered = rendered.replace("{{" + key + "}}", value)
41
 
42
+ # Vérifier qu'il ne reste pas de variables non résolues (CLAUDE.md §8)
43
+ unresolved = re.findall(r"\{\{\w+\}\}", rendered)
44
+ if unresolved:
45
+ raise ValueError(f"Variables non résolues dans le prompt : {unresolved}")
46
+
47
  logger.debug(
48
  "Prompt chargé et rendu",
49
  extra={"template": str(path), "variables": list(context.keys())},
backend/app/services/ai/provider_google_ai.py CHANGED
@@ -60,8 +60,15 @@ class GoogleAIProvider(AIProvider):
60
  raise RuntimeError(f"Variable d'environnement manquante : {_ENV_KEY}")
61
  client = genai.Client(api_key=os.environ[_ENV_KEY])
62
  image_part = types.Part.from_bytes(data=image_bytes, mime_type="image/jpeg")
63
- response = client.models.generate_content(
64
- model=model_id,
65
- contents=[image_part, prompt],
66
- )
 
 
 
 
 
 
 
67
  return response.text or ""
 
60
  raise RuntimeError(f"Variable d'environnement manquante : {_ENV_KEY}")
61
  client = genai.Client(api_key=os.environ[_ENV_KEY])
62
  image_part = types.Part.from_bytes(data=image_bytes, mime_type="image/jpeg")
63
+ try:
64
+ response = client.models.generate_content(
65
+ model=model_id,
66
+ contents=[image_part, prompt],
67
+ )
68
+ except Exception as exc:
69
+ logger.error(
70
+ "Appel API Google AI Studio échoué",
71
+ extra={"model": model_id, "error": str(exc)},
72
+ )
73
+ raise RuntimeError(f"Erreur API Google AI Studio ({model_id}) : {exc}") from exc
74
  return response.text or ""
backend/app/services/ai/provider_mistral.py CHANGED
@@ -208,10 +208,14 @@ class MistralProvider(AIProvider):
208
  # ── Chemin 1 : OCR dédié ─────────────────────────────────────────────
209
  if _is_ocr_model(model_id):
210
  logger.info("Mistral OCR : endpoint dédié client.ocr.process()", extra={"model": model_id})
211
- response = client.ocr.process(
212
- model=model_id,
213
- document={"type": "image_url", "image_url": {"url": data_url}},
214
- )
 
 
 
 
215
  # OCRResponse.pages : list[OCRPageObject], chacun avec .markdown
216
  pages = getattr(response, "pages", []) or []
217
  return "\n\n".join(
@@ -233,10 +237,14 @@ class MistralProvider(AIProvider):
233
  )
234
  content = prompt
235
 
236
- response = client.chat.complete(
237
- model=model_id,
238
- messages=[{"role": "user", "content": content}],
239
- )
 
 
 
 
240
  choices = response.choices or []
241
  if not choices:
242
  return ""
 
208
  # ── Chemin 1 : OCR dédié ─────────────────────────────────────────────
209
  if _is_ocr_model(model_id):
210
  logger.info("Mistral OCR : endpoint dédié client.ocr.process()", extra={"model": model_id})
211
+ try:
212
+ response = client.ocr.process(
213
+ model=model_id,
214
+ document={"type": "image_url", "image_url": {"url": data_url}},
215
+ )
216
+ except Exception as exc:
217
+ logger.error("Appel Mistral OCR échoué", extra={"model": model_id, "error": str(exc)})
218
+ raise RuntimeError(f"Erreur API Mistral OCR ({model_id}) : {exc}") from exc
219
  # OCRResponse.pages : list[OCRPageObject], chacun avec .markdown
220
  pages = getattr(response, "pages", []) or []
221
  return "\n\n".join(
 
237
  )
238
  content = prompt
239
 
240
+ try:
241
+ response = client.chat.complete(
242
+ model=model_id,
243
+ messages=[{"role": "user", "content": content}],
244
+ )
245
+ except Exception as exc:
246
+ logger.error("Appel Mistral chat échoué", extra={"model": model_id, "error": str(exc)})
247
+ raise RuntimeError(f"Erreur API Mistral ({model_id}) : {exc}") from exc
248
  choices = response.choices or []
249
  if not choices:
250
  return ""
backend/app/services/ai/provider_vertex_sa.py CHANGED
@@ -90,8 +90,15 @@ class VertexServiceAccountProvider(AIProvider):
90
  raise RuntimeError(f"Variable d'environnement manquante : {_ENV_KEY}")
91
  client = self._build_client()
92
  image_part = types.Part.from_bytes(data=image_bytes, mime_type="image/jpeg")
93
- response = client.models.generate_content(
94
- model=model_id,
95
- contents=[image_part, prompt],
96
- )
 
 
 
 
 
 
 
97
  return response.text or ""
 
90
  raise RuntimeError(f"Variable d'environnement manquante : {_ENV_KEY}")
91
  client = self._build_client()
92
  image_part = types.Part.from_bytes(data=image_bytes, mime_type="image/jpeg")
93
+ try:
94
+ response = client.models.generate_content(
95
+ model=model_id,
96
+ contents=[image_part, prompt],
97
+ )
98
+ except Exception as exc:
99
+ logger.error(
100
+ "Appel API Vertex AI échoué",
101
+ extra={"model": model_id, "error": str(exc)},
102
+ )
103
+ raise RuntimeError(f"Erreur API Vertex AI ({model_id}) : {exc}") from exc
104
  return response.text or ""