maribakulj commited on
Commit
bf9b809
·
unverified ·
2 Parent(s): 2d76892daf8420

Merge branch 'main' into claude/review-and-plan-r20qn

Browse files
Files changed (2) hide show
  1. CLAUDE.md +606 -305
  2. STATUS.md +182 -27
CLAUDE.md CHANGED
@@ -1,4 +1,7 @@
1
  # Scriptorium AI — Instructions permanentes pour Claude Code
 
 
 
2
 
3
  ## 1. Contexte du projet
4
 
@@ -10,26 +13,60 @@ 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
 
@@ -38,34 +75,64 @@ Le Beatus est un profil parmi d'autres.
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,22 +148,25 @@ 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
 
@@ -104,18 +174,16 @@ scriptorium-ai/
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 +215,23 @@ 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 +250,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 +277,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 +292,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,28 +315,24 @@ 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):
@@ -275,297 +352,522 @@ 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-patternsce 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
- # ✅ CORRECT — prompt 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
- # CORRECT depuis 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. Providers IAdétection dynamique multi-provider
484
 
485
- Les clés API vivent dans les **secrets HuggingFace** (variables d'environnement).
486
- L'interface ne demande **jamais** de clé à l'utilisateur (R06).
487
- Il n'y a **pas** de variable `AI_PROVIDER` globale : le provider est choisi par corpus.
488
 
489
- Secrets HuggingFace à configurer :
490
  ```
491
- GOOGLE_AI_STUDIO_API_KEY = AIza...
492
- VERTEX_API_KEY = AQ.Ab...
493
- VERTEX_SERVICE_ACCOUNT_JSON = {...} # JSON complet du compte de service
494
- MISTRAL_API_KEY = ...
495
- BASE_URL = https://ma-ri-ba-ku-scriptorium-ai.hf.space
496
- ```
497
-
498
- ### 4 providers supportés
499
 
500
- | Provider | Variable d'env | Modèles |
501
- |---------------------|-----------------------------|----------------------------------|
502
- | Google AI Studio | `GOOGLE_AI_STUDIO_API_KEY` | Gemini (liste dynamique via API) |
503
- | Vertex AI clé API | `VERTEX_API_KEY` | Gemini (liste dynamique via API) |
504
- | Vertex Compte serv. | `VERTEX_SERVICE_ACCOUNT_JSON` | Gemini (liste dynamique via API) |
505
- | Mistral AI | `MISTRAL_API_KEY` | pixtral-large-latest, pixtral-12b-2409 (liste statique) |
506
 
507
- ### Flux de sélection
 
 
 
508
 
509
- 1. Au démarrage, le backend détecte automatiquement quels providers sont
510
- disponibles selon les clés présentes → `GET /api/v1/providers`
511
- 2. L'interface affiche chaque provider avec badge Disponible / Clé manquante
512
- 3. L'utilisateur clique sur un provider disponible → charge ses modèles
513
- → `GET /api/v1/providers/{provider_type}/models`
514
- 4. L'utilisateur sélectionne un modèle → `PUT /api/v1/corpora/{id}/model`
515
- 5. Modèle stocké dans `ModelConfig` par corpus (pas dans CorpusProfile)
516
- 6. Chaque appel IA journalise `provider`, `model_id`, `model_display_name`
517
 
518
- ### Entité `ModelConfig` (par corpus)
519
 
520
  ```python
521
- class ModelConfig(BaseModel):
522
- corpus_id: str
523
- selected_model_id: str
524
- selected_model_display_name: str
525
- provider: ProviderType # google_ai_studio | vertex_api_key | vertex_service_account | mistral
526
- supports_vision: bool
527
- last_fetched_at: datetime
528
- available_models: list[dict] # cache sérialisé des ModelInfo
529
- ```
530
 
531
- ### Interface `AIProvider` (backend/app/services/ai/base.py)
532
 
533
- Chaque provider implémente :
534
- - `is_configured() → bool`
535
- - `list_models() list[ModelInfo]`
536
- - `generate_content(image_bytes, prompt, model_id) → str`
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
537
 
538
- L'analyseur (`analyzer.py`) appelle `get_provider(model_config.provider).generate_content(…)`
539
- de façon identique pour tous les providers.
540
 
541
- ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
542
 
543
- ## 10. Statuts métier
544
 
545
- ### Corpus / Page
546
- ```
547
- CREATED INGESTING → INGESTED → PROCESSING → READY → ERROR
548
- INGESTED → PREPARED → ANALYZED → LAYERED → EXPORTED → VALIDATED → ERROR
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
549
  ```
550
 
551
- ### Couche (AnnotationLayer)
552
- ```
553
- PENDING RUNNING DONE → FAILED → NEEDS_REVIEW → VALIDATED
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
554
  ```
555
 
556
- ### Éditorial (PageMaster.editorial.status)
 
 
 
557
  ```
558
- machine_draftneeds_reviewreviewedvalidatedpublished
 
 
 
559
  ```
560
 
561
  ---
562
 
563
- ## 11. Endpoints API — liste complète
564
 
565
  ```
566
- # Providers & modèles (détection automatique depuis les secrets HF)
567
- GET /api/v1/providers
568
- GET /api/v1/providers/{provider_type}/models
569
  POST /api/v1/models/refresh
570
  PUT /api/v1/corpora/{id}/model
571
  GET /api/v1/corpora/{id}/model
@@ -616,43 +918,42 @@ GET /api/v1/manuscripts/{id}/search?q=
616
 
617
  ---
618
 
619
- ## 12. État du projet par sprint
620
 
621
  ```
622
- Sprint 1 — Fondations du modèle de données [ EN COURS ]
623
- Schémas Pydantic + tests pytest + profils JSON + templates prompts
624
 
625
- Sprint 2 — Pipeline page unique [ À FAIRE ]
626
- Ingestion + appel Google AI + master.json
627
 
628
- Sprint 3 — Exports documentaires [ À FAIRE ]
629
- ALTO + METS + Manifest IIIF
630
 
631
- Sprint 4 — API FastAPI + interface de lecture [ À FAIRE ]
632
- Endpoints + visionneuse + 4 couches
633
 
634
- Sprint 5 — Traitement en lot + HuggingFace [ À FAIRE ]
635
- Pipeline batch + déploiement public
636
 
637
- Sprint 6 — Validation humaine + V1 complète [ À FAIRE ]
638
- Éditeur + versionnement + recherche
639
  ```
640
 
641
- **Règle :** ne jamais implémenter du code appartenant à un sprint ultérieur
642
- au sprint en cours. Si une idée émerge pour un sprint futur, la noter
643
- dans TODO.md section "Backlog" et ne pas la coder.
644
 
645
  ---
646
 
647
- ## 13. Ce que tu NE dois PAS faire sans demande explicite
648
 
649
  - Modifier le schéma PageMaster (champs, types, noms, structure)
650
  - Modifier la convention bbox
651
- - Ajouter des dépendances non listées dans pyproject.toml
652
- - Refactoriser du code existant si la session n'a pas ce but explicite
653
- - Créer des fichiers hors de l'arborescence définie section 3
654
- - Implémenter du code de sprint futur (voir section 12)
655
- - Simplifier un schéma pour "faire plus propre" — les schémas sont figés
656
- - Changer une règle listée en section 5 pour une raison de commodité
657
- - Utiliser une librairie alternative à celles listées section 2
658
- - 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
+ ---
5
 
6
  ## 1. Contexte du projet
7
 
 
13
  images sources → ingestion → normalisation → analyse Google AI → JSON maître
14
  → passes dérivées → ALTO / METS / Manifest IIIF → interface web → validation humaine
15
 
16
+ Premier démonstrateur : **Beatus de Saint-Sever** (BnF Latin 8878, manuscrit enluminé,
17
+ latin carolingien, XIe siècle). Le Beatus est un profil parmi d'autres pas un cas spécial.
 
18
 
19
  ---
20
 
21
  ## 2. Stack technique
22
 
23
+ | Composant | Technologie |
24
+ |-----------------|--------------------------------------------------------|
25
+ | Backend | Python 3.11+, FastAPI, Uvicorn |
26
+ | Validation | Pydantic v2 (JAMAIS v1) |
27
+ | Base de données | SQLite via SQLAlchemy 2.0 async + aiosqlite |
28
+ | IA | Google AI provider sélectionnable (section 9) |
29
+ | SDK Google | google-genai (PAS google-generativeai paquet différent)|
30
+ | XML | lxml |
31
+ | Images | Pillow (PIL) |
32
+ | HTTP client | httpx (téléchargement images IIIF) |
33
+ | Tests | pytest, pytest-cov, pytest-asyncio |
34
+ | Frontend | React + Vite, TypeScript, Tailwind CSS (sprint 4+) |
35
+ | Hébergement | HuggingFace Spaces (Docker) + HF Datasets |
36
+
37
+ ### pyproject.toml — dépendances exactes
38
+
39
+ ```toml
40
+ [project]
41
+ name = "scriptorium-ai"
42
+ version = "0.1.0"
43
+ requires-python = ">=3.11"
44
+
45
+ dependencies = [
46
+ "pydantic>=2.0",
47
+ "pydantic-settings>=2.0",
48
+ "fastapi>=0.104",
49
+ "uvicorn>=0.24",
50
+ "python-multipart>=0.0.6",
51
+ "google-genai>=0.3",
52
+ "lxml>=4.9",
53
+ "Pillow>=10.0",
54
+ "httpx>=0.25",
55
+ "sqlalchemy>=2.0",
56
+ "aiosqlite>=0.19",
57
+ ]
58
+
59
+ [project.optional-dependencies]
60
+ dev = [
61
+ "pytest>=7.0",
62
+ "pytest-cov>=4.0",
63
+ "pytest-asyncio>=0.21",
64
+ ]
65
+
66
+ [tool.pytest.ini_options]
67
+ testpaths = ["tests"]
68
+ asyncio_mode = "auto"
69
+ ```
70
 
71
  ---
72
 
 
75
  ```
76
  scriptorium-ai/
77
 
78
+ ├── CLAUDE.md ← CE FICHIER — ne pas modifier sans instruction
79
+ ├── STATUS.md ← état courant (mis à jour avant chaque session)
 
 
80
 
81
  ├── backend/
82
  │ ├── app/
83
+ │ │ ├── __init__.py
84
+ │ │ ├── main.py ← point d'entrée FastAPI (sprint 4+)
85
+ │ │ ├── config.py ← settings Pydantic depuis env vars
86
  │ │ ├── api/
87
+ │ │ │ └── v1/
88
+ │ │ │ ├── __init__.py
89
+ │ │ │ ├── corpora.py
90
+ │ │ │ ├── pages.py
91
+ │ │ │ ├── jobs.py
92
+ │ │ │ ├── models.py ← endpoints sélection modèle IA
93
+ │ │ │ └── export.py
94
  │ │ ├── models/ ← modèles SQLAlchemy (tables BDD)
 
95
  │ │ │ ├── __init__.py
96
+ │ │ │ ├── corpus.py
97
+ │ │ │ ├── page.py
98
+ │ │ │ └── job.py
99
+ │ │ ├── schemas/ ← modèles Pydantic (SOURCE CANONIQUE)
100
+ │ │ │ ├── __init__.py
101
+ │ │ │ ├── corpus_profile.py ← ✓ Sprint 1
102
+ │ │ │ ├── page_master.py ← ✓ Sprint 1
103
+ │ │ │ └── annotation.py ← ✓ Sprint 1
104
  │ │ └── services/
105
+ │ │ ├── __init__.py
106
+ │ │ ├── ingest/
107
+ │ │ ├── __init__.py
108
+ │ │ │ └── image_loader.py chargement images (URL/fichier)
109
+ │ │ ── image/
110
+ │ │ │ ├── __init__.py
111
+ │ │ │ └── processor.py ← dérivés + thumbnails
112
+ │ │ ├── ai/
113
+ │ │ │ ├── __init__.py
114
+ │ │ │ ├── client.py ← factory provider A/B/C
115
+ │ │ │ ├── models.py ← listage modèles disponibles
116
+ │ │ │ ├── prompt_loader.py ← chargement + rendu templates
117
+ │ │ │ └── pipeline.py ← orchestration appels IA
118
+ │ │ ├── export/
119
+ │ │ │ ├── __init__.py
120
+ │ │ │ ├── alto.py ← générateur ALTO (sprint 3+)
121
+ │ │ │ ├── mets.py ← générateur METS (sprint 3+)
122
+ │ │ │ └── iiif.py ← générateur manifest IIIF (sprint 3+)
123
+ │ │ └── search/
124
+ │ │ ├── __init__.py
125
+ │ │ └── index.py ← index recherche (sprint 6+)
126
  │ ├── tests/
127
  │ │ ├── __init__.py
128
+ │ │ ├── test_schemas.py ← ✓ 26 tests Sprint 1
129
+ │ │ ── test_profiles.py ← ✓ 28 tests Sprint 1
130
+ │ │ ├── test_ai_connection.py ← Sprint 2 Session A
131
+ │ │ ├── test_image_processing.py ← Sprint 2 Session B
132
+ │ │ └── test_pipeline.py ← Sprint 2 Session C
133
  │ └── pyproject.toml
134
 
135
+ ├── prompts/ ← ✓ Sprint 1
136
  │ ├── medieval-illuminated/
137
  │ │ ├── primary_v1.txt
138
  │ │ ├── transcription_v1.txt
 
148
  │ └── modern-handwritten/
149
  │ └── primary_v1.txt
150
 
151
+ ├── profiles/ ← ✓ Sprint 1
152
  │ ├── medieval-illuminated.json
153
  │ ├── medieval-textual.json
154
  │ ├── early-modern-print.json
155
  │ └── modern-handwritten.json
156
 
157
+ ├── data/ ← JAMAIS versionné (.gitignore)
158
  │ └── corpora/
159
  │ └── {corpus_slug}/
160
+ │ ├── masters/ ← images sources originales
161
+ │ ├── derivatives/ ← JPEG 1500px pour l'IA
162
+ │ ├── thumbnails/ ← aperçus 300px
163
  │ ├── iiif/
164
+ │ │ ├── manifest.json
165
+ │ │ └── annotations/
166
  │ └── pages/
167
+ │ └── {folio_label}/
168
+ │ ├── master.json ← PageMaster canonique
169
+ │ ├── ai_raw.json ← réponse brute IA (JAMAIS effacée)
170
  │ ├── alto.xml
171
  │ └── annotations.json
172
 
 
174
  └── Dockerfile
175
  ```
176
 
 
 
177
  ---
178
 
179
+ ## 4. Modèles de données — schémas Pydantic canoniques
 
 
180
 
181
+ ### 4.1 CorpusProfile (corpus_profile.py)
 
182
 
183
  ```python
184
+ from enum import Enum
185
+ from pydantic import BaseModel, ConfigDict, Field
186
+
187
  class LayerType(str, Enum):
188
  IMAGE = "image"
189
  OCR_DIPLOMATIC = "ocr_diplomatic"
 
215
 
216
  class CorpusProfile(BaseModel):
217
  model_config = ConfigDict(frozen=True)
 
218
  profile_id: str
219
  label: str
220
  language_hints: list[str]
221
  script_type: ScriptType
222
  active_layers: list[LayerType]
223
+ prompt_templates: dict[str, str] # {"primary": "prompts/.../v1.txt"}
224
  uncertainty_config: UncertaintyConfig
225
  export_config: ExportConfig
226
  ```
227
 
228
+ ### 4.2 PageMaster (page_master.py)
 
 
 
229
 
230
  ```python
231
+ from datetime import datetime
232
+ from typing import Any, Literal
233
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
234
+
235
  class RegionType(str, Enum):
236
  TEXT_BLOCK = "text_block"
237
  MINIATURE = "miniature"
 
250
 
251
  @field_validator("bbox")
252
  @classmethod
253
+ def bbox_must_be_valid(cls, v: list[int]) -> list[int]:
254
  if any(x < 0 for x in v):
255
+ raise ValueError("bbox: toutes les valeurs doivent être >= 0")
256
  if v[2] <= 0 or v[3] <= 0:
257
+ raise ValueError("bbox: width et height doivent être > 0")
258
  return v
259
 
260
+ class ImageInfo(BaseModel):
261
+ master: str # path ou URL source
262
+ derivative_web: str | None = None # JPEG 1500px
263
+ thumbnail: str | None = None # JPEG 300px
264
+ iiif_base: str | None = None
265
+ width: int
266
+ height: int
267
+
268
  class OCRResult(BaseModel):
269
  diplomatic_text: str = ""
270
  blocks: list[dict] = []
 
277
  fr: str = ""
278
  en: str = ""
279
 
280
+ class Summary(BaseModel):
281
+ short: str = ""
282
+ detailed: str = ""
283
+
284
  class CommentaryClaim(BaseModel):
285
  claim: str
286
  evidence_region_ids: list[str] = []
 
292
  claims: list[CommentaryClaim] = []
293
 
294
  class ProcessingInfo(BaseModel):
295
+ provider: str # "google_ai_studio"|"vertex_api_key"|"vertex_service_account"
296
+ model_id: str # ID technique retourné par l'API
297
  model_display_name: str
298
+ prompt_version: str # ex: "primary_v1"
299
+ raw_response_path: str # chemin vers ai_raw.json
300
  processed_at: datetime
301
  cost_estimate_usd: float | None = None
302
 
 
315
  notes: list[str] = []
316
 
317
  class PageMaster(BaseModel):
318
+ schema_version: str = "1.0" # OBLIGATOIRE — ne jamais omettre
319
+ page_id: str # format: {corpus_slug}-{folio_label}
320
+ corpus_profile: str # profile_id du CorpusProfile utilisé
321
  manuscript_id: str
322
+ folio_label: str # ex: "13r", "f29"
323
+ sequence: int # ordre dans le manuscrit (1-based)
324
+ image: ImageInfo
325
+ layout: dict # {"regions": [Region, ...]}
 
326
  ocr: OCRResult | None = None
327
  translation: Translation | None = None
328
+ summary: Summary | None = None
329
  commentary: Commentary | None = None
330
+ extensions: dict[str, Any] = {} # données spécifiques au profil
 
331
  processing: ProcessingInfo | None = None
332
  editorial: EditorialInfo = EditorialInfo()
333
  ```
334
 
335
+ ### 4.3 AnnotationLayer (annotation.py)
 
 
336
 
337
  ```python
338
  class LayerStatus(str, Enum):
 
352
  source_model: str | None = None
353
  prompt_version: str | None = None
354
  created_at: datetime
355
+
356
+ class ModelConfig(BaseModel):
357
+ corpus_id: str
358
+ provider: str
359
+ selected_model_id: str
360
+ selected_model_display_name: str
361
+ supports_vision: bool
362
+ last_fetched_at: datetime
363
+ available_models: list[dict] = []
364
  ```
365
 
366
  ---
367
 
368
+ ## 5. Exemple complet d'un master.json valide
369
+
370
+ Cet exemple est la référence. Tout master.json produit doit avoir cette forme.
371
+
372
+ ```json
373
+ {
374
+ "schema_version": "1.0",
375
+ "page_id": "beatus-lat8878-0013r",
376
+ "corpus_profile": "medieval-illuminated",
377
+ "manuscript_id": "beatus-lat8878",
378
+ "folio_label": "13r",
379
+ "sequence": 25,
380
+ "image": {
381
+ "master": "https://gallica.bnf.fr/ark:/12148/btv1b8432314s/f29.highres",
382
+ "derivative_web": "data/corpora/beatus-lat8878/derivatives/0013r.jpg",
383
+ "thumbnail": "data/corpora/beatus-lat8878/thumbnails/0013r.jpg",
384
+ "iiif_base": null,
385
+ "width": 3543,
386
+ "height": 4724
387
+ },
388
+ "layout": {
389
+ "regions": [
390
+ {
391
+ "id": "r1",
392
+ "type": "text_block",
393
+ "bbox": [320, 510, 2900, 3200],
394
+ "confidence": 0.91,
395
+ "polygon": null,
396
+ "parent_region_id": null
397
+ },
398
+ {
399
+ "id": "r2",
400
+ "type": "miniature",
401
+ "bbox": [320, 3750, 2900, 800],
402
+ "confidence": 0.95,
403
+ "polygon": null,
404
+ "parent_region_id": null
405
+ }
406
+ ]
407
+ },
408
+ "ocr": {
409
+ "diplomatic_text": "Explicit liber primus incipit secundus...",
410
+ "blocks": [],
411
+ "lines": [],
412
+ "language": "la",
413
+ "confidence": 0.74,
414
+ "uncertain_segments": ["primus incipit"]
415
+ },
416
+ "translation": {
417
+ "fr": "Fin du premier livre, début du second...",
418
+ "en": "End of the first book, beginning of the second..."
419
+ },
420
+ "summary": {
421
+ "short": "Page de transition entre deux livres avec scène apocalyptique.",
422
+ "detailed": "Ce folio marque la fin du livre I et l'ouverture du livre II..."
423
+ },
424
+ "commentary": {
425
+ "public": "Cette page illustre la transition narrative entre deux grandes parties...",
426
+ "scholarly": "Le programme iconographique de ce folio suit la tradition des Beatus...",
427
+ "claims": [
428
+ {
429
+ "claim": "La scène de la région r2 représente l'ouverture du cinquième sceau",
430
+ "evidence_region_ids": ["r2"],
431
+ "certainty": "medium"
432
+ }
433
+ ]
434
+ },
435
+ "extensions": {
436
+ "iconography": [
437
+ {
438
+ "region_id": "r2",
439
+ "label": "ouverture_cinquieme_sceau",
440
+ "description": "Personnages en prière, autel central, âmes des martyrs",
441
+ "confidence": 0.78,
442
+ "tags": ["apocalypse", "sceau", "martyrs", "autel"]
443
+ }
444
+ ],
445
+ "materiality": {
446
+ "notes": ["Légère décoloration dans la marge inférieure droite"],
447
+ "pigment_hints": ["ocre", "lapis-lazuli probable", "blanc de plomb"]
448
+ }
449
+ },
450
+ "processing": {
451
+ "provider": "vertex_api_key",
452
+ "model_id": "gemini-2.0-flash-exp",
453
+ "model_display_name": "Gemini 2.0 Flash Experimental",
454
+ "prompt_version": "primary_v1",
455
+ "raw_response_path": "data/corpora/beatus-lat8878/pages/0013r/ai_raw.json",
456
+ "processed_at": "2025-01-01T10:00:00Z",
457
+ "cost_estimate_usd": 0.004
458
+ },
459
+ "editorial": {
460
+ "status": "machine_draft",
461
+ "validated": false,
462
+ "validated_by": null,
463
+ "version": 1,
464
+ "notes": []
465
+ }
466
+ }
467
+ ```
468
 
469
  ---
470
 
471
+ ## 6. Règles absolues NE JAMAIS ENFREINDRE
472
 
473
+ ### R01 — Zéro logique hardcodée par corpus
474
  ```python
475
+ # ❌ INTERDIT
476
  if profile_id == "medieval-illuminated":
477
  process_iconography()
478
 
479
+ # ✅ CORRECT
480
  if "iconography_detection" in corpus_profile.active_layers:
481
  process_iconography()
482
+ ```
483
 
484
+ ### R02Le JSON maître est la source canonique
485
+ Toutes les sorties (IIIF, ALTO, METS) sont générées depuis PageMaster.
486
+ Jamais depuis ai_raw.json directement.
 
 
 
487
 
488
+ ### R03Convention bbox [x, y, width, height] UNIQUEMENT
489
+ ```python
490
+ # ❌ INTERDIT — coordonnées de coins opposés
491
  bbox = [x1, y1, x2, y2]
492
 
493
+ # ✅ CORRECT — origine + dimensions
494
  bbox = [x, y, x2 - x1, y2 - y1]
495
+ ```
496
+ Pixels entiers absolus dans l'image. Width et height > 0. Toujours validé par Pydantic.
497
 
498
+ ### R04Prompts dans des fichiers versionnés, jamais dans le code
499
+ ```python
500
+ # ❌ INTERDIT
501
+ prompt = f"Tu analyses un {profile.label}. Retourne ce JSON..."
502
+
503
+ # ✅ CORRECT
504
+ prompt = load_and_render_prompt(
505
+ corpus_profile.prompt_templates["primary"],
506
+ {"profile_label": profile.label, ...}
507
+ )
508
+ ```
509
 
510
+ ### R05Double stockage obligatoire des réponses IA
511
+ ```python
512
+ # INTERDIT — un seul fichier
513
+ master = parse(response)
514
+ save(master, "master.json")
515
+
516
+ # ✅ CORRECT — toujours deux fichiers distincts
517
+ save_raw(response.text, page_dir / "ai_raw.json") # brut, jamais effacé
518
+ master = parse_and_validate(response.text)
519
+ save_json(master.model_dump(), page_dir / "master.json")
520
+ ```
521
+
522
+ ### R06 — Secrets uniquement dans les variables d'environnement
523
+ Jamais dans le code, les logs, les fichiers versionnés, les exports JSON.
524
 
525
+ ### R07Pydantic v2 exclusivement
526
+ ```python
527
+ # ❌ INTERDIT — syntaxe v1
528
+ class Config:
529
+ frozen = True
530
+
531
+ # ✅ CORRECT — syntaxe v2
532
+ model_config = ConfigDict(frozen=True)
533
+ ```
534
 
535
+ ### R08Tests pour tout nouveau modèle
536
+ Aucun schéma Pydantic sans test de validation et de rejet.
 
 
537
 
538
+ ### R09schema_version dans tout PageMaster
539
+ `schema_version: str = "1.0"` — obligatoire, valeur par défaut suffit.
540
 
541
+ ### R10Endpoints préfixés /api/v1/
542
+
543
+ ### R11 — SDK google-genai, pas google-generativeai
544
+ ```python
545
+ # ❌ INTERDIT
546
+ import google.generativeai as genai
547
+
548
+ # ✅ CORRECT
549
+ from google import genai
550
  ```
551
 
552
+ ### R12 — Jamais le master TIFF/JP2 brut envoyé à l'IA
553
+ Toujours passer par le dérivé JPEG 1500px max.
554
+
555
  ---
556
 
557
+ ## 7. Patterns de code attendus
558
 
559
+ ### Config depuis variables d'environnement (config.py)
 
 
 
 
 
 
560
 
 
561
  ```python
562
+ from pydantic_settings import BaseSettings
563
+
564
+ class Settings(BaseSettings):
565
+ ai_provider: str = "vertex_api_key"
566
+ google_ai_studio_api_key: str | None = None
567
+ vertex_api_key: str | None = None
568
+ vertex_project_id: str | None = None
569
+ vertex_location: str = "europe-west1"
570
+ vertex_service_account_json: str | None = None
571
+ data_dir: str = "data"
572
+
573
+ model_config = ConfigDict(env_file=".env", extra="ignore")
574
+
575
+ settings = Settings()
576
+ ```
577
+
578
+ ### Pattern SQLAlchemy (models/)
579
+
580
+ ```python
581
+ from sqlalchemy import String, Integer, Float, DateTime, JSON
582
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
583
  from datetime import datetime
 
584
 
585
+ class Base(DeclarativeBase):
586
+ pass
587
 
588
+ class PageModel(Base):
589
+ __tablename__ = "pages"
590
+
591
+ id: Mapped[str] = mapped_column(String, primary_key=True)
592
+ manuscript_id: Mapped[str] = mapped_column(String, index=True)
593
+ folio_label: Mapped[str] = mapped_column(String)
594
+ sequence: Mapped[int] = mapped_column(Integer)
595
+ processing_status: Mapped[str] = mapped_column(String, default="ingested")
596
+ confidence_summary: Mapped[float | None] = mapped_column(Float, nullable=True)
597
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
598
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
599
  ```
600
 
601
+ ### Pattern FastAPI endpoint (api/v1/)
602
+
603
  ```python
604
+ from fastapi import APIRouter, HTTPException, Depends
605
+ from app.schemas.page_master import PageMaster
606
+
607
+ router = APIRouter(prefix="/api/v1")
608
+
609
+ @router.get("/pages/{page_id}/master-json", response_model=PageMaster)
610
+ async def get_master_json(page_id: str) -> PageMaster:
611
+ master_path = get_page_dir(page_id) / "master.json"
612
+ if not master_path.exists():
613
+ raise HTTPException(status_code=404, detail=f"Page {page_id} not found")
614
+ return PageMaster.model_validate_json(master_path.read_text())
615
+
616
+ @router.put("/pages/{page_id}/master-json", response_model=PageMaster)
617
+ async def update_master_json(page_id: str, master: PageMaster) -> PageMaster:
618
+ # incrémenter la version
619
+ master = master.model_copy(update={"editorial": {
620
+ **master.editorial.model_dump(),
621
+ "version": master.editorial.version + 1
622
+ }})
623
+ save_json(master.model_dump(), get_page_dir(page_id) / "master.json")
624
+ return master
625
+ ```
626
 
627
+ ### Pattern gestion d'erreur IA
628
+
629
+ ```python
630
+ import json
631
  import logging
632
+ from pydantic import ValidationError
633
+
634
  logger = logging.getLogger(__name__)
 
635
 
636
+ def parse_ai_response(raw_text: str, page_id: str) -> PageMaster:
637
+ # 1. Nettoyer les éventuels blocs markdown (triple backtick json)
638
+ cleaned = raw_text.strip()
639
+ if cleaned.startswith("```"):
640
+ lines = cleaned.split("\n")
641
+ cleaned = "\n".join(lines[1:-1])
642
+
643
+ # 2. Parser le JSON
644
+ try:
645
+ data = json.loads(cleaned)
646
+ except json.JSONDecodeError as e:
647
+ logger.error("JSON invalide", extra={"page_id": page_id, "error": str(e)})
648
+ raise ValueError(f"Réponse IA non parseable pour {page_id}: {e}")
649
+
650
+ # 3. Valider avec Pydantic
651
+ try:
652
+ return PageMaster.model_validate(data)
653
+ except ValidationError as e:
654
+ logger.error("Validation Pydantic échouée", extra={"page_id": page_id, "errors": e.errors()})
655
+ raise ValueError(f"JSON IA invalide pour {page_id}: {e}")
656
  ```
657
 
 
 
 
 
 
658
  ---
659
 
660
+ ## 8. Rendu des prompts conventions
661
+
662
+ ### Variables disponibles dans tous les templates
663
 
664
  ```
665
+ {{profile_label}} → CorpusProfile.label
666
+ {{language_hints}} → ", ".join(CorpusProfile.language_hints)
667
+ {{script_type}} → CorpusProfile.script_type.value
668
+ {{folio_label}} → Page.folio_label
669
+ {{manuscript_title}} → Manuscript.title (si disponible)
670
+ ```
671
 
672
+ ### Implémentation attendue (prompt_loader.py)
 
 
 
 
673
 
674
+ ```python
675
+ from pathlib import Path
676
+
677
+ def load_and_render_prompt(template_path: str, context: dict[str, str]) -> str:
678
+ """Charge un template de prompt et injecte les variables."""
679
+ path = Path(template_path)
680
+ if not path.exists():
681
+ raise FileNotFoundError(f"Template introuvable : {template_path}")
682
+
683
+ content = path.read_text(encoding="utf-8")
684
 
685
+ for key, value in context.items():
686
+ content = content.replace("{{" + key + "}}", str(value))
 
 
 
 
687
 
688
+ # Vérifier qu'il ne reste pas de variables non résolues
689
+ if "{{" in content:
690
+ import re
691
+ unresolved = re.findall(r"\{\{\w+\}\}", content)
692
+ raise ValueError(f"Variables non résolues dans le prompt : {unresolved}")
 
693
 
694
+ return content
 
 
 
695
  ```
696
 
697
  ---
698
 
699
+ ## 9. Providers Google AI architecture à 3 options
700
 
701
+ ### Variables d'environnement (GitHub Secrets)
 
 
702
 
 
703
  ```
704
+ # Option A — Google AI Studio (développement, gratuit)
705
+ GOOGLE_AI_STUDIO_API_KEY = AIza...
 
 
 
 
 
 
706
 
707
+ # Option B Vertex AI avec clé API Express (production)
708
+ VERTEX_API_KEY = AQ.Ab...
709
+ VERTEX_PROJECT_ID = beatus-490422
710
+ VERTEX_LOCATION = europe-west1
 
 
711
 
712
+ # Option C — Vertex AI avec compte de service (institutions)
713
+ VERTEX_SERVICE_ACCOUNT_JSON = { ...json complet... }
714
+ VERTEX_PROJECT_ID = (même)
715
+ VERTEX_LOCATION = (même)
716
 
717
+ # Sélecteur actif changer pour switcher de provider
718
+ AI_PROVIDER = vertex_api_key
719
+ ```
 
 
 
 
 
720
 
721
+ ### Factory client (client.py)
722
 
723
  ```python
724
+ from google import genai
725
+ import os, json, logging
 
 
 
 
 
 
 
726
 
727
+ logger = logging.getLogger(__name__)
728
 
729
+ def get_ai_client() -> genai.Client:
730
+ provider = os.environ.get("AI_PROVIDER", "google_ai_studio")
731
+ logger.info(f"Initialisation client IA", extra={"provider": provider})
732
+
733
+ if provider == "google_ai_studio":
734
+ # Option A — Google AI Studio, clé AIza
735
+ return genai.Client(
736
+ api_key=os.environ["GOOGLE_AI_STUDIO_API_KEY"]
737
+ )
738
+
739
+ elif provider == "vertex_api_key":
740
+ # Option B — Vertex Express, clé AQ.Ab
741
+ # SYNTAXE EXACTE À VALIDER EN SPRINT 2 SESSION A
742
+ # Tester approche 1 en premier :
743
+ return genai.Client(
744
+ api_key=os.environ["VERTEX_API_KEY"]
745
+ )
746
+ # Si approche 1 échoue, tester approche 2 :
747
+ # return genai.Client(
748
+ # api_key=os.environ["VERTEX_API_KEY"],
749
+ # http_options={"api_version": "v1beta"}
750
+ # )
751
+
752
+ elif provider == "vertex_service_account":
753
+ # Option C — Vertex avec compte de service JSON
754
+ creds_json = os.environ["VERTEX_SERVICE_ACCOUNT_JSON"]
755
+ creds_dict = json.loads(creds_json)
756
+ return genai.Client(
757
+ vertexai=True,
758
+ project=os.environ["VERTEX_PROJECT_ID"],
759
+ location=os.environ.get("VERTEX_LOCATION", "europe-west1"),
760
+ credentials=creds_dict,
761
+ )
762
+
763
+ raise ValueError(f"AI_PROVIDER inconnu : {provider!r}. "
764
+ "Valeurs acceptées : google_ai_studio, vertex_api_key, vertex_service_account")
765
+ ```
766
 
767
+ ### Listage des modèles disponibles (models.py)
 
768
 
769
+ ```python
770
+ def list_available_models(client: genai.Client) -> list[dict]:
771
+ """
772
+ Retourne les modèles disponibles supportant vision + generateContent.
773
+ Format : [{"id": str, "display_name": str, "supports_vision": bool}]
774
+ """
775
+ models = []
776
+ for model in client.models.list():
777
+ # Garder uniquement les modèles multimodaux
778
+ supported = getattr(model, "supported_generation_methods", [])
779
+ if "generateContent" not in supported:
780
+ continue
781
+ # Vérifier le support vision (input_token_limit et modalities)
782
+ modalities = getattr(model, "supported_actions", None) or []
783
+ supports_vision = "image" in str(model).lower() or "vision" in str(model.name).lower()
784
+ models.append({
785
+ "id": model.name,
786
+ "display_name": getattr(model, "display_name", model.name),
787
+ "supports_vision": supports_vision,
788
+ })
789
+ return models
790
+ ```
791
 
792
+ ---
793
 
794
+ ## 10. Structure des exports documentaires
795
+
796
+ ### ALTO (par page)
797
+
798
+ ALTO contient la géométrie textuelle uniquement.
799
+ ```xml
800
+ <alto>
801
+ <Layout>
802
+ <Page WIDTH="{width}" HEIGHT="{height}" ID="{page_id}">
803
+ <PrintSpace>
804
+ <!-- Pour chaque région de type text_block -->
805
+ <TextBlock ID="{region.id}"
806
+ HPOS="{bbox[0]}" VPOS="{bbox[1]}"
807
+ WIDTH="{bbox[2]}" HEIGHT="{bbox[3]}">
808
+ <TextLine>
809
+ <String CONTENT="{text}" WC="{confidence}"/>
810
+ </TextLine>
811
+ </TextBlock>
812
+ <!-- Pour chaque région de type miniature -->
813
+ <Illustration ID="{region.id}"
814
+ HPOS="{bbox[0]}" VPOS="{bbox[1]}"
815
+ WIDTH="{bbox[2]}" HEIGHT="{bbox[3]}"/>
816
+ </PrintSpace>
817
+ </Page>
818
+ </Layout>
819
+ </alto>
820
  ```
821
 
822
+ ALTO ne porte PAS : commentaires savants, iconographie, couches éditoriales.
823
+
824
+ ### IIIF Manifest (par manuscrit)
825
+
826
+ Structure minimale V1 :
827
+ ```json
828
+ {
829
+ "@context": "http://iiif.io/api/presentation/3/context.json",
830
+ "id": "https://{base_url}/api/v1/manuscripts/{id}/iiif-manifest",
831
+ "type": "Manifest",
832
+ "label": {"fr": ["{manuscript.title}"]},
833
+ "metadata": [],
834
+ "items": [
835
+ {
836
+ "id": "https://{base_url}/canvas/{page_id}",
837
+ "type": "Canvas",
838
+ "width": "{image.width}",
839
+ "height": "{image.height}",
840
+ "items": [{"type": "AnnotationPage", "items": [
841
+ {"type": "Annotation", "motivation": "painting",
842
+ "body": {"type": "Image", "id": "{image.derivative_web}",
843
+ "format": "image/jpeg",
844
+ "width": "{image.width}", "height": "{image.height}"},
845
+ "target": "https://{base_url}/canvas/{page_id}"}
846
+ ]}]
847
+ }
848
+ ]
849
+ }
850
  ```
851
 
852
+ ---
853
+
854
+ ## 11. Statuts métier
855
+
856
  ```
857
+ Corpus : CREATED INGESTINGINGESTEDPROCESSINGREADY → ERROR
858
+ Page : INGESTED → PREPARED → ANALYZED → LAYERED → EXPORTED → VALIDATED → ERROR
859
+ Layer : PENDING → RUNNING → DONE → FAILED → NEEDS_REVIEW → VALIDATED
860
+ Éditorial: machine_draft → needs_review → reviewed → validated → published
861
  ```
862
 
863
  ---
864
 
865
+ ## 12. Endpoints API — liste complète
866
 
867
  ```
868
+ # Configuration & modèles IA
869
+ POST /api/v1/settings/api-key
870
+ GET /api/v1/models
871
  POST /api/v1/models/refresh
872
  PUT /api/v1/corpora/{id}/model
873
  GET /api/v1/corpora/{id}/model
 
918
 
919
  ---
920
 
921
+ ## 13. État du projet par sprint
922
 
923
  ```
924
+ Sprint 1 — Fondations du modèle de données
925
+ 54 tests passants. Schémas Pydantic. 4 profils. 9 templates prompts.
926
 
927
+ Sprint 2 — Pipeline page unique
928
+ Connexion Google AI validée ingestion image master.json
929
 
930
+ Sprint 3 — Exports documentaires
931
+ ALTO par page + METS + Manifest IIIF
932
 
933
+ Sprint 4 — API FastAPI + interface de lecture
934
+ Endpoints + visionneuse OpenSeadragon + 4 couches
935
 
936
+ Sprint 5 — Traitement en lot + HuggingFace
937
+ Pipeline batch + déploiement public
938
 
939
+ Sprint 6 — Validation humaine + V1 complète
940
+ Éditeur + versionnement + recherche
941
  ```
942
 
943
+ **Règle stricte** : ne jamais implémenter du code d'un sprint futur.
944
+ Si une idée émerge, la noter dans STATUS.md section "Backlog" et ne pas la coder.
 
945
 
946
  ---
947
 
948
+ ## 14. Ce que tu NE dois PAS faire sans demande explicite
949
 
950
  - Modifier le schéma PageMaster (champs, types, noms, structure)
951
  - Modifier la convention bbox
952
+ - Ajouter des dépendances non listées dans pyproject.toml section 2
953
+ - Refactoriser du code existant si la session n'a pas ce but
954
+ - Créer des fichiers hors de l'arborescence section 3
955
+ - Implémenter du code d'un sprint futur (section 13)
956
+ - "Simplifier" un schéma pour "faire plus propre" — les schémas sont figés
957
+ - Créer une logique spécifique à un corpus (règle R01)
958
+ - Utiliser google-generativeai au lieu de google-genai (règle R11)
959
+ - Laisser une variable d'environnement dans le code (règle R06)
STATUS.md CHANGED
@@ -1,35 +1,190 @@
1
- # STATUS.md
2
 
3
- ## Sprint en cours : 1 — Session A
4
- ## Dernière mise à jour : [date]
5
 
6
- ## Ce qui est fait
7
- - [x] Repo créé, arborescence en place
8
- - [x] CLAUDE.md créé
9
- - [ ] Schémas Pydantic
10
- - [ ] Tests pytest
11
 
12
- ## Ce qui bloque
13
- Rien.
 
 
 
 
 
 
 
 
14
 
15
- ## Objectif de la prochaine session
16
- Créer les modèles Pydantic dans backend/app/schemas/.
17
- Voir section "Tâches" ci-dessous.
18
 
19
- ## Tâches (dans l'ordre)
20
- 1. corpus_profile.py — CorpusProfile + enums
21
- 2. page_master.py — Region, PageMaster + validators
22
- 3. annotation.py — AnnotationLayer
23
- 4. tests/test_schemas.py — 20+ tests
24
- 5. Lancer pytest → 0 failed
25
 
26
- ## Critère de done
27
- pytest 100%. Les 4 profils JSON chargés sans erreur.
 
 
 
28
 
29
- ## Décisions récentes
30
- - bbox : [x, y, w, h] pixels absolus (voir R03 dans CLAUDE.md)
31
- - SQLite retenu pour MVP HuggingFace
 
32
 
33
- ## Ne pas faire dans cette session
34
- Aucun appel Google AI. Aucune API FastAPI.
35
- ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # STATUS.md — Sprint 2 : Pipeline page unique
2
 
3
+ ## Sprint : 2 — Session A
4
+ ## Objectif du sprint : 1 image → 1 master.json valide via Google AI
5
 
6
+ ---
 
 
 
 
7
 
8
+ ## Ce qui est fait (Sprint 1 ✓)
9
+ - [x] Repo GitHub structuré, arborescence complète
10
+ - [x] Schémas Pydantic : corpus_profile.py, page_master.py, annotation.py
11
+ - [x] 4 profils JSON (medieval-illuminated, medieval-textual, early-modern-print, modern-handwritten)
12
+ - [x] 9 templates de prompts versionnés
13
+ - [x] 54 tests pytest passants (26 schemas + 28 profiles)
14
+ - [x] pyproject.toml configuré
15
+ - [x] 6 secrets GitHub en place :
16
+ GOOGLE_AI_STUDIO_API_KEY, VERTEX_API_KEY, VERTEX_PROJECT_ID,
17
+ VERTEX_LOCATION, VERTEX_SERVICE_ACCOUNT_JSON, AI_PROVIDER
18
 
19
+ ---
 
 
20
 
21
+ ## Contexte important pour ce sprint
 
 
 
 
 
22
 
23
+ ### Providers Google AI disponibles
24
+ Trois options configurées, priorité :
25
+ - AI_PROVIDER=vertex_api_key → clé AQ.Ab... (Vertex Express, production)
26
+ - AI_PROVIDER=google_ai_studio → clé AIza... (gratuit, développement)
27
+ - AI_PROVIDER=vertex_service_account → JSON credentials (institutions)
28
 
29
+ ### Format de clé Vertex non confirmé
30
+ La clé Vertex commence par AQ.Ab (format OAuth2 Vertex Express).
31
+ La syntaxe SDK exacte pour ce format N'EST PAS encore validée.
32
+ La Session A commence par ce test — avant tout le reste.
33
 
34
+ ### Images test disponibles
35
+ Pas d'images locales. On travaille avec des URLs IIIF directes.
36
+
37
+ URL Beatus haute résolution (profil medieval-illuminated) :
38
+ https://gallica.bnf.fr/iiif/ark:/12148/btv1b52505441p/f233/full/full/0/native.jpg
39
+
40
+ URL Beatus basse résolution (même folio, qualité réduite — test confidence) :
41
+ https://gallica.bnf.fr/iiif/ark:/12148/btv1b52505441p/f233/full/600,/0/native.jpg
42
+
43
+ URL second corpus — Grandes Chroniques de France (profil medieval-textual) :
44
+ https://gallica.bnf.fr/iiif/ark:/12148/btv1b84472995/f16/full/full/0/native.jpg
45
+
46
+ Pourquoi tester deux résolutions du Beatus :
47
+ Les deux images doivent produire un master.json valide.
48
+ La basse résolution doit retourner un score confidence plus faible
49
+ et potentiellement déclencher le statut needs_review si < flag_below (0.4).
50
+ Cela valide que les seuils du profil fonctionnent correctement.
51
+
52
+ ---
53
+
54
+ ## Session A — Connexion Google AI + listage modèles
55
+
56
+ ### Objectif
57
+ Valider que les 3 providers fonctionnent et lister les modèles disponibles.
58
+ Aucun traitement d'image. Aucun master.json. Juste la connexion.
59
+
60
+ ### Tâches dans l'ordre
61
+
62
+ 1. Créer backend/app/services/ai/__init__.py (vide)
63
+
64
+ 2. Créer backend/app/services/ai/client.py
65
+ → factory get_ai_client() avec les 3 options
66
+ → Option B (vertex_api_key) : tester les deux syntaxes possibles
67
+ et documenter celle qui fonctionne dans un commentaire
68
+
69
+ 3. Créer backend/app/services/ai/models.py
70
+ → fonction list_available_models(client) → list[dict]
71
+ → filtrer sur les modèles qui supportent generateContent + vision
72
+ → retourner : id, display_name, supports_vision
73
+
74
+ 4. Créer backend/tests/test_ai_connection.py
75
+ → test_option_a_google_ai_studio() : connexion + list_models
76
+ → test_option_b_vertex_api_key() : connexion + list_models
77
+ → test_option_c_vertex_service_account() : connexion + list_models
78
+ → Chaque test affiche les modèles disponibles dans les logs
79
+
80
+ 5. Lancer pytest test_ai_connection.py
81
+ → documenter dans DECISIONS.md la syntaxe exacte validée pour AQ.Ab
82
+
83
+ ### Critère de done Session A
84
+ Les 3 tests de connexion passent.
85
+ On sait quelle syntaxe fonctionne pour la clé AQ.Ab.
86
+ La liste des modèles disponibles est affichée pour chaque provider.
87
+
88
+ ### Ne pas faire en Session A
89
+ - Aucun traitement d'image
90
+ - Aucun appel de prompt
91
+ - Aucune ingestion de corpus
92
+
93
+ ---
94
+
95
+ ## Session B — Ingestion + préparation image
96
+
97
+ ### Objectif
98
+ Ingérer une image depuis une URL IIIF et produire un dérivé web prêt pour l'IA.
99
+
100
+ ### Tâches dans l'ordre
101
+
102
+ 1. Créer backend/app/services/ingest/__init__.py
103
+ 2. Créer backend/app/services/ingest/image_loader.py
104
+ → load_from_url(url) → image bytes + dimensions
105
+ → load_from_file(path) → image bytes + dimensions
106
+ → Pillow pour lire et redimensionner
107
+ 3. Créer backend/app/services/image/__init__.py
108
+ 4. Créer backend/app/services/image/processor.py
109
+ → make_derivative(image_bytes, max_size=1500) → JPEG bytes
110
+ → get_dimensions(image_bytes) → (width, height)
111
+ 5. Tester sur les 3 URLs dans l'ordre :
112
+ - Beatus haute résolution :
113
+ https://gallica.bnf.fr/iiif/ark:/12148/btv1b52505441p/f233/full/full/0/native.jpg
114
+ - Beatus basse résolution :
115
+ https://gallica.bnf.fr/iiif/ark:/12148/btv1b52505441p/f233/full/600,/0/native.jpg
116
+ - Grandes Chroniques :
117
+ https://gallica.bnf.fr/iiif/ark:/12148/btv1b84472995/f16/full/full/0/native.jpg
118
+ → vérifier que les 3 images se téléchargent et se redimensionnent
119
+ → vérifier que les dimensions sont bien extraites pour chaque cas
120
+ 6. Ajouter tests/test_image_processing.py
121
+
122
+ ### Critère de done Session B
123
+ Les 3 URLs produisent chacune un JPEG dérivé de 1500px max.
124
+ Les dimensions sont correctement extraites pour chaque image.
125
+ La basse résolution Beatus produit bien une image plus petite en entrée.
126
+
127
+ ---
128
+
129
+ ## Session C — Premier appel IA + master.json
130
+
131
+ ### Objectif
132
+ 1 image → 1 appel Google AI → 1 master.json valide.
133
+ C'est le cœur du Sprint 2.
134
+
135
+ ### Tâches dans l'ordre
136
+
137
+ 1. Créer backend/app/services/ai/prompt_loader.py
138
+ → load_and_render(template_path, context_dict) → str
139
+ → remplace {{profile_label}}, {{language_hints}}, {{script_type}}
140
+
141
+ 2. Créer backend/app/services/ai/pipeline.py
142
+ → analyze_page(image_bytes, corpus_profile, model_id) → PageMaster
143
+ → Appelle le prompt primary_v1.txt du profil
144
+ → Stocke ai_raw.json (brut) + master.json (validé Pydantic)
145
+ → Lève une erreur explicite si le JSON retourné est invalide
146
+
147
+ 3. Tester sur les 3 images dans l'ordre :
148
+ a. Beatus haute résolution + profil medieval-illuminated
149
+ → master.json valide, confidence attendue > 0.6
150
+ b. Beatus basse résolution + profil medieval-illuminated
151
+ → master.json valide, confidence attendue plus faible
152
+ → vérifier que editorial.status = "needs_review" si confidence < 0.4
153
+ c. Grandes Chroniques + profil medieval-textual
154
+ → master.json valide, extensions sans iconography
155
+ → valide la généricité (zéro logique Beatus dans le code)
156
+
157
+ 4. Vérifier pour chaque master.json :
158
+ → ai_raw.json bien séparé
159
+ → processing.provider = "vertex_api_key"
160
+ → schema_version = "1.0"
161
+ → bbox toutes en format [x, y, w, h] avec w > 0 et h > 0
162
+
163
+ 5. Ajouter tests/test_pipeline.py
164
+
165
+ ### Critère de done Session C
166
+ 3 master.json valides produits (Beatus HR + Beatus BR + Grandes Chroniques).
167
+ La basse résolution déclenche bien un score de confidence plus faible.
168
+ Les Grandes Chroniques ne contiennent pas de bloc iconography dans extensions.
169
+ pytest 100% sur tous les fichiers de test.
170
+ ai_raw.json et master.json bien séparés dans data/ pour chaque page.
171
+
172
+ ---
173
+
174
+ ## Critère de fin du Sprint 2
175
+ - [ ] 3 providers connectés et testés
176
+ - [ ] Syntaxe AQ.Ab documentée dans DECISIONS.md
177
+ - [ ] Pipeline page unique fonctionnel
178
+ - [ ] 3 master.json valides (Beatus HR + Beatus BR + Grandes Chroniques)
179
+ - [ ] La basse résolution produit un confidence plus faible que la haute résolution
180
+ - [ ] Règle de généricité respectée (zéro logique hardcodée Beatus)
181
+ - [ ] pytest 100%
182
+
183
+ ---
184
+
185
+ ## Ne pas faire dans ce sprint
186
+ - Aucune API FastAPI
187
+ - Aucune interface web
188
+ - Aucun ALTO / METS / IIIF
189
+ - Aucun traitement en lot
190
+ - Passes dérivées (traduction, commentaire) : Sprint 3