Spaces:
Build error
feat(sprint6-B): validation éditoriale + recherche plein texte
Browse filesBackend :
- POST /api/v1/pages/{id}/corrections — corrections partielles avec
versionnement automatique (archive master_v{n}.json, incrémente version)
- GET /api/v1/pages/{id}/history — liste des versions archivées
- GET /api/v1/search?q= — recherche plein texte dans les master.json
(ocr.diplomatic_text, translation.fr, extensions.iconography[].tags),
insensible à la casse et aux accents
Tests : 26 nouveaux tests (13 corrections + 13 search), 503 au total
Frontend :
- Editor.tsx — éditeur 50/50 : visionneuse + 4 panneaux (transcription,
commentaire, régions valider/rejeter, historique avec restauration)
- SearchBar.tsx — barre de recherche debouncée 300ms avec dropdown
- App.tsx — vue editor ajoutée
- Reader.tsx — bouton "Éditer cette page" (prop onEdit)
- Home.tsx — SearchBar dans le header
- api.ts — VersionInfo, CorrectionsInput, SearchResult, fetchPage,
applyCorrections, getHistory, searchPages
https://claude.ai/code/session_018woyEHc8HG2th7V4ewJ4Kg
- backend/app/api/v1/pages.py +203 -4
- backend/app/api/v1/search.py +134 -0
- backend/app/main.py +2 -1
- backend/tests/test_api_corrections.py +353 -0
- backend/tests/test_api_search.py +280 -0
- frontend/src/App.tsx +12 -0
- frontend/src/components/SearchBar.tsx +108 -0
- frontend/src/lib/api.ts +39 -0
- frontend/src/pages/Editor.tsx +356 -0
- frontend/src/pages/Home.tsx +5 -1
- frontend/src/pages/Reader.tsx +10 -1
|
@@ -1,21 +1,24 @@
|
|
| 1 |
"""
|
| 2 |
-
Endpoints lecture des pages et de leur master.json (R10 — préfixe /api/v1/).
|
| 3 |
|
| 4 |
GET /api/v1/pages/{page_id}
|
| 5 |
GET /api/v1/pages/{page_id}/master-json
|
| 6 |
GET /api/v1/pages/{page_id}/layers
|
|
|
|
|
|
|
| 7 |
|
| 8 |
-
Règle (R02) : le master.json est la source canonique.
|
| 9 |
-
depuis data/ — ils ne reconstruisent jamais une réponse depuis d'autres sources.
|
| 10 |
"""
|
| 11 |
# 1. stdlib
|
| 12 |
import json
|
| 13 |
import logging
|
|
|
|
| 14 |
from pathlib import Path
|
|
|
|
| 15 |
|
| 16 |
# 2. third-party
|
| 17 |
from fastapi import APIRouter, Depends, HTTPException
|
| 18 |
-
from pydantic import BaseModel, ConfigDict
|
| 19 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 20 |
|
| 21 |
# 3. local
|
|
@@ -32,6 +35,27 @@ router = APIRouter(prefix="/pages", tags=["pages"])
|
|
| 32 |
|
| 33 |
# ── Schémas de réponse ────────────────────────────────────────────────────────
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
class PageResponse(BaseModel):
|
| 36 |
model_config = ConfigDict(from_attributes=True)
|
| 37 |
|
|
@@ -82,6 +106,87 @@ async def _load_master(
|
|
| 82 |
return PageMaster.model_validate(raw)
|
| 83 |
|
| 84 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
| 86 |
|
| 87 |
@router.get("/{page_id}", response_model=PageResponse)
|
|
@@ -201,3 +306,97 @@ async def get_page_layers(
|
|
| 201 |
extra={"page_id": page_id, "count": len(layers)},
|
| 202 |
)
|
| 203 |
return layers
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
Endpoints lecture et écriture des pages et de leur master.json (R10 — préfixe /api/v1/).
|
| 3 |
|
| 4 |
GET /api/v1/pages/{page_id}
|
| 5 |
GET /api/v1/pages/{page_id}/master-json
|
| 6 |
GET /api/v1/pages/{page_id}/layers
|
| 7 |
+
POST /api/v1/pages/{page_id}/corrections
|
| 8 |
+
GET /api/v1/pages/{page_id}/history
|
| 9 |
|
| 10 |
+
Règle (R02) : le master.json est la source canonique.
|
|
|
|
| 11 |
"""
|
| 12 |
# 1. stdlib
|
| 13 |
import json
|
| 14 |
import logging
|
| 15 |
+
from datetime import datetime, timezone
|
| 16 |
from pathlib import Path
|
| 17 |
+
from typing import Any
|
| 18 |
|
| 19 |
# 2. third-party
|
| 20 |
from fastapi import APIRouter, Depends, HTTPException
|
| 21 |
+
from pydantic import BaseModel, ConfigDict, ValidationError
|
| 22 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 23 |
|
| 24 |
# 3. local
|
|
|
|
| 35 |
|
| 36 |
# ── Schémas de réponse ────────────────────────────────────────────────────────
|
| 37 |
|
| 38 |
+
class CorrectionsRequest(BaseModel):
|
| 39 |
+
"""Corrections partielles du master.json. Tous les champs sont optionnels.
|
| 40 |
+
|
| 41 |
+
Si restore_to_version est fourni, les autres champs sont ignorés et la version
|
| 42 |
+
indiquée est restaurée (avec incrémentation de editorial.version).
|
| 43 |
+
"""
|
| 44 |
+
|
| 45 |
+
ocr_diplomatic_text: str | None = None
|
| 46 |
+
editorial_status: str | None = None
|
| 47 |
+
commentary_public: str | None = None
|
| 48 |
+
commentary_scholarly: str | None = None
|
| 49 |
+
region_validations: dict[str, str] | None = None
|
| 50 |
+
restore_to_version: int | None = None
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class VersionInfo(BaseModel):
|
| 54 |
+
version: int
|
| 55 |
+
saved_at: datetime
|
| 56 |
+
status: str
|
| 57 |
+
|
| 58 |
+
|
| 59 |
class PageResponse(BaseModel):
|
| 60 |
model_config = ConfigDict(from_attributes=True)
|
| 61 |
|
|
|
|
| 106 |
return PageMaster.model_validate(raw)
|
| 107 |
|
| 108 |
|
| 109 |
+
# ── Helpers corrections & versioning ──────────────────────────────────────────
|
| 110 |
+
|
| 111 |
+
async def _get_page_dir(page: PageModel, db: AsyncSession) -> Path | None:
|
| 112 |
+
"""Retourne le répertoire data/corpora/{slug}/pages/{page.id} ou None."""
|
| 113 |
+
manuscript = await db.get(ManuscriptModel, page.manuscript_id)
|
| 114 |
+
if manuscript is None:
|
| 115 |
+
return None
|
| 116 |
+
corpus = await db.get(CorpusModel, manuscript.corpus_id)
|
| 117 |
+
if corpus is None:
|
| 118 |
+
return None
|
| 119 |
+
return (
|
| 120 |
+
_config_module.settings.data_dir
|
| 121 |
+
/ "corpora"
|
| 122 |
+
/ corpus.slug
|
| 123 |
+
/ "pages"
|
| 124 |
+
/ page.id
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def _archive_master(page_dir: Path, master: PageMaster) -> None:
|
| 129 |
+
"""Archive master.json sous master_v{version}.json avant toute modification."""
|
| 130 |
+
archive_path = page_dir / f"master_v{master.editorial.version}.json"
|
| 131 |
+
archive_path.write_text(
|
| 132 |
+
json.dumps(master.model_dump(mode="json"), ensure_ascii=False, indent=2),
|
| 133 |
+
encoding="utf-8",
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def _write_master(page_dir: Path, master: PageMaster) -> None:
|
| 138 |
+
"""Écrit le master.json validé sur le disque."""
|
| 139 |
+
master_path = page_dir / "master.json"
|
| 140 |
+
master_path.write_text(
|
| 141 |
+
json.dumps(master.model_dump(mode="json"), ensure_ascii=False, indent=2),
|
| 142 |
+
encoding="utf-8",
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _apply_corrections_to_master(
|
| 147 |
+
master: PageMaster, req: CorrectionsRequest
|
| 148 |
+
) -> PageMaster:
|
| 149 |
+
"""Applique les corrections partielles et incrémente editorial.version."""
|
| 150 |
+
data: dict[str, Any] = master.model_dump(mode="json")
|
| 151 |
+
|
| 152 |
+
if req.ocr_diplomatic_text is not None:
|
| 153 |
+
if data.get("ocr") is None:
|
| 154 |
+
data["ocr"] = {
|
| 155 |
+
"diplomatic_text": "",
|
| 156 |
+
"blocks": [],
|
| 157 |
+
"lines": [],
|
| 158 |
+
"language": "la",
|
| 159 |
+
"confidence": 0.0,
|
| 160 |
+
"uncertain_segments": [],
|
| 161 |
+
}
|
| 162 |
+
data["ocr"]["diplomatic_text"] = req.ocr_diplomatic_text
|
| 163 |
+
|
| 164 |
+
if req.editorial_status is not None:
|
| 165 |
+
data["editorial"]["status"] = req.editorial_status
|
| 166 |
+
|
| 167 |
+
if req.commentary_public is not None:
|
| 168 |
+
if data.get("commentary") is None:
|
| 169 |
+
data["commentary"] = {"public": "", "scholarly": "", "claims": []}
|
| 170 |
+
data["commentary"]["public"] = req.commentary_public
|
| 171 |
+
|
| 172 |
+
if req.commentary_scholarly is not None:
|
| 173 |
+
if data.get("commentary") is None:
|
| 174 |
+
data["commentary"] = {"public": "", "scholarly": "", "claims": []}
|
| 175 |
+
data["commentary"]["scholarly"] = req.commentary_scholarly
|
| 176 |
+
|
| 177 |
+
if req.region_validations is not None:
|
| 178 |
+
existing: dict[str, str] = (
|
| 179 |
+
data.get("extensions", {}).get("region_validations") or {}
|
| 180 |
+
)
|
| 181 |
+
existing.update(req.region_validations)
|
| 182 |
+
if "extensions" not in data:
|
| 183 |
+
data["extensions"] = {}
|
| 184 |
+
data["extensions"]["region_validations"] = existing
|
| 185 |
+
|
| 186 |
+
data["editorial"]["version"] += 1
|
| 187 |
+
return PageMaster.model_validate(data)
|
| 188 |
+
|
| 189 |
+
|
| 190 |
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
| 191 |
|
| 192 |
@router.get("/{page_id}", response_model=PageResponse)
|
|
|
|
| 306 |
extra={"page_id": page_id, "count": len(layers)},
|
| 307 |
)
|
| 308 |
return layers
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
@router.post("/{page_id}/corrections")
|
| 312 |
+
async def apply_corrections(
|
| 313 |
+
page_id: str,
|
| 314 |
+
body: CorrectionsRequest,
|
| 315 |
+
db: AsyncSession = Depends(get_db),
|
| 316 |
+
) -> dict:
|
| 317 |
+
"""Applique des corrections partielles au master.json.
|
| 318 |
+
|
| 319 |
+
Avant toute modification, l'état courant est archivé sous master_v{n}.json.
|
| 320 |
+
editorial.version est incrémenté à chaque correction.
|
| 321 |
+
Si restore_to_version est fourni, restaure la version archivée demandée.
|
| 322 |
+
"""
|
| 323 |
+
page = await db.get(PageModel, page_id)
|
| 324 |
+
if page is None:
|
| 325 |
+
raise HTTPException(status_code=404, detail="Page introuvable")
|
| 326 |
+
|
| 327 |
+
master = await _load_master(page, db)
|
| 328 |
+
if master is None:
|
| 329 |
+
raise HTTPException(
|
| 330 |
+
status_code=404,
|
| 331 |
+
detail="master.json introuvable — la page n'a pas encore été analysée",
|
| 332 |
+
)
|
| 333 |
+
|
| 334 |
+
page_dir = await _get_page_dir(page, db)
|
| 335 |
+
if page_dir is None:
|
| 336 |
+
raise HTTPException(status_code=500, detail="Répertoire de page introuvable")
|
| 337 |
+
|
| 338 |
+
if body.restore_to_version is not None:
|
| 339 |
+
archive_path = page_dir / f"master_v{body.restore_to_version}.json"
|
| 340 |
+
if not archive_path.exists():
|
| 341 |
+
raise HTTPException(
|
| 342 |
+
status_code=404,
|
| 343 |
+
detail=f"Version {body.restore_to_version} introuvable",
|
| 344 |
+
)
|
| 345 |
+
_archive_master(page_dir, master)
|
| 346 |
+
old_data: dict[str, Any] = json.loads(archive_path.read_text(encoding="utf-8"))
|
| 347 |
+
old_data["editorial"]["version"] = master.editorial.version + 1
|
| 348 |
+
try:
|
| 349 |
+
new_master = PageMaster.model_validate(old_data)
|
| 350 |
+
except ValidationError as exc:
|
| 351 |
+
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
| 352 |
+
else:
|
| 353 |
+
_archive_master(page_dir, master)
|
| 354 |
+
try:
|
| 355 |
+
new_master = _apply_corrections_to_master(master, body)
|
| 356 |
+
except ValidationError as exc:
|
| 357 |
+
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
| 358 |
+
|
| 359 |
+
_write_master(page_dir, new_master)
|
| 360 |
+
logger.info(
|
| 361 |
+
"Corrections appliquées",
|
| 362 |
+
extra={"page_id": page_id, "version": new_master.editorial.version},
|
| 363 |
+
)
|
| 364 |
+
return new_master.model_dump(mode="json")
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
@router.get("/{page_id}/history", response_model=list[VersionInfo])
|
| 368 |
+
async def get_page_history(
|
| 369 |
+
page_id: str,
|
| 370 |
+
db: AsyncSession = Depends(get_db),
|
| 371 |
+
) -> list[VersionInfo]:
|
| 372 |
+
"""Liste les versions archivées du master.json (master_v*.json).
|
| 373 |
+
|
| 374 |
+
Retourne [] si le répertoire de page n'existe pas encore.
|
| 375 |
+
"""
|
| 376 |
+
page = await db.get(PageModel, page_id)
|
| 377 |
+
if page is None:
|
| 378 |
+
raise HTTPException(status_code=404, detail="Page introuvable")
|
| 379 |
+
|
| 380 |
+
page_dir = await _get_page_dir(page, db)
|
| 381 |
+
if page_dir is None:
|
| 382 |
+
raise HTTPException(status_code=500, detail="Répertoire de page introuvable")
|
| 383 |
+
|
| 384 |
+
if not page_dir.exists():
|
| 385 |
+
return []
|
| 386 |
+
|
| 387 |
+
versions: list[VersionInfo] = []
|
| 388 |
+
for vpath in sorted(page_dir.glob("master_v*.json")):
|
| 389 |
+
try:
|
| 390 |
+
data = json.loads(vpath.read_text(encoding="utf-8"))
|
| 391 |
+
version_num = data.get("editorial", {}).get("version", 0)
|
| 392 |
+
status = data.get("editorial", {}).get("status", "machine_draft")
|
| 393 |
+
saved_at = datetime.fromtimestamp(
|
| 394 |
+
vpath.stat().st_mtime, tz=timezone.utc
|
| 395 |
+
)
|
| 396 |
+
versions.append(
|
| 397 |
+
VersionInfo(version=version_num, saved_at=saved_at, status=status)
|
| 398 |
+
)
|
| 399 |
+
except (json.JSONDecodeError, KeyError, OSError):
|
| 400 |
+
continue
|
| 401 |
+
|
| 402 |
+
return sorted(versions, key=lambda v: v.version)
|
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Endpoint de recherche plein texte (R10 — préfixe /api/v1/).
|
| 3 |
+
|
| 4 |
+
GET /api/v1/search?q={query}
|
| 5 |
+
|
| 6 |
+
Implémentation MVP : scan des fichiers master.json (pas d'index externe).
|
| 7 |
+
Insensible à la casse et aux accents (unicodedata NFD + ASCII).
|
| 8 |
+
"""
|
| 9 |
+
# 1. stdlib
|
| 10 |
+
import json
|
| 11 |
+
import logging
|
| 12 |
+
import unicodedata
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
# 2. third-party
|
| 16 |
+
from fastapi import APIRouter, Query
|
| 17 |
+
from pydantic import BaseModel
|
| 18 |
+
|
| 19 |
+
# 3. local
|
| 20 |
+
from app import config as _config_module
|
| 21 |
+
|
| 22 |
+
logger = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
router = APIRouter(tags=["search"])
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
# ── Schémas ───────────────────────────────────────────────────────────────────
|
| 28 |
+
|
| 29 |
+
class SearchResult(BaseModel):
|
| 30 |
+
page_id: str
|
| 31 |
+
folio_label: str
|
| 32 |
+
manuscript_id: str
|
| 33 |
+
excerpt: str
|
| 34 |
+
score: int
|
| 35 |
+
corpus_profile: str
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
| 39 |
+
|
| 40 |
+
def _normalize(text: str) -> str:
|
| 41 |
+
"""Minuscules + suppression des accents (NFD → ASCII)."""
|
| 42 |
+
nfd = unicodedata.normalize("NFD", text.lower())
|
| 43 |
+
return nfd.encode("ascii", "ignore").decode("ascii")
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _excerpt(text: str, query_normalized: str, context: int = 120) -> str:
|
| 47 |
+
"""Extrait un contexte autour de la première occurrence de la requête."""
|
| 48 |
+
text_n = _normalize(text)
|
| 49 |
+
idx = text_n.find(query_normalized)
|
| 50 |
+
if idx == -1:
|
| 51 |
+
return text[: context * 2]
|
| 52 |
+
start = max(0, idx - context // 2)
|
| 53 |
+
end = min(len(text), idx + len(query_normalized) + context // 2)
|
| 54 |
+
result = text[start:end]
|
| 55 |
+
if start > 0:
|
| 56 |
+
result = "…" + result
|
| 57 |
+
if end < len(text):
|
| 58 |
+
result = result + "…"
|
| 59 |
+
return result
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _score_master(data: dict, query_normalized: str) -> tuple[int, str]:
|
| 63 |
+
"""Retourne (nombre d'occurrences, premier extrait) pour un master.json."""
|
| 64 |
+
texts: list[str] = []
|
| 65 |
+
|
| 66 |
+
if data.get("ocr") and data["ocr"].get("diplomatic_text"):
|
| 67 |
+
texts.append(data["ocr"]["diplomatic_text"])
|
| 68 |
+
|
| 69 |
+
if data.get("translation") and data["translation"].get("fr"):
|
| 70 |
+
texts.append(data["translation"]["fr"])
|
| 71 |
+
|
| 72 |
+
# Extensions : champs iconography[].tags (profils qui les exposent)
|
| 73 |
+
extensions = data.get("extensions") or {}
|
| 74 |
+
icono = extensions.get("iconography") or []
|
| 75 |
+
if isinstance(icono, list):
|
| 76 |
+
for item in icono:
|
| 77 |
+
if isinstance(item, dict):
|
| 78 |
+
tags = item.get("tags") or []
|
| 79 |
+
if isinstance(tags, list):
|
| 80 |
+
texts.extend(str(t) for t in tags)
|
| 81 |
+
|
| 82 |
+
count = 0
|
| 83 |
+
first_excerpt = ""
|
| 84 |
+
for text in texts:
|
| 85 |
+
n = _normalize(text)
|
| 86 |
+
hits = n.count(query_normalized)
|
| 87 |
+
count += hits
|
| 88 |
+
if hits > 0 and not first_excerpt:
|
| 89 |
+
first_excerpt = _excerpt(text, query_normalized)
|
| 90 |
+
|
| 91 |
+
return count, first_excerpt
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
# ── Endpoint ──────────────────────────────────────────────────────────────────
|
| 95 |
+
|
| 96 |
+
@router.get("/search", response_model=list[SearchResult])
|
| 97 |
+
async def search_pages(
|
| 98 |
+
q: str = Query(..., min_length=2, description="Requête de recherche (min. 2 caractères)"),
|
| 99 |
+
) -> list[SearchResult]:
|
| 100 |
+
"""Recherche plein texte dans les master.json de tous les corpus.
|
| 101 |
+
|
| 102 |
+
Cherche dans : ocr.diplomatic_text, translation.fr,
|
| 103 |
+
extensions.iconography[].tags (si présent).
|
| 104 |
+
Insensible à la casse et aux accents.
|
| 105 |
+
"""
|
| 106 |
+
query_normalized = _normalize(q.strip())
|
| 107 |
+
data_dir = _config_module.settings.data_dir
|
| 108 |
+
|
| 109 |
+
results: list[SearchResult] = []
|
| 110 |
+
|
| 111 |
+
for master_path in data_dir.glob("corpora/*/pages/*/master.json"):
|
| 112 |
+
try:
|
| 113 |
+
raw: dict = json.loads(master_path.read_text(encoding="utf-8"))
|
| 114 |
+
except (json.JSONDecodeError, OSError):
|
| 115 |
+
continue
|
| 116 |
+
|
| 117 |
+
score, excerpt = _score_master(raw, query_normalized)
|
| 118 |
+
if score == 0:
|
| 119 |
+
continue
|
| 120 |
+
|
| 121 |
+
results.append(
|
| 122 |
+
SearchResult(
|
| 123 |
+
page_id=raw.get("page_id", ""),
|
| 124 |
+
folio_label=raw.get("folio_label", ""),
|
| 125 |
+
manuscript_id=raw.get("manuscript_id", ""),
|
| 126 |
+
excerpt=excerpt,
|
| 127 |
+
score=score,
|
| 128 |
+
corpus_profile=raw.get("corpus_profile", ""),
|
| 129 |
+
)
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
results.sort(key=lambda r: r.score, reverse=True)
|
| 133 |
+
logger.info("Recherche exécutée", extra={"q": q, "results": len(results)})
|
| 134 |
+
return results
|
|
@@ -17,7 +17,7 @@ from fastapi.responses import FileResponse, RedirectResponse
|
|
| 17 |
|
| 18 |
# 3. local — on importe les modèles pour que Base.metadata les connaisse
|
| 19 |
import app.models # noqa: F401 (enregistrement des modèles SQLAlchemy)
|
| 20 |
-
from app.api.v1 import corpora, export, ingest, jobs, manuscripts, models_api, pages, profiles
|
| 21 |
from app.models.database import Base, engine
|
| 22 |
|
| 23 |
logger = logging.getLogger(__name__)
|
|
@@ -61,6 +61,7 @@ app.include_router(profiles.router, prefix=_V1_PREFIX)
|
|
| 61 |
app.include_router(jobs.router, prefix=_V1_PREFIX)
|
| 62 |
app.include_router(ingest.router, prefix=_V1_PREFIX)
|
| 63 |
app.include_router(models_api.router, prefix=_V1_PREFIX)
|
|
|
|
| 64 |
|
| 65 |
# ── Serving frontend SPA (production) ou redirect /docs (dev) ────────────────
|
| 66 |
_STATIC_DIR = Path("/app/static")
|
|
|
|
| 17 |
|
| 18 |
# 3. local — on importe les modèles pour que Base.metadata les connaisse
|
| 19 |
import app.models # noqa: F401 (enregistrement des modèles SQLAlchemy)
|
| 20 |
+
from app.api.v1 import corpora, export, ingest, jobs, manuscripts, models_api, pages, profiles, search
|
| 21 |
from app.models.database import Base, engine
|
| 22 |
|
| 23 |
logger = logging.getLogger(__name__)
|
|
|
|
| 61 |
app.include_router(jobs.router, prefix=_V1_PREFIX)
|
| 62 |
app.include_router(ingest.router, prefix=_V1_PREFIX)
|
| 63 |
app.include_router(models_api.router, prefix=_V1_PREFIX)
|
| 64 |
+
app.include_router(search.router, prefix=_V1_PREFIX)
|
| 65 |
|
| 66 |
# ── Serving frontend SPA (production) ou redirect /docs (dev) ────────────────
|
| 67 |
_STATIC_DIR = Path("/app/static")
|
|
@@ -0,0 +1,353 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tests des endpoints corrections et historique (Sprint 6 — Session B).
|
| 3 |
+
|
| 4 |
+
POST /api/v1/pages/{id}/corrections → corrections partielles, versionnement
|
| 5 |
+
GET /api/v1/pages/{id}/history → liste des versions archivées
|
| 6 |
+
"""
|
| 7 |
+
# 1. stdlib
|
| 8 |
+
import json
|
| 9 |
+
import uuid
|
| 10 |
+
from datetime import datetime, timezone
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
# 2. third-party
|
| 14 |
+
import pytest
|
| 15 |
+
|
| 16 |
+
# 3. local
|
| 17 |
+
from app.models.corpus import CorpusModel, ManuscriptModel, PageModel
|
| 18 |
+
from tests.conftest_api import async_client, db_session # noqa: F401
|
| 19 |
+
|
| 20 |
+
_NOW = datetime.now(timezone.utc)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
# ── Helpers ────────────────────────────────────────────────────────────────────
|
| 24 |
+
|
| 25 |
+
async def _create_corpus(db_session, slug: str = "test-corpus") -> CorpusModel:
|
| 26 |
+
corpus = CorpusModel(
|
| 27 |
+
id=str(uuid.uuid4()),
|
| 28 |
+
slug=slug,
|
| 29 |
+
title="Test Corpus",
|
| 30 |
+
profile_id="medieval-illuminated",
|
| 31 |
+
created_at=_NOW,
|
| 32 |
+
updated_at=_NOW,
|
| 33 |
+
)
|
| 34 |
+
db_session.add(corpus)
|
| 35 |
+
await db_session.commit()
|
| 36 |
+
await db_session.refresh(corpus)
|
| 37 |
+
return corpus
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
async def _create_manuscript(db_session, corpus_id: str) -> ManuscriptModel:
|
| 41 |
+
ms = ManuscriptModel(
|
| 42 |
+
id=str(uuid.uuid4()),
|
| 43 |
+
corpus_id=corpus_id,
|
| 44 |
+
title="Test MS",
|
| 45 |
+
total_pages=1,
|
| 46 |
+
)
|
| 47 |
+
db_session.add(ms)
|
| 48 |
+
await db_session.commit()
|
| 49 |
+
await db_session.refresh(ms)
|
| 50 |
+
return ms
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
async def _create_page(db_session, manuscript_id: str) -> PageModel:
|
| 54 |
+
page = PageModel(
|
| 55 |
+
id=str(uuid.uuid4()),
|
| 56 |
+
manuscript_id=manuscript_id,
|
| 57 |
+
folio_label="f001r",
|
| 58 |
+
sequence=1,
|
| 59 |
+
image_master_path="/data/f001r.jpg",
|
| 60 |
+
processing_status="ANALYZED",
|
| 61 |
+
)
|
| 62 |
+
db_session.add(page)
|
| 63 |
+
await db_session.commit()
|
| 64 |
+
await db_session.refresh(page)
|
| 65 |
+
return page
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _make_master(
|
| 69 |
+
page_id: str, version: int = 1, status: str = "machine_draft"
|
| 70 |
+
) -> str:
|
| 71 |
+
return json.dumps({
|
| 72 |
+
"schema_version": "1.0",
|
| 73 |
+
"page_id": page_id,
|
| 74 |
+
"corpus_profile": "medieval-illuminated",
|
| 75 |
+
"manuscript_id": "ms-test",
|
| 76 |
+
"folio_label": "f001r",
|
| 77 |
+
"sequence": 1,
|
| 78 |
+
"image": {"original_url": "https://example.com/f.jpg", "width": 1500, "height": 2000},
|
| 79 |
+
"layout": {"regions": []},
|
| 80 |
+
"ocr": {
|
| 81 |
+
"diplomatic_text": "Incipit liber primus",
|
| 82 |
+
"blocks": [], "lines": [], "language": "la",
|
| 83 |
+
"confidence": 0.87, "uncertain_segments": [],
|
| 84 |
+
},
|
| 85 |
+
"translation": {"fr": "", "en": ""},
|
| 86 |
+
"summary": None,
|
| 87 |
+
"commentary": {
|
| 88 |
+
"public": "Texte public", "scholarly": "Analyse savante", "claims": [],
|
| 89 |
+
},
|
| 90 |
+
"editorial": {
|
| 91 |
+
"status": status,
|
| 92 |
+
"validated": False, "validated_by": None,
|
| 93 |
+
"version": version, "notes": [],
|
| 94 |
+
},
|
| 95 |
+
})
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
# ── POST /api/v1/pages/{id}/corrections ───────────────────────────────────────
|
| 99 |
+
|
| 100 |
+
@pytest.mark.asyncio
|
| 101 |
+
async def test_corrections_page_not_found(async_client):
|
| 102 |
+
resp = await async_client.post(
|
| 103 |
+
"/api/v1/pages/nonexistent/corrections",
|
| 104 |
+
json={"ocr_diplomatic_text": "texte"},
|
| 105 |
+
)
|
| 106 |
+
assert resp.status_code == 404
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
@pytest.mark.asyncio
|
| 110 |
+
async def test_corrections_no_master_json(async_client, db_session, monkeypatch):
|
| 111 |
+
corpus = await _create_corpus(db_session)
|
| 112 |
+
ms = await _create_manuscript(db_session, corpus.id)
|
| 113 |
+
page = await _create_page(db_session, ms.id)
|
| 114 |
+
|
| 115 |
+
monkeypatch.setattr(Path, "exists", lambda self: False)
|
| 116 |
+
|
| 117 |
+
resp = await async_client.post(
|
| 118 |
+
f"/api/v1/pages/{page.id}/corrections",
|
| 119 |
+
json={"ocr_diplomatic_text": "texte"},
|
| 120 |
+
)
|
| 121 |
+
assert resp.status_code == 404
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
@pytest.mark.asyncio
|
| 125 |
+
async def test_corrections_updates_ocr_text(async_client, db_session, monkeypatch):
|
| 126 |
+
corpus = await _create_corpus(db_session)
|
| 127 |
+
ms = await _create_manuscript(db_session, corpus.id)
|
| 128 |
+
page = await _create_page(db_session, ms.id)
|
| 129 |
+
|
| 130 |
+
monkeypatch.setattr(Path, "exists", lambda self: True)
|
| 131 |
+
monkeypatch.setattr(Path, "read_text", lambda self, **kw: _make_master(page.id))
|
| 132 |
+
monkeypatch.setattr(Path, "write_text", lambda self, content, **kw: None)
|
| 133 |
+
|
| 134 |
+
resp = await async_client.post(
|
| 135 |
+
f"/api/v1/pages/{page.id}/corrections",
|
| 136 |
+
json={"ocr_diplomatic_text": "Texte corrigé manuellement"},
|
| 137 |
+
)
|
| 138 |
+
assert resp.status_code == 200
|
| 139 |
+
assert resp.json()["ocr"]["diplomatic_text"] == "Texte corrigé manuellement"
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
@pytest.mark.asyncio
|
| 143 |
+
async def test_corrections_increments_version(async_client, db_session, monkeypatch):
|
| 144 |
+
corpus = await _create_corpus(db_session)
|
| 145 |
+
ms = await _create_manuscript(db_session, corpus.id)
|
| 146 |
+
page = await _create_page(db_session, ms.id)
|
| 147 |
+
|
| 148 |
+
monkeypatch.setattr(Path, "exists", lambda self: True)
|
| 149 |
+
monkeypatch.setattr(Path, "read_text", lambda self, **kw: _make_master(page.id, version=1))
|
| 150 |
+
monkeypatch.setattr(Path, "write_text", lambda self, content, **kw: None)
|
| 151 |
+
|
| 152 |
+
resp = await async_client.post(
|
| 153 |
+
f"/api/v1/pages/{page.id}/corrections",
|
| 154 |
+
json={"editorial_status": "needs_review"},
|
| 155 |
+
)
|
| 156 |
+
assert resp.status_code == 200
|
| 157 |
+
assert resp.json()["editorial"]["version"] == 2
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
@pytest.mark.asyncio
|
| 161 |
+
async def test_corrections_updates_editorial_status(async_client, db_session, monkeypatch):
|
| 162 |
+
corpus = await _create_corpus(db_session)
|
| 163 |
+
ms = await _create_manuscript(db_session, corpus.id)
|
| 164 |
+
page = await _create_page(db_session, ms.id)
|
| 165 |
+
|
| 166 |
+
monkeypatch.setattr(Path, "exists", lambda self: True)
|
| 167 |
+
monkeypatch.setattr(Path, "read_text", lambda self, **kw: _make_master(page.id))
|
| 168 |
+
monkeypatch.setattr(Path, "write_text", lambda self, content, **kw: None)
|
| 169 |
+
|
| 170 |
+
resp = await async_client.post(
|
| 171 |
+
f"/api/v1/pages/{page.id}/corrections",
|
| 172 |
+
json={"editorial_status": "reviewed"},
|
| 173 |
+
)
|
| 174 |
+
assert resp.status_code == 200
|
| 175 |
+
assert resp.json()["editorial"]["status"] == "reviewed"
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
@pytest.mark.asyncio
|
| 179 |
+
async def test_corrections_updates_commentary_public(async_client, db_session, monkeypatch):
|
| 180 |
+
corpus = await _create_corpus(db_session)
|
| 181 |
+
ms = await _create_manuscript(db_session, corpus.id)
|
| 182 |
+
page = await _create_page(db_session, ms.id)
|
| 183 |
+
|
| 184 |
+
monkeypatch.setattr(Path, "exists", lambda self: True)
|
| 185 |
+
monkeypatch.setattr(Path, "read_text", lambda self, **kw: _make_master(page.id))
|
| 186 |
+
monkeypatch.setattr(Path, "write_text", lambda self, content, **kw: None)
|
| 187 |
+
|
| 188 |
+
resp = await async_client.post(
|
| 189 |
+
f"/api/v1/pages/{page.id}/corrections",
|
| 190 |
+
json={"commentary_public": "Commentaire public révisé"},
|
| 191 |
+
)
|
| 192 |
+
assert resp.status_code == 200
|
| 193 |
+
assert resp.json()["commentary"]["public"] == "Commentaire public révisé"
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
@pytest.mark.asyncio
|
| 197 |
+
async def test_corrections_updates_commentary_scholarly(async_client, db_session, monkeypatch):
|
| 198 |
+
corpus = await _create_corpus(db_session)
|
| 199 |
+
ms = await _create_manuscript(db_session, corpus.id)
|
| 200 |
+
page = await _create_page(db_session, ms.id)
|
| 201 |
+
|
| 202 |
+
monkeypatch.setattr(Path, "exists", lambda self: True)
|
| 203 |
+
monkeypatch.setattr(Path, "read_text", lambda self, **kw: _make_master(page.id))
|
| 204 |
+
monkeypatch.setattr(Path, "write_text", lambda self, content, **kw: None)
|
| 205 |
+
|
| 206 |
+
resp = await async_client.post(
|
| 207 |
+
f"/api/v1/pages/{page.id}/corrections",
|
| 208 |
+
json={"commentary_scholarly": "Analyse savante révisée"},
|
| 209 |
+
)
|
| 210 |
+
assert resp.status_code == 200
|
| 211 |
+
assert resp.json()["commentary"]["scholarly"] == "Analyse savante révisée"
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
@pytest.mark.asyncio
|
| 215 |
+
async def test_corrections_region_validations(async_client, db_session, monkeypatch):
|
| 216 |
+
corpus = await _create_corpus(db_session)
|
| 217 |
+
ms = await _create_manuscript(db_session, corpus.id)
|
| 218 |
+
page = await _create_page(db_session, ms.id)
|
| 219 |
+
|
| 220 |
+
monkeypatch.setattr(Path, "exists", lambda self: True)
|
| 221 |
+
monkeypatch.setattr(Path, "read_text", lambda self, **kw: _make_master(page.id))
|
| 222 |
+
monkeypatch.setattr(Path, "write_text", lambda self, content, **kw: None)
|
| 223 |
+
|
| 224 |
+
resp = await async_client.post(
|
| 225 |
+
f"/api/v1/pages/{page.id}/corrections",
|
| 226 |
+
json={"region_validations": {"r001": "validated", "r002": "rejected"}},
|
| 227 |
+
)
|
| 228 |
+
assert resp.status_code == 200
|
| 229 |
+
validations = resp.json().get("extensions", {}).get("region_validations", {})
|
| 230 |
+
assert validations.get("r001") == "validated"
|
| 231 |
+
assert validations.get("r002") == "rejected"
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
@pytest.mark.asyncio
|
| 235 |
+
async def test_corrections_archives_old_version(async_client, db_session, monkeypatch):
|
| 236 |
+
"""Vérifie qu'une copie master_v1.json est écrite avant la correction."""
|
| 237 |
+
corpus = await _create_corpus(db_session)
|
| 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 |
+
|
| 251 |
+
await async_client.post(
|
| 252 |
+
f"/api/v1/pages/{page.id}/corrections",
|
| 253 |
+
json={"editorial_status": "needs_review"},
|
| 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):
|
| 264 |
+
"""Plusieurs corrections peuvent être envoyées en un seul appel."""
|
| 265 |
+
corpus = await _create_corpus(db_session)
|
| 266 |
+
ms = await _create_manuscript(db_session, corpus.id)
|
| 267 |
+
page = await _create_page(db_session, ms.id)
|
| 268 |
+
|
| 269 |
+
monkeypatch.setattr(Path, "exists", lambda self: True)
|
| 270 |
+
monkeypatch.setattr(Path, "read_text", lambda self, **kw: _make_master(page.id))
|
| 271 |
+
monkeypatch.setattr(Path, "write_text", lambda self, content, **kw: None)
|
| 272 |
+
|
| 273 |
+
resp = await async_client.post(
|
| 274 |
+
f"/api/v1/pages/{page.id}/corrections",
|
| 275 |
+
json={
|
| 276 |
+
"ocr_diplomatic_text": "Nouveau texte diplomatique",
|
| 277 |
+
"editorial_status": "reviewed",
|
| 278 |
+
"commentary_public": "Nouveau commentaire",
|
| 279 |
+
},
|
| 280 |
+
)
|
| 281 |
+
assert resp.status_code == 200
|
| 282 |
+
data = resp.json()
|
| 283 |
+
assert data["ocr"]["diplomatic_text"] == "Nouveau texte diplomatique"
|
| 284 |
+
assert data["editorial"]["status"] == "reviewed"
|
| 285 |
+
assert data["commentary"]["public"] == "Nouveau commentaire"
|
| 286 |
+
assert data["editorial"]["version"] == 2
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
# ── GET /api/v1/pages/{id}/history ────────────────────────────────────────────
|
| 290 |
+
|
| 291 |
+
@pytest.mark.asyncio
|
| 292 |
+
async def test_history_page_not_found(async_client):
|
| 293 |
+
resp = await async_client.get("/api/v1/pages/nonexistent/history")
|
| 294 |
+
assert resp.status_code == 404
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
@pytest.mark.asyncio
|
| 298 |
+
async def test_history_returns_empty_list_when_no_dir(async_client, db_session, monkeypatch):
|
| 299 |
+
"""Retourne [] si le répertoire de page n'existe pas."""
|
| 300 |
+
corpus = await _create_corpus(db_session)
|
| 301 |
+
ms = await _create_manuscript(db_session, corpus.id)
|
| 302 |
+
page = await _create_page(db_session, ms.id)
|
| 303 |
+
|
| 304 |
+
monkeypatch.setattr(Path, "exists", lambda self: False)
|
| 305 |
+
|
| 306 |
+
resp = await async_client.get(f"/api/v1/pages/{page.id}/history")
|
| 307 |
+
assert resp.status_code == 200
|
| 308 |
+
assert resp.json() == []
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
@pytest.mark.asyncio
|
| 312 |
+
async def test_history_returns_list(async_client, db_session, monkeypatch):
|
| 313 |
+
"""Le type de retour est une liste (même vide)."""
|
| 314 |
+
corpus = await _create_corpus(db_session)
|
| 315 |
+
ms = await _create_manuscript(db_session, corpus.id)
|
| 316 |
+
page = await _create_page(db_session, ms.id)
|
| 317 |
+
|
| 318 |
+
monkeypatch.setattr(Path, "exists", lambda self: False)
|
| 319 |
+
|
| 320 |
+
resp = await async_client.get(f"/api/v1/pages/{page.id}/history")
|
| 321 |
+
assert resp.status_code == 200
|
| 322 |
+
assert isinstance(resp.json(), list)
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
@pytest.mark.asyncio
|
| 326 |
+
async def test_history_with_archived_files(async_client, db_session, tmp_path, monkeypatch):
|
| 327 |
+
"""Retourne les versions trouvées dans les fichiers master_v*.json."""
|
| 328 |
+
corpus = await _create_corpus(db_session, slug="hist-corpus")
|
| 329 |
+
ms = await _create_manuscript(db_session, corpus.id)
|
| 330 |
+
page = await _create_page(db_session, ms.id)
|
| 331 |
+
|
| 332 |
+
# Crée le répertoire avec des fichiers de version
|
| 333 |
+
page_dir = tmp_path / "corpora" / corpus.slug / "pages" / page.id
|
| 334 |
+
page_dir.mkdir(parents=True)
|
| 335 |
+
(page_dir / "master_v1.json").write_text(_make_master(page.id, version=1, status="machine_draft"))
|
| 336 |
+
(page_dir / "master_v2.json").write_text(_make_master(page.id, version=2, status="reviewed"))
|
| 337 |
+
|
| 338 |
+
import app.api.v1.pages as pages_module
|
| 339 |
+
import app.config as config_mod
|
| 340 |
+
|
| 341 |
+
original_data_dir = config_mod.settings.data_dir
|
| 342 |
+
config_mod.settings.__dict__["data_dir"] = tmp_path
|
| 343 |
+
try:
|
| 344 |
+
resp = await async_client.get(f"/api/v1/pages/{page.id}/history")
|
| 345 |
+
finally:
|
| 346 |
+
config_mod.settings.__dict__["data_dir"] = original_data_dir
|
| 347 |
+
|
| 348 |
+
assert resp.status_code == 200
|
| 349 |
+
versions = resp.json()
|
| 350 |
+
assert len(versions) == 2
|
| 351 |
+
statuses = [v["status"] for v in versions]
|
| 352 |
+
assert "machine_draft" in statuses
|
| 353 |
+
assert "reviewed" in statuses
|
|
@@ -0,0 +1,280 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tests de l'endpoint GET /api/v1/search (Sprint 6 — Session B).
|
| 3 |
+
|
| 4 |
+
Stratégie :
|
| 5 |
+
- Fichiers master.json réels dans tmp_path
|
| 6 |
+
- Override de settings.data_dir pour pointer sur tmp_path
|
| 7 |
+
- Vérifie : 422 (paramètre manquant / trop court), résultats vides,
|
| 8 |
+
correspondance OCR, insensibilité casse et accents, tri par score,
|
| 9 |
+
extrait (excerpt) présent.
|
| 10 |
+
"""
|
| 11 |
+
# 1. stdlib
|
| 12 |
+
import json
|
| 13 |
+
import uuid
|
| 14 |
+
from datetime import datetime, timezone
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
# 2. third-party
|
| 18 |
+
import pytest
|
| 19 |
+
|
| 20 |
+
# 3. local
|
| 21 |
+
from tests.conftest_api import async_client, db_session # noqa: F401
|
| 22 |
+
|
| 23 |
+
_NOW = datetime.now(timezone.utc)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# ── Helpers ────────────────────────────────────────────────────────────────────
|
| 27 |
+
|
| 28 |
+
def _make_master(page_id: str, diplomatic_text: str = "", translation_fr: str = "") -> dict:
|
| 29 |
+
return {
|
| 30 |
+
"schema_version": "1.0",
|
| 31 |
+
"page_id": page_id,
|
| 32 |
+
"corpus_profile": "medieval-illuminated",
|
| 33 |
+
"manuscript_id": "ms-test",
|
| 34 |
+
"folio_label": "f001r",
|
| 35 |
+
"sequence": 1,
|
| 36 |
+
"image": {"original_url": "https://example.com/f.jpg", "width": 1500, "height": 2000},
|
| 37 |
+
"layout": {"regions": []},
|
| 38 |
+
"ocr": {
|
| 39 |
+
"diplomatic_text": diplomatic_text,
|
| 40 |
+
"blocks": [], "lines": [], "language": "la",
|
| 41 |
+
"confidence": 0.87, "uncertain_segments": [],
|
| 42 |
+
},
|
| 43 |
+
"translation": {"fr": translation_fr, "en": ""},
|
| 44 |
+
"summary": None,
|
| 45 |
+
"commentary": {"public": "", "scholarly": "", "claims": []},
|
| 46 |
+
"editorial": {
|
| 47 |
+
"status": "machine_draft",
|
| 48 |
+
"validated": False, "validated_by": None,
|
| 49 |
+
"version": 1, "notes": [],
|
| 50 |
+
},
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _write_master(tmp_path: Path, corpus_slug: str, page_id: str, data: dict) -> None:
|
| 55 |
+
page_dir = tmp_path / "corpora" / corpus_slug / "pages" / page_id
|
| 56 |
+
page_dir.mkdir(parents=True)
|
| 57 |
+
(page_dir / "master.json").write_text(json.dumps(data), encoding="utf-8")
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# ── Tests ──────────────────────────────────────────────────────────────────────
|
| 61 |
+
|
| 62 |
+
@pytest.mark.asyncio
|
| 63 |
+
async def test_search_missing_q(async_client):
|
| 64 |
+
"""q est obligatoire — 422 si absent."""
|
| 65 |
+
resp = await async_client.get("/api/v1/search")
|
| 66 |
+
assert resp.status_code == 422
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
@pytest.mark.asyncio
|
| 70 |
+
async def test_search_q_too_short(async_client):
|
| 71 |
+
"""q doit faire au moins 2 caractères — 422 si trop court."""
|
| 72 |
+
resp = await async_client.get("/api/v1/search?q=a")
|
| 73 |
+
assert resp.status_code == 422
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@pytest.mark.asyncio
|
| 77 |
+
async def test_search_empty_results(async_client, tmp_path):
|
| 78 |
+
"""Retourne [] quand aucun master.json ne correspond."""
|
| 79 |
+
import app.config as config_mod
|
| 80 |
+
original = config_mod.settings.data_dir
|
| 81 |
+
config_mod.settings.__dict__["data_dir"] = tmp_path
|
| 82 |
+
try:
|
| 83 |
+
resp = await async_client.get("/api/v1/search?q=rien")
|
| 84 |
+
finally:
|
| 85 |
+
config_mod.settings.__dict__["data_dir"] = original
|
| 86 |
+
|
| 87 |
+
assert resp.status_code == 200
|
| 88 |
+
assert resp.json() == []
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
@pytest.mark.asyncio
|
| 92 |
+
async def test_search_returns_list(async_client, tmp_path):
|
| 93 |
+
"""Le type de retour est toujours une liste."""
|
| 94 |
+
import app.config as config_mod
|
| 95 |
+
original = config_mod.settings.data_dir
|
| 96 |
+
config_mod.settings.__dict__["data_dir"] = tmp_path
|
| 97 |
+
try:
|
| 98 |
+
resp = await async_client.get("/api/v1/search?q=texte")
|
| 99 |
+
finally:
|
| 100 |
+
config_mod.settings.__dict__["data_dir"] = original
|
| 101 |
+
|
| 102 |
+
assert resp.status_code == 200
|
| 103 |
+
assert isinstance(resp.json(), list)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
@pytest.mark.asyncio
|
| 107 |
+
async def test_search_finds_ocr_text(async_client, tmp_path):
|
| 108 |
+
"""Trouve un master.json dont ocr.diplomatic_text contient la requête."""
|
| 109 |
+
import app.config as config_mod
|
| 110 |
+
|
| 111 |
+
page_id = str(uuid.uuid4())
|
| 112 |
+
_write_master(tmp_path, "corpus-a", page_id, _make_master(page_id, diplomatic_text="Incipit liber primus"))
|
| 113 |
+
|
| 114 |
+
original = config_mod.settings.data_dir
|
| 115 |
+
config_mod.settings.__dict__["data_dir"] = tmp_path
|
| 116 |
+
try:
|
| 117 |
+
resp = await async_client.get("/api/v1/search?q=Incipit")
|
| 118 |
+
finally:
|
| 119 |
+
config_mod.settings.__dict__["data_dir"] = original
|
| 120 |
+
|
| 121 |
+
assert resp.status_code == 200
|
| 122 |
+
results = resp.json()
|
| 123 |
+
assert len(results) == 1
|
| 124 |
+
assert results[0]["page_id"] == page_id
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
@pytest.mark.asyncio
|
| 128 |
+
async def test_search_case_insensitive(async_client, tmp_path):
|
| 129 |
+
"""La recherche est insensible à la casse."""
|
| 130 |
+
import app.config as config_mod
|
| 131 |
+
|
| 132 |
+
page_id = str(uuid.uuid4())
|
| 133 |
+
_write_master(tmp_path, "corpus-b", page_id, _make_master(page_id, diplomatic_text="INCIPIT LIBER"))
|
| 134 |
+
|
| 135 |
+
original = config_mod.settings.data_dir
|
| 136 |
+
config_mod.settings.__dict__["data_dir"] = tmp_path
|
| 137 |
+
try:
|
| 138 |
+
resp = await async_client.get("/api/v1/search?q=incipit")
|
| 139 |
+
finally:
|
| 140 |
+
config_mod.settings.__dict__["data_dir"] = original
|
| 141 |
+
|
| 142 |
+
assert resp.status_code == 200
|
| 143 |
+
results = resp.json()
|
| 144 |
+
assert len(results) >= 1
|
| 145 |
+
assert any(r["page_id"] == page_id for r in results)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
@pytest.mark.asyncio
|
| 149 |
+
async def test_search_accent_insensitive(async_client, tmp_path):
|
| 150 |
+
"""La recherche est insensible aux accents."""
|
| 151 |
+
import app.config as config_mod
|
| 152 |
+
|
| 153 |
+
page_id = str(uuid.uuid4())
|
| 154 |
+
_write_master(tmp_path, "corpus-c", page_id, _make_master(page_id, diplomatic_text="Édition française médiévale"))
|
| 155 |
+
|
| 156 |
+
original = config_mod.settings.data_dir
|
| 157 |
+
config_mod.settings.__dict__["data_dir"] = tmp_path
|
| 158 |
+
try:
|
| 159 |
+
resp = await async_client.get("/api/v1/search?q=edition")
|
| 160 |
+
finally:
|
| 161 |
+
config_mod.settings.__dict__["data_dir"] = original
|
| 162 |
+
|
| 163 |
+
assert resp.status_code == 200
|
| 164 |
+
results = resp.json()
|
| 165 |
+
assert len(results) >= 1
|
| 166 |
+
assert any(r["page_id"] == page_id for r in results)
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
@pytest.mark.asyncio
|
| 170 |
+
async def test_search_finds_translation_fr(async_client, tmp_path):
|
| 171 |
+
"""Trouve également dans translation.fr."""
|
| 172 |
+
import app.config as config_mod
|
| 173 |
+
|
| 174 |
+
page_id = str(uuid.uuid4())
|
| 175 |
+
_write_master(tmp_path, "corpus-d", page_id, _make_master(page_id, translation_fr="Ici commence le premier livre"))
|
| 176 |
+
|
| 177 |
+
original = config_mod.settings.data_dir
|
| 178 |
+
config_mod.settings.__dict__["data_dir"] = tmp_path
|
| 179 |
+
try:
|
| 180 |
+
resp = await async_client.get("/api/v1/search?q=premier")
|
| 181 |
+
finally:
|
| 182 |
+
config_mod.settings.__dict__["data_dir"] = original
|
| 183 |
+
|
| 184 |
+
assert resp.status_code == 200
|
| 185 |
+
results = resp.json()
|
| 186 |
+
assert any(r["page_id"] == page_id for r in results)
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
@pytest.mark.asyncio
|
| 190 |
+
async def test_search_no_match_returns_empty(async_client, tmp_path):
|
| 191 |
+
"""Ne retourne rien quand la requête ne correspond à aucun texte."""
|
| 192 |
+
import app.config as config_mod
|
| 193 |
+
|
| 194 |
+
page_id = str(uuid.uuid4())
|
| 195 |
+
_write_master(tmp_path, "corpus-e", page_id, _make_master(page_id, diplomatic_text="Incipit liber"))
|
| 196 |
+
|
| 197 |
+
original = config_mod.settings.data_dir
|
| 198 |
+
config_mod.settings.__dict__["data_dir"] = tmp_path
|
| 199 |
+
try:
|
| 200 |
+
resp = await async_client.get("/api/v1/search?q=xyznomatch")
|
| 201 |
+
finally:
|
| 202 |
+
config_mod.settings.__dict__["data_dir"] = original
|
| 203 |
+
|
| 204 |
+
assert resp.status_code == 200
|
| 205 |
+
assert resp.json() == []
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
@pytest.mark.asyncio
|
| 209 |
+
async def test_search_result_has_excerpt(async_client, tmp_path):
|
| 210 |
+
"""Chaque résultat contient un champ excerpt non vide."""
|
| 211 |
+
import app.config as config_mod
|
| 212 |
+
|
| 213 |
+
page_id = str(uuid.uuid4())
|
| 214 |
+
_write_master(tmp_path, "corpus-f", page_id, _make_master(page_id, diplomatic_text="Incipit liber primus"))
|
| 215 |
+
|
| 216 |
+
original = config_mod.settings.data_dir
|
| 217 |
+
config_mod.settings.__dict__["data_dir"] = tmp_path
|
| 218 |
+
try:
|
| 219 |
+
resp = await async_client.get("/api/v1/search?q=liber")
|
| 220 |
+
finally:
|
| 221 |
+
config_mod.settings.__dict__["data_dir"] = original
|
| 222 |
+
|
| 223 |
+
assert resp.status_code == 200
|
| 224 |
+
results = resp.json()
|
| 225 |
+
assert len(results) >= 1
|
| 226 |
+
assert results[0]["excerpt"] != ""
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
@pytest.mark.asyncio
|
| 230 |
+
async def test_search_sorted_by_score_desc(async_client, tmp_path):
|
| 231 |
+
"""Les résultats sont triés par score décroissant."""
|
| 232 |
+
import app.config as config_mod
|
| 233 |
+
|
| 234 |
+
page_id_1 = str(uuid.uuid4())
|
| 235 |
+
page_id_2 = str(uuid.uuid4())
|
| 236 |
+
# page_id_1 contient 3 occurrences, page_id_2 en contient 1
|
| 237 |
+
_write_master(tmp_path, "corpus-g", page_id_1, _make_master(
|
| 238 |
+
page_id_1, diplomatic_text="liber liber liber"
|
| 239 |
+
))
|
| 240 |
+
_write_master(tmp_path, "corpus-g", page_id_2, _make_master(
|
| 241 |
+
page_id_2, diplomatic_text="liber unus"
|
| 242 |
+
))
|
| 243 |
+
|
| 244 |
+
original = config_mod.settings.data_dir
|
| 245 |
+
config_mod.settings.__dict__["data_dir"] = tmp_path
|
| 246 |
+
try:
|
| 247 |
+
resp = await async_client.get("/api/v1/search?q=liber")
|
| 248 |
+
finally:
|
| 249 |
+
config_mod.settings.__dict__["data_dir"] = original
|
| 250 |
+
|
| 251 |
+
assert resp.status_code == 200
|
| 252 |
+
results = resp.json()
|
| 253 |
+
assert len(results) == 2
|
| 254 |
+
assert results[0]["score"] >= results[1]["score"]
|
| 255 |
+
assert results[0]["page_id"] == page_id_1
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
@pytest.mark.asyncio
|
| 259 |
+
async def test_search_result_fields(async_client, tmp_path):
|
| 260 |
+
"""Chaque résultat expose les champs attendus."""
|
| 261 |
+
import app.config as config_mod
|
| 262 |
+
|
| 263 |
+
page_id = str(uuid.uuid4())
|
| 264 |
+
_write_master(tmp_path, "corpus-h", page_id, _make_master(page_id, diplomatic_text="Incipit liber"))
|
| 265 |
+
|
| 266 |
+
original = config_mod.settings.data_dir
|
| 267 |
+
config_mod.settings.__dict__["data_dir"] = tmp_path
|
| 268 |
+
try:
|
| 269 |
+
resp = await async_client.get("/api/v1/search?q=Incipit")
|
| 270 |
+
finally:
|
| 271 |
+
config_mod.settings.__dict__["data_dir"] = original
|
| 272 |
+
|
| 273 |
+
assert resp.status_code == 200
|
| 274 |
+
result = resp.json()[0]
|
| 275 |
+
assert "page_id" in result
|
| 276 |
+
assert "folio_label" in result
|
| 277 |
+
assert "manuscript_id" in result
|
| 278 |
+
assert "excerpt" in result
|
| 279 |
+
assert "score" in result
|
| 280 |
+
assert "corpus_profile" in result
|
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import { useState } from 'react'
|
| 2 |
import Admin from './pages/Admin.tsx'
|
|
|
|
| 3 |
import Home from './pages/Home.tsx'
|
| 4 |
import Reader from './pages/Reader.tsx'
|
| 5 |
|
|
@@ -7,6 +8,7 @@ type View =
|
|
| 7 |
| { name: 'home' }
|
| 8 |
| { name: 'reader'; manuscriptId: string; profileId: string }
|
| 9 |
| { name: 'admin' }
|
|
|
|
| 10 |
|
| 11 |
export default function App() {
|
| 12 |
const [view, setView] = useState<View>({ name: 'home' })
|
|
@@ -17,6 +19,7 @@ export default function App() {
|
|
| 17 |
manuscriptId={view.manuscriptId}
|
| 18 |
profileId={view.profileId}
|
| 19 |
onBack={() => setView({ name: 'home' })}
|
|
|
|
| 20 |
/>
|
| 21 |
)
|
| 22 |
}
|
|
@@ -25,6 +28,15 @@ export default function App() {
|
|
| 25 |
return <Admin onHome={() => setView({ name: 'home' })} />
|
| 26 |
}
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
return (
|
| 29 |
<Home
|
| 30 |
onOpenManuscript={(manuscriptId, profileId) =>
|
|
|
|
| 1 |
import { useState } from 'react'
|
| 2 |
import Admin from './pages/Admin.tsx'
|
| 3 |
+
import Editor from './pages/Editor.tsx'
|
| 4 |
import Home from './pages/Home.tsx'
|
| 5 |
import Reader from './pages/Reader.tsx'
|
| 6 |
|
|
|
|
| 8 |
| { name: 'home' }
|
| 9 |
| { name: 'reader'; manuscriptId: string; profileId: string }
|
| 10 |
| { name: 'admin' }
|
| 11 |
+
| { name: 'editor'; pageId: string }
|
| 12 |
|
| 13 |
export default function App() {
|
| 14 |
const [view, setView] = useState<View>({ name: 'home' })
|
|
|
|
| 19 |
manuscriptId={view.manuscriptId}
|
| 20 |
profileId={view.profileId}
|
| 21 |
onBack={() => setView({ name: 'home' })}
|
| 22 |
+
onEdit={(pageId) => setView({ name: 'editor', pageId })}
|
| 23 |
/>
|
| 24 |
)
|
| 25 |
}
|
|
|
|
| 28 |
return <Admin onHome={() => setView({ name: 'home' })} />
|
| 29 |
}
|
| 30 |
|
| 31 |
+
if (view.name === 'editor') {
|
| 32 |
+
return (
|
| 33 |
+
<Editor
|
| 34 |
+
pageId={view.pageId}
|
| 35 |
+
onBack={() => setView({ name: 'home' })}
|
| 36 |
+
/>
|
| 37 |
+
)
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
return (
|
| 41 |
<Home
|
| 42 |
onOpenManuscript={(manuscriptId, profileId) =>
|
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
| 2 |
+
import { searchPages, type SearchResult } from '../lib/api.ts'
|
| 3 |
+
|
| 4 |
+
interface Props {
|
| 5 |
+
onSelectResult?: (result: SearchResult) => void
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
export default function SearchBar({ onSelectResult }: Props) {
|
| 9 |
+
const [query, setQuery] = useState('')
|
| 10 |
+
const [results, setResults] = useState<SearchResult[]>([])
|
| 11 |
+
const [open, setOpen] = useState(false)
|
| 12 |
+
const [loading, setLoading] = useState(false)
|
| 13 |
+
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
| 14 |
+
const containerRef = useRef<HTMLDivElement>(null)
|
| 15 |
+
|
| 16 |
+
const runSearch = useCallback(async (q: string) => {
|
| 17 |
+
if (q.trim().length < 2) {
|
| 18 |
+
setResults([])
|
| 19 |
+
setOpen(false)
|
| 20 |
+
return
|
| 21 |
+
}
|
| 22 |
+
setLoading(true)
|
| 23 |
+
try {
|
| 24 |
+
const res = await searchPages(q.trim())
|
| 25 |
+
setResults(res)
|
| 26 |
+
setOpen(true)
|
| 27 |
+
} catch {
|
| 28 |
+
setResults([])
|
| 29 |
+
} finally {
|
| 30 |
+
setLoading(false)
|
| 31 |
+
}
|
| 32 |
+
}, [])
|
| 33 |
+
|
| 34 |
+
useEffect(() => {
|
| 35 |
+
if (debounceRef.current) clearTimeout(debounceRef.current)
|
| 36 |
+
debounceRef.current = setTimeout(() => {
|
| 37 |
+
void runSearch(query)
|
| 38 |
+
}, 300)
|
| 39 |
+
return () => {
|
| 40 |
+
if (debounceRef.current) clearTimeout(debounceRef.current)
|
| 41 |
+
}
|
| 42 |
+
}, [query, runSearch])
|
| 43 |
+
|
| 44 |
+
// Close dropdown on outside click
|
| 45 |
+
useEffect(() => {
|
| 46 |
+
const handler = (e: MouseEvent) => {
|
| 47 |
+
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
| 48 |
+
setOpen(false)
|
| 49 |
+
}
|
| 50 |
+
}
|
| 51 |
+
document.addEventListener('mousedown', handler)
|
| 52 |
+
return () => document.removeEventListener('mousedown', handler)
|
| 53 |
+
}, [])
|
| 54 |
+
|
| 55 |
+
return (
|
| 56 |
+
<div ref={containerRef} className="relative w-72">
|
| 57 |
+
<div className="relative">
|
| 58 |
+
<input
|
| 59 |
+
type="search"
|
| 60 |
+
value={query}
|
| 61 |
+
onChange={(e) => setQuery(e.target.value)}
|
| 62 |
+
onFocus={() => results.length > 0 && setOpen(true)}
|
| 63 |
+
placeholder="Rechercher dans les manuscrits…"
|
| 64 |
+
className="w-full bg-stone-800 text-stone-100 placeholder-stone-500 text-sm px-3 py-1.5 pr-8 rounded-md border border-stone-700 focus:outline-none focus:border-amber-500 focus:ring-1 focus:ring-amber-500"
|
| 65 |
+
/>
|
| 66 |
+
{loading && (
|
| 67 |
+
<span className="absolute right-2.5 top-1/2 -translate-y-1/2 text-stone-400 text-xs">
|
| 68 |
+
…
|
| 69 |
+
</span>
|
| 70 |
+
)}
|
| 71 |
+
</div>
|
| 72 |
+
|
| 73 |
+
{open && (
|
| 74 |
+
<div className="absolute top-full left-0 right-0 mt-1 bg-white border border-stone-200 rounded-lg shadow-xl z-50 max-h-80 overflow-y-auto">
|
| 75 |
+
{results.length === 0 ? (
|
| 76 |
+
<div className="px-4 py-3 text-sm text-stone-400 italic">Aucun résultat.</div>
|
| 77 |
+
) : (
|
| 78 |
+
<ul>
|
| 79 |
+
{results.map((r) => (
|
| 80 |
+
<li key={r.page_id}>
|
| 81 |
+
<button
|
| 82 |
+
onClick={() => {
|
| 83 |
+
setOpen(false)
|
| 84 |
+
onSelectResult?.(r)
|
| 85 |
+
}}
|
| 86 |
+
className="w-full text-left px-4 py-3 hover:bg-amber-50 border-b border-stone-100 last:border-0 transition-colors"
|
| 87 |
+
>
|
| 88 |
+
<div className="flex items-center justify-between gap-2">
|
| 89 |
+
<span className="font-medium text-stone-800 text-sm">
|
| 90 |
+
{r.folio_label}
|
| 91 |
+
</span>
|
| 92 |
+
<span className="text-xs text-stone-400 shrink-0">
|
| 93 |
+
score : {r.score}
|
| 94 |
+
</span>
|
| 95 |
+
</div>
|
| 96 |
+
<div className="text-xs text-stone-500 mt-0.5 truncate">
|
| 97 |
+
{r.excerpt}
|
| 98 |
+
</div>
|
| 99 |
+
</button>
|
| 100 |
+
</li>
|
| 101 |
+
))}
|
| 102 |
+
</ul>
|
| 103 |
+
)}
|
| 104 |
+
</div>
|
| 105 |
+
)}
|
| 106 |
+
</div>
|
| 107 |
+
)
|
| 108 |
+
}
|
|
@@ -293,3 +293,42 @@ export const getJob = (jobId: string): Promise<Job> =>
|
|
| 293 |
|
| 294 |
export const retryJob = (jobId: string): Promise<Job> =>
|
| 295 |
post(`/api/v1/jobs/${jobId}/retry`)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
|
| 294 |
export const retryJob = (jobId: string): Promise<Job> =>
|
| 295 |
post(`/api/v1/jobs/${jobId}/retry`)
|
| 296 |
+
|
| 297 |
+
export interface VersionInfo {
|
| 298 |
+
version: number
|
| 299 |
+
saved_at: string
|
| 300 |
+
status: string
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
export interface CorrectionsInput {
|
| 304 |
+
ocr_diplomatic_text?: string
|
| 305 |
+
editorial_status?: string
|
| 306 |
+
commentary_public?: string
|
| 307 |
+
commentary_scholarly?: string
|
| 308 |
+
region_validations?: Record<string, string>
|
| 309 |
+
restore_to_version?: number
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
export interface SearchResult {
|
| 313 |
+
page_id: string
|
| 314 |
+
folio_label: string
|
| 315 |
+
manuscript_id: string
|
| 316 |
+
excerpt: string
|
| 317 |
+
score: number
|
| 318 |
+
corpus_profile: string
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
export const fetchPage = (pageId: string): Promise<Page> =>
|
| 322 |
+
get(`/api/v1/pages/${pageId}`)
|
| 323 |
+
|
| 324 |
+
export const applyCorrections = (
|
| 325 |
+
pageId: string,
|
| 326 |
+
corrections: CorrectionsInput,
|
| 327 |
+
): Promise<PageMaster> =>
|
| 328 |
+
post(`/api/v1/pages/${pageId}/corrections`, corrections)
|
| 329 |
+
|
| 330 |
+
export const getHistory = (pageId: string): Promise<VersionInfo[]> =>
|
| 331 |
+
get(`/api/v1/pages/${pageId}/history`)
|
| 332 |
+
|
| 333 |
+
export const searchPages = (q: string): Promise<SearchResult[]> =>
|
| 334 |
+
get(`/api/v1/search?q=${encodeURIComponent(q)}`)
|
|
@@ -0,0 +1,356 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
| 2 |
+
import {
|
| 3 |
+
applyCorrections,
|
| 4 |
+
getHistory,
|
| 5 |
+
fetchMasterJson,
|
| 6 |
+
type PageMaster,
|
| 7 |
+
type VersionInfo,
|
| 8 |
+
} from '../lib/api.ts'
|
| 9 |
+
import Viewer from '../components/Viewer.tsx'
|
| 10 |
+
|
| 11 |
+
interface Props {
|
| 12 |
+
pageId: string
|
| 13 |
+
onBack: () => void
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
type Panel = 'transcription' | 'commentary' | 'regions' | 'history'
|
| 17 |
+
|
| 18 |
+
export default function Editor({ pageId, onBack }: Props) {
|
| 19 |
+
const [master, setMaster] = useState<PageMaster | null>(null)
|
| 20 |
+
const [history, setHistory] = useState<VersionInfo[]>([])
|
| 21 |
+
const [activePanel, setActivePanel] = useState<Panel>('transcription')
|
| 22 |
+
const [loading, setLoading] = useState(true)
|
| 23 |
+
const [saving, setSaving] = useState(false)
|
| 24 |
+
const [error, setError] = useState<string | null>(null)
|
| 25 |
+
const [saveError, setSaveError] = useState<string | null>(null)
|
| 26 |
+
const [saveSuccess, setSaveSuccess] = useState(false)
|
| 27 |
+
|
| 28 |
+
// Editable field values
|
| 29 |
+
const [ocrText, setOcrText] = useState('')
|
| 30 |
+
const [commentaryPublic, setCommentaryPublic] = useState('')
|
| 31 |
+
const [commentaryScholarly, setCommentaryScholarly] = useState('')
|
| 32 |
+
const [editorialStatus, setEditorialStatus] = useState('')
|
| 33 |
+
const [regionValidations, setRegionValidations] = useState<Record<string, string>>({})
|
| 34 |
+
|
| 35 |
+
const successTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
|
| 36 |
+
|
| 37 |
+
const loadData = useCallback(async () => {
|
| 38 |
+
setLoading(true)
|
| 39 |
+
setError(null)
|
| 40 |
+
try {
|
| 41 |
+
const [m, h] = await Promise.all([fetchMasterJson(pageId), getHistory(pageId)])
|
| 42 |
+
setMaster(m)
|
| 43 |
+
setHistory(h)
|
| 44 |
+
setOcrText(m.ocr?.diplomatic_text ?? '')
|
| 45 |
+
setCommentaryPublic(m.commentary?.public ?? '')
|
| 46 |
+
setCommentaryScholarly(m.commentary?.scholarly ?? '')
|
| 47 |
+
setEditorialStatus(m.editorial.status)
|
| 48 |
+
// Restore existing region validations from extensions
|
| 49 |
+
const ext = (m as unknown as { extensions?: { region_validations?: Record<string, string> } }).extensions
|
| 50 |
+
setRegionValidations(ext?.region_validations ?? {})
|
| 51 |
+
} catch (e: unknown) {
|
| 52 |
+
setError((e as Error).message)
|
| 53 |
+
} finally {
|
| 54 |
+
setLoading(false)
|
| 55 |
+
}
|
| 56 |
+
}, [pageId])
|
| 57 |
+
|
| 58 |
+
useEffect(() => {
|
| 59 |
+
void loadData()
|
| 60 |
+
}, [loadData])
|
| 61 |
+
|
| 62 |
+
const handleSave = async () => {
|
| 63 |
+
setSaving(true)
|
| 64 |
+
setSaveError(null)
|
| 65 |
+
setSaveSuccess(false)
|
| 66 |
+
try {
|
| 67 |
+
const updated = await applyCorrections(pageId, {
|
| 68 |
+
ocr_diplomatic_text: ocrText !== (master?.ocr?.diplomatic_text ?? '') ? ocrText : undefined,
|
| 69 |
+
editorial_status: editorialStatus !== master?.editorial.status ? editorialStatus : undefined,
|
| 70 |
+
commentary_public: commentaryPublic !== (master?.commentary?.public ?? '') ? commentaryPublic : undefined,
|
| 71 |
+
commentary_scholarly: commentaryScholarly !== (master?.commentary?.scholarly ?? '') ? commentaryScholarly : undefined,
|
| 72 |
+
region_validations: Object.keys(regionValidations).length > 0 ? regionValidations : undefined,
|
| 73 |
+
})
|
| 74 |
+
setMaster(updated)
|
| 75 |
+
const h = await getHistory(pageId)
|
| 76 |
+
setHistory(h)
|
| 77 |
+
setSaveSuccess(true)
|
| 78 |
+
if (successTimeout.current) clearTimeout(successTimeout.current)
|
| 79 |
+
successTimeout.current = setTimeout(() => setSaveSuccess(false), 3000)
|
| 80 |
+
} catch (e: unknown) {
|
| 81 |
+
setSaveError((e as Error).message)
|
| 82 |
+
} finally {
|
| 83 |
+
setSaving(false)
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
const handleRestore = async (version: number) => {
|
| 88 |
+
setSaving(true)
|
| 89 |
+
setSaveError(null)
|
| 90 |
+
try {
|
| 91 |
+
const updated = await applyCorrections(pageId, { restore_to_version: version })
|
| 92 |
+
setMaster(updated)
|
| 93 |
+
setOcrText(updated.ocr?.diplomatic_text ?? '')
|
| 94 |
+
setCommentaryPublic(updated.commentary?.public ?? '')
|
| 95 |
+
setCommentaryScholarly(updated.commentary?.scholarly ?? '')
|
| 96 |
+
setEditorialStatus(updated.editorial.status)
|
| 97 |
+
const h = await getHistory(pageId)
|
| 98 |
+
setHistory(h)
|
| 99 |
+
} catch (e: unknown) {
|
| 100 |
+
setSaveError((e as Error).message)
|
| 101 |
+
} finally {
|
| 102 |
+
setSaving(false)
|
| 103 |
+
}
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
const setRegionValidation = (regionId: string, val: string) => {
|
| 107 |
+
setRegionValidations((prev) => ({ ...prev, [regionId]: val }))
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
if (loading) {
|
| 111 |
+
return (
|
| 112 |
+
<div className="flex items-center justify-center h-screen text-stone-500">
|
| 113 |
+
Chargement…
|
| 114 |
+
</div>
|
| 115 |
+
)
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
if (error) {
|
| 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 (
|
| 126 |
+
<div className="flex flex-col h-screen bg-stone-100">
|
| 127 |
+
{/* ── Header ──────────────────────────────────────────────────────────── */}
|
| 128 |
+
<header className="flex items-center gap-3 bg-stone-900 text-stone-100 px-5 py-2.5 shrink-0">
|
| 129 |
+
<button
|
| 130 |
+
onClick={onBack}
|
| 131 |
+
className="text-stone-400 hover:text-stone-100 text-sm transition-colors"
|
| 132 |
+
>
|
| 133 |
+
← Retour
|
| 134 |
+
</button>
|
| 135 |
+
<span className="text-stone-600">|</span>
|
| 136 |
+
<span className="text-sm font-medium text-stone-200">
|
| 137 |
+
Éditeur — {master?.folio_label ?? pageId}
|
| 138 |
+
</span>
|
| 139 |
+
{master && (
|
| 140 |
+
<span className="ml-2 text-xs text-stone-400">
|
| 141 |
+
v{master.editorial.version} · {master.editorial.status}
|
| 142 |
+
</span>
|
| 143 |
+
)}
|
| 144 |
+
|
| 145 |
+
<div className="ml-auto flex items-center gap-3">
|
| 146 |
+
{saveSuccess && (
|
| 147 |
+
<span className="text-green-400 text-xs">Enregistré</span>
|
| 148 |
+
)}
|
| 149 |
+
{saveError && (
|
| 150 |
+
<span className="text-red-400 text-xs">{saveError}</span>
|
| 151 |
+
)}
|
| 152 |
+
<button
|
| 153 |
+
onClick={() => void handleSave()}
|
| 154 |
+
disabled={saving}
|
| 155 |
+
className="px-4 py-1.5 bg-amber-600 hover:bg-amber-500 disabled:opacity-40 text-white text-sm rounded transition-colors"
|
| 156 |
+
>
|
| 157 |
+
{saving ? 'Enregistrement…' : 'Enregistrer'}
|
| 158 |
+
</button>
|
| 159 |
+
</div>
|
| 160 |
+
</header>
|
| 161 |
+
|
| 162 |
+
{/* ── Layout 50 / 50 ──────────────────────────────────────────────────── */}
|
| 163 |
+
<div className="flex flex-1 overflow-hidden">
|
| 164 |
+
{/* Visionneuse gauche */}
|
| 165 |
+
<div className="relative" style={{ width: '50%' }}>
|
| 166 |
+
<Viewer imageUrl={imageUrl} onViewerReady={() => {}} />
|
| 167 |
+
{!imageUrl && (
|
| 168 |
+
<div className="absolute inset-0 flex items-center justify-center bg-stone-200 text-stone-400 text-sm">
|
| 169 |
+
Aperçu image non disponible
|
| 170 |
+
</div>
|
| 171 |
+
)}
|
| 172 |
+
</div>
|
| 173 |
+
|
| 174 |
+
{/* Panneaux droite */}
|
| 175 |
+
<div
|
| 176 |
+
className="flex flex-col border-l border-stone-200 bg-white"
|
| 177 |
+
style={{ width: '50%' }}
|
| 178 |
+
>
|
| 179 |
+
{/* Onglets */}
|
| 180 |
+
<div className="flex border-b border-stone-200 shrink-0">
|
| 181 |
+
{(['transcription', 'commentary', 'regions', 'history'] as Panel[]).map((p) => (
|
| 182 |
+
<button
|
| 183 |
+
key={p}
|
| 184 |
+
onClick={() => setActivePanel(p)}
|
| 185 |
+
className={`flex-1 py-2.5 text-xs font-medium capitalize transition-colors ${
|
| 186 |
+
activePanel === p
|
| 187 |
+
? 'border-b-2 border-amber-500 text-amber-700 bg-amber-50'
|
| 188 |
+
: 'text-stone-500 hover:text-stone-800'
|
| 189 |
+
}`}
|
| 190 |
+
>
|
| 191 |
+
{p === 'transcription' ? 'Transcription' :
|
| 192 |
+
p === 'commentary' ? 'Commentaire' :
|
| 193 |
+
p === 'regions' ? 'Régions' : 'Historique'}
|
| 194 |
+
</button>
|
| 195 |
+
))}
|
| 196 |
+
</div>
|
| 197 |
+
|
| 198 |
+
{/* Contenu du panneau actif */}
|
| 199 |
+
<div className="flex-1 overflow-y-auto p-4">
|
| 200 |
+
|
| 201 |
+
{/* ── Transcription ─────────────────────────────────────────── */}
|
| 202 |
+
{activePanel === 'transcription' && (
|
| 203 |
+
<div className="space-y-4">
|
| 204 |
+
<div>
|
| 205 |
+
<label className="block text-xs font-semibold text-stone-600 mb-1.5 uppercase tracking-wide">
|
| 206 |
+
Texte diplomatique (OCR)
|
| 207 |
+
</label>
|
| 208 |
+
<textarea
|
| 209 |
+
value={ocrText}
|
| 210 |
+
onChange={(e) => setOcrText(e.target.value)}
|
| 211 |
+
rows={12}
|
| 212 |
+
className="w-full border border-stone-300 rounded-md px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-amber-400 resize-y"
|
| 213 |
+
/>
|
| 214 |
+
</div>
|
| 215 |
+
<div>
|
| 216 |
+
<label className="block text-xs font-semibold text-stone-600 mb-1.5 uppercase tracking-wide">
|
| 217 |
+
Statut éditorial
|
| 218 |
+
</label>
|
| 219 |
+
<select
|
| 220 |
+
value={editorialStatus}
|
| 221 |
+
onChange={(e) => setEditorialStatus(e.target.value)}
|
| 222 |
+
className="w-full border border-stone-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-400"
|
| 223 |
+
>
|
| 224 |
+
<option value="machine_draft">machine_draft</option>
|
| 225 |
+
<option value="needs_review">needs_review</option>
|
| 226 |
+
<option value="reviewed">reviewed</option>
|
| 227 |
+
<option value="validated">validated</option>
|
| 228 |
+
<option value="published">published</option>
|
| 229 |
+
</select>
|
| 230 |
+
</div>
|
| 231 |
+
{master?.ocr && (
|
| 232 |
+
<div className="text-xs text-stone-400 space-y-0.5">
|
| 233 |
+
<div>Langue : {master.ocr.language}</div>
|
| 234 |
+
<div>Confiance : {(master.ocr.confidence * 100).toFixed(0)} %</div>
|
| 235 |
+
</div>
|
| 236 |
+
)}
|
| 237 |
+
</div>
|
| 238 |
+
)}
|
| 239 |
+
|
| 240 |
+
{/* ── Commentaire ───────────────────────────────────────────── */}
|
| 241 |
+
{activePanel === 'commentary' && (
|
| 242 |
+
<div className="space-y-5">
|
| 243 |
+
<div>
|
| 244 |
+
<label className="block text-xs font-semibold text-stone-600 mb-1.5 uppercase tracking-wide">
|
| 245 |
+
Commentaire public
|
| 246 |
+
</label>
|
| 247 |
+
<textarea
|
| 248 |
+
value={commentaryPublic}
|
| 249 |
+
onChange={(e) => setCommentaryPublic(e.target.value)}
|
| 250 |
+
rows={6}
|
| 251 |
+
className="w-full border border-stone-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-400 resize-y"
|
| 252 |
+
/>
|
| 253 |
+
</div>
|
| 254 |
+
<div>
|
| 255 |
+
<label className="block text-xs font-semibold text-stone-600 mb-1.5 uppercase tracking-wide">
|
| 256 |
+
Commentaire savant
|
| 257 |
+
</label>
|
| 258 |
+
<textarea
|
| 259 |
+
value={commentaryScholarly}
|
| 260 |
+
onChange={(e) => setCommentaryScholarly(e.target.value)}
|
| 261 |
+
rows={8}
|
| 262 |
+
className="w-full border border-stone-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-400 resize-y"
|
| 263 |
+
/>
|
| 264 |
+
</div>
|
| 265 |
+
</div>
|
| 266 |
+
)}
|
| 267 |
+
|
| 268 |
+
{/* ── Régions ───────────────────────────────────────────────── */}
|
| 269 |
+
{activePanel === 'regions' && (
|
| 270 |
+
<div className="space-y-2">
|
| 271 |
+
{regions.length === 0 ? (
|
| 272 |
+
<p className="text-sm text-stone-400 italic">Aucune région détectée.</p>
|
| 273 |
+
) : (
|
| 274 |
+
regions.map((region) => {
|
| 275 |
+
const validation = regionValidations[region.id]
|
| 276 |
+
return (
|
| 277 |
+
<div
|
| 278 |
+
key={region.id}
|
| 279 |
+
className="flex items-center justify-between border border-stone-200 rounded-lg px-3 py-2.5 text-sm"
|
| 280 |
+
>
|
| 281 |
+
<div>
|
| 282 |
+
<span className="font-medium text-stone-800 capitalize">
|
| 283 |
+
{region.type.replace(/_/g, ' ')}
|
| 284 |
+
</span>
|
| 285 |
+
<span className="ml-2 text-xs text-stone-400 font-mono">{region.id}</span>
|
| 286 |
+
<div className="text-xs text-stone-400">
|
| 287 |
+
confiance : {(region.confidence * 100).toFixed(0)} %
|
| 288 |
+
</div>
|
| 289 |
+
</div>
|
| 290 |
+
<div className="flex gap-2 ml-4 shrink-0">
|
| 291 |
+
<button
|
| 292 |
+
onClick={() => setRegionValidation(region.id, 'validated')}
|
| 293 |
+
className={`px-2.5 py-1 text-xs rounded-md transition-colors ${
|
| 294 |
+
validation === 'validated'
|
| 295 |
+
? 'bg-green-600 text-white'
|
| 296 |
+
: 'bg-stone-100 text-stone-600 hover:bg-green-100'
|
| 297 |
+
}`}
|
| 298 |
+
>
|
| 299 |
+
Valider
|
| 300 |
+
</button>
|
| 301 |
+
<button
|
| 302 |
+
onClick={() => setRegionValidation(region.id, 'rejected')}
|
| 303 |
+
className={`px-2.5 py-1 text-xs rounded-md transition-colors ${
|
| 304 |
+
validation === 'rejected'
|
| 305 |
+
? 'bg-red-600 text-white'
|
| 306 |
+
: 'bg-stone-100 text-stone-600 hover:bg-red-100'
|
| 307 |
+
}`}
|
| 308 |
+
>
|
| 309 |
+
Rejeter
|
| 310 |
+
</button>
|
| 311 |
+
</div>
|
| 312 |
+
</div>
|
| 313 |
+
)
|
| 314 |
+
})
|
| 315 |
+
)}
|
| 316 |
+
</div>
|
| 317 |
+
)}
|
| 318 |
+
|
| 319 |
+
{/* ── Historique ────────────────────────────────────────────── */}
|
| 320 |
+
{activePanel === 'history' && (
|
| 321 |
+
<div className="space-y-2">
|
| 322 |
+
{history.length === 0 ? (
|
| 323 |
+
<p className="text-sm text-stone-400 italic">
|
| 324 |
+
Aucune version archivée.
|
| 325 |
+
</p>
|
| 326 |
+
) : (
|
| 327 |
+
history.map((v) => (
|
| 328 |
+
<div
|
| 329 |
+
key={v.version}
|
| 330 |
+
className="flex items-center justify-between border border-stone-200 rounded-lg px-3 py-2.5 text-sm"
|
| 331 |
+
>
|
| 332 |
+
<div>
|
| 333 |
+
<span className="font-medium text-stone-800">v{v.version}</span>
|
| 334 |
+
<span className="ml-2 text-xs text-stone-500">{v.status}</span>
|
| 335 |
+
<div className="text-xs text-stone-400 mt-0.5">
|
| 336 |
+
{new Date(v.saved_at).toLocaleString('fr-FR')}
|
| 337 |
+
</div>
|
| 338 |
+
</div>
|
| 339 |
+
<button
|
| 340 |
+
onClick={() => void handleRestore(v.version)}
|
| 341 |
+
disabled={saving}
|
| 342 |
+
className="ml-4 px-3 py-1 text-xs bg-stone-100 text-stone-600 hover:bg-amber-100 hover:text-amber-700 disabled:opacity-40 rounded-md transition-colors"
|
| 343 |
+
>
|
| 344 |
+
Restaurer
|
| 345 |
+
</button>
|
| 346 |
+
</div>
|
| 347 |
+
))
|
| 348 |
+
)}
|
| 349 |
+
</div>
|
| 350 |
+
)}
|
| 351 |
+
</div>
|
| 352 |
+
</div>
|
| 353 |
+
</div>
|
| 354 |
+
</div>
|
| 355 |
+
)
|
| 356 |
+
}
|
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import { useEffect, useState } from 'react'
|
| 2 |
import AdminNav from '../components/AdminNav.tsx'
|
|
|
|
| 3 |
import {
|
| 4 |
fetchCorpora,
|
| 5 |
fetchManuscripts,
|
|
@@ -71,7 +72,10 @@ export default function Home({ onOpenManuscript, onAdmin }: Props) {
|
|
| 71 |
Plateforme de génération d'éditions savantes augmentées
|
| 72 |
</p>
|
| 73 |
</div>
|
| 74 |
-
<
|
|
|
|
|
|
|
|
|
|
| 75 |
</header>
|
| 76 |
|
| 77 |
<main className="max-w-3xl mx-auto py-10 px-8">
|
|
|
|
| 1 |
import { useEffect, useState } from 'react'
|
| 2 |
import AdminNav from '../components/AdminNav.tsx'
|
| 3 |
+
import SearchBar from '../components/SearchBar.tsx'
|
| 4 |
import {
|
| 5 |
fetchCorpora,
|
| 6 |
fetchManuscripts,
|
|
|
|
| 72 |
Plateforme de génération d'éditions savantes augmentées
|
| 73 |
</p>
|
| 74 |
</div>
|
| 75 |
+
<div className="flex items-center gap-4">
|
| 76 |
+
<SearchBar />
|
| 77 |
+
<AdminNav onClick={onAdmin} />
|
| 78 |
+
</div>
|
| 79 |
</header>
|
| 80 |
|
| 81 |
<main className="max-w-3xl mx-auto py-10 px-8">
|
|
@@ -20,9 +20,10 @@ interface Props {
|
|
| 20 |
manuscriptId: string
|
| 21 |
profileId: string
|
| 22 |
onBack: () => void
|
|
|
|
| 23 |
}
|
| 24 |
|
| 25 |
-
export default function Reader({ manuscriptId, profileId, onBack }: Props) {
|
| 26 |
const [pages, setPages] = useState<Page[]>([])
|
| 27 |
const [currentIndex, setCurrentIndex] = useState(0)
|
| 28 |
const [master, setMaster] = useState<PageMaster | null>(null)
|
|
@@ -113,6 +114,14 @@ export default function Reader({ manuscriptId, profileId, onBack }: Props) {
|
|
| 113 |
<span className="text-stone-400 text-xs">
|
| 114 |
{currentPage.folio_label} — {currentIndex + 1} / {pages.length}
|
| 115 |
</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
<button
|
| 117 |
disabled={currentIndex === 0}
|
| 118 |
onClick={() => setCurrentIndex((i) => i - 1)}
|
|
|
|
| 20 |
manuscriptId: string
|
| 21 |
profileId: string
|
| 22 |
onBack: () => void
|
| 23 |
+
onEdit?: (pageId: string) => void
|
| 24 |
}
|
| 25 |
|
| 26 |
+
export default function Reader({ manuscriptId, profileId, onBack, onEdit }: Props) {
|
| 27 |
const [pages, setPages] = useState<Page[]>([])
|
| 28 |
const [currentIndex, setCurrentIndex] = useState(0)
|
| 29 |
const [master, setMaster] = useState<PageMaster | null>(null)
|
|
|
|
| 114 |
<span className="text-stone-400 text-xs">
|
| 115 |
{currentPage.folio_label} — {currentIndex + 1} / {pages.length}
|
| 116 |
</span>
|
| 117 |
+
{onEdit && (
|
| 118 |
+
<button
|
| 119 |
+
onClick={() => onEdit(currentPage.id)}
|
| 120 |
+
className="px-3 py-1 bg-amber-600 hover:bg-amber-500 rounded text-sm text-white transition-colors"
|
| 121 |
+
>
|
| 122 |
+
Éditer cette page
|
| 123 |
+
</button>
|
| 124 |
+
)}
|
| 125 |
<button
|
| 126 |
disabled={currentIndex === 0}
|
| 127 |
onClick={() => setCurrentIndex((i) => i - 1)}
|