Spaces:
Build error
Build error
Claude commited on
feat(sprint2-session-c): premier appel Google AI → master.json valide
Browse filesPipeline d'analyse primaire complet :
- prompt_loader.py : charge et rend les templates {{var}} (R04)
- client_factory.py : construit genai.Client selon le provider (R06, R11)
- response_parser.py: parse JSON brut → layout + OCRResult ; bbox invalide
ignorée individuellement, JSON non parseable → ParseError
- master_writer.py : gemini_raw.json toujours écrit en premier (R05),
master.json seulement si parsing OK
- analyzer.py : run_primary_analysis() chaîne les 4 composants
- test_ai_analyzer.py : 46 tests unitaires (tous passent, aucun appel réseau)
Total : 162 tests passent, 3 skippés (intégration réseau).
https://claude.ai/code/session_018woyEHc8HG2th7V4ewJ4Kg
- backend/app/services/ai/__init__.py +10 -1
- backend/app/services/ai/analyzer.py +153 -0
- backend/app/services/ai/client_factory.py +97 -0
- backend/app/services/ai/master_writer.py +54 -0
- backend/app/services/ai/prompt_loader.py +45 -0
- backend/app/services/ai/response_parser.py +106 -0
- backend/tests/test_ai_analyzer.py +871 -0
backend/app/services/ai/__init__.py
CHANGED
|
@@ -1,10 +1,14 @@
|
|
| 1 |
"""
|
| 2 |
-
Services AI — providers Google AI
|
| 3 |
"""
|
|
|
|
|
|
|
| 4 |
from app.services.ai.model_registry import build_model_config, list_all_models
|
|
|
|
| 5 |
from app.services.ai.provider_google_ai import GoogleAIProvider
|
| 6 |
from app.services.ai.provider_vertex_key import VertexAPIKeyProvider
|
| 7 |
from app.services.ai.provider_vertex_sa import VertexServiceAccountProvider
|
|
|
|
| 8 |
|
| 9 |
__all__ = [
|
| 10 |
"GoogleAIProvider",
|
|
@@ -12,4 +16,9 @@ __all__ = [
|
|
| 12 |
"VertexServiceAccountProvider",
|
| 13 |
"list_all_models",
|
| 14 |
"build_model_config",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
]
|
|
|
|
| 1 |
"""
|
| 2 |
+
Services AI — providers Google AI, registre de modèles, et analyse IA.
|
| 3 |
"""
|
| 4 |
+
from app.services.ai.analyzer import run_primary_analysis
|
| 5 |
+
from app.services.ai.client_factory import build_client
|
| 6 |
from app.services.ai.model_registry import build_model_config, list_all_models
|
| 7 |
+
from app.services.ai.prompt_loader import load_and_render_prompt
|
| 8 |
from app.services.ai.provider_google_ai import GoogleAIProvider
|
| 9 |
from app.services.ai.provider_vertex_key import VertexAPIKeyProvider
|
| 10 |
from app.services.ai.provider_vertex_sa import VertexServiceAccountProvider
|
| 11 |
+
from app.services.ai.response_parser import ParseError, parse_ai_response
|
| 12 |
|
| 13 |
__all__ = [
|
| 14 |
"GoogleAIProvider",
|
|
|
|
| 16 |
"VertexServiceAccountProvider",
|
| 17 |
"list_all_models",
|
| 18 |
"build_model_config",
|
| 19 |
+
"build_client",
|
| 20 |
+
"load_and_render_prompt",
|
| 21 |
+
"parse_ai_response",
|
| 22 |
+
"ParseError",
|
| 23 |
+
"run_primary_analysis",
|
| 24 |
]
|
backend/app/services/ai/analyzer.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Analyse primaire IA d'un folio : appel Google AI + écriture master.json (R02, R04, R05).
|
| 3 |
+
|
| 4 |
+
Point d'entrée : run_primary_analysis().
|
| 5 |
+
Chaîne : prompt_loader → client_factory → Google AI → master_writer → response_parser.
|
| 6 |
+
"""
|
| 7 |
+
# 1. stdlib
|
| 8 |
+
import logging
|
| 9 |
+
from datetime import datetime, timezone
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
# 2. third-party
|
| 13 |
+
from google.genai import types
|
| 14 |
+
|
| 15 |
+
# 3. local
|
| 16 |
+
from app.schemas.corpus_profile import CorpusProfile
|
| 17 |
+
from app.schemas.image import ImageDerivativeInfo
|
| 18 |
+
from app.schemas.model_config import ModelConfig
|
| 19 |
+
from app.schemas.page_master import EditorialInfo, EditorialStatus, PageMaster, ProcessingInfo
|
| 20 |
+
from app.services.ai.client_factory import build_client
|
| 21 |
+
from app.services.ai.master_writer import write_gemini_raw, write_master_json
|
| 22 |
+
from app.services.ai.prompt_loader import load_and_render_prompt
|
| 23 |
+
from app.services.ai.response_parser import ParseError, parse_ai_response # noqa: F401
|
| 24 |
+
|
| 25 |
+
logger = logging.getLogger(__name__)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def run_primary_analysis(
|
| 29 |
+
derivative_image_path: Path,
|
| 30 |
+
corpus_profile: CorpusProfile,
|
| 31 |
+
model_config: ModelConfig,
|
| 32 |
+
page_id: str,
|
| 33 |
+
manuscript_id: str,
|
| 34 |
+
corpus_slug: str,
|
| 35 |
+
folio_label: str,
|
| 36 |
+
sequence: int,
|
| 37 |
+
image_info: ImageDerivativeInfo,
|
| 38 |
+
base_data_dir: Path = Path("data"),
|
| 39 |
+
project_root: Path = Path("."),
|
| 40 |
+
) -> PageMaster:
|
| 41 |
+
"""Analyse primaire d'un folio : charge le prompt, appelle l'IA, écrit les fichiers.
|
| 42 |
+
|
| 43 |
+
Respecte R05 : gemini_raw.json est toujours écrit en premier, même en cas
|
| 44 |
+
d'erreur de parsing. master.json n'est écrit QUE si le parsing a réussi.
|
| 45 |
+
|
| 46 |
+
Args:
|
| 47 |
+
derivative_image_path: chemin vers le JPEG dérivé (1500px max).
|
| 48 |
+
corpus_profile: profil du corpus (pilote le prompt et les layers).
|
| 49 |
+
model_config: configuration du modèle sélectionné (provider + model_id).
|
| 50 |
+
page_id: identifiant unique de la page (ex. "beatus-lat8878-0013r").
|
| 51 |
+
manuscript_id: identifiant du manuscrit.
|
| 52 |
+
corpus_slug: identifiant du corpus (ex. "beatus-lat8878").
|
| 53 |
+
folio_label: label du folio (ex. "0013r").
|
| 54 |
+
sequence: numéro de séquence dans le manuscrit.
|
| 55 |
+
image_info: métadonnées de l'image normalisée (dimensions, chemins).
|
| 56 |
+
base_data_dir: racine du dossier data.
|
| 57 |
+
project_root: racine du projet (pour résoudre les chemins des prompts).
|
| 58 |
+
|
| 59 |
+
Returns:
|
| 60 |
+
PageMaster validé (gemini_raw.json et master.json écrits sur disque).
|
| 61 |
+
|
| 62 |
+
Raises:
|
| 63 |
+
ParseError: si la réponse IA n'est pas un JSON valide.
|
| 64 |
+
FileNotFoundError: si le template de prompt est introuvable.
|
| 65 |
+
RuntimeError: si le provider n'est pas configuré (variable d'env absente).
|
| 66 |
+
"""
|
| 67 |
+
# ── Chemins de sortie ───────────────────────────────────────────────────
|
| 68 |
+
page_dir = base_data_dir / "corpora" / corpus_slug / "pages" / folio_label
|
| 69 |
+
raw_path = page_dir / "gemini_raw.json"
|
| 70 |
+
master_path = page_dir / "master.json"
|
| 71 |
+
|
| 72 |
+
# ── 1. Chargement et rendu du prompt (R04) ──────────────────────────────
|
| 73 |
+
prompt_rel_path: str = corpus_profile.prompt_templates["primary"]
|
| 74 |
+
prompt_abs_path = project_root / prompt_rel_path
|
| 75 |
+
|
| 76 |
+
context = {
|
| 77 |
+
"profile_label": corpus_profile.label,
|
| 78 |
+
"language_hints": ", ".join(corpus_profile.language_hints),
|
| 79 |
+
"script_type": corpus_profile.script_type.value,
|
| 80 |
+
}
|
| 81 |
+
prompt_text = load_and_render_prompt(prompt_abs_path, context)
|
| 82 |
+
logger.info(
|
| 83 |
+
"Prompt rendu",
|
| 84 |
+
extra={"template": prompt_rel_path, "corpus": corpus_slug, "folio": folio_label},
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
# ── 2. Chargement de l'image dérivée ────────────────────────────────────
|
| 88 |
+
jpeg_bytes = derivative_image_path.read_bytes()
|
| 89 |
+
|
| 90 |
+
# ── 3. Appel Google AI ──────────────────────────────────────────────────
|
| 91 |
+
client = build_client(model_config.provider)
|
| 92 |
+
image_part = types.Part.from_bytes(data=jpeg_bytes, mime_type="image/jpeg")
|
| 93 |
+
|
| 94 |
+
logger.info(
|
| 95 |
+
"Appel Google AI",
|
| 96 |
+
extra={
|
| 97 |
+
"model": model_config.selected_model_id,
|
| 98 |
+
"corpus": corpus_slug,
|
| 99 |
+
"folio": folio_label,
|
| 100 |
+
},
|
| 101 |
+
)
|
| 102 |
+
response = client.models.generate_content(
|
| 103 |
+
model=model_config.selected_model_id,
|
| 104 |
+
contents=[image_part, prompt_text],
|
| 105 |
+
)
|
| 106 |
+
raw_text: str = response.text or ""
|
| 107 |
+
|
| 108 |
+
# ── 4. Écriture gemini_raw.json TOUJOURS EN PREMIER (R05) ───────────────
|
| 109 |
+
write_gemini_raw(raw_text, raw_path)
|
| 110 |
+
|
| 111 |
+
# ── 5. Parsing + validation (ParseError si JSON invalide) ───────────────
|
| 112 |
+
layout, ocr = parse_ai_response(raw_text)
|
| 113 |
+
|
| 114 |
+
# ── 6. Construction du PageMaster ───────────────────────────────────────
|
| 115 |
+
processed_at = datetime.now(tz=timezone.utc)
|
| 116 |
+
page_master = PageMaster(
|
| 117 |
+
page_id=page_id,
|
| 118 |
+
corpus_profile=corpus_profile.profile_id,
|
| 119 |
+
manuscript_id=manuscript_id,
|
| 120 |
+
folio_label=folio_label,
|
| 121 |
+
sequence=sequence,
|
| 122 |
+
image={
|
| 123 |
+
"original_url": image_info.original_url,
|
| 124 |
+
"derivative_web": image_info.derivative_path,
|
| 125 |
+
"thumbnail": image_info.thumbnail_path,
|
| 126 |
+
"width": image_info.derivative_width,
|
| 127 |
+
"height": image_info.derivative_height,
|
| 128 |
+
},
|
| 129 |
+
layout=layout,
|
| 130 |
+
ocr=ocr,
|
| 131 |
+
processing=ProcessingInfo(
|
| 132 |
+
model_id=model_config.selected_model_id,
|
| 133 |
+
model_display_name=model_config.selected_model_display_name,
|
| 134 |
+
prompt_version=prompt_rel_path,
|
| 135 |
+
raw_response_path=str(raw_path),
|
| 136 |
+
processed_at=processed_at,
|
| 137 |
+
),
|
| 138 |
+
editorial=EditorialInfo(status=EditorialStatus.MACHINE_DRAFT),
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
# ── 7. Écriture master.json (seulement si parsing OK) ───────────────────
|
| 142 |
+
write_master_json(page_master, master_path)
|
| 143 |
+
|
| 144 |
+
logger.info(
|
| 145 |
+
"Analyse primaire terminée",
|
| 146 |
+
extra={
|
| 147 |
+
"page_id": page_id,
|
| 148 |
+
"corpus": corpus_slug,
|
| 149 |
+
"folio": folio_label,
|
| 150 |
+
"regions": len(layout.get("regions", [])),
|
| 151 |
+
},
|
| 152 |
+
)
|
| 153 |
+
return page_master
|
backend/app/services/ai/client_factory.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Factory pour créer un genai.Client selon le type de provider (R06, R11).
|
| 3 |
+
|
| 4 |
+
Standalone — ne modifie pas les fichiers providers de la Session A.
|
| 5 |
+
La clé API n'est jamais dans le code : lue exclusivement depuis les variables
|
| 6 |
+
d'environnement (R06).
|
| 7 |
+
"""
|
| 8 |
+
# 1. stdlib
|
| 9 |
+
import json
|
| 10 |
+
import logging
|
| 11 |
+
import os
|
| 12 |
+
|
| 13 |
+
# 2. third-party
|
| 14 |
+
from google import genai
|
| 15 |
+
from google.oauth2 import service_account
|
| 16 |
+
|
| 17 |
+
# 3. local
|
| 18 |
+
from app.schemas.model_config import ProviderType
|
| 19 |
+
|
| 20 |
+
logger = logging.getLogger(__name__)
|
| 21 |
+
|
| 22 |
+
_VERTEX_SCOPES = ["https://www.googleapis.com/auth/cloud-platform"]
|
| 23 |
+
_DEFAULT_VERTEX_LOCATION = "us-central1"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def build_client(provider_type: ProviderType) -> genai.Client:
|
| 27 |
+
"""Crée un genai.Client configuré pour le provider indiqué.
|
| 28 |
+
|
| 29 |
+
Lit les variables d'environnement nécessaires selon le provider :
|
| 30 |
+
- GOOGLE_AI_STUDIO → GOOGLE_AI_STUDIO_API_KEY
|
| 31 |
+
- VERTEX_API_KEY → VERTEX_API_KEY
|
| 32 |
+
- VERTEX_SA → VERTEX_SERVICE_ACCOUNT_JSON
|
| 33 |
+
|
| 34 |
+
Args:
|
| 35 |
+
provider_type: type de provider (GOOGLE_AI_STUDIO, VERTEX_API_KEY,
|
| 36 |
+
VERTEX_SERVICE_ACCOUNT).
|
| 37 |
+
|
| 38 |
+
Returns:
|
| 39 |
+
Instance genai.Client prête à l'emploi.
|
| 40 |
+
|
| 41 |
+
Raises:
|
| 42 |
+
RuntimeError: si la variable d'environnement requise est absente.
|
| 43 |
+
ValueError: si le JSON du compte de service est invalide ou incomplet.
|
| 44 |
+
"""
|
| 45 |
+
if provider_type == ProviderType.GOOGLE_AI_STUDIO:
|
| 46 |
+
api_key = os.environ.get("GOOGLE_AI_STUDIO_API_KEY")
|
| 47 |
+
if not api_key:
|
| 48 |
+
raise RuntimeError(
|
| 49 |
+
"Variable d'environnement manquante : GOOGLE_AI_STUDIO_API_KEY"
|
| 50 |
+
)
|
| 51 |
+
logger.debug("Client Google AI Studio créé")
|
| 52 |
+
return genai.Client(api_key=api_key)
|
| 53 |
+
|
| 54 |
+
if provider_type == ProviderType.VERTEX_API_KEY:
|
| 55 |
+
api_key = os.environ.get("VERTEX_API_KEY")
|
| 56 |
+
if not api_key:
|
| 57 |
+
raise RuntimeError(
|
| 58 |
+
"Variable d'environnement manquante : VERTEX_API_KEY"
|
| 59 |
+
)
|
| 60 |
+
logger.debug("Client Vertex AI (clé API) créé")
|
| 61 |
+
return genai.Client(api_key=api_key)
|
| 62 |
+
|
| 63 |
+
if provider_type == ProviderType.VERTEX_SERVICE_ACCOUNT:
|
| 64 |
+
sa_json_str = os.environ.get("VERTEX_SERVICE_ACCOUNT_JSON")
|
| 65 |
+
if not sa_json_str:
|
| 66 |
+
raise RuntimeError(
|
| 67 |
+
"Variable d'environnement manquante : VERTEX_SERVICE_ACCOUNT_JSON"
|
| 68 |
+
)
|
| 69 |
+
try:
|
| 70 |
+
sa_info = json.loads(sa_json_str)
|
| 71 |
+
except json.JSONDecodeError as exc:
|
| 72 |
+
raise ValueError(
|
| 73 |
+
f"VERTEX_SERVICE_ACCOUNT_JSON : JSON invalide — {exc}"
|
| 74 |
+
) from exc
|
| 75 |
+
|
| 76 |
+
project_id: str | None = sa_info.get("project_id")
|
| 77 |
+
if not project_id:
|
| 78 |
+
raise ValueError(
|
| 79 |
+
"VERTEX_SERVICE_ACCOUNT_JSON : champ 'project_id' manquant dans le JSON"
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
credentials = service_account.Credentials.from_service_account_info(
|
| 83 |
+
sa_info,
|
| 84 |
+
scopes=_VERTEX_SCOPES,
|
| 85 |
+
)
|
| 86 |
+
logger.debug(
|
| 87 |
+
"Client Vertex AI (compte de service) créé",
|
| 88 |
+
extra={"project": project_id},
|
| 89 |
+
)
|
| 90 |
+
return genai.Client(
|
| 91 |
+
vertexai=True,
|
| 92 |
+
project=project_id,
|
| 93 |
+
location=_DEFAULT_VERTEX_LOCATION,
|
| 94 |
+
credentials=credentials,
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
raise ValueError(f"Type de provider inconnu : {provider_type}")
|
backend/app/services/ai/master_writer.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Écriture des fichiers gemini_raw.json et master.json (R02, R05).
|
| 3 |
+
|
| 4 |
+
Règle R05 non négociable :
|
| 5 |
+
1. gemini_raw.json est TOUJOURS écrit en premier.
|
| 6 |
+
2. master.json n'est écrit QUE si le parsing et la validation Pydantic ont réussi.
|
| 7 |
+
"""
|
| 8 |
+
# 1. stdlib
|
| 9 |
+
import json
|
| 10 |
+
import logging
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
# 3. local
|
| 14 |
+
from app.schemas.page_master import PageMaster
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def write_gemini_raw(raw_text: str, output_path: Path) -> None:
|
| 20 |
+
"""Écrit la réponse brute de l'IA dans gemini_raw.json (R05).
|
| 21 |
+
|
| 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 |
+
|
| 39 |
+
def write_master_json(page_master: PageMaster, output_path: Path) -> None:
|
| 40 |
+
"""Écrit le PageMaster validé dans master.json (R02, R05).
|
| 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)})
|
backend/app/services/ai/prompt_loader.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Chargement et rendu des templates de prompts depuis le système de fichiers (R04).
|
| 3 |
+
|
| 4 |
+
Les prompts vivent dans prompts/{profile_id}/{famille}_v{n}.txt.
|
| 5 |
+
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__)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def load_and_render_prompt(template_path: str | Path, context: dict[str, str]) -> str:
|
| 15 |
+
"""Charge un template de prompt depuis un fichier et substitue les variables.
|
| 16 |
+
|
| 17 |
+
Les variables du template ont la forme {{nom_variable}}.
|
| 18 |
+
Toutes les clés de `context` sont substituées ; les clés absentes du template
|
| 19 |
+
sont ignorées silencieusement.
|
| 20 |
+
|
| 21 |
+
Args:
|
| 22 |
+
template_path: chemin vers le fichier template (.txt), absolu ou relatif au CWD.
|
| 23 |
+
context: dictionnaire {nom_variable: valeur} pour la substitution.
|
| 24 |
+
|
| 25 |
+
Returns:
|
| 26 |
+
Texte du prompt avec toutes les variables substituées.
|
| 27 |
+
|
| 28 |
+
Raises:
|
| 29 |
+
FileNotFoundError: si le fichier template n'existe pas.
|
| 30 |
+
"""
|
| 31 |
+
path = Path(template_path)
|
| 32 |
+
if not path.exists():
|
| 33 |
+
raise FileNotFoundError(f"Template de prompt introuvable : {path}")
|
| 34 |
+
|
| 35 |
+
template = path.read_text(encoding="utf-8")
|
| 36 |
+
|
| 37 |
+
rendered = template
|
| 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())},
|
| 44 |
+
)
|
| 45 |
+
return rendered
|
backend/app/services/ai/response_parser.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Parsing et validation de la réponse brute de l'IA → layout dict + OCRResult.
|
| 3 |
+
|
| 4 |
+
Comportement :
|
| 5 |
+
- JSON non parseable → ParseError (toute la page échoue)
|
| 6 |
+
- Région avec bbox invalide → région ignorée + log (la page continue)
|
| 7 |
+
- OCR invalide → OCRResult() par défaut + log (la page continue)
|
| 8 |
+
"""
|
| 9 |
+
# 1. stdlib
|
| 10 |
+
import json
|
| 11 |
+
import logging
|
| 12 |
+
|
| 13 |
+
# 2. third-party
|
| 14 |
+
from pydantic import ValidationError
|
| 15 |
+
|
| 16 |
+
# 3. local
|
| 17 |
+
from app.schemas.page_master import OCRResult, Region
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class ParseError(Exception):
|
| 23 |
+
"""Levée si la réponse de l'IA est un JSON invalide ou structurellement incorrecte."""
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def parse_ai_response(raw_text: str) -> tuple[dict, OCRResult]:
|
| 27 |
+
"""Parse la réponse textuelle de l'IA en layout dict + OCRResult validés.
|
| 28 |
+
|
| 29 |
+
Les régions avec bbox invalide sont ignorées individuellement (loguées) sans
|
| 30 |
+
faire échouer toute la page. Un JSON non parseable lève ParseError.
|
| 31 |
+
|
| 32 |
+
Gère les balises Markdown (```json ... ```) que certains modèles ajoutent
|
| 33 |
+
malgré les instructions.
|
| 34 |
+
|
| 35 |
+
Args:
|
| 36 |
+
raw_text: texte brut retourné par l'IA (censé être du JSON strict).
|
| 37 |
+
|
| 38 |
+
Returns:
|
| 39 |
+
Tuple (layout_dict, ocr_result) où layout_dict = {"regions": [...]}.
|
| 40 |
+
|
| 41 |
+
Raises:
|
| 42 |
+
ParseError: si le texte n'est pas du JSON valide ou pas un objet JSON.
|
| 43 |
+
"""
|
| 44 |
+
# Suppression des balises Markdown éventuelles
|
| 45 |
+
text = raw_text.strip()
|
| 46 |
+
if text.startswith("```"):
|
| 47 |
+
lines = text.splitlines()
|
| 48 |
+
end = len(lines) - 1 if lines[-1].strip() == "```" else len(lines)
|
| 49 |
+
text = "\n".join(lines[1:end])
|
| 50 |
+
|
| 51 |
+
try:
|
| 52 |
+
data = json.loads(text)
|
| 53 |
+
except json.JSONDecodeError as exc:
|
| 54 |
+
raise ParseError(
|
| 55 |
+
f"Réponse IA non parseable en JSON : {exc}"
|
| 56 |
+
) from exc
|
| 57 |
+
|
| 58 |
+
if not isinstance(data, dict):
|
| 59 |
+
raise ParseError(
|
| 60 |
+
f"Réponse IA invalide — objet JSON attendu, reçu : {type(data).__name__}"
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
# ── Layout / régions ────────────────────────────────────────────────────
|
| 64 |
+
raw_layout = data.get("layout")
|
| 65 |
+
raw_regions: list = []
|
| 66 |
+
if isinstance(raw_layout, dict):
|
| 67 |
+
raw_regions = raw_layout.get("regions") or []
|
| 68 |
+
|
| 69 |
+
valid_regions: list[dict] = []
|
| 70 |
+
for i, raw_region in enumerate(raw_regions):
|
| 71 |
+
try:
|
| 72 |
+
region = Region.model_validate(raw_region)
|
| 73 |
+
valid_regions.append(region.model_dump())
|
| 74 |
+
except (ValidationError, Exception) as exc:
|
| 75 |
+
logger.warning(
|
| 76 |
+
"Région ignorée — bbox ou champ invalide",
|
| 77 |
+
extra={"index": i, "region": raw_region, "error": str(exc)},
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
layout: dict = {"regions": valid_regions}
|
| 81 |
+
|
| 82 |
+
# ── OCR ─────────────────────────────────────────────────────────────────
|
| 83 |
+
raw_ocr = data.get("ocr")
|
| 84 |
+
ocr: OCRResult
|
| 85 |
+
if raw_ocr and isinstance(raw_ocr, dict):
|
| 86 |
+
try:
|
| 87 |
+
ocr = OCRResult.model_validate(raw_ocr)
|
| 88 |
+
except ValidationError as exc:
|
| 89 |
+
logger.warning(
|
| 90 |
+
"OCR invalide — utilisation des valeurs par défaut",
|
| 91 |
+
extra={"error": str(exc)},
|
| 92 |
+
)
|
| 93 |
+
ocr = OCRResult()
|
| 94 |
+
else:
|
| 95 |
+
ocr = OCRResult()
|
| 96 |
+
|
| 97 |
+
logger.info(
|
| 98 |
+
"Réponse IA parsée",
|
| 99 |
+
extra={
|
| 100 |
+
"regions_total": len(raw_regions),
|
| 101 |
+
"regions_valides": len(valid_regions),
|
| 102 |
+
"regions_ignorees": len(raw_regions) - len(valid_regions),
|
| 103 |
+
"ocr_confidence": ocr.confidence,
|
| 104 |
+
},
|
| 105 |
+
)
|
| 106 |
+
return layout, ocr
|
backend/tests/test_ai_analyzer.py
ADDED
|
@@ -0,0 +1,871 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tests du pipeline d'analyse IA :
|
| 3 |
+
- prompt_loader : chargement + rendu des templates
|
| 4 |
+
- client_factory : construction du genai.Client selon le provider
|
| 5 |
+
- response_parser: parsing JSON brut → layout + OCRResult
|
| 6 |
+
- master_writer : écriture gemini_raw.json et master.json
|
| 7 |
+
- analyzer : run_primary_analysis (end-to-end mocké)
|
| 8 |
+
"""
|
| 9 |
+
# 1. stdlib
|
| 10 |
+
import io
|
| 11 |
+
import json
|
| 12 |
+
from datetime import datetime, timezone
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from unittest.mock import MagicMock, call, patch
|
| 15 |
+
|
| 16 |
+
# 2. third-party
|
| 17 |
+
import pytest
|
| 18 |
+
from PIL import Image
|
| 19 |
+
from pydantic import ValidationError
|
| 20 |
+
|
| 21 |
+
# 3. local
|
| 22 |
+
from app.schemas.corpus_profile import (
|
| 23 |
+
CorpusProfile,
|
| 24 |
+
ExportConfig,
|
| 25 |
+
LayerType,
|
| 26 |
+
ScriptType,
|
| 27 |
+
UncertaintyConfig,
|
| 28 |
+
)
|
| 29 |
+
from app.schemas.image import ImageDerivativeInfo
|
| 30 |
+
from app.schemas.model_config import ModelConfig, ProviderType
|
| 31 |
+
from app.schemas.page_master import OCRResult, PageMaster
|
| 32 |
+
from app.services.ai.analyzer import run_primary_analysis
|
| 33 |
+
from app.services.ai.client_factory import build_client
|
| 34 |
+
from app.services.ai.master_writer import write_gemini_raw, write_master_json
|
| 35 |
+
from app.services.ai.prompt_loader import load_and_render_prompt
|
| 36 |
+
from app.services.ai.response_parser import ParseError, parse_ai_response
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# ---------------------------------------------------------------------------
|
| 40 |
+
# Helpers
|
| 41 |
+
# ---------------------------------------------------------------------------
|
| 42 |
+
|
| 43 |
+
def _make_jpeg_bytes(width: int = 100, height: int = 100) -> bytes:
|
| 44 |
+
img = Image.new("RGB", (width, height), color=(128, 64, 32))
|
| 45 |
+
buf = io.BytesIO()
|
| 46 |
+
img.save(buf, format="JPEG", quality=85)
|
| 47 |
+
return buf.getvalue()
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _make_corpus_profile(
|
| 51 |
+
profile_id: str = "medieval-illuminated",
|
| 52 |
+
prompt_rel_path: str = "prompts/medieval-illuminated/primary_v1.txt",
|
| 53 |
+
) -> CorpusProfile:
|
| 54 |
+
return CorpusProfile(
|
| 55 |
+
profile_id=profile_id,
|
| 56 |
+
label="Manuscrit médiéval enluminé",
|
| 57 |
+
language_hints=["la"],
|
| 58 |
+
script_type=ScriptType.CAROLINE,
|
| 59 |
+
active_layers=[LayerType.IMAGE, LayerType.OCR_DIPLOMATIC],
|
| 60 |
+
prompt_templates={"primary": prompt_rel_path},
|
| 61 |
+
uncertainty_config=UncertaintyConfig(),
|
| 62 |
+
export_config=ExportConfig(),
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _make_model_config(provider: ProviderType = ProviderType.GOOGLE_AI_STUDIO) -> ModelConfig:
|
| 67 |
+
return ModelConfig(
|
| 68 |
+
corpus_id="test-corpus",
|
| 69 |
+
selected_model_id="gemini-2.0-flash",
|
| 70 |
+
selected_model_display_name="Gemini 2.0 Flash",
|
| 71 |
+
provider=provider,
|
| 72 |
+
supports_vision=True,
|
| 73 |
+
last_fetched_at=datetime.now(tz=timezone.utc),
|
| 74 |
+
available_models=[],
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _make_image_info() -> ImageDerivativeInfo:
|
| 79 |
+
return ImageDerivativeInfo(
|
| 80 |
+
original_url="https://example.com/iiif/f001.jpg",
|
| 81 |
+
original_width=3000,
|
| 82 |
+
original_height=4000,
|
| 83 |
+
derivative_path="/data/corpora/test-corpus/derivatives/0001r.jpg",
|
| 84 |
+
derivative_width=1125,
|
| 85 |
+
derivative_height=1500,
|
| 86 |
+
thumbnail_path="/data/corpora/test-corpus/derivatives/0001r_thumb.jpg",
|
| 87 |
+
thumbnail_width=192,
|
| 88 |
+
thumbnail_height=256,
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _valid_ai_json(regions: list | None = None) -> str:
|
| 93 |
+
if regions is None:
|
| 94 |
+
regions = [
|
| 95 |
+
{"id": "r1", "type": "text_block", "bbox": [10, 20, 300, 400], "confidence": 0.95},
|
| 96 |
+
{"id": "r2", "type": "miniature", "bbox": [0, 0, 500, 600], "confidence": 0.88},
|
| 97 |
+
]
|
| 98 |
+
return json.dumps({
|
| 99 |
+
"layout": {"regions": regions},
|
| 100 |
+
"ocr": {
|
| 101 |
+
"diplomatic_text": "Incipit liber beati Ieronimi",
|
| 102 |
+
"blocks": [],
|
| 103 |
+
"lines": [],
|
| 104 |
+
"language": "la",
|
| 105 |
+
"confidence": 0.87,
|
| 106 |
+
"uncertain_segments": [],
|
| 107 |
+
},
|
| 108 |
+
})
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
# ---------------------------------------------------------------------------
|
| 112 |
+
# Tests — load_and_render_prompt
|
| 113 |
+
# ---------------------------------------------------------------------------
|
| 114 |
+
|
| 115 |
+
def test_prompt_loader_renders_variables(tmp_path):
|
| 116 |
+
tpl = tmp_path / "prompt.txt"
|
| 117 |
+
tpl.write_text("Corpus : {{profile_label}}\nLangue : {{language_hints}}")
|
| 118 |
+
|
| 119 |
+
result = load_and_render_prompt(tpl, {
|
| 120 |
+
"profile_label": "Manuscrit test",
|
| 121 |
+
"language_hints": "la, fr",
|
| 122 |
+
})
|
| 123 |
+
|
| 124 |
+
assert "Manuscrit test" in result
|
| 125 |
+
assert "la, fr" in result
|
| 126 |
+
assert "{{profile_label}}" not in result
|
| 127 |
+
assert "{{language_hints}}" not in result
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def test_prompt_loader_unknown_variable_kept(tmp_path):
|
| 131 |
+
"""Une variable absente du contexte reste telle quelle dans le texte."""
|
| 132 |
+
tpl = tmp_path / "prompt.txt"
|
| 133 |
+
tpl.write_text("Hello {{name}} — {{unknown}}")
|
| 134 |
+
|
| 135 |
+
result = load_and_render_prompt(tpl, {"name": "World"})
|
| 136 |
+
|
| 137 |
+
assert "World" in result
|
| 138 |
+
assert "{{unknown}}" in result
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def test_prompt_loader_empty_context(tmp_path):
|
| 142 |
+
tpl = tmp_path / "prompt.txt"
|
| 143 |
+
tpl.write_text("Texte sans variables.")
|
| 144 |
+
|
| 145 |
+
result = load_and_render_prompt(tpl, {})
|
| 146 |
+
assert result == "Texte sans variables."
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def test_prompt_loader_file_not_found():
|
| 150 |
+
with pytest.raises(FileNotFoundError):
|
| 151 |
+
load_and_render_prompt("/nonexistent/path/prompt.txt", {})
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def test_prompt_loader_accepts_path_object(tmp_path):
|
| 155 |
+
tpl = tmp_path / "sub" / "tpl.txt"
|
| 156 |
+
tpl.parent.mkdir()
|
| 157 |
+
tpl.write_text("OK {{var}}")
|
| 158 |
+
|
| 159 |
+
result = load_and_render_prompt(tpl, {"var": "value"})
|
| 160 |
+
assert result == "OK value"
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def test_prompt_loader_multiple_occurrences(tmp_path):
|
| 164 |
+
"""Une même variable peut apparaître plusieurs fois."""
|
| 165 |
+
tpl = tmp_path / "prompt.txt"
|
| 166 |
+
tpl.write_text("{{x}} et {{x}} encore")
|
| 167 |
+
|
| 168 |
+
result = load_and_render_prompt(tpl, {"x": "Z"})
|
| 169 |
+
assert result == "Z et Z encore"
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
# ---------------------------------------------------------------------------
|
| 173 |
+
# Tests — build_client
|
| 174 |
+
# ---------------------------------------------------------------------------
|
| 175 |
+
|
| 176 |
+
def test_build_client_google_ai_studio(monkeypatch):
|
| 177 |
+
monkeypatch.setenv("GOOGLE_AI_STUDIO_API_KEY", "fake-key-studio")
|
| 178 |
+
|
| 179 |
+
with patch("app.services.ai.client_factory.genai.Client") as mock_cls:
|
| 180 |
+
mock_cls.return_value = MagicMock()
|
| 181 |
+
client = build_client(ProviderType.GOOGLE_AI_STUDIO)
|
| 182 |
+
|
| 183 |
+
mock_cls.assert_called_once_with(api_key="fake-key-studio")
|
| 184 |
+
assert client is mock_cls.return_value
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def test_build_client_google_ai_studio_missing_env(monkeypatch):
|
| 188 |
+
monkeypatch.delenv("GOOGLE_AI_STUDIO_API_KEY", raising=False)
|
| 189 |
+
|
| 190 |
+
with pytest.raises(RuntimeError, match="GOOGLE_AI_STUDIO_API_KEY"):
|
| 191 |
+
build_client(ProviderType.GOOGLE_AI_STUDIO)
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def test_build_client_vertex_api_key(monkeypatch):
|
| 195 |
+
monkeypatch.setenv("VERTEX_API_KEY", "fake-vertex-key")
|
| 196 |
+
|
| 197 |
+
with patch("app.services.ai.client_factory.genai.Client") as mock_cls:
|
| 198 |
+
mock_cls.return_value = MagicMock()
|
| 199 |
+
client = build_client(ProviderType.VERTEX_API_KEY)
|
| 200 |
+
|
| 201 |
+
mock_cls.assert_called_once_with(api_key="fake-vertex-key")
|
| 202 |
+
assert client is mock_cls.return_value
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def test_build_client_vertex_api_key_missing_env(monkeypatch):
|
| 206 |
+
monkeypatch.delenv("VERTEX_API_KEY", raising=False)
|
| 207 |
+
|
| 208 |
+
with pytest.raises(RuntimeError, match="VERTEX_API_KEY"):
|
| 209 |
+
build_client(ProviderType.VERTEX_API_KEY)
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def test_build_client_vertex_service_account(monkeypatch):
|
| 213 |
+
sa_json = json.dumps({
|
| 214 |
+
"type": "service_account",
|
| 215 |
+
"project_id": "my-project",
|
| 216 |
+
"private_key_id": "key-id",
|
| 217 |
+
"private_key": "-----BEGIN RSA PRIVATE KEY-----\nfake\n-----END RSA PRIVATE KEY-----\n",
|
| 218 |
+
"client_email": "sa@my-project.iam.gserviceaccount.com",
|
| 219 |
+
"client_id": "123",
|
| 220 |
+
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
| 221 |
+
"token_uri": "https://oauth2.googleapis.com/token",
|
| 222 |
+
})
|
| 223 |
+
monkeypatch.setenv("VERTEX_SERVICE_ACCOUNT_JSON", sa_json)
|
| 224 |
+
|
| 225 |
+
mock_creds = MagicMock()
|
| 226 |
+
with (
|
| 227 |
+
patch("app.services.ai.client_factory.service_account.Credentials.from_service_account_info",
|
| 228 |
+
return_value=mock_creds) as mock_sa,
|
| 229 |
+
patch("app.services.ai.client_factory.genai.Client") as mock_cls,
|
| 230 |
+
):
|
| 231 |
+
mock_cls.return_value = MagicMock()
|
| 232 |
+
client = build_client(ProviderType.VERTEX_SERVICE_ACCOUNT)
|
| 233 |
+
|
| 234 |
+
mock_sa.assert_called_once()
|
| 235 |
+
mock_cls.assert_called_once_with(
|
| 236 |
+
vertexai=True,
|
| 237 |
+
project="my-project",
|
| 238 |
+
location="us-central1",
|
| 239 |
+
credentials=mock_creds,
|
| 240 |
+
)
|
| 241 |
+
assert client is mock_cls.return_value
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def test_build_client_vertex_sa_missing_env(monkeypatch):
|
| 245 |
+
monkeypatch.delenv("VERTEX_SERVICE_ACCOUNT_JSON", raising=False)
|
| 246 |
+
|
| 247 |
+
with pytest.raises(RuntimeError, match="VERTEX_SERVICE_ACCOUNT_JSON"):
|
| 248 |
+
build_client(ProviderType.VERTEX_SERVICE_ACCOUNT)
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def test_build_client_vertex_sa_invalid_json(monkeypatch):
|
| 252 |
+
monkeypatch.setenv("VERTEX_SERVICE_ACCOUNT_JSON", "not-valid-json{{{")
|
| 253 |
+
|
| 254 |
+
with pytest.raises(ValueError, match="JSON invalide"):
|
| 255 |
+
build_client(ProviderType.VERTEX_SERVICE_ACCOUNT)
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def test_build_client_vertex_sa_missing_project_id(monkeypatch):
|
| 259 |
+
sa_json = json.dumps({"type": "service_account"}) # no project_id
|
| 260 |
+
monkeypatch.setenv("VERTEX_SERVICE_ACCOUNT_JSON", sa_json)
|
| 261 |
+
|
| 262 |
+
with pytest.raises(ValueError, match="project_id"):
|
| 263 |
+
build_client(ProviderType.VERTEX_SERVICE_ACCOUNT)
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
# ---------------------------------------------------------------------------
|
| 267 |
+
# Tests — parse_ai_response
|
| 268 |
+
# ---------------------------------------------------------------------------
|
| 269 |
+
|
| 270 |
+
def test_parse_valid_response():
|
| 271 |
+
layout, ocr = parse_ai_response(_valid_ai_json())
|
| 272 |
+
|
| 273 |
+
assert len(layout["regions"]) == 2
|
| 274 |
+
assert layout["regions"][0]["id"] == "r1"
|
| 275 |
+
assert layout["regions"][0]["type"] == "text_block"
|
| 276 |
+
assert layout["regions"][0]["bbox"] == [10, 20, 300, 400]
|
| 277 |
+
assert isinstance(ocr, OCRResult)
|
| 278 |
+
assert ocr.diplomatic_text == "Incipit liber beati Ieronimi"
|
| 279 |
+
assert ocr.confidence == pytest.approx(0.87)
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def test_parse_invalid_json_raises_parse_error():
|
| 283 |
+
with pytest.raises(ParseError, match="non parseable"):
|
| 284 |
+
parse_ai_response("this is not json at all {{}")
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def test_parse_non_object_raises_parse_error():
|
| 288 |
+
with pytest.raises(ParseError, match="objet JSON attendu"):
|
| 289 |
+
parse_ai_response("[1, 2, 3]")
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
def test_parse_invalid_bbox_region_is_skipped():
|
| 293 |
+
"""Une région avec bbox invalide est ignorée ; les autres sont conservées."""
|
| 294 |
+
raw = json.dumps({
|
| 295 |
+
"layout": {
|
| 296 |
+
"regions": [
|
| 297 |
+
{"id": "r1", "type": "text_block", "bbox": [0, 0, 100, 100], "confidence": 0.9},
|
| 298 |
+
{"id": "r_bad", "type": "text_block", "bbox": [0, 0, -10, 50], "confidence": 0.7},
|
| 299 |
+
{"id": "r3", "type": "miniature", "bbox": [5, 5, 200, 300], "confidence": 0.8},
|
| 300 |
+
]
|
| 301 |
+
},
|
| 302 |
+
"ocr": {},
|
| 303 |
+
})
|
| 304 |
+
|
| 305 |
+
layout, ocr = parse_ai_response(raw)
|
| 306 |
+
|
| 307 |
+
assert len(layout["regions"]) == 2
|
| 308 |
+
ids = [r["id"] for r in layout["regions"]]
|
| 309 |
+
assert "r1" in ids
|
| 310 |
+
assert "r3" in ids
|
| 311 |
+
assert "r_bad" not in ids
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
def test_parse_zero_width_bbox_is_skipped():
|
| 315 |
+
"""Une bbox avec width=0 est rejetée par le validator Pydantic."""
|
| 316 |
+
raw = json.dumps({
|
| 317 |
+
"layout": {
|
| 318 |
+
"regions": [
|
| 319 |
+
{"id": "r1", "type": "text_block", "bbox": [0, 0, 0, 100], "confidence": 0.9},
|
| 320 |
+
]
|
| 321 |
+
},
|
| 322 |
+
"ocr": {},
|
| 323 |
+
})
|
| 324 |
+
|
| 325 |
+
layout, _ = parse_ai_response(raw)
|
| 326 |
+
assert len(layout["regions"]) == 0
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
def test_parse_all_bad_regions_returns_empty_layout():
|
| 330 |
+
raw = json.dumps({
|
| 331 |
+
"layout": {"regions": [
|
| 332 |
+
{"id": "r1", "type": "text_block", "bbox": [-5, 0, 100, 100], "confidence": 0.9},
|
| 333 |
+
]},
|
| 334 |
+
"ocr": {},
|
| 335 |
+
})
|
| 336 |
+
|
| 337 |
+
layout, _ = parse_ai_response(raw)
|
| 338 |
+
assert layout == {"regions": []}
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
def test_parse_missing_layout_returns_empty():
|
| 342 |
+
raw = json.dumps({"ocr": {"diplomatic_text": "hello", "confidence": 0.5}})
|
| 343 |
+
|
| 344 |
+
layout, ocr = parse_ai_response(raw)
|
| 345 |
+
assert layout == {"regions": []}
|
| 346 |
+
assert ocr.diplomatic_text == "hello"
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
def test_parse_missing_ocr_returns_default():
|
| 350 |
+
raw = json.dumps({"layout": {"regions": []}})
|
| 351 |
+
|
| 352 |
+
layout, ocr = parse_ai_response(raw)
|
| 353 |
+
assert isinstance(ocr, OCRResult)
|
| 354 |
+
assert ocr.diplomatic_text == ""
|
| 355 |
+
assert ocr.confidence == 0.0
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
def test_parse_markdown_code_fence_stripped():
|
| 359 |
+
"""Les balises ```json ... ``` sont supprimées avant parsing."""
|
| 360 |
+
inner = json.dumps({"layout": {"regions": []}, "ocr": {}})
|
| 361 |
+
fenced = f"```json\n{inner}\n```"
|
| 362 |
+
|
| 363 |
+
layout, ocr = parse_ai_response(fenced)
|
| 364 |
+
assert layout == {"regions": []}
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
def test_parse_markdown_code_fence_no_lang_stripped():
|
| 368 |
+
inner = json.dumps({"layout": {"regions": []}, "ocr": {}})
|
| 369 |
+
fenced = f"```\n{inner}\n```"
|
| 370 |
+
|
| 371 |
+
layout, ocr = parse_ai_response(fenced)
|
| 372 |
+
assert layout == {"regions": []}
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
def test_parse_invalid_ocr_uses_defaults():
|
| 376 |
+
"""Un champ OCR hors bornes (confidence > 1) → valeurs par défaut."""
|
| 377 |
+
raw = json.dumps({
|
| 378 |
+
"layout": {"regions": []},
|
| 379 |
+
"ocr": {"confidence": 9.9}, # confidence > 1.0 : Pydantic rejette
|
| 380 |
+
})
|
| 381 |
+
|
| 382 |
+
layout, ocr = parse_ai_response(raw)
|
| 383 |
+
assert ocr.confidence == 0.0 # valeur par défaut
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
def test_parse_empty_regions_list():
|
| 387 |
+
raw = json.dumps({"layout": {"regions": []}, "ocr": {}})
|
| 388 |
+
layout, _ = parse_ai_response(raw)
|
| 389 |
+
assert layout == {"regions": []}
|
| 390 |
+
|
| 391 |
+
|
| 392 |
+
# ---------------------------------------------------------------------------
|
| 393 |
+
# Tests — write_gemini_raw / write_master_json
|
| 394 |
+
# ---------------------------------------------------------------------------
|
| 395 |
+
|
| 396 |
+
def test_write_gemini_raw_creates_file(tmp_path):
|
| 397 |
+
out = tmp_path / "page" / "gemini_raw.json"
|
| 398 |
+
write_gemini_raw("raw AI text here", out)
|
| 399 |
+
|
| 400 |
+
assert out.exists()
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
def test_write_gemini_raw_valid_json(tmp_path):
|
| 404 |
+
out = tmp_path / "gemini_raw.json"
|
| 405 |
+
write_gemini_raw('{"not": "valid json from AI"}', out)
|
| 406 |
+
|
| 407 |
+
content = json.loads(out.read_text(encoding="utf-8"))
|
| 408 |
+
assert "response_text" in content
|
| 409 |
+
assert content["response_text"] == '{"not": "valid json from AI"}'
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
def test_write_gemini_raw_creates_parent_dirs(tmp_path):
|
| 413 |
+
out = tmp_path / "deep" / "nested" / "dir" / "gemini_raw.json"
|
| 414 |
+
write_gemini_raw("text", out)
|
| 415 |
+
assert out.exists()
|
| 416 |
+
|
| 417 |
+
|
| 418 |
+
def test_write_gemini_raw_with_non_json_text(tmp_path):
|
| 419 |
+
"""Même si le texte brut est invalide, gemini_raw.json est créé."""
|
| 420 |
+
out = tmp_path / "gemini_raw.json"
|
| 421 |
+
write_gemini_raw("this is not json at all", out)
|
| 422 |
+
|
| 423 |
+
content = json.loads(out.read_text(encoding="utf-8"))
|
| 424 |
+
assert content["response_text"] == "this is not json at all"
|
| 425 |
+
|
| 426 |
+
|
| 427 |
+
def _make_page_master() -> PageMaster:
|
| 428 |
+
return PageMaster(
|
| 429 |
+
page_id="test-ms-0001r",
|
| 430 |
+
corpus_profile="medieval-illuminated",
|
| 431 |
+
manuscript_id="ms-test",
|
| 432 |
+
folio_label="0001r",
|
| 433 |
+
sequence=1,
|
| 434 |
+
image={
|
| 435 |
+
"original_url": "https://example.com/img.jpg",
|
| 436 |
+
"derivative_web": "/data/deriv.jpg",
|
| 437 |
+
"thumbnail": "/data/thumb.jpg",
|
| 438 |
+
"width": 1500,
|
| 439 |
+
"height": 2000,
|
| 440 |
+
},
|
| 441 |
+
layout={"regions": []},
|
| 442 |
+
processing={
|
| 443 |
+
"model_id": "gemini-2.0-flash",
|
| 444 |
+
"model_display_name": "Gemini 2.0 Flash",
|
| 445 |
+
"prompt_version": "prompts/medieval-illuminated/primary_v1.txt",
|
| 446 |
+
"raw_response_path": "/data/gemini_raw.json",
|
| 447 |
+
"processed_at": datetime.now(tz=timezone.utc),
|
| 448 |
+
},
|
| 449 |
+
)
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
def test_write_master_json_creates_file(tmp_path):
|
| 453 |
+
out = tmp_path / "master.json"
|
| 454 |
+
pm = _make_page_master()
|
| 455 |
+
write_master_json(pm, out)
|
| 456 |
+
assert out.exists()
|
| 457 |
+
|
| 458 |
+
|
| 459 |
+
def test_write_master_json_valid_json(tmp_path):
|
| 460 |
+
out = tmp_path / "master.json"
|
| 461 |
+
pm = _make_page_master()
|
| 462 |
+
write_master_json(pm, out)
|
| 463 |
+
|
| 464 |
+
content = json.loads(out.read_text(encoding="utf-8"))
|
| 465 |
+
assert content["page_id"] == "test-ms-0001r"
|
| 466 |
+
assert content["schema_version"] == "1.0"
|
| 467 |
+
|
| 468 |
+
|
| 469 |
+
def test_write_master_json_creates_parent_dirs(tmp_path):
|
| 470 |
+
out = tmp_path / "a" / "b" / "c" / "master.json"
|
| 471 |
+
write_master_json(_make_page_master(), out)
|
| 472 |
+
assert out.exists()
|
| 473 |
+
|
| 474 |
+
|
| 475 |
+
def test_write_master_json_contains_processing_info(tmp_path):
|
| 476 |
+
out = tmp_path / "master.json"
|
| 477 |
+
write_master_json(_make_page_master(), out)
|
| 478 |
+
|
| 479 |
+
content = json.loads(out.read_text(encoding="utf-8"))
|
| 480 |
+
assert content["processing"]["model_id"] == "gemini-2.0-flash"
|
| 481 |
+
assert content["processing"]["prompt_version"] == "prompts/medieval-illuminated/primary_v1.txt"
|
| 482 |
+
|
| 483 |
+
|
| 484 |
+
# ---------------------------------------------------------------------------
|
| 485 |
+
# Tests — run_primary_analysis (end-to-end mocké)
|
| 486 |
+
# ---------------------------------------------------------------------------
|
| 487 |
+
|
| 488 |
+
def _setup_prompt_file(tmp_path: Path, rel_path: str) -> Path:
|
| 489 |
+
"""Crée le fichier template dans tmp_path/rel_path."""
|
| 490 |
+
full = tmp_path / rel_path
|
| 491 |
+
full.parent.mkdir(parents=True, exist_ok=True)
|
| 492 |
+
full.write_text(
|
| 493 |
+
"Analyse {{profile_label}} en {{language_hints}} ({{script_type}}).",
|
| 494 |
+
encoding="utf-8",
|
| 495 |
+
)
|
| 496 |
+
return full
|
| 497 |
+
|
| 498 |
+
|
| 499 |
+
def _setup_derivative(tmp_path: Path) -> Path:
|
| 500 |
+
"""Crée un JPEG dérivé factice dans tmp_path."""
|
| 501 |
+
deriv = tmp_path / "derivative.jpg"
|
| 502 |
+
deriv.write_bytes(_make_jpeg_bytes(200, 300))
|
| 503 |
+
return deriv
|
| 504 |
+
|
| 505 |
+
|
| 506 |
+
def _make_mock_client(ai_response_text: str) -> MagicMock:
|
| 507 |
+
mock_response = MagicMock()
|
| 508 |
+
mock_response.text = ai_response_text
|
| 509 |
+
mock_client = MagicMock()
|
| 510 |
+
mock_client.models.generate_content.return_value = mock_response
|
| 511 |
+
return mock_client
|
| 512 |
+
|
| 513 |
+
|
| 514 |
+
def test_run_primary_analysis_success(tmp_path):
|
| 515 |
+
"""Cas nominal : retourne un PageMaster, crée les deux fichiers."""
|
| 516 |
+
prompt_rel = "prompts/medieval-illuminated/primary_v1.txt"
|
| 517 |
+
_setup_prompt_file(tmp_path, prompt_rel)
|
| 518 |
+
deriv_path = _setup_derivative(tmp_path)
|
| 519 |
+
|
| 520 |
+
profile = _make_corpus_profile(prompt_rel_path=prompt_rel)
|
| 521 |
+
model_cfg = _make_model_config()
|
| 522 |
+
image_info = _make_image_info()
|
| 523 |
+
|
| 524 |
+
mock_client = _make_mock_client(_valid_ai_json())
|
| 525 |
+
|
| 526 |
+
with patch("app.services.ai.analyzer.build_client", return_value=mock_client):
|
| 527 |
+
result = run_primary_analysis(
|
| 528 |
+
derivative_image_path=deriv_path,
|
| 529 |
+
corpus_profile=profile,
|
| 530 |
+
model_config=model_cfg,
|
| 531 |
+
page_id="test-corpus-0001r",
|
| 532 |
+
manuscript_id="ms-test",
|
| 533 |
+
corpus_slug="test-corpus",
|
| 534 |
+
folio_label="0001r",
|
| 535 |
+
sequence=1,
|
| 536 |
+
image_info=image_info,
|
| 537 |
+
base_data_dir=tmp_path / "data",
|
| 538 |
+
project_root=tmp_path,
|
| 539 |
+
)
|
| 540 |
+
|
| 541 |
+
assert isinstance(result, PageMaster)
|
| 542 |
+
assert result.page_id == "test-corpus-0001r"
|
| 543 |
+
assert result.corpus_profile == "medieval-illuminated"
|
| 544 |
+
assert result.folio_label == "0001r"
|
| 545 |
+
assert result.sequence == 1
|
| 546 |
+
|
| 547 |
+
|
| 548 |
+
def test_run_primary_analysis_files_created(tmp_path):
|
| 549 |
+
"""Les deux fichiers obligatoires (R05) sont créés sur disque."""
|
| 550 |
+
prompt_rel = "prompts/medieval-illuminated/primary_v1.txt"
|
| 551 |
+
_setup_prompt_file(tmp_path, prompt_rel)
|
| 552 |
+
deriv_path = _setup_derivative(tmp_path)
|
| 553 |
+
|
| 554 |
+
mock_client = _make_mock_client(_valid_ai_json())
|
| 555 |
+
|
| 556 |
+
with patch("app.services.ai.analyzer.build_client", return_value=mock_client):
|
| 557 |
+
run_primary_analysis(
|
| 558 |
+
derivative_image_path=deriv_path,
|
| 559 |
+
corpus_profile=_make_corpus_profile(prompt_rel_path=prompt_rel),
|
| 560 |
+
model_config=_make_model_config(),
|
| 561 |
+
page_id="test-corpus-0001r",
|
| 562 |
+
manuscript_id="ms-test",
|
| 563 |
+
corpus_slug="test-corpus",
|
| 564 |
+
folio_label="0001r",
|
| 565 |
+
sequence=1,
|
| 566 |
+
image_info=_make_image_info(),
|
| 567 |
+
base_data_dir=tmp_path / "data",
|
| 568 |
+
project_root=tmp_path,
|
| 569 |
+
)
|
| 570 |
+
|
| 571 |
+
page_dir = tmp_path / "data" / "corpora" / "test-corpus" / "pages" / "0001r"
|
| 572 |
+
assert (page_dir / "gemini_raw.json").exists()
|
| 573 |
+
assert (page_dir / "master.json").exists()
|
| 574 |
+
|
| 575 |
+
|
| 576 |
+
def test_run_primary_analysis_raw_written_before_parse(tmp_path):
|
| 577 |
+
"""gemini_raw.json est écrit AVANT que le parsing échoue (R05)."""
|
| 578 |
+
prompt_rel = "prompts/medieval-illuminated/primary_v1.txt"
|
| 579 |
+
_setup_prompt_file(tmp_path, prompt_rel)
|
| 580 |
+
deriv_path = _setup_derivative(tmp_path)
|
| 581 |
+
|
| 582 |
+
mock_client = _make_mock_client("this is definitely not json {{{{")
|
| 583 |
+
|
| 584 |
+
with patch("app.services.ai.analyzer.build_client", return_value=mock_client):
|
| 585 |
+
with pytest.raises(ParseError):
|
| 586 |
+
run_primary_analysis(
|
| 587 |
+
derivative_image_path=deriv_path,
|
| 588 |
+
corpus_profile=_make_corpus_profile(prompt_rel_path=prompt_rel),
|
| 589 |
+
model_config=_make_model_config(),
|
| 590 |
+
page_id="test-corpus-0001r",
|
| 591 |
+
manuscript_id="ms-test",
|
| 592 |
+
corpus_slug="test-corpus",
|
| 593 |
+
folio_label="0001r",
|
| 594 |
+
sequence=1,
|
| 595 |
+
image_info=_make_image_info(),
|
| 596 |
+
base_data_dir=tmp_path / "data",
|
| 597 |
+
project_root=tmp_path,
|
| 598 |
+
)
|
| 599 |
+
|
| 600 |
+
# gemini_raw.json existe malgré l'échec de parsing
|
| 601 |
+
raw_path = tmp_path / "data" / "corpora" / "test-corpus" / "pages" / "0001r" / "gemini_raw.json"
|
| 602 |
+
assert raw_path.exists()
|
| 603 |
+
|
| 604 |
+
# master.json N'existe PAS (parsing a échoué)
|
| 605 |
+
master_path = tmp_path / "data" / "corpora" / "test-corpus" / "pages" / "0001r" / "master.json"
|
| 606 |
+
assert not master_path.exists()
|
| 607 |
+
|
| 608 |
+
|
| 609 |
+
def test_run_primary_analysis_processing_info(tmp_path):
|
| 610 |
+
"""ProcessingInfo contient le bon model_id et prompt_version."""
|
| 611 |
+
prompt_rel = "prompts/test-profile/primary_v1.txt"
|
| 612 |
+
_setup_prompt_file(tmp_path, prompt_rel)
|
| 613 |
+
deriv_path = _setup_derivative(tmp_path)
|
| 614 |
+
|
| 615 |
+
profile = _make_corpus_profile(
|
| 616 |
+
profile_id="test-profile",
|
| 617 |
+
prompt_rel_path=prompt_rel,
|
| 618 |
+
)
|
| 619 |
+
model_cfg = _make_model_config()
|
| 620 |
+
mock_client = _make_mock_client(_valid_ai_json())
|
| 621 |
+
|
| 622 |
+
with patch("app.services.ai.analyzer.build_client", return_value=mock_client):
|
| 623 |
+
result = run_primary_analysis(
|
| 624 |
+
derivative_image_path=deriv_path,
|
| 625 |
+
corpus_profile=profile,
|
| 626 |
+
model_config=model_cfg,
|
| 627 |
+
page_id="test-corpus-0001r",
|
| 628 |
+
manuscript_id="ms-test",
|
| 629 |
+
corpus_slug="test-corpus",
|
| 630 |
+
folio_label="0001r",
|
| 631 |
+
sequence=1,
|
| 632 |
+
image_info=_make_image_info(),
|
| 633 |
+
base_data_dir=tmp_path / "data",
|
| 634 |
+
project_root=tmp_path,
|
| 635 |
+
)
|
| 636 |
+
|
| 637 |
+
assert result.processing is not None
|
| 638 |
+
assert result.processing.model_id == "gemini-2.0-flash"
|
| 639 |
+
assert result.processing.model_display_name == "Gemini 2.0 Flash"
|
| 640 |
+
assert result.processing.prompt_version == prompt_rel
|
| 641 |
+
|
| 642 |
+
|
| 643 |
+
def test_run_primary_analysis_image_dict(tmp_path):
|
| 644 |
+
"""Le dict image du PageMaster reprend les données de ImageDerivativeInfo."""
|
| 645 |
+
prompt_rel = "prompts/medieval-illuminated/primary_v1.txt"
|
| 646 |
+
_setup_prompt_file(tmp_path, prompt_rel)
|
| 647 |
+
deriv_path = _setup_derivative(tmp_path)
|
| 648 |
+
|
| 649 |
+
image_info = _make_image_info()
|
| 650 |
+
mock_client = _make_mock_client(_valid_ai_json())
|
| 651 |
+
|
| 652 |
+
with patch("app.services.ai.analyzer.build_client", return_value=mock_client):
|
| 653 |
+
result = run_primary_analysis(
|
| 654 |
+
derivative_image_path=deriv_path,
|
| 655 |
+
corpus_profile=_make_corpus_profile(prompt_rel_path=prompt_rel),
|
| 656 |
+
model_config=_make_model_config(),
|
| 657 |
+
page_id="test-corpus-0001r",
|
| 658 |
+
manuscript_id="ms-test",
|
| 659 |
+
corpus_slug="test-corpus",
|
| 660 |
+
folio_label="0001r",
|
| 661 |
+
sequence=1,
|
| 662 |
+
image_info=image_info,
|
| 663 |
+
base_data_dir=tmp_path / "data",
|
| 664 |
+
project_root=tmp_path,
|
| 665 |
+
)
|
| 666 |
+
|
| 667 |
+
assert result.image["original_url"] == image_info.original_url
|
| 668 |
+
assert result.image["width"] == image_info.derivative_width
|
| 669 |
+
assert result.image["height"] == image_info.derivative_height
|
| 670 |
+
|
| 671 |
+
|
| 672 |
+
def test_run_primary_analysis_regions_in_layout(tmp_path):
|
| 673 |
+
"""Les régions valides de la réponse IA sont dans layout du PageMaster."""
|
| 674 |
+
prompt_rel = "prompts/medieval-illuminated/primary_v1.txt"
|
| 675 |
+
_setup_prompt_file(tmp_path, prompt_rel)
|
| 676 |
+
deriv_path = _setup_derivative(tmp_path)
|
| 677 |
+
|
| 678 |
+
mock_client = _make_mock_client(_valid_ai_json())
|
| 679 |
+
|
| 680 |
+
with patch("app.services.ai.analyzer.build_client", return_value=mock_client):
|
| 681 |
+
result = run_primary_analysis(
|
| 682 |
+
derivative_image_path=deriv_path,
|
| 683 |
+
corpus_profile=_make_corpus_profile(prompt_rel_path=prompt_rel),
|
| 684 |
+
model_config=_make_model_config(),
|
| 685 |
+
page_id="test-corpus-0001r",
|
| 686 |
+
manuscript_id="ms-test",
|
| 687 |
+
corpus_slug="test-corpus",
|
| 688 |
+
folio_label="0001r",
|
| 689 |
+
sequence=1,
|
| 690 |
+
image_info=_make_image_info(),
|
| 691 |
+
base_data_dir=tmp_path / "data",
|
| 692 |
+
project_root=tmp_path,
|
| 693 |
+
)
|
| 694 |
+
|
| 695 |
+
assert len(result.layout["regions"]) == 2
|
| 696 |
+
|
| 697 |
+
|
| 698 |
+
def test_run_primary_analysis_prompt_rendered_with_profile(tmp_path):
|
| 699 |
+
"""Le prompt envoyé à l'IA contient les valeurs du profil substituées."""
|
| 700 |
+
prompt_rel = "prompts/medieval-illuminated/primary_v1.txt"
|
| 701 |
+
tpl = tmp_path / prompt_rel
|
| 702 |
+
tpl.parent.mkdir(parents=True, exist_ok=True)
|
| 703 |
+
tpl.write_text("Profil: {{profile_label}} | Script: {{script_type}}")
|
| 704 |
+
deriv_path = _setup_derivative(tmp_path)
|
| 705 |
+
|
| 706 |
+
mock_client = _make_mock_client(_valid_ai_json())
|
| 707 |
+
profile = _make_corpus_profile(prompt_rel_path=prompt_rel)
|
| 708 |
+
|
| 709 |
+
with patch("app.services.ai.analyzer.build_client", return_value=mock_client):
|
| 710 |
+
run_primary_analysis(
|
| 711 |
+
derivative_image_path=deriv_path,
|
| 712 |
+
corpus_profile=profile,
|
| 713 |
+
model_config=_make_model_config(),
|
| 714 |
+
page_id="test-corpus-0001r",
|
| 715 |
+
manuscript_id="ms-test",
|
| 716 |
+
corpus_slug="test-corpus",
|
| 717 |
+
folio_label="0001r",
|
| 718 |
+
sequence=1,
|
| 719 |
+
image_info=_make_image_info(),
|
| 720 |
+
base_data_dir=tmp_path / "data",
|
| 721 |
+
project_root=tmp_path,
|
| 722 |
+
)
|
| 723 |
+
|
| 724 |
+
# Vérifier que generate_content a été appelé avec le prompt rendu
|
| 725 |
+
call_args = mock_client.models.generate_content.call_args
|
| 726 |
+
contents = call_args.kwargs.get("contents") or call_args.args[0] if call_args.args else call_args.kwargs["contents"]
|
| 727 |
+
prompt_sent = contents[-1] # le prompt est le dernier élément
|
| 728 |
+
assert "Manuscrit médiéval enluminé" in prompt_sent
|
| 729 |
+
assert "caroline" in prompt_sent
|
| 730 |
+
assert "{{profile_label}}" not in prompt_sent
|
| 731 |
+
|
| 732 |
+
|
| 733 |
+
def test_run_primary_analysis_prompt_not_found_raises(tmp_path):
|
| 734 |
+
"""FileNotFoundError si le template de prompt n'existe pas."""
|
| 735 |
+
deriv_path = _setup_derivative(tmp_path)
|
| 736 |
+
profile = _make_corpus_profile(prompt_rel_path="prompts/nonexistent/prompt.txt")
|
| 737 |
+
|
| 738 |
+
mock_client = _make_mock_client(_valid_ai_json())
|
| 739 |
+
|
| 740 |
+
with patch("app.services.ai.analyzer.build_client", return_value=mock_client):
|
| 741 |
+
with pytest.raises(FileNotFoundError):
|
| 742 |
+
run_primary_analysis(
|
| 743 |
+
derivative_image_path=deriv_path,
|
| 744 |
+
corpus_profile=profile,
|
| 745 |
+
model_config=_make_model_config(),
|
| 746 |
+
page_id="test-corpus-0001r",
|
| 747 |
+
manuscript_id="ms-test",
|
| 748 |
+
corpus_slug="test-corpus",
|
| 749 |
+
folio_label="0001r",
|
| 750 |
+
sequence=1,
|
| 751 |
+
image_info=_make_image_info(),
|
| 752 |
+
base_data_dir=tmp_path / "data",
|
| 753 |
+
project_root=tmp_path,
|
| 754 |
+
)
|
| 755 |
+
|
| 756 |
+
|
| 757 |
+
def test_run_primary_analysis_ocr_in_result(tmp_path):
|
| 758 |
+
"""Le résultat OCR est bien présent dans le PageMaster."""
|
| 759 |
+
prompt_rel = "prompts/medieval-illuminated/primary_v1.txt"
|
| 760 |
+
_setup_prompt_file(tmp_path, prompt_rel)
|
| 761 |
+
deriv_path = _setup_derivative(tmp_path)
|
| 762 |
+
|
| 763 |
+
mock_client = _make_mock_client(_valid_ai_json())
|
| 764 |
+
|
| 765 |
+
with patch("app.services.ai.analyzer.build_client", return_value=mock_client):
|
| 766 |
+
result = run_primary_analysis(
|
| 767 |
+
derivative_image_path=deriv_path,
|
| 768 |
+
corpus_profile=_make_corpus_profile(prompt_rel_path=prompt_rel),
|
| 769 |
+
model_config=_make_model_config(),
|
| 770 |
+
page_id="test-corpus-0001r",
|
| 771 |
+
manuscript_id="ms-test",
|
| 772 |
+
corpus_slug="test-corpus",
|
| 773 |
+
folio_label="0001r",
|
| 774 |
+
sequence=1,
|
| 775 |
+
image_info=_make_image_info(),
|
| 776 |
+
base_data_dir=tmp_path / "data",
|
| 777 |
+
project_root=tmp_path,
|
| 778 |
+
)
|
| 779 |
+
|
| 780 |
+
assert result.ocr is not None
|
| 781 |
+
assert result.ocr.diplomatic_text == "Incipit liber beati Ieronimi"
|
| 782 |
+
assert result.ocr.language == "la"
|
| 783 |
+
|
| 784 |
+
|
| 785 |
+
def test_run_primary_analysis_editorial_status_machine_draft(tmp_path):
|
| 786 |
+
"""Le statut éditorial initial est machine_draft."""
|
| 787 |
+
prompt_rel = "prompts/medieval-illuminated/primary_v1.txt"
|
| 788 |
+
_setup_prompt_file(tmp_path, prompt_rel)
|
| 789 |
+
deriv_path = _setup_derivative(tmp_path)
|
| 790 |
+
|
| 791 |
+
mock_client = _make_mock_client(_valid_ai_json())
|
| 792 |
+
|
| 793 |
+
with patch("app.services.ai.analyzer.build_client", return_value=mock_client):
|
| 794 |
+
result = run_primary_analysis(
|
| 795 |
+
derivative_image_path=deriv_path,
|
| 796 |
+
corpus_profile=_make_corpus_profile(prompt_rel_path=prompt_rel),
|
| 797 |
+
model_config=_make_model_config(),
|
| 798 |
+
page_id="test-corpus-0001r",
|
| 799 |
+
manuscript_id="ms-test",
|
| 800 |
+
corpus_slug="test-corpus",
|
| 801 |
+
folio_label="0001r",
|
| 802 |
+
sequence=1,
|
| 803 |
+
image_info=_make_image_info(),
|
| 804 |
+
base_data_dir=tmp_path / "data",
|
| 805 |
+
project_root=tmp_path,
|
| 806 |
+
)
|
| 807 |
+
|
| 808 |
+
assert result.editorial.status.value == "machine_draft"
|
| 809 |
+
|
| 810 |
+
|
| 811 |
+
def test_run_primary_analysis_master_json_content(tmp_path):
|
| 812 |
+
"""Le master.json écrit sur disque est un JSON valide avec schema_version."""
|
| 813 |
+
prompt_rel = "prompts/medieval-illuminated/primary_v1.txt"
|
| 814 |
+
_setup_prompt_file(tmp_path, prompt_rel)
|
| 815 |
+
deriv_path = _setup_derivative(tmp_path)
|
| 816 |
+
|
| 817 |
+
mock_client = _make_mock_client(_valid_ai_json())
|
| 818 |
+
|
| 819 |
+
with patch("app.services.ai.analyzer.build_client", return_value=mock_client):
|
| 820 |
+
run_primary_analysis(
|
| 821 |
+
derivative_image_path=deriv_path,
|
| 822 |
+
corpus_profile=_make_corpus_profile(prompt_rel_path=prompt_rel),
|
| 823 |
+
model_config=_make_model_config(),
|
| 824 |
+
page_id="test-corpus-0001r",
|
| 825 |
+
manuscript_id="ms-test",
|
| 826 |
+
corpus_slug="test-corpus",
|
| 827 |
+
folio_label="0001r",
|
| 828 |
+
sequence=1,
|
| 829 |
+
image_info=_make_image_info(),
|
| 830 |
+
base_data_dir=tmp_path / "data",
|
| 831 |
+
project_root=tmp_path,
|
| 832 |
+
)
|
| 833 |
+
|
| 834 |
+
master_path = tmp_path / "data" / "corpora" / "test-corpus" / "pages" / "0001r" / "master.json"
|
| 835 |
+
content = json.loads(master_path.read_text(encoding="utf-8"))
|
| 836 |
+
assert content["schema_version"] == "1.0"
|
| 837 |
+
assert content["page_id"] == "test-corpus-0001r"
|
| 838 |
+
|
| 839 |
+
|
| 840 |
+
def test_run_primary_analysis_invalid_region_skipped(tmp_path):
|
| 841 |
+
"""Une région invalide dans la réponse IA est ignorée sans lever d'exception."""
|
| 842 |
+
prompt_rel = "prompts/medieval-illuminated/primary_v1.txt"
|
| 843 |
+
_setup_prompt_file(tmp_path, prompt_rel)
|
| 844 |
+
deriv_path = _setup_derivative(tmp_path)
|
| 845 |
+
|
| 846 |
+
response_with_bad_region = json.dumps({
|
| 847 |
+
"layout": {"regions": [
|
| 848 |
+
{"id": "r_good", "type": "text_block", "bbox": [0, 0, 100, 100], "confidence": 0.9},
|
| 849 |
+
{"id": "r_bad", "type": "text_block", "bbox": [-1, 0, 100, 100], "confidence": 0.9},
|
| 850 |
+
]},
|
| 851 |
+
"ocr": {},
|
| 852 |
+
})
|
| 853 |
+
mock_client = _make_mock_client(response_with_bad_region)
|
| 854 |
+
|
| 855 |
+
with patch("app.services.ai.analyzer.build_client", return_value=mock_client):
|
| 856 |
+
result = run_primary_analysis(
|
| 857 |
+
derivative_image_path=deriv_path,
|
| 858 |
+
corpus_profile=_make_corpus_profile(prompt_rel_path=prompt_rel),
|
| 859 |
+
model_config=_make_model_config(),
|
| 860 |
+
page_id="test-corpus-0001r",
|
| 861 |
+
manuscript_id="ms-test",
|
| 862 |
+
corpus_slug="test-corpus",
|
| 863 |
+
folio_label="0001r",
|
| 864 |
+
sequence=1,
|
| 865 |
+
image_info=_make_image_info(),
|
| 866 |
+
base_data_dir=tmp_path / "data",
|
| 867 |
+
project_root=tmp_path,
|
| 868 |
+
)
|
| 869 |
+
|
| 870 |
+
assert len(result.layout["regions"]) == 1
|
| 871 |
+
assert result.layout["regions"][0]["id"] == "r_good"
|