Spaces:
Build error
Build error
maribakulj commited on
CLAUDE modifié
Browse files
CLAUDE.md
CHANGED
|
@@ -1,71 +1,110 @@
|
|
| 1 |
-
|
|
|
|
| 2 |
|
| 3 |
-
|
| 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 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
scriptorium-ai/
|
| 40 |
│
|
| 41 |
-
├── CLAUDE.md ← CE FICHIER
|
| 42 |
-
├──
|
| 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/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
│ │ ├── models/ ← modèles SQLAlchemy (tables BDD)
|
| 51 |
-
│ │ ├── schemas/ ← modèles Pydantic (source canonique des types)
|
| 52 |
│ │ │ ├── __init__.py
|
| 53 |
-
│ │ │ ├─
|
| 54 |
-
│ │ │ ├──
|
| 55 |
-
│ │ │ └──
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
│ │ └── services/
|
| 57 |
-
│ │ ├──
|
| 58 |
-
│ │ ├──
|
| 59 |
-
│ │ ├──
|
| 60 |
-
│ │
|
| 61 |
-
│ │
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
│ ├── tests/
|
| 63 |
│ │ ├── __init__.py
|
| 64 |
-
│ │ ├── test_schemas.py
|
| 65 |
-
│ │
|
|
|
|
|
|
|
|
|
|
| 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/
|
| 91 |
│ └── corpora/
|
| 92 |
│ └── {corpus_slug}/
|
| 93 |
-
│ ├── masters/
|
| 94 |
-
│ ├── derivatives/
|
|
|
|
| 95 |
│ ├── iiif/
|
|
|
|
|
|
|
| 96 |
│ └── pages/
|
| 97 |
-
│ └── {
|
| 98 |
-
│ ├── master.json
|
| 99 |
-
│ ├──
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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": "
|
| 157 |
uncertainty_config: UncertaintyConfig
|
| 158 |
export_config: ExportConfig
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 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
|
| 186 |
if any(x < 0 for x in v):
|
| 187 |
-
raise ValueError("bbox
|
| 188 |
if v[2] <= 0 or v[3] <= 0:
|
| 189 |
-
raise ValueError("bbox width
|
| 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 |
-
|
|
|
|
| 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
|
| 240 |
manuscript_id: str
|
| 241 |
-
folio_label: str
|
| 242 |
-
sequence: int
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
layout: dict # {"regions": [Region, ...]}
|
| 246 |
ocr: OCRResult | None = None
|
| 247 |
translation: Translation | None = None
|
| 248 |
-
summary:
|
| 249 |
commentary: Commentary | None = None
|
| 250 |
-
extensions: dict[str, Any] = {}
|
| 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 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
--
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 341 |
if profile_id == "medieval-illuminated":
|
| 342 |
process_iconography()
|
| 343 |
|
| 344 |
-
# ✅ CORRECT
|
| 345 |
if "iconography_detection" in corpus_profile.active_layers:
|
| 346 |
process_iconography()
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
#
|
| 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 —
|
| 359 |
bbox = [x, y, x2 - x1, y2 - y1]
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
#
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 405 |
from datetime import datetime
|
| 406 |
-
from typing import Any
|
| 407 |
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 422 |
import logging
|
| 423 |
-
|
| 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 |
-
|
| 526 |
|
| 527 |
-
|
| 528 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 [
|
| 585 |
-
|
| 586 |
|
| 587 |
-
Sprint 2 — Pipeline page unique [
|
| 588 |
-
|
| 589 |
|
| 590 |
Sprint 3 — Exports documentaires [ À FAIRE ]
|
| 591 |
-
|
| 592 |
|
| 593 |
Sprint 4 — API FastAPI + interface de lecture [ À FAIRE ]
|
| 594 |
-
|
| 595 |
|
| 596 |
Sprint 5 — Traitement en lot + HuggingFace [ À FAIRE ]
|
| 597 |
-
|
| 598 |
|
| 599 |
Sprint 6 — Validation humaine + V1 complète [ À FAIRE ]
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
-
|
| 615 |
-
|
| 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# ❌ INTERDIT — coordonné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 |
+
R06 — Secrets 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# ❌ INTERDIT — syntaxe 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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|