Spaces:
Build error
fix(sprint-f2): fiabilité — error handling, async I/O, pagination, cache, prompts
Browse filesSprint 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 +8 -4
- backend/app/api/v1/export.py +10 -4
- backend/app/api/v1/ingest.py +9 -1
- backend/app/api/v1/profiles.py +19 -8
- backend/app/api/v1/search.py +28 -23
- backend/app/services/ai/analyzer.py +6 -1
- backend/app/services/ai/master_writer.py +19 -19
- backend/app/services/ai/model_registry.py +10 -2
- backend/app/services/ai/prompt_loader.py +6 -0
- backend/app/services/ai/provider_google_ai.py +11 -4
- backend/app/services/ai/provider_mistral.py +16 -8
- backend/app/services/ai/provider_vertex_sa.py +11 -4
|
@@ -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(
|
| 63 |
-
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
@@ -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
|
| 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,
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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",
|
|
@@ -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 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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()
|
|
@@ -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 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
|
|
|
|
|
|
| 129 |
)
|
| 130 |
-
)
|
|
|
|
| 131 |
|
| 132 |
-
results
|
| 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]
|
|
@@ -85,7 +85,12 @@ def run_primary_analysis(
|
|
| 85 |
)
|
| 86 |
|
| 87 |
# ── 2. Chargement de l'image dérivée ────────────────────────────────────
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|
|
@@ -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 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 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 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 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)})
|
|
@@ -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 |
-
|
| 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]:
|
|
@@ -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())},
|
|
@@ -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 |
-
|
| 64 |
-
|
| 65 |
-
|
| 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 ""
|
|
@@ -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 |
-
|
| 212 |
-
|
| 213 |
-
|
| 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 |
-
|
| 237 |
-
|
| 238 |
-
|
| 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 ""
|
|
@@ -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 |
-
|
| 94 |
-
|
| 95 |
-
|
| 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 ""
|