Spaces:
Build error
fix(sprint-f4-f5): tests faux-positifs, frontend bugs, Dockerfile unique
Browse filesSprint 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 +1 -1
- backend/tests/test_api_corrections.py +9 -2
- backend/tests/test_api_ingest.py +9 -0
- backend/tests/test_export_iiif.py +4 -1
- backend/tests/test_export_mets.py +3 -1
- frontend/src/App.tsx +1 -0
- frontend/src/lib/api.ts +8 -0
- frontend/src/pages/Editor.tsx +1 -1
- frontend/src/pages/Home.tsx +3 -2
- infra/Dockerfile +0 -71
|
@@ -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 |
-
#
|
| 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 .
|
|
@@ -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 |
-
|
| 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 |
-
|
| 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):
|
|
@@ -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):
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
# ---------------------------------------------------------------------------
|
|
@@ -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 |
-
|
|
|
|
|
|
|
| 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):
|
|
@@ -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 |
)
|
|
@@ -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 {
|
|
@@ -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 ?
|
| 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 (
|
|
@@ -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>
|
|
@@ -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"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|