Claude commited on
Commit
f842a06
·
unverified ·
1 Parent(s): 6dc650c

feat(sprint6-B): validation éditoriale + recherche plein texte

Browse files

Backend :
- 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 CHANGED
@@ -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. Ces endpoints le lisent
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)
backend/app/api/v1/search.py ADDED
@@ -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
backend/app/main.py CHANGED
@@ -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")
backend/tests/test_api_corrections.py ADDED
@@ -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
backend/tests/test_api_search.py ADDED
@@ -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
frontend/src/App.tsx CHANGED
@@ -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) =>
frontend/src/components/SearchBar.tsx ADDED
@@ -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
+ }
frontend/src/lib/api.ts CHANGED
@@ -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)}`)
frontend/src/pages/Editor.tsx ADDED
@@ -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
+ }
frontend/src/pages/Home.tsx CHANGED
@@ -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
- <AdminNav onClick={onAdmin} />
 
 
 
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">
frontend/src/pages/Reader.tsx CHANGED
@@ -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)}