Claude commited on
Commit
568e457
·
unverified ·
1 Parent(s): 894cbe3

feat: Sprint 7 — integration test, export limit, French accents

Browse files

- Full pipeline integration test (create → ingest → analyze → verify files + index)
- Export ZIP limited to 500 pages max (HTTP 413 with helpful message)
- Fixed 30 missing French accents across Home, Admin, Reader, Editor pages

https://claude.ai/code/session_012NCh8yLxMXkRmBYQgHCTik

backend/app/api/v1/export.py CHANGED
@@ -193,6 +193,15 @@ async def get_export_zip(
193
  manuscript, corpus, masters = await _load_manuscript_with_masters(
194
  manuscript_id, db
195
  )
 
 
 
 
 
 
 
 
 
196
  meta = _build_manuscript_meta(manuscript, corpus)
197
 
198
  buf = io.BytesIO()
 
193
  manuscript, corpus, masters = await _load_manuscript_with_masters(
194
  manuscript_id, db
195
  )
196
+
197
+ if len(masters) > 500:
198
+ raise HTTPException(
199
+ status_code=413,
200
+ detail=f"Le manuscrit contient {len(masters)} pages. "
201
+ "L'export ZIP est limité à 500 pages maximum. "
202
+ "Exportez les pages individuellement via GET /pages/{id}/alto.",
203
+ )
204
+
205
  meta = _build_manuscript_meta(manuscript, corpus)
206
 
207
  buf = io.BytesIO()
backend/tests/test_integration_pipeline.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Test d'integration du pipeline complet : ingestion -> analyse IA -> exports.
3
+
4
+ Valide la chaine sans appel reseau (provider IA et fetch image mockes).
5
+ """
6
+ import json
7
+ import uuid
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+ from unittest.mock import MagicMock, patch
11
+
12
+ import pytest
13
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
14
+
15
+ import app.models # noqa: F401
16
+ from app.models.database import Base
17
+ from app.models.corpus import CorpusModel, ManuscriptModel, PageModel
18
+ from app.models.job import JobModel
19
+ from app.models.model_config_db import ModelConfigDB
20
+ from app.models.page_search import PageSearchIndex
21
+
22
+
23
+ _FAKE_AI_RESPONSE = json.dumps({
24
+ "layout": {
25
+ "regions": [
26
+ {"id": "r1", "type": "text_block", "bbox": [100, 200, 800, 600], "confidence": 0.92}
27
+ ]
28
+ },
29
+ "ocr": {
30
+ "diplomatic_text": "Incipit liber primus de apocalypsi",
31
+ "blocks": [],
32
+ "lines": [],
33
+ "language": "la",
34
+ "confidence": 0.85,
35
+ "uncertain_segments": []
36
+ }
37
+ })
38
+
39
+ # Minimal 1x1 white JPEG
40
+ _FAKE_JPEG = bytes([
41
+ 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01,
42
+ 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xDB, 0x00, 0x43,
43
+ 0x00, 0x08, 0x06, 0x06, 0x07, 0x06, 0x05, 0x08, 0x07, 0x07, 0x07, 0x09,
44
+ 0x09, 0x08, 0x0A, 0x0C, 0x14, 0x0D, 0x0C, 0x0B, 0x0B, 0x0C, 0x19, 0x12,
45
+ 0x13, 0x0F, 0x14, 0x1D, 0x1A, 0x1F, 0x1E, 0x1D, 0x1A, 0x1C, 0x1C, 0x20,
46
+ 0x24, 0x2E, 0x27, 0x20, 0x22, 0x2C, 0x23, 0x1C, 0x1C, 0x28, 0x37, 0x29,
47
+ 0x2C, 0x30, 0x31, 0x34, 0x34, 0x34, 0x1F, 0x27, 0x39, 0x3D, 0x38, 0x32,
48
+ 0x3C, 0x2E, 0x33, 0x34, 0x32, 0xFF, 0xC0, 0x00, 0x0B, 0x08, 0x00, 0x01,
49
+ 0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xFF, 0xC4, 0x00, 0x1F, 0x00, 0x00,
50
+ 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00,
51
+ 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
52
+ 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00, 0x02, 0x01, 0x03,
53
+ 0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, 0x00, 0x01, 0x7D,
54
+ 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06,
55
+ 0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08,
56
+ 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3F, 0x00, 0x7B, 0x94,
57
+ 0x11, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
58
+ 0xFF, 0xD9,
59
+ ])
60
+
61
+
62
+ @pytest.fixture
63
+ async def pipeline_db():
64
+ """BDD en memoire avec toutes les tables creees."""
65
+ engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
66
+ async with engine.begin() as conn:
67
+ await conn.run_sync(Base.metadata.create_all)
68
+ factory = async_sessionmaker(engine, expire_on_commit=False)
69
+ async with factory() as session:
70
+ yield session
71
+ await engine.dispose()
72
+
73
+
74
+ @pytest.fixture
75
+ async def pipeline_fixtures(pipeline_db, tmp_path):
76
+ """Cree corpus + manuscrit + page + model config + job en BDD."""
77
+ db = pipeline_db
78
+ corpus_id = str(uuid.uuid4())
79
+ ms_id = str(uuid.uuid4())
80
+ page_id = "test-corpus-f001r"
81
+ job_id = str(uuid.uuid4())
82
+ now = datetime.now(timezone.utc)
83
+
84
+ corpus = CorpusModel(
85
+ id=corpus_id, slug="test-corpus", title="Test",
86
+ profile_id="medieval-illuminated", created_at=now, updated_at=now,
87
+ )
88
+ ms = ManuscriptModel(
89
+ id=ms_id, corpus_id=corpus_id, title="Ms Test", total_pages=1,
90
+ )
91
+ page = PageModel(
92
+ id=page_id, manuscript_id=ms_id, folio_label="f001r", sequence=1,
93
+ iiif_service_url="https://example.com/iiif/image1",
94
+ processing_status="INGESTED",
95
+ )
96
+ model_config = ModelConfigDB(
97
+ corpus_id=corpus_id, provider_type="google_ai_studio",
98
+ selected_model_id="gemini-2.0-flash",
99
+ selected_model_display_name="Gemini Flash",
100
+ supports_vision=True, updated_at=now,
101
+ )
102
+ job = JobModel(
103
+ id=job_id, corpus_id=corpus_id, page_id=page_id,
104
+ status="pending", created_at=now,
105
+ )
106
+
107
+ db.add_all([corpus, ms, page, model_config, job])
108
+ await db.commit()
109
+
110
+ return {
111
+ "db": db,
112
+ "corpus_id": corpus_id, "ms_id": ms_id, "page_id": page_id,
113
+ "job_id": job_id, "data_dir": tmp_path,
114
+ }
115
+
116
+
117
+ @pytest.mark.asyncio
118
+ async def test_full_pipeline(pipeline_fixtures, tmp_path):
119
+ """Le pipeline complet produit master.json, ai_raw.json, alto.xml et indexe la page."""
120
+ fx = pipeline_fixtures
121
+ db = fx["db"]
122
+
123
+ import app.config as config_mod
124
+
125
+ # Mock settings to use tmp_path as data_dir
126
+ original_data_dir = config_mod.settings.data_dir
127
+ original_profiles_dir = config_mod.settings.profiles_dir
128
+ config_mod.settings.__dict__["data_dir"] = tmp_path
129
+
130
+ # Ensure profiles_dir points to the real profiles directory
131
+ # (profiles_dir is resolved from _REPO_ROOT in config.py and should
132
+ # already point to the correct location, but we set it explicitly
133
+ # for safety in case tests run from a different CWD.)
134
+ repo_root = Path(__file__).resolve().parent.parent.parent
135
+ real_profiles_dir = repo_root / "profiles"
136
+ if real_profiles_dir.exists():
137
+ config_mod.settings.__dict__["profiles_dir"] = real_profiles_dir
138
+
139
+ # Mock the AI provider and image fetcher
140
+ mock_provider = MagicMock()
141
+ mock_provider.generate_content.return_value = _FAKE_AI_RESPONSE
142
+
143
+ try:
144
+ with patch(
145
+ "app.services.job_runner.fetch_ai_derivative_bytes",
146
+ return_value=(_FAKE_JPEG, 1500, 1000),
147
+ ), patch(
148
+ "app.services.ai.model_registry.get_provider",
149
+ return_value=mock_provider,
150
+ ):
151
+ from app.services.job_runner import _run_job_impl
152
+ await _run_job_impl(fx["job_id"], db)
153
+ finally:
154
+ config_mod.settings.__dict__["data_dir"] = original_data_dir
155
+ config_mod.settings.__dict__["profiles_dir"] = original_profiles_dir
156
+
157
+ # -- Assertions ----------------------------------------------------------
158
+ # Job should be done
159
+ job = await db.get(JobModel, fx["job_id"])
160
+ assert job.status == "done", f"Job status: {job.status}, error: {job.error_message}"
161
+
162
+ # Page should be ANALYZED
163
+ page = await db.get(PageModel, fx["page_id"])
164
+ assert page.processing_status == "ANALYZED"
165
+
166
+ # Files should exist
167
+ page_dir = tmp_path / "corpora" / "test-corpus" / "pages" / "f001r"
168
+ assert (page_dir / "master.json").exists(), "master.json not written"
169
+ assert (page_dir / "ai_raw.json").exists(), "ai_raw.json not written"
170
+ assert (page_dir / "alto.xml").exists(), "alto.xml not written"
171
+
172
+ # master.json should be valid
173
+ master_data = json.loads((page_dir / "master.json").read_text())
174
+ assert master_data["page_id"] == fx["page_id"]
175
+ assert len(master_data["layout"]["regions"]) == 1
176
+
177
+ # Search index should be populated
178
+ search_entry = await db.get(PageSearchIndex, fx["page_id"])
179
+ assert search_entry is not None
180
+ assert "Incipit" in search_entry.diplomatic_text
frontend/src/pages/Admin.tsx CHANGED
@@ -84,7 +84,7 @@ function CreateCorpusPanel({ onCreated }: CreateCorpusPanelProps) {
84
  setLoading(true)
85
  try {
86
  const corpus = await createCorpus(form)
87
- setSuccess(`Corpus "${corpus.title}" cree.`)
88
  setForm((f) => ({ ...f, slug: '', title: '' }))
89
  onCreated(corpus)
90
  } catch (err) {
@@ -95,7 +95,7 @@ function CreateCorpusPanel({ onCreated }: CreateCorpusPanelProps) {
95
  }
96
 
97
  return (
98
- <RetroWindow title="Creer un corpus" className="max-w-lg">
99
  <form onSubmit={(e) => void handleSubmit(e)} className="p-3 flex flex-col gap-2">
100
  <RetroInput
101
  label="Slug (identifiant unique)"
@@ -128,7 +128,7 @@ function CreateCorpusPanel({ onCreated }: CreateCorpusPanelProps) {
128
  type="submit"
129
  disabled={loading || !form.slug || !form.title || !form.profile_id}
130
  >
131
- {loading ? 'Creation...' : 'Creer le corpus'}
132
  </RetroButton>
133
  </div>
134
  </form>
@@ -193,7 +193,7 @@ function ModelPanel({ corpusId, onSaved }: ModelPanelProps) {
193
  await selectModel(corpusId, selectedModelId, model?.display_name ?? selectedModelId, selectedProvider, model?.supports_vision ?? true)
194
  const updated = await getCorpusModel(corpusId)
195
  setCurrentModel(updated)
196
- setSaveSuccess(`Modele "${model?.display_name ?? selectedModelId}" associe.`)
197
  onSaved()
198
  } catch (err) {
199
  setSaveError(err instanceof Error ? err.message : 'Erreur')
@@ -206,12 +206,12 @@ function ModelPanel({ corpusId, onSaved }: ModelPanelProps) {
206
  <div className="flex flex-col gap-2">
207
  {currentModel && (
208
  <div className="text-retro-sm border border-retro-black p-2 bg-retro-light">
209
- Modele actuel: <span className="font-bold">{currentModel.selected_model_display_name}</span>
210
  {' '}({currentModel.provider_type})
211
  </div>
212
  )}
213
 
214
- {loadingProviders && <div className="text-retro-sm text-retro-darkgray">Detection providers...</div>}
215
  {!loadingProviders && providersError && <ErrorMsg message={providersError} />}
216
  {!loadingProviders && providers.length > 0 && (
217
  <div className="flex flex-wrap gap-[2px]">
@@ -231,11 +231,11 @@ function ModelPanel({ corpusId, onSaved }: ModelPanelProps) {
231
 
232
  {selectedProvider && (
233
  <form onSubmit={(e) => void handleSelectModel(e)} className="flex flex-col gap-2 max-w-sm">
234
- {loadingModels && <div className="text-retro-sm text-retro-darkgray">Chargement modeles...</div>}
235
  {!loadingModels && modelsError && <ErrorMsg message={modelsError} />}
236
  {!loadingModels && models.length > 0 && (
237
  <RetroSelect
238
- label={`Modele — ${providers.find((p) => p.provider_type === selectedProvider)?.display_name}`}
239
  value={selectedModelId}
240
  onChange={(e) => setSelectedModelId(e.target.value)}
241
  options={models.map((m) => ({
@@ -248,7 +248,7 @@ function ModelPanel({ corpusId, onSaved }: ModelPanelProps) {
248
  {saveSuccess && <SuccessMsg message={saveSuccess} />}
249
  {!loadingModels && models.length > 0 && (
250
  <RetroButton type="submit" disabled={savingModel || !selectedModelId}>
251
- {savingModel ? 'Enregistrement...' : 'Selectionner'}
252
  </RetroButton>
253
  )}
254
  </form>
@@ -285,7 +285,7 @@ function IngestPanel({ corpusId }: { corpusId: string }) {
285
  setUrlsLoading(true)
286
  try {
287
  const resp = await ingestImages(corpusId, urls, labels)
288
- setUrlsSuccess(`${resp.pages_created} page(s) ingeree(s).`)
289
  setUrlsText(''); setFolioLabelsText('')
290
  } catch (err) { setUrlsError(err instanceof Error ? err.message : 'Erreur') }
291
  finally { setUrlsLoading(false) }
@@ -296,7 +296,7 @@ function IngestPanel({ corpusId }: { corpusId: string }) {
296
  setManifestError(null); setManifestSuccess(null); setManifestLoading(true)
297
  try {
298
  const resp = await ingestManifest(corpusId, manifestUrl)
299
- setManifestSuccess(`${resp.pages_created} page(s) ingeree(s).`)
300
  setManifestUrl('')
301
  } catch (err) { setManifestError(err instanceof Error ? err.message : 'Erreur') }
302
  finally { setManifestLoading(false) }
@@ -309,7 +309,7 @@ function IngestPanel({ corpusId }: { corpusId: string }) {
309
  setFilesLoading(true)
310
  try {
311
  const resp = await ingestFiles(corpusId, selectedFiles)
312
- setFilesSuccess(`${resp.pages_created} page(s) ingeree(s).`)
313
  setSelectedFiles([])
314
  } catch (err) { setFilesError(err instanceof Error ? err.message : 'Erreur') }
315
  finally { setFilesLoading(false) }
@@ -431,18 +431,18 @@ function RunPanel({ corpusId, hasModel }: { corpusId: string; hasModel: boolean
431
  }
432
 
433
  if (!hasModel) {
434
- return <div className="text-retro-sm border border-retro-black p-2 bg-retro-white">Configurez d'abord un modele IA.</div>
435
  }
436
 
437
  return (
438
  <div className="flex flex-col gap-2">
439
  {pageCount !== null && (
440
- <div className="text-retro-sm">{pageCount === 0 ? 'Aucune page ingeree.' : `${pageCount} page(s).`}</div>
441
  )}
442
  {launchError && <ErrorMsg message={launchError} />}
443
  <div className="flex flex-wrap gap-[2px]">
444
  <RetroButton onClick={() => void handleRun()} disabled={launching || polling || pageCount === 0}>
445
- {launching ? 'Demarrage...' : polling ? 'En cours...' : 'Analyser tout'}
446
  </RetroButton>
447
  {failedCount > 0 && !polling && (
448
  <RetroButton onClick={() => void handleRetryFailed()}>
@@ -453,7 +453,7 @@ function RunPanel({ corpusId, hasModel }: { corpusId: string; hasModel: boolean
453
  {totalCount > 0 && (
454
  <div>
455
  <div className="text-retro-sm mb-1">
456
- <span className="font-bold">{doneCount}</span>/{totalCount} traitees
457
  {failedCount > 0 && <span className="ml-2 font-bold">{failedCount} erreur(s)</span>}
458
  {polling && <span className="ml-2 text-retro-darkgray">(actualisation 3s)</span>}
459
  </div>
@@ -516,7 +516,7 @@ function CorpusDetail({ corpus, onDeleted }: { corpus: Corpus; onDeleted: () =>
516
  </div>
517
  </div>
518
 
519
- <RetroWindow title="Modele IA">
520
  <div className="p-2">
521
  <ModelPanel key={corpus.id} corpusId={corpus.id} onSaved={() => setHasModel(true)} />
522
  </div>
@@ -632,7 +632,7 @@ export default function Admin() {
632
  />
633
  )}
634
  {!showCreate && !selectedCorpus && corpora.length > 0 && (
635
- <div className="text-retro-sm text-retro-darkgray p-2">Selectionnez un corpus.</div>
636
  )}
637
  </div>
638
  </div>
 
84
  setLoading(true)
85
  try {
86
  const corpus = await createCorpus(form)
87
+ setSuccess(`Corpus "${corpus.title}" créé.`)
88
  setForm((f) => ({ ...f, slug: '', title: '' }))
89
  onCreated(corpus)
90
  } catch (err) {
 
95
  }
96
 
97
  return (
98
+ <RetroWindow title="Créer un corpus" className="max-w-lg">
99
  <form onSubmit={(e) => void handleSubmit(e)} className="p-3 flex flex-col gap-2">
100
  <RetroInput
101
  label="Slug (identifiant unique)"
 
128
  type="submit"
129
  disabled={loading || !form.slug || !form.title || !form.profile_id}
130
  >
131
+ {loading ? 'Création...' : 'Créer le corpus'}
132
  </RetroButton>
133
  </div>
134
  </form>
 
193
  await selectModel(corpusId, selectedModelId, model?.display_name ?? selectedModelId, selectedProvider, model?.supports_vision ?? true)
194
  const updated = await getCorpusModel(corpusId)
195
  setCurrentModel(updated)
196
+ setSaveSuccess(`Modèle "${model?.display_name ?? selectedModelId}" associé.`)
197
  onSaved()
198
  } catch (err) {
199
  setSaveError(err instanceof Error ? err.message : 'Erreur')
 
206
  <div className="flex flex-col gap-2">
207
  {currentModel && (
208
  <div className="text-retro-sm border border-retro-black p-2 bg-retro-light">
209
+ Modèle actuel: <span className="font-bold">{currentModel.selected_model_display_name}</span>
210
  {' '}({currentModel.provider_type})
211
  </div>
212
  )}
213
 
214
+ {loadingProviders && <div className="text-retro-sm text-retro-darkgray">Détection providers...</div>}
215
  {!loadingProviders && providersError && <ErrorMsg message={providersError} />}
216
  {!loadingProviders && providers.length > 0 && (
217
  <div className="flex flex-wrap gap-[2px]">
 
231
 
232
  {selectedProvider && (
233
  <form onSubmit={(e) => void handleSelectModel(e)} className="flex flex-col gap-2 max-w-sm">
234
+ {loadingModels && <div className="text-retro-sm text-retro-darkgray">Chargement modèles...</div>}
235
  {!loadingModels && modelsError && <ErrorMsg message={modelsError} />}
236
  {!loadingModels && models.length > 0 && (
237
  <RetroSelect
238
+ label={`Modèle — ${providers.find((p) => p.provider_type === selectedProvider)?.display_name}`}
239
  value={selectedModelId}
240
  onChange={(e) => setSelectedModelId(e.target.value)}
241
  options={models.map((m) => ({
 
248
  {saveSuccess && <SuccessMsg message={saveSuccess} />}
249
  {!loadingModels && models.length > 0 && (
250
  <RetroButton type="submit" disabled={savingModel || !selectedModelId}>
251
+ {savingModel ? 'Enregistrement...' : 'Sélectionner'}
252
  </RetroButton>
253
  )}
254
  </form>
 
285
  setUrlsLoading(true)
286
  try {
287
  const resp = await ingestImages(corpusId, urls, labels)
288
+ setUrlsSuccess(`${resp.pages_created} page(s) ingérée(s).`)
289
  setUrlsText(''); setFolioLabelsText('')
290
  } catch (err) { setUrlsError(err instanceof Error ? err.message : 'Erreur') }
291
  finally { setUrlsLoading(false) }
 
296
  setManifestError(null); setManifestSuccess(null); setManifestLoading(true)
297
  try {
298
  const resp = await ingestManifest(corpusId, manifestUrl)
299
+ setManifestSuccess(`${resp.pages_created} page(s) ingérée(s).`)
300
  setManifestUrl('')
301
  } catch (err) { setManifestError(err instanceof Error ? err.message : 'Erreur') }
302
  finally { setManifestLoading(false) }
 
309
  setFilesLoading(true)
310
  try {
311
  const resp = await ingestFiles(corpusId, selectedFiles)
312
+ setFilesSuccess(`${resp.pages_created} page(s) ingérée(s).`)
313
  setSelectedFiles([])
314
  } catch (err) { setFilesError(err instanceof Error ? err.message : 'Erreur') }
315
  finally { setFilesLoading(false) }
 
431
  }
432
 
433
  if (!hasModel) {
434
+ return <div className="text-retro-sm border border-retro-black p-2 bg-retro-white">Configurez d'abord un modèle IA.</div>
435
  }
436
 
437
  return (
438
  <div className="flex flex-col gap-2">
439
  {pageCount !== null && (
440
+ <div className="text-retro-sm">{pageCount === 0 ? 'Aucune page ingérée.' : `${pageCount} page(s).`}</div>
441
  )}
442
  {launchError && <ErrorMsg message={launchError} />}
443
  <div className="flex flex-wrap gap-[2px]">
444
  <RetroButton onClick={() => void handleRun()} disabled={launching || polling || pageCount === 0}>
445
+ {launching ? 'Démarrage...' : polling ? 'En cours...' : 'Analyser tout'}
446
  </RetroButton>
447
  {failedCount > 0 && !polling && (
448
  <RetroButton onClick={() => void handleRetryFailed()}>
 
453
  {totalCount > 0 && (
454
  <div>
455
  <div className="text-retro-sm mb-1">
456
+ <span className="font-bold">{doneCount}</span>/{totalCount} traitées
457
  {failedCount > 0 && <span className="ml-2 font-bold">{failedCount} erreur(s)</span>}
458
  {polling && <span className="ml-2 text-retro-darkgray">(actualisation 3s)</span>}
459
  </div>
 
516
  </div>
517
  </div>
518
 
519
+ <RetroWindow title="Modèle IA">
520
  <div className="p-2">
521
  <ModelPanel key={corpus.id} corpusId={corpus.id} onSaved={() => setHasModel(true)} />
522
  </div>
 
632
  />
633
  )}
634
  {!showCreate && !selectedCorpus && corpora.length > 0 && (
635
+ <div className="text-retro-sm text-retro-darkgray p-2">Sélectionnez un corpus.</div>
636
  )}
637
  </div>
638
  </div>
frontend/src/pages/Editor.tsx CHANGED
@@ -69,7 +69,7 @@ export default function Editor() {
69
  setRegionValidations(ext?.region_validations ?? {})
70
  } catch (e: unknown) {
71
  if (e instanceof ApiError && e.status === 404) {
72
- setError('Cette page n\'a pas encore ete analysee par l\'IA. Lancez le pipeline depuis Administration.')
73
  } else {
74
  const msg = e instanceof Error ? e.message : ''
75
  setError(msg || 'Erreur de chargement')
@@ -165,7 +165,7 @@ export default function Editor() {
165
  <RetroMenuBar
166
  items={[
167
  { label: 'IIIF Studio', onClick: () => navigate('/') },
168
- { label: `Editeur — ${master?.folio_label ?? pageId}` },
169
  ]}
170
  right={
171
  <div className="flex items-center gap-1">
@@ -199,7 +199,7 @@ export default function Editor() {
199
  <Viewer iiifServiceUrl={iiifServiceUrl} fallbackImageUrl={fallbackImageUrl} onViewerReady={() => {}} />
200
  {!iiifServiceUrl && !fallbackImageUrl && (
201
  <div className="absolute inset-0 flex items-center justify-center bg-retro-gray text-retro-darkgray text-retro-sm">
202
- Apercu non disponible
203
  </div>
204
  )}
205
  </div>
@@ -207,7 +207,7 @@ export default function Editor() {
207
 
208
  {/* ── Editor window (right) ──────────────────────────────── */}
209
  <RetroWindow
210
- title="Editeur"
211
  className="flex-1 min-w-0"
212
  scrollable
213
  >
@@ -240,7 +240,7 @@ export default function Editor() {
240
  rows={12}
241
  />
242
  <RetroSelect
243
- label="Statut editorial"
244
  value={editorialStatus}
245
  onChange={(e) => setEditorialStatus(e.target.value)}
246
  options={[
@@ -281,7 +281,7 @@ export default function Editor() {
281
  {activePanel === 'regions' && (
282
  <div className="flex flex-col gap-[2px]">
283
  {regions.length === 0 ? (
284
- <p className="text-retro-sm text-retro-darkgray p-2">Aucune region detectee.</p>
285
  ) : (
286
  regions.map((region) => {
287
  const validation = regionValidations[region.id]
@@ -332,7 +332,7 @@ export default function Editor() {
332
  {activePanel === 'history' && (
333
  <div className="flex flex-col gap-[2px]">
334
  {history.length === 0 ? (
335
- <p className="text-retro-sm text-retro-darkgray p-2">Aucune version archivee.</p>
336
  ) : (
337
  history.map((v) => (
338
  <div
 
69
  setRegionValidations(ext?.region_validations ?? {})
70
  } catch (e: unknown) {
71
  if (e instanceof ApiError && e.status === 404) {
72
+ setError('Cette page n\'a pas encore été analysée par l\'IA. Lancez le pipeline depuis Administration.')
73
  } else {
74
  const msg = e instanceof Error ? e.message : ''
75
  setError(msg || 'Erreur de chargement')
 
165
  <RetroMenuBar
166
  items={[
167
  { label: 'IIIF Studio', onClick: () => navigate('/') },
168
+ { label: `Éditeur — ${master?.folio_label ?? pageId}` },
169
  ]}
170
  right={
171
  <div className="flex items-center gap-1">
 
199
  <Viewer iiifServiceUrl={iiifServiceUrl} fallbackImageUrl={fallbackImageUrl} onViewerReady={() => {}} />
200
  {!iiifServiceUrl && !fallbackImageUrl && (
201
  <div className="absolute inset-0 flex items-center justify-center bg-retro-gray text-retro-darkgray text-retro-sm">
202
+ Aperçu non disponible
203
  </div>
204
  )}
205
  </div>
 
207
 
208
  {/* ── Editor window (right) ──────────────────────────────── */}
209
  <RetroWindow
210
+ title="Éditeur"
211
  className="flex-1 min-w-0"
212
  scrollable
213
  >
 
240
  rows={12}
241
  />
242
  <RetroSelect
243
+ label="Statut éditorial"
244
  value={editorialStatus}
245
  onChange={(e) => setEditorialStatus(e.target.value)}
246
  options={[
 
281
  {activePanel === 'regions' && (
282
  <div className="flex flex-col gap-[2px]">
283
  {regions.length === 0 ? (
284
+ <p className="text-retro-sm text-retro-darkgray p-2">Aucune région détectée.</p>
285
  ) : (
286
  regions.map((region) => {
287
  const validation = regionValidations[region.id]
 
332
  {activePanel === 'history' && (
333
  <div className="flex flex-col gap-[2px]">
334
  {history.length === 0 ? (
335
+ <p className="text-retro-sm text-retro-darkgray p-2">Aucune version archivée.</p>
336
  ) : (
337
  history.map((v) => (
338
  <div
frontend/src/pages/Home.tsx CHANGED
@@ -105,13 +105,13 @@ export default function Home() {
105
  <div className="flex-1 flex items-start justify-center p-6 gap-4">
106
  <RetroWindow
107
  title="Corpus disponibles"
108
- statusBar={`${corpora.length} corpus enregistre${corpora.length > 1 ? 's' : ''}`}
109
  className="w-full max-w-2xl"
110
  scrollable
111
  >
112
  {corpora.length === 0 ? (
113
  <div className="p-4 text-retro-sm text-retro-darkgray">
114
- Aucun corpus enregistre. Creez-en un via Administration.
115
  </div>
116
  ) : (
117
  <div className="divide-y divide-retro-gray">
 
105
  <div className="flex-1 flex items-start justify-center p-6 gap-4">
106
  <RetroWindow
107
  title="Corpus disponibles"
108
+ statusBar={`${corpora.length} corpus enregistré${corpora.length > 1 ? 's' : ''}`}
109
  className="w-full max-w-2xl"
110
  scrollable
111
  >
112
  {corpora.length === 0 ? (
113
  <div className="p-4 text-retro-sm text-retro-darkgray">
114
+ Aucun corpus enregistré. Créez-en un via Administration.
115
  </div>
116
  ) : (
117
  <div className="divide-y divide-retro-gray">
frontend/src/pages/Reader.tsx CHANGED
@@ -155,7 +155,7 @@ export default function Reader() {
155
  Next
156
  </RetroButton>
157
  <RetroButton size="sm" onClick={() => navigate(`/editor/${currentPage.id}`)}>
158
- Editer
159
  </RetroButton>
160
  </div>
161
  }
@@ -170,7 +170,7 @@ export default function Reader() {
170
  statusBar={
171
  master
172
  ? `${master.editorial.status} — v${master.editorial.version}`
173
- : (iiifServiceUrl || fallbackImageUrl) ? 'Page non analysee' : 'Aucune image'
174
  }
175
  className="flex-[7] min-w-0"
176
  >
@@ -217,7 +217,7 @@ export default function Reader() {
217
  <div className="absolute top-2 left-2">
218
  {masterError
219
  ? <RetroBadge variant="error">Erreur: {masterError}</RetroBadge>
220
- : <RetroBadge variant="warning">Non analysee</RetroBadge>
221
  }
222
  </div>
223
  )}
@@ -264,8 +264,8 @@ export default function Reader() {
264
  ) : (
265
  <div className="p-3 text-retro-sm text-retro-darkgray">
266
  {(iiifServiceUrl || fallbackImageUrl)
267
- ? 'Page non encore analysee par l\'IA.'
268
- : 'Aucune image associee a cette page.'
269
  }
270
  </div>
271
  )}
 
155
  Next
156
  </RetroButton>
157
  <RetroButton size="sm" onClick={() => navigate(`/editor/${currentPage.id}`)}>
158
+ Éditer
159
  </RetroButton>
160
  </div>
161
  }
 
170
  statusBar={
171
  master
172
  ? `${master.editorial.status} — v${master.editorial.version}`
173
+ : (iiifServiceUrl || fallbackImageUrl) ? 'Page non analysée' : 'Aucune image'
174
  }
175
  className="flex-[7] min-w-0"
176
  >
 
217
  <div className="absolute top-2 left-2">
218
  {masterError
219
  ? <RetroBadge variant="error">Erreur: {masterError}</RetroBadge>
220
+ : <RetroBadge variant="warning">Non analysée</RetroBadge>
221
  }
222
  </div>
223
  )}
 
264
  ) : (
265
  <div className="p-3 text-retro-sm text-retro-darkgray">
266
  {(iiifServiceUrl || fallbackImageUrl)
267
+ ? 'Page non encore analysée par l\'IA.'
268
+ : 'Aucune image associée à cette page.'
269
  }
270
  </div>
271
  )}