Claude commited on
Commit
9bb793c
·
unverified ·
1 Parent(s): 8e5d22a

feat(sprint2-session-c): premier appel Google AI → master.json valide

Browse files

Pipeline 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 CHANGED
@@ -1,10 +1,14 @@
1
  """
2
- Services AI — providers Google AI et registre de modèles.
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"