maribakulj commited on
Commit
1c1e759
·
unverified ·
1 Parent(s): 0f821d8

CLAUDE modifié

Browse files
Files changed (1) hide show
  1. CLAUDE.md +566 -373
CLAUDE.md CHANGED
@@ -1,71 +1,110 @@
1
- # Scriptorium AI — Instructions permanentes pour Claude Code
 
2
 
3
- ## 1. Contexte du projet
4
-
5
- Scriptorium AI est une **plateforme générique** de génération d'éditions savantes augmentées
6
  pour documents patrimoniaux numérisés : manuscrits médiévaux, incunables, cartulaires,
7
  archives, chartes, papyri — tout type de document, toute époque, toute langue.
8
-
9
  Pipeline général :
10
- images sources → ingestion → normalisation → analyse Google AI → JSON maître
11
- → passes dérivées → ALTO / METS / Manifest IIIF → interface web → validation humaine
12
-
13
- Le premier démonstrateur est le **Beatus de Saint-Sever** (manuscrit enluminé médiéval,
14
- latin, BnF Latin 8878). Mais la plateforme n'est PAS un outil Beatus.
15
- Le Beatus est un profil parmi d'autres.
16
-
17
- ---
18
-
19
- ## 2. Stack technique
20
-
21
- | Composant | Technologie |
22
- |-----------------|--------------------------------------------------|
23
- | Backend | Python 3.11+, FastAPI, Uvicorn |
24
- | Validation | Pydantic v2 (jamais v1) |
25
- | Base de données | SQLite via SQLAlchemy 2.0 async |
26
- | IA | Google AI API, modèle sélectionnable dynamiquement|
27
- | SDK Google | google-generativeai >= 0.3 |
28
- | XML | lxml |
29
- | Images | Pillow |
30
- | Tests | pytest, pytest-cov, pytest-asyncio |
31
- | Frontend | React + Vite, TypeScript, Tailwind CSS (sprint 4+)|
32
- | Hébergement | HuggingFace Spaces (Docker) + HF Datasets |
33
-
34
- ---
35
-
36
- ## 3. Arborescence du repo — structure canonique
37
-
38
- ```
 
 
 
 
 
 
 
 
 
 
39
  scriptorium-ai/
40
 
41
- ├── CLAUDE.md ← CE FICHIER
42
- ├── CONTEXT.md ← état courant du projet (tu ne le modifies pas)
43
- ├── DECISIONS.md ← décisions figées (tu ne les remets pas en question)
44
- ├── TODO.md ← tâches de la session courante
45
 
46
  ├── backend/
47
  │ ├── app/
 
 
 
48
  │ │ ├── api/
49
- │ │ │ └── v1/ ← tous les endpoints FastAPI
 
 
 
 
 
 
50
  │ │ ├── models/ ← modèles SQLAlchemy (tables BDD)
51
- │ │ ├── schemas/ ← modèles Pydantic (source canonique des types)
52
  │ │ │ ├── __init__.py
53
- │ │ │ ├─ corpus_profile.py
54
- │ │ │ ├── page_master.py
55
- │ │ │ └── annotation.py
 
 
 
 
 
56
  │ │ └── services/
57
- │ │ ├── ingest/ ← ingestion corpus
58
- │ │ ├── image/ ← normalisation + dérivés
59
- │ │ ├── ai/ ← appels Google AI + parsing + validation
60
- │ │ ── export/ générateurs ALTO, METS, IIIF
61
- │ │ ── search/ ← index recherche
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  │ ├── tests/
63
  │ │ ├── __init__.py
64
- │ │ ├── test_schemas.py
65
- │ │ ── test_profiles.py
 
 
 
66
  │ └── pyproject.toml
67
 
68
- ├── prompts/
69
  │ ├── medieval-illuminated/
70
  │ │ ├── primary_v1.txt
71
  │ │ ├── transcription_v1.txt
@@ -81,41 +120,36 @@ scriptorium-ai/
81
  │ └── modern-handwritten/
82
  │ └── primary_v1.txt
83
 
84
- ├── profiles/
85
  │ ├── medieval-illuminated.json
86
  │ ├── medieval-textual.json
87
  │ ├── early-modern-print.json
88
  │ └── modern-handwritten.json
89
 
90
- ├── data/ ← JAMAIS versionné (.gitignore)
91
  │ └── corpora/
92
  │ └── {corpus_slug}/
93
- │ ├── masters/
94
- │ ├── derivatives/
 
95
  │ ├── iiif/
 
 
96
  │ └── pages/
97
- │ └── {folio}/
98
- │ ├── master.json
99
- │ ├── gemini_raw.json
100
  │ ├── alto.xml
101
  │ └── annotations.json
102
 
103
  └── infra/
104
  └── Dockerfile
105
- ```
106
-
107
- Ne jamais créer de fichiers en dehors de cette arborescence sans demande explicite.
108
-
109
- ---
110
-
111
- ## 4. Modèle de données — schémas canoniques
112
 
113
- ### 4.1 CorpusProfile
 
 
 
114
 
115
- Entité centrale. Tout le pipeline en est piloté.
116
- Fichier : `backend/app/schemas/corpus_profile.py`
117
-
118
- ```python
119
  class LayerType(str, Enum):
120
  IMAGE = "image"
121
  OCR_DIPLOMATIC = "ocr_diplomatic"
@@ -147,23 +181,19 @@ class UncertaintyConfig(BaseModel):
147
 
148
  class CorpusProfile(BaseModel):
149
  model_config = ConfigDict(frozen=True)
150
-
151
  profile_id: str
152
  label: str
153
  language_hints: list[str]
154
  script_type: ScriptType
155
  active_layers: list[LayerType]
156
- prompt_templates: dict[str, str] # {"primary": "path/v1.txt", ...}
157
  uncertainty_config: UncertaintyConfig
158
  export_config: ExportConfig
159
- ```
160
-
161
- ### 4.2 PageMaster
162
-
163
- Source canonique de toute page. Toutes les sorties en dérivent.
164
- Fichier : `backend/app/schemas/page_master.py`
165
 
166
- ```python
167
  class RegionType(str, Enum):
168
  TEXT_BLOCK = "text_block"
169
  MINIATURE = "miniature"
@@ -182,13 +212,21 @@ class Region(BaseModel):
182
 
183
  @field_validator("bbox")
184
  @classmethod
185
- def bbox_must_be_positive(cls, v):
186
  if any(x < 0 for x in v):
187
- raise ValueError("bbox values must be >= 0")
188
  if v[2] <= 0 or v[3] <= 0:
189
- raise ValueError("bbox width and height must be > 0")
190
  return v
191
 
 
 
 
 
 
 
 
 
192
  class OCRResult(BaseModel):
193
  diplomatic_text: str = ""
194
  blocks: list[dict] = []
@@ -201,6 +239,10 @@ class Translation(BaseModel):
201
  fr: str = ""
202
  en: str = ""
203
 
 
 
 
 
204
  class CommentaryClaim(BaseModel):
205
  claim: str
206
  evidence_region_ids: list[str] = []
@@ -212,10 +254,11 @@ class Commentary(BaseModel):
212
  claims: list[CommentaryClaim] = []
213
 
214
  class ProcessingInfo(BaseModel):
215
- model_id: str
 
216
  model_display_name: str
217
- prompt_version: str
218
- raw_response_path: str
219
  processed_at: datetime
220
  cost_estimate_usd: float | None = None
221
 
@@ -234,31 +277,23 @@ class EditorialInfo(BaseModel):
234
  notes: list[str] = []
235
 
236
  class PageMaster(BaseModel):
237
- schema_version: str = "1.0"
238
- page_id: str
239
- corpus_profile: str # profile_id du CorpusProfile
240
  manuscript_id: str
241
- folio_label: str
242
- sequence: int
243
-
244
- image: dict # master, derivative_web, iiif_base, width, height
245
- layout: dict # {"regions": [Region, ...]}
246
  ocr: OCRResult | None = None
247
  translation: Translation | None = None
248
- summary: dict | None = None # {"short": str, "detailed": str}
249
  commentary: Commentary | None = None
250
- extensions: dict[str, Any] = {} # données spécifiques au profil
251
-
252
  processing: ProcessingInfo | None = None
253
  editorial: EditorialInfo = EditorialInfo()
254
- ```
255
-
256
- ### 4.3 AnnotationLayer
257
-
258
- Fichier : `backend/app/schemas/annotation.py`
259
-
260
- ```python
261
- class LayerStatus(str, Enum):
262
  PENDING = "pending"
263
  RUNNING = "running"
264
  DONE = "done"
@@ -275,257 +310,425 @@ class AnnotationLayer(BaseModel):
275
  source_model: str | None = None
276
  prompt_version: str | None = None
277
  created_at: datetime
278
- ```
279
-
280
- ---
281
-
282
- ## 5. Règles absolues — NE JAMAIS ENFREINDRE
283
-
284
- ### R01 — Aucune logique hardcodée par corpus
285
- Jamais de condition du type `if corpus == "beatus"` ou `if profile == "medieval-illuminated"`.
286
- Toute logique spécifique passe par le CorpusProfile. Le code est générique.
287
-
288
- ### R02 — Le JSON maître est la source canonique
289
- Toutes les sorties (IIIF, ALTO, METS, annotations) sont générées depuis le PageMaster JSON.
290
- On ne génère jamais une sortie directement depuis la réponse brute de l'IA.
291
-
292
- ### R03 — Convention bbox [x, y, width, height] UNIQUEMENT
293
- Format : [x, y, largeur, hauteur] en pixels entiers dans l'image source.
294
- - x, y = coin supérieur gauche
295
- - width, height = dimensions
296
- JAMAIS [x1, y1, x2, y2] (coins opposés).
297
- JAMAIS de coordonnées relatives ou normalisées (0.0–1.0).
298
- Le validator Pydantic doit rejeter toute bbox avec width ou height <= 0.
299
-
300
- ### R04 — Prompts dans des fichiers, jamais dans le code
301
- Les prompts vivent dans prompts/{profile_id}/{famille}_v{n}.txt
302
- Le code charge le fichier, injecte les variables, envoie à l'API.
303
- Jamais de f-string de prompt hardcodée dans un fichier .py.
304
-
305
- ### R05 — Double stockage des réponses IA
306
- Toujours écrire DEUX fichiers distincts :
307
- - `gemini_raw.json` : réponse brute telle que retournée par l'API
308
- - `master.json` : JSON parsé, validé par Pydantic, canonique
309
- Un seul fichier = bug. Les deux sont obligatoires.
310
-
311
- ### R06 — Clé API jamais dans le code
312
- La clé API Google AI vit uniquement dans les variables d'environnement.
313
- Jamais dans : le code, les logs, les fichiers versionnés, les exports, les JSON maîtres.
314
- Variable d'environnement : GOOGLE_AI_API_KEY
315
-
316
- ### R07 — Pydantic v2 exclusivement
317
- Syntaxe v2 : `model_config = ConfigDict(...)` et non `class Config:`
318
- `@field_validator` et non `@validator`
319
- `model_validate()` et non `parse_obj()`
320
- Imports : `from pydantic import BaseModel, ConfigDict, Field, field_validator`
321
 
322
- ### R08 — Tests pour tout modèle de données
323
- Aucun nouveau schéma Pydantic sans test correspondant.
324
- Aucun profil JSON sans test de chargement et validation.
325
- Les tests ne sont pas optionnels.
326
-
327
- ### R09 — schema_version dans tout JSON maître
328
- Le champ `schema_version: str = "1.0"` est obligatoire dans PageMaster.
329
- Si le schéma change, la version change.
330
-
331
- ### R10 Endpoints préfixés /api/v1/
332
- Tous les endpoints FastAPI sont sous /api/v1/.
333
- Exemple : /api/v1/corpora, /api/v1/pages/{id}/master-json
334
-
335
- ---
336
-
337
- ## 6. Anti-patterns — ce qui est interdit
338
-
339
- ```python
340
- # ❌ INTERDIT — logique hardcodée par corpus
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
341
  if profile_id == "medieval-illuminated":
342
  process_iconography()
343
 
344
- # ✅ CORRECT — piloté par le profil
345
  if "iconography_detection" in corpus_profile.active_layers:
346
  process_iconography()
347
-
348
- # INTERDIT prompt hardcodé dans le code
349
- prompt = f"Tu analyses un manuscrit {profile.label}. Retourne ce JSON..."
350
-
351
- # CORRECTprompt chargé depuis fichier versionné
352
- prompt_path = corpus_profile.prompt_templates["primary"]
353
- prompt = load_and_render_prompt(prompt_path, context)
354
-
355
- # ❌ INTERDIT — bbox en coordonnées de coins opposés
356
  bbox = [x1, y1, x2, y2]
357
 
358
- # ✅ CORRECT — bbox en [x, y, width, height]
359
  bbox = [x, y, x2 - x1, y2 - y1]
360
-
361
- # INTERDIT pydantic v1
362
- class MyModel(BaseModel):
363
- class Config:
364
- frozen = True
365
-
366
- # CORRECT — pydantic v2
367
- class MyModel(BaseModel):
368
- model_config = ConfigDict(frozen=True)
369
-
370
- # INTERDIT réponse brute non conservée
371
- master_json = parse_ai_response(response)
372
- save(master_json)
373
-
374
- # ✅ CORRECT — double stockage obligatoire
375
- save_raw(response, path="gemini_raw.json")
376
- master_json = parse_and_validate(response)
377
- save_canonical(master_json, path="master.json")
378
-
379
- # ❌ INTERDIT clé API dans le code
380
- client = genai.Client(api_key="AIza...")
381
-
382
- # CORRECTdepuis l'environnement
383
- client = genai.Client(api_key=os.environ["GOOGLE_AI_API_KEY"])
384
- ```
385
-
386
- ---
387
-
388
- ## 7. Conventions de code
389
-
390
- ### Nommage
391
- - Python : snake_case pour variables et fonctions, PascalCase pour classes
392
- - TypeScript (sprint 4+) : camelCase pour variables, PascalCase pour composants
393
- - Fichiers Python : snake_case.py
394
- - Fichiers de prompts : {famille}_v{n}.txt (ex: primary_v1.txt, commentary_v2.txt)
395
- - Profils JSON : {profile_id}.json (ex: medieval-illuminated.json)
396
- - IDs de pages : {corpus_slug}-{folio_label} (ex: beatus-lat8878-0013r)
397
-
398
- ### Structure d'un fichier Python
399
- ```python
400
- """
401
- Module docstring courte (1–2 lignes max).
402
- """
403
- # 1. stdlib
404
- import os
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
405
  from datetime import datetime
406
- from typing import Any
407
 
408
- # 2. third-party
409
- from pydantic import BaseModel, Field
410
-
411
- # 3. local
412
- from app.schemas.corpus_profile import CorpusProfile
413
- ```
414
-
415
- ### Gestion d'erreurs
416
- ```python
417
- # Exceptions explicites avec message utile
418
- if not image_path.exists():
419
- raise FileNotFoundError(f"Image not found: {image_path}")
420
-
421
- # Logging structuré
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
422
  import logging
423
- logger = logging.getLogger(__name__)
424
- logger.info("Processing page", extra={"page_id": page_id, "profile": profile_id})
425
-
426
- # ❌ Jamais
427
- try:
428
- ...
429
- except:
430
- pass # silence total = bug silencieux
431
- ```
432
-
433
- ### Type hints
434
- - Obligatoires sur toutes les signatures de fonctions
435
- - `Any` accepté uniquement pour les extensions de profil
436
- - Préférer `str | None` à `Optional[str]` (Python 3.10+ syntax)
437
-
438
- ---
439
-
440
- ## 8. Pipeline — étapes et responsabilités
441
-
442
- ```
443
- Étape 1 — Ingestion
444
- Input : dossier local / ZIP / URLs IIIF / manifest IIIF
445
- Output : enregistrements Corpus + Manuscript + Page en SQLite
446
- Status : INGESTED
447
- Règle : aucun appel IA, aucune image modifiée
448
-
449
- Étape 2 — Préparation image
450
- Input : image master (TIFF / JP2 / JPEG / PNG)
451
- Output : dérivé JPEG 1500px max pour l'IA + thumbnail
452
- Status : PREPARED
453
- Règle : jamais envoyer le master brut à l'IA
454
-
455
- Étape 3 — Analyse primaire IA (1 seul appel par page)
456
- Input : dérivé JPEG + prompt primary_v1.txt rendu avec le profil
457
- Output : gemini_raw.json (brut) + master.json partiel (layout + OCR)
458
- Status : ANALYZED
459
- Règle : 1 seule passe visuelle. Pas d'appels concurrents sur la même image.
460
-
461
- Étape 4 — Passes dérivées (selon active_layers du profil)
462
- Input : master.json de l'étape 3
463
- Output : master.json enrichi (traduction, commentaire, iconographie)
464
- Status : LAYERED
465
- Règle : les passes dérivées sont textuelles. Pas de nouvelle passe visuelle
466
- sauf pour l'iconographie (crops des régions uniquement).
467
-
468
- Étape 5 — Génération documentaire
469
- Input : master.json complet
470
- Output : alto.xml + mets.xml + manifest.json + annotations IIIF
471
- Status : EXPORTED
472
- Règle : toujours régénérable depuis master.json. Ne jamais éditer les XML
473
- manuellement — ils sont des sorties dérivées.
474
-
475
- Étape 6 — Validation humaine
476
- Input : master.json + interface
477
- Output : master.json corrigé avec version incrémentée
478
- Status : VALIDATED → PUBLISHED
479
- ```
480
-
481
- ---
482
-
483
- ## 9. Modèle Google AI — sélection dynamique
484
-
485
- Le modèle n'est jamais hardcodé. Flux :
486
- 1. Utilisateur fournit sa clé API → `POST /api/v1/settings/api-key`
487
- 2. Plateforme appelle Google AI List Models → filtre sur `generateContent` + vision
488
- 3. Utilisateur sélectionne un modèle → `PUT /api/v1/corpora/{id}/model`
489
- 4. Modèle stocké dans `ModelConfig` par corpus (pas dans CorpusProfile)
490
- 5. Chaque appel IA journalise `model_id` + `model_display_name`
491
-
492
- Entité `ModelConfig` (par corpus) :
493
- ```python
494
- class ModelConfig(BaseModel):
495
- corpus_id: str
496
- selected_model_id: str # ID technique Google AI
497
- selected_model_display_name: str
498
- supports_vision: bool
499
- last_fetched_at: datetime
500
- available_models: list[dict] # cache de la liste
501
- ```
502
 
503
- ---
504
-
505
- ## 10. Statuts métier
506
-
507
- ### Corpus / Page
508
- ```
509
- CREATED → INGESTING → INGESTED → PROCESSING → READY → ERROR
510
- INGESTED → PREPARED → ANALYZED → LAYERED → EXPORTED → VALIDATED → ERROR
511
- ```
512
-
513
- ### Couche (AnnotationLayer)
514
- ```
515
- PENDING → RUNNING → DONE → FAILED → NEEDS_REVIEW → VALIDATED
516
- ```
517
-
518
- ### Éditorial (PageMaster.editorial.status)
519
- ```
520
- machine_draft → needs_review → reviewed → validated → published
521
- ```
522
 
523
- ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
524
 
525
- ## 11. Endpoints API — liste complète
526
 
527
- ```
528
- # Configuration & modèles
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
529
  POST /api/v1/settings/api-key
530
  GET /api/v1/models
531
  POST /api/v1/models/refresh
@@ -574,47 +777,37 @@ GET /api/v1/pages/{id}/history
574
  # Recherche
575
  GET /api/v1/search?q=
576
  GET /api/v1/manuscripts/{id}/search?q=
577
- ```
578
-
579
- ---
580
-
581
- ## 12. État du projet par sprint
582
 
583
- ```
584
- Sprint 1 — Fondations du modèle de données [ EN COURS ]
585
- Schémas Pydantic + tests pytest + profils JSON + templates prompts
586
 
587
- Sprint 2 — Pipeline page unique [ À FAIRE ]
588
- Ingestion + appel Google AI + master.json
589
 
590
  Sprint 3 — Exports documentaires [ À FAIRE ]
591
- ALTO + METS + Manifest IIIF
592
 
593
  Sprint 4 — API FastAPI + interface de lecture [ À FAIRE ]
594
- Endpoints + visionneuse + 4 couches
595
 
596
  Sprint 5 — Traitement en lot + HuggingFace [ À FAIRE ]
597
- Pipeline batch + déploiement public
598
 
599
  Sprint 6 — Validation humaine + V1 complète [ À FAIRE ]
600
- Éditeur + versionnement + recherche
601
- ```
602
-
603
- **Règle :** ne jamais implémenter du code appartenant à un sprint ultérieur
604
- au sprint en cours. Si une idée émerge pour un sprint futur, la noter
605
- dans TODO.md section "Backlog" et ne pas la coder.
606
-
607
- ---
608
-
609
- ## 13. Ce que tu NE dois PAS faire sans demande explicite
610
-
611
- - Modifier le schéma PageMaster (champs, types, noms, structure)
612
- - Modifier la convention bbox
613
- - Ajouter des dépendances non listées dans pyproject.toml
614
- - Refactoriser du code existant si la session n'a pas ce but explicite
615
- - Créer des fichiers hors de l'arborescence définie section 3
616
- - Implémenter du code de sprint futur (voir section 12)
617
- - Simplifier un schéma pour "faire plus propre" — les schémas sont figés
618
- - Changer une règle listée en section 5 pour une raison de commodité
619
- - Utiliser une librairie alternative à celles listées section 2
620
- - Créer une logique spécifique à un corpus particulier
 
1
+ Scriptorium AI — Instructions permanentes pour Claude Code
2
+ Version 2.0 — mise à jour Sprint 2
3
 
4
+ 1. Contexte du projet
5
+ Scriptorium AI est une plateforme générique de génération d'éditions savantes augmentées
 
6
  pour documents patrimoniaux numérisés : manuscrits médiévaux, incunables, cartulaires,
7
  archives, chartes, papyri — tout type de document, toute époque, toute langue.
 
8
  Pipeline général :
9
+ images sources → ingestion → normalisation → analyse Google AI → JSON maître
10
+ → passes dérivées → ALTO / METS / Manifest IIIF → interface web → validation humaine
11
+ Premier démonstrateur : Beatus de Saint-Sever (BnF Latin 8878, manuscrit enluminé,
12
+ latin carolingien, XIe siècle). Le Beatus est un profil parmi d'autres — pas un cas spécial.
13
+
14
+ 2. Stack technique
15
+ ComposantTechnologieBackendPython 3.11+, FastAPI, UvicornValidationPydantic v2 (JAMAIS v1)Base de donnéesSQLite via SQLAlchemy 2.0 async + aiosqliteIAGoogle AI — provider sélectionnable (section 9)SDK Googlegoogle-genai (PAS google-generativeai — paquet différent)XMLlxmlImagesPillow (PIL)HTTP clienthttpx (téléchargement images IIIF)Testspytest, pytest-cov, pytest-asyncioFrontendReact + Vite, TypeScript, Tailwind CSS (sprint 4+)HébergementHuggingFace Spaces (Docker) + HF Datasets
16
+ pyproject.toml — dépendances exactes
17
+ toml[project]
18
+ name = "scriptorium-ai"
19
+ version = "0.1.0"
20
+ requires-python = ">=3.11"
21
+
22
+ dependencies = [
23
+ "pydantic>=2.0",
24
+ "pydantic-settings>=2.0",
25
+ "fastapi>=0.104",
26
+ "uvicorn>=0.24",
27
+ "python-multipart>=0.0.6",
28
+ "google-genai>=0.3",
29
+ "lxml>=4.9",
30
+ "Pillow>=10.0",
31
+ "httpx>=0.25",
32
+ "sqlalchemy>=2.0",
33
+ "aiosqlite>=0.19",
34
+ ]
35
+
36
+ [project.optional-dependencies]
37
+ dev = [
38
+ "pytest>=7.0",
39
+ "pytest-cov>=4.0",
40
+ "pytest-asyncio>=0.21",
41
+ ]
42
+
43
+ [tool.pytest.ini_options]
44
+ testpaths = ["tests"]
45
+ asyncio_mode = "auto"
46
+
47
+ 3. Arborescence du repo — structure canonique
48
  scriptorium-ai/
49
 
50
+ ├── CLAUDE.md ← CE FICHIER — ne pas modifier sans instruction
51
+ ├── STATUS.md ← état courant (mis à jour avant chaque session)
 
 
52
 
53
  ├── backend/
54
  │ ├── app/
55
+ │ │ ├── __init__.py
56
+ │ │ ├── main.py ← point d'entrée FastAPI (sprint 4+)
57
+ │ │ ├── config.py ← settings Pydantic depuis env vars
58
  │ │ ├── api/
59
+ │ │ │ └── v1/
60
+ │ │ │ ├── __init__.py
61
+ │ │ │ ├── corpora.py
62
+ │ │ │ ├── pages.py
63
+ │ │ │ ├── jobs.py
64
+ │ │ │ ├── models.py ← endpoints sélection modèle IA
65
+ │ │ │ └── export.py
66
  │ │ ├── models/ ← modèles SQLAlchemy (tables BDD)
 
67
  │ │ │ ├── __init__.py
68
+ │ │ │ ├���corpus.py
69
+ │ │ │ ├── page.py
70
+ │ │ │ └── job.py
71
+ │ │ ├── schemas/ ← modèles Pydantic (SOURCE CANONIQUE)
72
+ │ │ │ ├── __init__.py
73
+ │ │ │ ├── corpus_profile.py ← ✓ Sprint 1
74
+ │ │ │ ├── page_master.py ← ✓ Sprint 1
75
+ │ │ │ └── annotation.py ← ✓ Sprint 1
76
  │ │ └── services/
77
+ │ │ ├── __init__.py
78
+ │ │ ├── ingest/
79
+ │ │ ├── __init__.py
80
+ │ │ │ └── image_loader.py chargement images (URL/fichier)
81
+ │ │ ── image/
82
+ │ │ │ ├── __init__.py
83
+ │ │ │ └── processor.py ← dérivés + thumbnails
84
+ │ │ ├── ai/
85
+ │ │ │ ├── __init__.py
86
+ │ │ │ ├── client.py ← factory provider A/B/C
87
+ │ │ │ ├── models.py ← listage modèles disponibles
88
+ │ │ │ ├── prompt_loader.py ← chargement + rendu templates
89
+ │ │ │ └── pipeline.py ← orchestration appels IA
90
+ │ │ ├── export/
91
+ │ │ │ ├── __init__.py
92
+ │ │ │ ├── alto.py ← générateur ALTO (sprint 3+)
93
+ │ │ │ ├── mets.py ← générateur METS (sprint 3+)
94
+ │ │ │ └── iiif.py ← générateur manifest IIIF (sprint 3+)
95
+ │ │ └── search/
96
+ │ │ ├── __init__.py
97
+ │ │ └── index.py ← index recherche (sprint 6+)
98
  │ ├── tests/
99
  │ │ ├── __init__.py
100
+ │ │ ├── test_schemas.py ← ✓ 26 tests Sprint 1
101
+ │ │ ── test_profiles.py ← ✓ 28 tests Sprint 1
102
+ │ │ ├── test_ai_connection.py ← Sprint 2 Session A
103
+ │ │ ├── test_image_processing.py ← Sprint 2 Session B
104
+ │ │ └── test_pipeline.py ← Sprint 2 Session C
105
  │ └── pyproject.toml
106
 
107
+ ├── prompts/ ← ✓ Sprint 1
108
  │ ├── medieval-illuminated/
109
  │ │ ├── primary_v1.txt
110
  │ │ ├── transcription_v1.txt
 
120
  │ └── modern-handwritten/
121
  │ └── primary_v1.txt
122
 
123
+ ├── profiles/ ← ✓ Sprint 1
124
  │ ├── medieval-illuminated.json
125
  │ ├── medieval-textual.json
126
  │ ├── early-modern-print.json
127
  │ └── modern-handwritten.json
128
 
129
+ ├── data/ ← JAMAIS versionné (.gitignore)
130
  │ └── corpora/
131
  │ └── {corpus_slug}/
132
+ │ ├── masters/ ← images sources originales
133
+ │ ├── derivatives/ ← JPEG 1500px pour l'IA
134
+ │ ├── thumbnails/ ← aperçus 300px
135
  │ ├── iiif/
136
+ │ │ ├── manifest.json
137
+ │ │ └── annotations/
138
  │ └── pages/
139
+ │ └── {folio_label}/
140
+ │ ├── master.json ← PageMaster canonique
141
+ │ ├── ai_raw.json ← réponse brute IA (JAMAIS effacée)
142
  │ ├── alto.xml
143
  │ └── annotations.json
144
 
145
  └── infra/
146
  └── Dockerfile
 
 
 
 
 
 
 
147
 
148
+ 4. Modèles de données — schémas Pydantic canoniques
149
+ 4.1 CorpusProfile (corpus_profile.py)
150
+ pythonfrom enum import Enum
151
+ from pydantic import BaseModel, ConfigDict, Field
152
 
 
 
 
 
153
  class LayerType(str, Enum):
154
  IMAGE = "image"
155
  OCR_DIPLOMATIC = "ocr_diplomatic"
 
181
 
182
  class CorpusProfile(BaseModel):
183
  model_config = ConfigDict(frozen=True)
 
184
  profile_id: str
185
  label: str
186
  language_hints: list[str]
187
  script_type: ScriptType
188
  active_layers: list[LayerType]
189
+ prompt_templates: dict[str, str] # {"primary": "prompts/.../v1.txt"}
190
  uncertainty_config: UncertaintyConfig
191
  export_config: ExportConfig
192
+ 4.2 PageMaster (page_master.py)
193
+ pythonfrom datetime import datetime
194
+ from typing import Any, Literal
195
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
 
 
196
 
 
197
  class RegionType(str, Enum):
198
  TEXT_BLOCK = "text_block"
199
  MINIATURE = "miniature"
 
212
 
213
  @field_validator("bbox")
214
  @classmethod
215
+ def bbox_must_be_valid(cls, v: list[int]) -> list[int]:
216
  if any(x < 0 for x in v):
217
+ raise ValueError("bbox: toutes les valeurs doivent être >= 0")
218
  if v[2] <= 0 or v[3] <= 0:
219
+ raise ValueError("bbox: width et height doivent être > 0")
220
  return v
221
 
222
+ class ImageInfo(BaseModel):
223
+ master: str # path ou URL source
224
+ derivative_web: str | None = None # JPEG 1500px
225
+ thumbnail: str | None = None # JPEG 300px
226
+ iiif_base: str | None = None
227
+ width: int
228
+ height: int
229
+
230
  class OCRResult(BaseModel):
231
  diplomatic_text: str = ""
232
  blocks: list[dict] = []
 
239
  fr: str = ""
240
  en: str = ""
241
 
242
+ class Summary(BaseModel):
243
+ short: str = ""
244
+ detailed: str = ""
245
+
246
  class CommentaryClaim(BaseModel):
247
  claim: str
248
  evidence_region_ids: list[str] = []
 
254
  claims: list[CommentaryClaim] = []
255
 
256
  class ProcessingInfo(BaseModel):
257
+ provider: str # "google_ai_studio"|"vertex_api_key"|"vertex_service_account"
258
+ model_id: str # ID technique retourné par l'API
259
  model_display_name: str
260
+ prompt_version: str # ex: "primary_v1"
261
+ raw_response_path: str # chemin vers ai_raw.json
262
  processed_at: datetime
263
  cost_estimate_usd: float | None = None
264
 
 
277
  notes: list[str] = []
278
 
279
  class PageMaster(BaseModel):
280
+ schema_version: str = "1.0" # OBLIGATOIRE — ne jamais omettre
281
+ page_id: str # format: {corpus_slug}-{folio_label}
282
+ corpus_profile: str # profile_id du CorpusProfile utilisé
283
  manuscript_id: str
284
+ folio_label: str # ex: "13r", "f29"
285
+ sequence: int # ordre dans le manuscrit (1-based)
286
+ image: ImageInfo
287
+ layout: dict # {"regions": [Region, ...]}
 
288
  ocr: OCRResult | None = None
289
  translation: Translation | None = None
290
+ summary: Summary | None = None
291
  commentary: Commentary | None = None
292
+ extensions: dict[str, Any] = {} # données spécifiques au profil
 
293
  processing: ProcessingInfo | None = None
294
  editorial: EditorialInfo = EditorialInfo()
295
+ 4.3 AnnotationLayer (annotation.py)
296
+ pythonclass LayerStatus(str, Enum):
 
 
 
 
 
 
297
  PENDING = "pending"
298
  RUNNING = "running"
299
  DONE = "done"
 
310
  source_model: str | None = None
311
  prompt_version: str | None = None
312
  created_at: datetime
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
313
 
314
+ class ModelConfig(BaseModel):
315
+ corpus_id: str
316
+ provider: str
317
+ selected_model_id: str
318
+ selected_model_display_name: str
319
+ supports_vision: bool
320
+ last_fetched_at: datetime
321
+ available_models: list[dict] = []
322
+
323
+ 5. Exemple complet d'un master.json valide
324
+ Cet exemple est la référence. Tout master.json produit doit avoir cette forme.
325
+ json{
326
+ "schema_version": "1.0",
327
+ "page_id": "beatus-lat8878-0013r",
328
+ "corpus_profile": "medieval-illuminated",
329
+ "manuscript_id": "beatus-lat8878",
330
+ "folio_label": "13r",
331
+ "sequence": 25,
332
+ "image": {
333
+ "master": "https://gallica.bnf.fr/ark:/12148/btv1b8432314s/f29.highres",
334
+ "derivative_web": "data/corpora/beatus-lat8878/derivatives/0013r.jpg",
335
+ "thumbnail": "data/corpora/beatus-lat8878/thumbnails/0013r.jpg",
336
+ "iiif_base": null,
337
+ "width": 3543,
338
+ "height": 4724
339
+ },
340
+ "layout": {
341
+ "regions": [
342
+ {
343
+ "id": "r1",
344
+ "type": "text_block",
345
+ "bbox": [320, 510, 2900, 3200],
346
+ "confidence": 0.91,
347
+ "polygon": null,
348
+ "parent_region_id": null
349
+ },
350
+ {
351
+ "id": "r2",
352
+ "type": "miniature",
353
+ "bbox": [320, 3750, 2900, 800],
354
+ "confidence": 0.95,
355
+ "polygon": null,
356
+ "parent_region_id": null
357
+ }
358
+ ]
359
+ },
360
+ "ocr": {
361
+ "diplomatic_text": "Explicit liber primus incipit secundus...",
362
+ "blocks": [],
363
+ "lines": [],
364
+ "language": "la",
365
+ "confidence": 0.74,
366
+ "uncertain_segments": ["primus incipit"]
367
+ },
368
+ "translation": {
369
+ "fr": "Fin du premier livre, début du second...",
370
+ "en": "End of the first book, beginning of the second..."
371
+ },
372
+ "summary": {
373
+ "short": "Page de transition entre deux livres avec scène apocalyptique.",
374
+ "detailed": "Ce folio marque la fin du livre I et l'ouverture du livre II..."
375
+ },
376
+ "commentary": {
377
+ "public": "Cette page illustre la transition narrative entre deux grandes parties...",
378
+ "scholarly": "Le programme iconographique de ce folio suit la tradition des Beatus...",
379
+ "claims": [
380
+ {
381
+ "claim": "La scène de la région r2 représente l'ouverture du cinquième sceau",
382
+ "evidence_region_ids": ["r2"],
383
+ "certainty": "medium"
384
+ }
385
+ ]
386
+ },
387
+ "extensions": {
388
+ "iconography": [
389
+ {
390
+ "region_id": "r2",
391
+ "label": "ouverture_cinquieme_sceau",
392
+ "description": "Personnages en prière, autel central, âmes des martyrs",
393
+ "confidence": 0.78,
394
+ "tags": ["apocalypse", "sceau", "martyrs", "autel"]
395
+ }
396
+ ],
397
+ "materiality": {
398
+ "notes": ["Légère décoloration dans la marge inférieure droite"],
399
+ "pigment_hints": ["ocre", "lapis-lazuli probable", "blanc de plomb"]
400
+ }
401
+ },
402
+ "processing": {
403
+ "provider": "vertex_api_key",
404
+ "model_id": "gemini-2.0-flash-exp",
405
+ "model_display_name": "Gemini 2.0 Flash Experimental",
406
+ "prompt_version": "primary_v1",
407
+ "raw_response_path": "data/corpora/beatus-lat8878/pages/0013r/ai_raw.json",
408
+ "processed_at": "2025-01-01T10:00:00Z",
409
+ "cost_estimate_usd": 0.004
410
+ },
411
+ "editorial": {
412
+ "status": "machine_draft",
413
+ "validated": false,
414
+ "validated_by": null,
415
+ "version": 1,
416
+ "notes": []
417
+ }
418
+ }
419
+
420
+ 6. Règles absolues — NE JAMAIS ENFREINDRE
421
+ R01 — Zéro logique hardcodée par corpus
422
+ python# ❌ INTERDIT
423
  if profile_id == "medieval-illuminated":
424
  process_iconography()
425
 
426
+ # ✅ CORRECT
427
  if "iconography_detection" in corpus_profile.active_layers:
428
  process_iconography()
429
+ R02 — Le JSON maître est la source canonique
430
+ Toutes les sorties (IIIF, ALTO, METS) sont générées depuis PageMaster.
431
+ Jamais depuis ai_raw.json directement.
432
+ R03 — Convention bbox [x, y, width, height] UNIQUEMENT
433
+ python# INTERDITcoordonnées de coins opposés
 
 
 
 
434
  bbox = [x1, y1, x2, y2]
435
 
436
+ # ✅ CORRECT — origine + dimensions
437
  bbox = [x, y, x2 - x1, y2 - y1]
438
+ Pixels entiers absolus dans l'image. Width et height > 0. Toujours validé par Pydantic.
439
+ R04 Prompts dans des fichiers versionnés, jamais dans le code
440
+ python# ❌ INTERDIT
441
+ prompt = f"Tu analyses un {profile.label}. Retourne ce JSON..."
442
+
443
+ # ✅ CORRECT
444
+ prompt = load_and_render_prompt(
445
+ corpus_profile.prompt_templates["primary"],
446
+ {"profile_label": profile.label, ...}
447
+ )
448
+ R05 Double stockage obligatoire des réponses IA
449
+ python# INTERDIT — un seul fichier
450
+ master = parse(response)
451
+ save(master, "master.json")
452
+
453
+ # ✅ CORRECT — toujours deux fichiers distincts
454
+ save_raw(response.text, page_dir / "ai_raw.json") # brut, jamais effacé
455
+ master = parse_and_validate(response.text)
456
+ save_json(master.model_dump(), page_dir / "master.json")
457
+ R06Secrets uniquement dans les variables d'environnement
458
+ Jamais dans le code, les logs, les fichiers versionnés, les exports JSON.
459
+ R07 — Pydantic v2 exclusivement
460
+ python# INTERDITsyntaxe v1
461
+ class Config:
462
+ frozen = True
463
+
464
+ # ✅ CORRECT — syntaxe v2
465
+ model_config = ConfigDict(frozen=True)
466
+ R08 Tests pour tout nouveau modèle
467
+ Aucun schéma Pydantic sans test de validation et de rejet.
468
+ R09 — schema_version dans tout PageMaster
469
+ schema_version: str = "1.0" obligatoire, valeur par défaut suffit.
470
+ R10 Endpoints préfixés /api/v1/
471
+ R11 SDK google-genai, pas google-generativeai
472
+ python# INTERDIT
473
+ import google.generativeai as genai
474
+
475
+ # ✅ CORRECT
476
+ from google import genai
477
+ R12 — Jamais le master TIFF/JP2 brut envoyé à l'IA
478
+ Toujours passer par le dérivé JPEG 1500px max.
479
+
480
+ 7. Patterns de code attendus
481
+ Config depuis variables d'environnement (config.py)
482
+ pythonfrom pydantic_settings import BaseSettings
483
+
484
+ class Settings(BaseSettings):
485
+ ai_provider: str = "vertex_api_key"
486
+ google_ai_studio_api_key: str | None = None
487
+ vertex_api_key: str | None = None
488
+ vertex_project_id: str | None = None
489
+ vertex_location: str = "europe-west1"
490
+ vertex_service_account_json: str | None = None
491
+ data_dir: str = "data"
492
+
493
+ model_config = ConfigDict(env_file=".env", extra="ignore")
494
+
495
+ settings = Settings()
496
+ Pattern SQLAlchemy (models/)
497
+ pythonfrom sqlalchemy import String, Integer, Float, DateTime, JSON
498
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
499
  from datetime import datetime
 
500
 
501
+ class Base(DeclarativeBase):
502
+ pass
503
+
504
+ class PageModel(Base):
505
+ __tablename__ = "pages"
506
+
507
+ id: Mapped[str] = mapped_column(String, primary_key=True)
508
+ manuscript_id: Mapped[str] = mapped_column(String, index=True)
509
+ folio_label: Mapped[str] = mapped_column(String)
510
+ sequence: Mapped[int] = mapped_column(Integer)
511
+ processing_status: Mapped[str] = mapped_column(String, default="ingested")
512
+ confidence_summary: Mapped[float | None] = mapped_column(Float, nullable=True)
513
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
514
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
515
+ Pattern FastAPI endpoint (api/v1/)
516
+ pythonfrom fastapi import APIRouter, HTTPException, Depends
517
+ from app.schemas.page_master import PageMaster
518
+
519
+ router = APIRouter(prefix="/api/v1")
520
+
521
+ @router.get("/pages/{page_id}/master-json", response_model=PageMaster)
522
+ async def get_master_json(page_id: str) -> PageMaster:
523
+ master_path = get_page_dir(page_id) / "master.json"
524
+ if not master_path.exists():
525
+ raise HTTPException(status_code=404, detail=f"Page {page_id} not found")
526
+ return PageMaster.model_validate_json(master_path.read_text())
527
+
528
+ @router.put("/pages/{page_id}/master-json", response_model=PageMaster)
529
+ async def update_master_json(page_id: str, master: PageMaster) -> PageMaster:
530
+ # incrémenter la version
531
+ master = master.model_copy(update={"editorial": {
532
+ **master.editorial.model_dump(),
533
+ "version": master.editorial.version + 1
534
+ }})
535
+ save_json(master.model_dump(), get_page_dir(page_id) / "master.json")
536
+ return master
537
+ Pattern gestion d'erreur IA
538
+ pythonimport json
539
  import logging
540
+ from pydantic import ValidationError
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
541
 
542
+ logger = logging.getLogger(__name__)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
543
 
544
+ def parse_ai_response(raw_text: str, page_id: str) -> PageMaster:
545
+ # 1. Nettoyer les éventuels blocs markdown (triple backtick json)
546
+ cleaned = raw_text.strip()
547
+ if cleaned.startswith("```"):
548
+ lines = cleaned.split("\n")
549
+ cleaned = "\n".join(lines[1:-1])
550
+
551
+ # 2. Parser le JSON
552
+ try:
553
+ data = json.loads(cleaned)
554
+ except json.JSONDecodeError as e:
555
+ logger.error("JSON invalide", extra={"page_id": page_id, "error": str(e)})
556
+ raise ValueError(f"Réponse IA non parseable pour {page_id}: {e}")
557
+
558
+ # 3. Valider avec Pydantic
559
+ try:
560
+ return PageMaster.model_validate(data)
561
+ except ValidationError as e:
562
+ logger.error("Validation Pydantic échouée", extra={"page_id": page_id, "errors": e.errors()})
563
+ raise ValueError(f"JSON IA invalide pour {page_id}: {e}")
564
+
565
+ 8. Rendu des prompts — conventions
566
+ Variables disponibles dans tous les templates
567
+ {{profile_label}} → CorpusProfile.label
568
+ {{language_hints}} → ", ".join(CorpusProfile.language_hints)
569
+ {{script_type}} → CorpusProfile.script_type.value
570
+ {{folio_label}} → Page.folio_label
571
+ {{manuscript_title}} → Manuscript.title (si disponible)
572
+ Implémentation attendue (prompt_loader.py)
573
+ pythonfrom pathlib import Path
574
+
575
+ def load_and_render_prompt(template_path: str, context: dict[str, str]) -> str:
576
+ """Charge un template de prompt et injecte les variables."""
577
+ path = Path(template_path)
578
+ if not path.exists():
579
+ raise FileNotFoundError(f"Template introuvable : {template_path}")
580
+
581
+ content = path.read_text(encoding="utf-8")
582
+
583
+ for key, value in context.items():
584
+ content = content.replace("{{" + key + "}}", str(value))
585
+
586
+ # Vérifier qu'il ne reste pas de variables non résolues
587
+ if "{{" in content:
588
+ import re
589
+ unresolved = re.findall(r"\{\{\w+\}\}", content)
590
+ raise ValueError(f"Variables non résolues dans le prompt : {unresolved}")
591
+
592
+ return content
593
+
594
+ 9. Providers Google AI — architecture à 3 options
595
+ Variables d'environnement (GitHub Secrets)
596
+ # Option A — Google AI Studio (développement, gratuit)
597
+ GOOGLE_AI_STUDIO_API_KEY = AIza...
598
+
599
+ # Option B — Vertex AI avec clé API Express (production)
600
+ VERTEX_API_KEY = AQ.Ab...
601
+ VERTEX_PROJECT_ID = beatus-490422
602
+ VERTEX_LOCATION = europe-west1
603
+
604
+ # Option C — Vertex AI avec compte de service (institutions)
605
+ VERTEX_SERVICE_ACCOUNT_JSON = { ...json complet... }
606
+ VERTEX_PROJECT_ID = (même)
607
+ VERTEX_LOCATION = (même)
608
+
609
+ # Sélecteur actif — changer pour switcher de provider
610
+ AI_PROVIDER = vertex_api_key
611
+ Factory client (client.py)
612
+ pythonfrom google import genai
613
+ import os, json, logging
614
 
615
+ logger = logging.getLogger(__name__)
616
 
617
+ def get_ai_client() -> genai.Client:
618
+ provider = os.environ.get("AI_PROVIDER", "google_ai_studio")
619
+ logger.info(f"Initialisation client IA", extra={"provider": provider})
620
+
621
+ if provider == "google_ai_studio":
622
+ # Option A — Google AI Studio, clé AIza
623
+ return genai.Client(
624
+ api_key=os.environ["GOOGLE_AI_STUDIO_API_KEY"]
625
+ )
626
+
627
+ elif provider == "vertex_api_key":
628
+ # Option B — Vertex Express, clé AQ.Ab
629
+ # SYNTAXE EXACTE À VALIDER EN SPRINT 2 SESSION A
630
+ # Tester approche 1 en premier :
631
+ return genai.Client(
632
+ api_key=os.environ["VERTEX_API_KEY"]
633
+ )
634
+ # Si approche 1 échoue, tester approche 2 :
635
+ # return genai.Client(
636
+ # api_key=os.environ["VERTEX_API_KEY"],
637
+ # http_options={"api_version": "v1beta"}
638
+ # )
639
+
640
+ elif provider == "vertex_service_account":
641
+ # Option C — Vertex avec compte de service JSON
642
+ creds_json = os.environ["VERTEX_SERVICE_ACCOUNT_JSON"]
643
+ creds_dict = json.loads(creds_json)
644
+ return genai.Client(
645
+ vertexai=True,
646
+ project=os.environ["VERTEX_PROJECT_ID"],
647
+ location=os.environ.get("VERTEX_LOCATION", "europe-west1"),
648
+ credentials=creds_dict,
649
+ )
650
+
651
+ raise ValueError(f"AI_PROVIDER inconnu : {provider!r}. "
652
+ "Valeurs acceptées : google_ai_studio, vertex_api_key, vertex_service_account")
653
+ Listage des modèles disponibles (models.py)
654
+ pythondef list_available_models(client: genai.Client) -> list[dict]:
655
+ """
656
+ Retourne les modèles disponibles supportant vision + generateContent.
657
+ Format : [{"id": str, "display_name": str, "supports_vision": bool}]
658
+ """
659
+ models = []
660
+ for model in client.models.list():
661
+ # Garder uniquement les modèles multimodaux
662
+ supported = getattr(model, "supported_generation_methods", [])
663
+ if "generateContent" not in supported:
664
+ continue
665
+ # Vérifier le support vision (input_token_limit et modalities)
666
+ modalities = getattr(model, "supported_actions", None) or []
667
+ supports_vision = "image" in str(model).lower() or "vision" in str(model.name).lower()
668
+ models.append({
669
+ "id": model.name,
670
+ "display_name": getattr(model, "display_name", model.name),
671
+ "supports_vision": supports_vision,
672
+ })
673
+ return models
674
+
675
+ 10. Structure des exports documentaires
676
+ ALTO (par page)
677
+ ALTO contient la géométrie textuelle uniquement.
678
+ xml<alto>
679
+ <Layout>
680
+ <Page WIDTH="{width}" HEIGHT="{height}" ID="{page_id}">
681
+ <PrintSpace>
682
+ <!-- Pour chaque région de type text_block -->
683
+ <TextBlock ID="{region.id}"
684
+ HPOS="{bbox[0]}" VPOS="{bbox[1]}"
685
+ WIDTH="{bbox[2]}" HEIGHT="{bbox[3]}">
686
+ <TextLine>
687
+ <String CONTENT="{text}" WC="{confidence}"/>
688
+ </TextLine>
689
+ </TextBlock>
690
+ <!-- Pour chaque région de type miniature -->
691
+ <Illustration ID="{region.id}"
692
+ HPOS="{bbox[0]}" VPOS="{bbox[1]}"
693
+ WIDTH="{bbox[2]}" HEIGHT="{bbox[3]}"/>
694
+ </PrintSpace>
695
+ </Page>
696
+ </Layout>
697
+ </alto>
698
+ ALTO ne porte PAS : commentaires savants, iconographie, couches éditoriales.
699
+ IIIF Manifest (par manuscrit)
700
+ Structure minimale V1 :
701
+ json{
702
+ "@context": "http://iiif.io/api/presentation/3/context.json",
703
+ "id": "https://{base_url}/api/v1/manuscripts/{id}/iiif-manifest",
704
+ "type": "Manifest",
705
+ "label": {"fr": ["{manuscript.title}"]},
706
+ "metadata": [],
707
+ "items": [
708
+ {
709
+ "id": "https://{base_url}/canvas/{page_id}",
710
+ "type": "Canvas",
711
+ "width": "{image.width}",
712
+ "height": "{image.height}",
713
+ "items": [{"type": "AnnotationPage", "items": [
714
+ {"type": "Annotation", "motivation": "painting",
715
+ "body": {"type": "Image", "id": "{image.derivative_web}",
716
+ "format": "image/jpeg",
717
+ "width": "{image.width}", "height": "{image.height}"},
718
+ "target": "https://{base_url}/canvas/{page_id}"}
719
+ ]}]
720
+ }
721
+ ]
722
+ }
723
+
724
+ 11. Statuts métier
725
+ Corpus : CREATED → INGESTING → INGESTED → PROCESSING → READY → ERROR
726
+ Page : INGESTED → PREPARED → ANALYZED → LAYERED → EXPORTED → VALIDATED → ERROR
727
+ Layer : PENDING → RUNNING → DONE → FAILED → NEEDS_REVIEW → VALIDATED
728
+ Éditorial: machine_draft → needs_review → reviewed → validated → published
729
+
730
+ 12. Endpoints API — liste complète
731
+ # Configuration & modèles IA
732
  POST /api/v1/settings/api-key
733
  GET /api/v1/models
734
  POST /api/v1/models/refresh
 
777
  # Recherche
778
  GET /api/v1/search?q=
779
  GET /api/v1/manuscripts/{id}/search?q=
 
 
 
 
 
780
 
781
+ 13. État du projet par sprint
782
+ Sprint 1 — Fondations du modèle de données [ TERMINÉ ]
783
+ 54 tests passants. Schémas Pydantic. 4 profils. 9 templates prompts.
784
 
785
+ Sprint 2 — Pipeline page unique [ EN COURS ]
786
+ Connexion Google AI validée ingestion image master.json
787
 
788
  Sprint 3 — Exports documentaires [ À FAIRE ]
789
+ ALTO par page + METS + Manifest IIIF
790
 
791
  Sprint 4 — API FastAPI + interface de lecture [ À FAIRE ]
792
+ Endpoints + visionneuse OpenSeadragon + 4 couches
793
 
794
  Sprint 5 — Traitement en lot + HuggingFace [ À FAIRE ]
795
+ Pipeline batch + déploiement public
796
 
797
  Sprint 6 — Validation humaine + V1 complète [ À FAIRE ]
798
+ Éditeur + versionnement + recherche
799
+ Règle stricte : ne jamais implémenter du code d'un sprint futur.
800
+ Si une idée émerge, la noter dans STATUS.md section "Backlog" et ne pas la coder.
801
+
802
+ 14. Ce que tu NE dois PAS faire sans demande explicite
803
+
804
+ Modifier le schéma PageMaster (champs, types, noms, structure)
805
+ Modifier la convention bbox
806
+ Ajouter des dépendances non listées dans pyproject.toml section 2
807
+ Refactoriser du code existant si la session n'a pas ce but
808
+ Créer des fichiers hors de l'arborescence section 3
809
+ Implémenter du code d'un sprint futur (section 13)
810
+ "Simplifier" un schéma pour "faire plus propre" — les schémas sont figés
811
+ Créer une logique spécifique à un corpus (règle R01)
812
+ Utiliser google-generativeai au lieu de google-genai (règle R11)
813
+ Laisser une variable d'environnement dans le code (règle R06)