Claude commited on
Commit
35a94af
Β·
unverified Β·
1 Parent(s): cd353f9

fix(sprint-f4-f5): tests faux-positifs, frontend bugs, Dockerfile unique

Browse files

Sprint F4 β€” Correction des tests faux-positifs :
- IIIF double-slash : le replace("://","X") masquait le bug, corrigΓ©
avec split("://",1) + vΓ©rification du path
- METS OBJID : ajout assert is not None avant comparaison
- Corrections archive : vΓ©rification du contenu JSON archivΓ© (pas juste
le path), assertion version == 1 dans l'archive
- Ingest idempotence : vérification BDD (count pages == 2) après
rΓ©ingestion, pas juste les compteurs de rΓ©ponse

Sprint F5 β€” Frontend + infra :
- Editor imageUrl : remplacΓ© `master ? '' : ''` par
`master?.image?.derivative_web ?? master?.image?.master ?? ''`
- SearchBar : connectΓ© onSelectResult β†’ onOpenPage β†’ Editor dans
Home.tsx et App.tsx (navigation fonctionnelle)
- api.ts : warning console si VITE_API_URL absent en production
- Dockerfile : supprimΓ© infra/Dockerfile (copie divergente),
Dockerfile racine est dΓ©sormais la source unique

477 tests passants, 0 Γ©checs.

https://claude.ai/code/session_015Lht7wNQRzhUaLw94dE9z9

Dockerfile CHANGED
@@ -1,6 +1,6 @@
1
  # Scriptorium AI β€” image de production (multi-stage)
2
  # Ce fichier est utilisΓ© par HuggingFace Spaces (SDK docker, dΓ©tection automatique).
3
- # Il doit rester synchronisΓ© avec infra/Dockerfile.
4
  #
5
  # Build depuis la racine du dΓ©pΓ΄t :
6
  # docker build -t scriptorium-ai .
 
1
  # Scriptorium AI β€” image de production (multi-stage)
2
  # Ce fichier est utilisΓ© par HuggingFace Spaces (SDK docker, dΓ©tection automatique).
3
+ # Source unique β€” le fichier infra/Dockerfile a Γ©tΓ© supprimΓ© pour Γ©viter la divergence.
4
  #
5
  # Build depuis la racine du dΓ©pΓ΄t :
6
  # docker build -t scriptorium-ai .
backend/tests/test_api_corrections.py CHANGED
@@ -238,13 +238,13 @@ async def test_corrections_archives_old_version(async_client, db_session, monkey
238
  ms = await _create_manuscript(db_session, corpus.id)
239
  page = await _create_page(db_session, ms.id)
240
 
241
- written_paths: list[str] = []
242
 
243
  monkeypatch.setattr(Path, "exists", lambda self: True)
244
  monkeypatch.setattr(Path, "read_text", lambda self, **kw: _make_master(page.id, version=1))
245
 
246
  def _capture_write(self: Path, content: str, **kw: object) -> None:
247
- written_paths.append(str(self))
248
 
249
  monkeypatch.setattr(Path, "write_text", _capture_write)
250
 
@@ -254,10 +254,17 @@ async def test_corrections_archives_old_version(async_client, db_session, monkey
254
  )
255
 
256
  # Deux Γ©critures attendues : master_v1.json (archive) + master.json (nouveau)
 
257
  assert len(written_paths) >= 2
258
  assert any("master_v1.json" in p for p in written_paths)
259
  assert any("master.json" in p and "master_v" not in p for p in written_paths)
260
 
 
 
 
 
 
 
261
 
262
  @pytest.mark.asyncio
263
  async def test_corrections_multiple_fields(async_client, db_session, monkeypatch):
 
238
  ms = await _create_manuscript(db_session, corpus.id)
239
  page = await _create_page(db_session, ms.id)
240
 
241
+ written_data: dict[str, str] = {}
242
 
243
  monkeypatch.setattr(Path, "exists", lambda self: True)
244
  monkeypatch.setattr(Path, "read_text", lambda self, **kw: _make_master(page.id, version=1))
245
 
246
  def _capture_write(self: Path, content: str, **kw: object) -> None:
247
+ written_data[str(self)] = content
248
 
249
  monkeypatch.setattr(Path, "write_text", _capture_write)
250
 
 
254
  )
255
 
256
  # Deux Γ©critures attendues : master_v1.json (archive) + master.json (nouveau)
257
+ written_paths = list(written_data.keys())
258
  assert len(written_paths) >= 2
259
  assert any("master_v1.json" in p for p in written_paths)
260
  assert any("master.json" in p and "master_v" not in p for p in written_paths)
261
 
262
+ # VΓ©rifier que l'archive contient bien la version originale (v1)
263
+ import json as _json
264
+ archive_path = next(p for p in written_paths if "master_v1.json" in p)
265
+ archive_data = _json.loads(written_data[archive_path])
266
+ assert archive_data["editorial"]["version"] == 1
267
+
268
 
269
  @pytest.mark.asyncio
270
  async def test_corrections_multiple_fields(async_client, db_session, monkeypatch):
backend/tests/test_api_ingest.py CHANGED
@@ -457,6 +457,15 @@ async def test_reingest_manifest_skips_existing_pages(async_client, db_session,
457
  assert data2["pages_created"] == 0
458
  assert data2["pages_skipped"] == 2
459
 
 
 
 
 
 
 
 
 
 
460
 
461
  @pytest.mark.asyncio
462
  async def test_reingest_images_skips_existing_pages(async_client, db_session):
 
457
  assert data2["pages_created"] == 0
458
  assert data2["pages_skipped"] == 2
459
 
460
+ # VΓ©rifier que la BDD n'a bien que 2 pages (pas de doublons)
461
+ from sqlalchemy import select as sa_select
462
+ from app.models.corpus import PageModel
463
+ page_result = await db_session.execute(
464
+ sa_select(PageModel).where(PageModel.manuscript_id == data1["manuscript_id"])
465
+ )
466
+ pages_in_db = list(page_result.scalars().all())
467
+ assert len(pages_in_db) == 2
468
+
469
 
470
  @pytest.mark.asyncio
471
  async def test_reingest_images_skips_existing_pages(async_client, db_session):
backend/tests/test_export_iiif.py CHANGED
@@ -480,7 +480,10 @@ def test_base_url_trailing_slash_stripped():
480
  """Un base_url avec slash final ne gΓ©nΓ¨re pas de double slash dans les IDs."""
481
  pages = [_make_page("ms-0001r", "0001r", 1)]
482
  manifest = generate_manifest(pages, _base_meta(), "https://example.com/")
483
- assert "//" not in manifest["id"].replace("://", "X")
 
 
 
484
 
485
 
486
  # ---------------------------------------------------------------------------
 
480
  """Un base_url avec slash final ne gΓ©nΓ¨re pas de double slash dans les IDs."""
481
  pages = [_make_page("ms-0001r", "0001r", 1)]
482
  manifest = generate_manifest(pages, _base_meta(), "https://example.com/")
483
+ manifest_id = manifest["id"]
484
+ # Retirer le protocole puis vΓ©rifier qu'il n'y a pas de double slash
485
+ without_protocol = manifest_id.split("://", 1)[1]
486
+ assert "//" not in without_protocol
487
 
488
 
489
  # ---------------------------------------------------------------------------
backend/tests/test_export_mets.py CHANGED
@@ -195,7 +195,9 @@ def test_generate_mets_namespace(beatus_pages, beatus_meta):
195
 
196
  def test_generate_mets_objid(beatus_pages, beatus_meta):
197
  root = _parse(generate_mets(beatus_pages, beatus_meta))
198
- assert root.get("OBJID") == "BnF-Latin-8878"
 
 
199
 
200
 
201
  def test_generate_mets_label(beatus_pages, beatus_meta):
 
195
 
196
  def test_generate_mets_objid(beatus_pages, beatus_meta):
197
  root = _parse(generate_mets(beatus_pages, beatus_meta))
198
+ objid = root.get("OBJID")
199
+ assert objid is not None, "OBJID attribute absent du root mets"
200
+ assert objid == "BnF-Latin-8878"
201
 
202
 
203
  def test_generate_mets_label(beatus_pages, beatus_meta):
frontend/src/App.tsx CHANGED
@@ -42,6 +42,7 @@ export default function App() {
42
  onOpenManuscript={(manuscriptId, profileId) =>
43
  setView({ name: 'reader', manuscriptId, profileId })
44
  }
 
45
  onAdmin={() => setView({ name: 'admin' })}
46
  />
47
  )
 
42
  onOpenManuscript={(manuscriptId, profileId) =>
43
  setView({ name: 'reader', manuscriptId, profileId })
44
  }
45
+ onOpenPage={(pageId) => setView({ name: 'editor', pageId })}
46
  onAdmin={() => setView({ name: 'admin' })}
47
  />
48
  )
frontend/src/lib/api.ts CHANGED
@@ -1,5 +1,13 @@
1
  const BASE_URL: string = import.meta.env.VITE_API_URL ?? ''
2
 
 
 
 
 
 
 
 
 
3
  // ── Types ─────────────────────────────────────────────────────────────────────
4
 
5
  export interface ProviderInfo {
 
1
  const BASE_URL: string = import.meta.env.VITE_API_URL ?? ''
2
 
3
+ if (!BASE_URL && import.meta.env.PROD) {
4
+ console.warn(
5
+ '[Scriptorium] VITE_API_URL non dΓ©fini en production. ' +
6
+ 'Les appels API utiliseront des chemins relatifs, ce qui peut Γ©chouer ' +
7
+ 'si le frontend n\'est pas servi par le mΓͺme domaine que le backend.'
8
+ )
9
+ }
10
+
11
  // ── Types ─────────────────────────────────────────────────────────────────────
12
 
13
  export interface ProviderInfo {
frontend/src/pages/Editor.tsx CHANGED
@@ -119,7 +119,7 @@ export default function Editor({ pageId, onBack }: Props) {
119
  return <div className="p-8 text-red-600">Erreur : {error}</div>
120
  }
121
 
122
- const imageUrl = master ? '' : '' // image path not directly stored on PageMaster
123
  const regions = master?.layout?.regions ?? []
124
 
125
  return (
 
119
  return <div className="p-8 text-red-600">Erreur : {error}</div>
120
  }
121
 
122
+ const imageUrl = master?.image?.derivative_web ?? master?.image?.master ?? ''
123
  const regions = master?.layout?.regions ?? []
124
 
125
  return (
frontend/src/pages/Home.tsx CHANGED
@@ -10,10 +10,11 @@ import {
10
 
11
  interface Props {
12
  onOpenManuscript: (manuscriptId: string, profileId: string) => void
 
13
  onAdmin: () => void
14
  }
15
 
16
- export default function Home({ onOpenManuscript, onAdmin }: Props) {
17
  const [corpora, setCorpora] = useState<Corpus[]>([])
18
  const [loading, setLoading] = useState(true)
19
  const [error, setError] = useState<string | null>(null)
@@ -73,7 +74,7 @@ export default function Home({ onOpenManuscript, onAdmin }: Props) {
73
  </p>
74
  </div>
75
  <div className="flex items-center gap-4">
76
- <SearchBar />
77
  <AdminNav onClick={onAdmin} />
78
  </div>
79
  </header>
 
10
 
11
  interface Props {
12
  onOpenManuscript: (manuscriptId: string, profileId: string) => void
13
+ onOpenPage?: (pageId: string) => void
14
  onAdmin: () => void
15
  }
16
 
17
+ export default function Home({ onOpenManuscript, onOpenPage, onAdmin }: Props) {
18
  const [corpora, setCorpora] = useState<Corpus[]>([])
19
  const [loading, setLoading] = useState(true)
20
  const [error, setError] = useState<string | null>(null)
 
74
  </p>
75
  </div>
76
  <div className="flex items-center gap-4">
77
+ <SearchBar onSelectResult={onOpenPage ? (r) => onOpenPage(r.page_id) : undefined} />
78
  <AdminNav onClick={onAdmin} />
79
  </div>
80
  </header>
infra/Dockerfile DELETED
@@ -1,71 +0,0 @@
1
- # Scriptorium AI β€” image de production (multi-stage)
2
- # Ce fichier est la copie exacte de Dockerfile (racine).
3
- # Build depuis la racine du dΓ©pΓ΄t :
4
- # docker build -f infra/Dockerfile -t scriptorium-ai .
5
- #
6
- # Structure attendue dans l'image :
7
- # /app/backend/app/ ← source Python (importable via PYTHONPATH)
8
- # /app/profiles/ ← profils JSON
9
- # /app/prompts/ ← templates de prompts
10
- # /app/static/ ← frontend React buildΓ©
11
- # /app/data/ ← créé vide ; Γ  monter en volume pour les artefacts
12
-
13
- # ── Stage 1 : build du frontend React ────────────────────────────────────────
14
- FROM node:20-slim AS frontend-builder
15
-
16
- WORKDIR /frontend
17
-
18
- # Installer les dΓ©pendances (cache layer sΓ©parΓ©)
19
- COPY frontend/package.json ./
20
- RUN npm install
21
-
22
- # Copier les sources et builder
23
- COPY frontend/ ./
24
- RUN npm run build
25
-
26
- # ── Stage 2 : image Python finale ────────────────────────────────────────────
27
- FROM python:3.11-slim
28
-
29
- WORKDIR /app
30
-
31
- # ── DΓ©pendances Python ─────────────────────────────────────────────────────
32
- # On copie uniquement pyproject.toml pour exploiter le cache de layers Docker.
33
- # Un stub app/__init__.py satisfait setuptools (discover packages) sans avoir
34
- # besoin de copier tout le code source Γ  ce stade.
35
- COPY backend/pyproject.toml /tmp/build/
36
- RUN mkdir -p /tmp/build/app \
37
- && touch /tmp/build/app/__init__.py \
38
- && pip install --no-cache-dir --upgrade /tmp/build/ \
39
- && rm -rf /tmp/build
40
-
41
- # ── Layer dΓ©diΓ© mistralai β€” invalide le cache HF si v0.x est prΓ©sent ─────
42
- # Layer sΓ©parΓ© de l'install principal pour forcer la mise Γ  jour mΓͺme si
43
- # HuggingFace rΓ©utilise le layer pyproject.toml depuis un build antΓ©rieur.
44
- RUN pip install --no-cache-dir 'mistralai>=1.0,<2.0'
45
-
46
- # ── Code source backend ────────────────────────────────────────────────────
47
- COPY backend/app ./backend/app
48
- COPY profiles/ ./profiles/
49
- COPY prompts/ ./prompts/
50
-
51
- # ── Frontend buildΓ© ────────────────────────────────────────────────────────
52
- COPY --from=frontend-builder /frontend/dist ./static
53
-
54
- # ── RΓ©pertoire des artefacts (vide dans l'image ; montΓ© en volume) ─────────
55
- RUN mkdir -p /app/data
56
-
57
- # ── Secrets Google AI : JAMAIS dans l'image (R06) ─────────────────────────
58
- # Passer au runtime via -e ou docker-compose environment :
59
- # AI_PROVIDER, GOOGLE_AI_STUDIO_API_KEY, GOOGLE_AI_API_KEY,
60
- # GOOGLE_VERTEX_PROJECT, GOOGLE_VERTEX_LOCATION
61
-
62
- # PYTHONPATH permet l'import `app.main:app` depuis /app/backend/app/
63
- ENV PYTHONPATH=/app/backend
64
- ENV PROFILES_DIR=/app/profiles
65
- ENV PROMPTS_DIR=/app/prompts
66
- ENV DATA_DIR=/app/data
67
-
68
- EXPOSE 7860
69
-
70
- # 1 worker au MVP β€” pas de Gunicorn, pas de multiprocessing
71
- CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"]