Spaces:
Running
fix(security,metrics): Sprint A14-S1 — boucher les 6 P0 du rewrite ciblé
Browse filesSprint S1 du plan rewrite ciblé (rewrite-2026, étape 0 :
stabilisation de l'existant avant la migration de structure).
P0-1 — normalization_profile propagé end-to-end (web → runner)
Ajout de ``normalization_profile: Optional[str] = None`` à la
signature de ``run_benchmark`` ; résolution one-shot dans le main
process via ``get_builtin_profile`` puis propagation aux deux
workers (process pool : tuple à 10 éléments rétrocompat ; thread
pool : kwarg) jusqu'à ``_compute_document_result`` et
``compute_metrics``. Avant ce sprint, le paramètre était exposé
par ``BenchmarkRequest`` / ``BenchmarkRunRequest`` mais
silencieusement perdu — l'option de l'UI était un faux bouton.
P0-2 — 11 profils alignés (README ↔ Pydantic ↔ runtime)
``NormalizationProfileId`` ajoute ``secretary_hand``,
``sans_ponctuation``, ``sans_apostrophes`` (Pydantic refusait 3
profils valides du runtime).
P0-3 — compact() devient opt-in (text_limit, drop_analyses)
Avant : le runner appelait ``dr.compact()`` avant la
sérialisation JSON, ce qui amputait silencieusement 13 dicts
d'analyse per-document (taxonomy, philological, calibration,
searchability, etc.) et tronquait les textes à 200 chars. Le
rapport HTML — qui consomme ce JSON — recevait des données déjà
mutilées, contredisant la promesse "self-contained HTML report".
Désormais ``compact()`` est no-op par défaut ; le caller doit
demander explicitement ``compact(text_limit=200,
drop_analyses=True)`` pour reproduire l'ancien comportement.
P0-4 — compute_metrics retourne None en erreur (au lieu de 0.0)
Avant : jiwer absent ou exception → ``MetricsResult(cer=0.0,
wer=0.0, ...)`` indistinguable d'un score parfait pour tout
consommateur ne lisant pas systématiquement ``error``. Désormais
``MetricsResult.cer`` (et 6 autres champs) sont
``Optional[float]`` à ``None`` quand ``error`` est non-None.
``cer_percent`` / ``wer_percent`` / ``as_dict`` gèrent None.
L'agrégateur double-filtre (``error is None`` + ``v is not
None``) pour défense en profondeur.
P0-5 — corpus_path / output_dir validés contre workspace_roots
Nouveau ``validated_path(user_path, allowed_roots, must_exist,
must_be_dir)`` avec ``Path.resolve().is_relative_to()``.
Nouvelle ``compute_workspace_roots(uploads_dir)`` qui ajoute
``./rapports`` et ``./corpus`` à ``compute_browse_roots`` et
qu'un admin peut épingler via ``PICARONES_WORKSPACE_ROOTS``.
Appliquée dans ``/api/benchmark/start`` et
``/api/benchmark/run`` après la check mode public (l'ordre est
testé par ``test_sprint24_security``).
P0-6 — prompt_file restreint à la bibliothèque intégrée
Nouveau ``validated_prompt_filename(name)`` qui refuse les
séparateurs de chemin, les chemins absolus, ``..``, les
caractères de contrôle. Appliqué dans ``/api/benchmark/run``
pour bloquer l'exfiltration de fichiers locaux via prompt LLM.
Bonus — ``safe_report_name`` durcit la concaténation
``output_dir / f"{report_name}.html"`` contre les escapes via
``../`` et caractères de contrôle (défense en profondeur :
``output_dir`` est déjà validé en amont par le router).
Tests
-----
- 5 tests existants utilisaient ``compact()`` pour vérifier
l'effacement des analyses : mis à jour pour appeler
``compact(drop_analyses=True)`` (nouvelle sémantique opt-in).
Un test "défaut sans argument est no-op" ajouté.
- 51 nouveaux tests S1 :
* tests/security/test_sprint_a14_s1_path_validation.py (20)
— validated_path, safe_report_name, validated_prompt_filename.
* tests/core/test_sprint_a14_s1_metrics_error_returns_none.py (9)
— None plutôt que 0.0, propriétés safe, agrégateur robuste.
* tests/core/test_sprint_a14_s1_compact_optin.py (10)
— défaut no-op, text_limit, drop_analyses, combiné legacy.
* tests/measurements/test_sprint_a14_s1_normalization_propagation.py
(7) — signatures, parité 11 profils Pydantic ↔ runtime,
cer_diplomatic effectivement différent selon profil.
État de la suite
----------------
``pytest tests/ -q`` → 3913 passed, 3 skipped, 3 failed.
Les 3 fails restants sont environnementaux (pas une régression
S1) et seront corrigés au Sprint S2 du rewrite ciblé :
* test_engines.py::TestPeroOCREngine::test_run_without_config_raises
(dépend de Pillow vs pero_ocr non installé) ;
* test_readme_consistency.py::test_readme_test_count_matches_baseline
(sous-processus pytest sans ``pip install -e .``) ;
* test_readme_dual_lang.py::test_readme_tables_consistent_with_code
(idem).
Aucune fonctionnalité supprimée ni renommée. Rétrocompat stricte
sur la signature publique de ``run_benchmark`` (nouveau paramètre
en kwarg avec défaut ``None``) et sur ``DocumentResult.compact()``
(nouveaux paramètres avec défauts conservant l'API actuelle au
prix d'un changement de comportement assumé : compact() devient
no-op pour ne plus saboter le JSON exporté).
Refs : analyse repo + plan rewrite ciblé (S1).
Voir https://claude.ai/code/session_011XQZNitg1rCgia8ZD1a2hP
https://claude.ai/code/session_011XQZNitg1rCgia8ZD1a2hP
- picarones/core/metrics.py +48 -23
- picarones/core/results.py +61 -26
- picarones/measurements/metrics.py +9 -4
- picarones/measurements/runner/document.py +10 -1
- picarones/measurements/runner/orchestration.py +33 -5
- picarones/measurements/runner/workers.py +10 -1
- picarones/web/benchmark_utils.py +16 -2
- picarones/web/models.py +8 -1
- picarones/web/routers/benchmark.py +55 -11
- picarones/web/security.py +219 -0
- tests/architecture/test_file_budgets.py +5 -1
- tests/core/test_sprint_a14_s1_compact_optin.py +137 -0
- tests/core/test_sprint_a14_s1_metrics_error_returns_none.py +121 -0
- tests/measurements/test_sprint40_ner_runner.py +11 -1
- tests/measurements/test_sprint42_calibration_runner.py +2 -1
- tests/measurements/test_sprint61_philological_runner.py +2 -1
- tests/measurements/test_sprint_a14_s1_normalization_propagation.py +121 -0
- tests/report/test_sprint86_aii5_html.py +2 -1
- tests/report/test_sprint87_readability_html.py +2 -1
- tests/security/__init__.py +0 -0
- tests/security/test_sprint_a14_s1_path_validation.py +179 -0
|
@@ -19,17 +19,30 @@ from typing import Optional
|
|
| 19 |
|
| 20 |
@dataclass
|
| 21 |
class MetricsResult:
|
| 22 |
-
"""Ensemble des métriques calculées pour une paire (référence, hypothèse).
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
error: Optional[str] = None
|
| 34 |
cer_diplomatic: Optional[float] = None
|
| 35 |
"""CER calculé après normalisation diplomatique (ſ=s, u=v, i=j…).
|
|
@@ -39,14 +52,16 @@ class MetricsResult:
|
|
| 39 |
"""Nom du profil de normalisation diplomatique utilisé."""
|
| 40 |
|
| 41 |
def as_dict(self) -> dict:
|
|
|
|
|
|
|
| 42 |
d = {
|
| 43 |
-
"cer":
|
| 44 |
-
"cer_nfc":
|
| 45 |
-
"cer_caseless":
|
| 46 |
-
"wer":
|
| 47 |
-
"wer_normalized":
|
| 48 |
-
"mer":
|
| 49 |
-
"wil":
|
| 50 |
"reference_length": self.reference_length,
|
| 51 |
"hypothesis_length": self.hypothesis_length,
|
| 52 |
"error": self.error,
|
|
@@ -57,12 +72,12 @@ class MetricsResult:
|
|
| 57 |
return d
|
| 58 |
|
| 59 |
@property
|
| 60 |
-
def cer_percent(self) -> float:
|
| 61 |
-
return round(self.cer * 100, 2)
|
| 62 |
|
| 63 |
@property
|
| 64 |
-
def wer_percent(self) -> float:
|
| 65 |
-
return round(self.wer * 100, 2)
|
| 66 |
|
| 67 |
|
| 68 |
def aggregate_metrics(results: list[MetricsResult]) -> dict:
|
|
@@ -95,7 +110,17 @@ def aggregate_metrics(results: list[MetricsResult]) -> dict:
|
|
| 95 |
metric_names = ["cer", "cer_nfc", "cer_caseless", "wer", "wer_normalized", "mer", "wil"]
|
| 96 |
aggregated: dict = {}
|
| 97 |
for metric in metric_names:
|
| 98 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
aggregated[metric] = _stats(values)
|
| 100 |
|
| 101 |
# CER diplomatique (optionnel — présent seulement si calculé)
|
|
|
|
| 19 |
|
| 20 |
@dataclass
|
| 21 |
class MetricsResult:
|
| 22 |
+
"""Ensemble des métriques calculées pour une paire (référence, hypothèse).
|
| 23 |
+
|
| 24 |
+
Sprint A14-S1 — A.I.0 P0 : les champs CER/WER/MER/WIL sont
|
| 25 |
+
``Optional[float]``. Auparavant, en cas d'erreur de calcul (jiwer
|
| 26 |
+
absent, exception levée), ces champs étaient remplis avec ``0.0``,
|
| 27 |
+
ce qui était indistinguable d'un score parfait pour tout
|
| 28 |
+
consommateur ne lisant pas systématiquement ``error``. Désormais
|
| 29 |
+
ils sont à ``None`` quand ``error`` est non-None — les agrégateurs
|
| 30 |
+
filtrent déjà sur ``error is None``, les rendus HTML utilisent
|
| 31 |
+
``safe_round`` qui mappe ``None → 0.0`` à l'affichage seul, et un
|
| 32 |
+
accès direct sans vérification d'erreur lève désormais un
|
| 33 |
+
``TypeError`` explicite plutôt que de retourner silencieusement
|
| 34 |
+
une valeur factice.
|
| 35 |
+
"""
|
| 36 |
+
|
| 37 |
+
cer: Optional[float] = None
|
| 38 |
+
cer_nfc: Optional[float] = None
|
| 39 |
+
cer_caseless: Optional[float] = None
|
| 40 |
+
wer: Optional[float] = None
|
| 41 |
+
wer_normalized: Optional[float] = None
|
| 42 |
+
mer: Optional[float] = None
|
| 43 |
+
wil: Optional[float] = None
|
| 44 |
+
reference_length: int = 0
|
| 45 |
+
hypothesis_length: int = 0
|
| 46 |
error: Optional[str] = None
|
| 47 |
cer_diplomatic: Optional[float] = None
|
| 48 |
"""CER calculé après normalisation diplomatique (ſ=s, u=v, i=j…).
|
|
|
|
| 52 |
"""Nom du profil de normalisation diplomatique utilisé."""
|
| 53 |
|
| 54 |
def as_dict(self) -> dict:
|
| 55 |
+
def _round(v: Optional[float]) -> Optional[float]:
|
| 56 |
+
return None if v is None else round(v, 6)
|
| 57 |
d = {
|
| 58 |
+
"cer": _round(self.cer),
|
| 59 |
+
"cer_nfc": _round(self.cer_nfc),
|
| 60 |
+
"cer_caseless": _round(self.cer_caseless),
|
| 61 |
+
"wer": _round(self.wer),
|
| 62 |
+
"wer_normalized": _round(self.wer_normalized),
|
| 63 |
+
"mer": _round(self.mer),
|
| 64 |
+
"wil": _round(self.wil),
|
| 65 |
"reference_length": self.reference_length,
|
| 66 |
"hypothesis_length": self.hypothesis_length,
|
| 67 |
"error": self.error,
|
|
|
|
| 72 |
return d
|
| 73 |
|
| 74 |
@property
|
| 75 |
+
def cer_percent(self) -> Optional[float]:
|
| 76 |
+
return None if self.cer is None else round(self.cer * 100, 2)
|
| 77 |
|
| 78 |
@property
|
| 79 |
+
def wer_percent(self) -> Optional[float]:
|
| 80 |
+
return None if self.wer is None else round(self.wer * 100, 2)
|
| 81 |
|
| 82 |
|
| 83 |
def aggregate_metrics(results: list[MetricsResult]) -> dict:
|
|
|
|
| 110 |
metric_names = ["cer", "cer_nfc", "cer_caseless", "wer", "wer_normalized", "mer", "wil"]
|
| 111 |
aggregated: dict = {}
|
| 112 |
for metric in metric_names:
|
| 113 |
+
# Sprint A14-S1 — défense en profondeur : double filtre. Un
|
| 114 |
+
# MetricsResult avec ``error`` doit avoir ses métriques à
|
| 115 |
+
# ``None`` (cf. compute_metrics), mais on filtre aussi les
|
| 116 |
+
# ``None`` directement au cas où un caller construirait un
|
| 117 |
+
# MetricsResult partiel.
|
| 118 |
+
values = [
|
| 119 |
+
v for r in results
|
| 120 |
+
if r.error is None
|
| 121 |
+
for v in (getattr(r, metric),)
|
| 122 |
+
if v is not None
|
| 123 |
+
]
|
| 124 |
aggregated[metric] = _stats(values)
|
| 125 |
|
| 126 |
# CER diplomatique (optionnel — présent seulement si calculé)
|
|
@@ -160,35 +160,70 @@ class DocumentResult:
|
|
| 160 |
d["readability_metrics"] = self.readability_metrics
|
| 161 |
return d
|
| 162 |
|
| 163 |
-
def compact(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
"""Libère les champs lourds pour réduire l'empreinte mémoire.
|
| 165 |
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
"""
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
|
|
|
| 192 |
|
| 193 |
|
| 194 |
@dataclass
|
|
|
|
| 160 |
d["readability_metrics"] = self.readability_metrics
|
| 161 |
return d
|
| 162 |
|
| 163 |
+
def compact(
|
| 164 |
+
self,
|
| 165 |
+
text_limit: Optional[int] = None,
|
| 166 |
+
drop_analyses: bool = False,
|
| 167 |
+
) -> None:
|
| 168 |
"""Libère les champs lourds pour réduire l'empreinte mémoire.
|
| 169 |
|
| 170 |
+
Sprint A14-S1 — A.I.0 P0 : compaction désormais opt-in.
|
| 171 |
+
Auparavant, le runner appelait ``compact()`` sans paramètres
|
| 172 |
+
avant de sérialiser le JSON, ce qui amputait silencieusement
|
| 173 |
+
toutes les analyses per-document (confusion, taxonomy,
|
| 174 |
+
philological, searchability, etc.) et tronquait
|
| 175 |
+
``ground_truth``/``hypothesis``/``ocr_intermediate`` à 200
|
| 176 |
+
caractères. Le rapport HTML — qui consomme ce JSON — recevait
|
| 177 |
+
des données déjà mutilées, contredisant directement la
|
| 178 |
+
promesse "self-contained HTML report" du README.
|
| 179 |
+
|
| 180 |
+
Désormais, l'appel par défaut ``compact()`` est un **no-op**.
|
| 181 |
+
Le caller doit explicitement demander la troncature et/ou la
|
| 182 |
+
suppression des analyses :
|
| 183 |
+
|
| 184 |
+
- ``compact(text_limit=200)`` : tronque les textes à 200 chars.
|
| 185 |
+
- ``compact(drop_analyses=True)`` : supprime les dicts d'analyse.
|
| 186 |
+
- ``compact(text_limit=200, drop_analyses=True)`` : ancien
|
| 187 |
+
comportement, à utiliser en pipeline web pour un rendu
|
| 188 |
+
interactif léger uniquement.
|
| 189 |
+
|
| 190 |
+
Le runner (``runner/orchestration.py``) ne compacte plus par
|
| 191 |
+
défaut ; le JSON exporté contient désormais toutes les
|
| 192 |
+
analyses détaillées.
|
| 193 |
+
|
| 194 |
+
Parameters
|
| 195 |
+
----------
|
| 196 |
+
text_limit:
|
| 197 |
+
Si fourni (int > 0), tronque ``ground_truth``,
|
| 198 |
+
``hypothesis`` et ``ocr_intermediate`` à cette longueur en
|
| 199 |
+
ajoutant "…". ``None`` (défaut) = pas de troncature.
|
| 200 |
+
drop_analyses:
|
| 201 |
+
Si ``True``, met à ``None`` toutes les analyses
|
| 202 |
+
per-document (confusion, taxonomy, philological…). Défaut :
|
| 203 |
+
``False`` = on conserve toutes les analyses.
|
| 204 |
"""
|
| 205 |
+
if text_limit is not None and text_limit > 0:
|
| 206 |
+
if len(self.ground_truth) > text_limit:
|
| 207 |
+
self.ground_truth = self.ground_truth[:text_limit] + "…"
|
| 208 |
+
if len(self.hypothesis) > text_limit:
|
| 209 |
+
self.hypothesis = self.hypothesis[:text_limit] + "…"
|
| 210 |
+
if self.ocr_intermediate and len(self.ocr_intermediate) > text_limit:
|
| 211 |
+
self.ocr_intermediate = self.ocr_intermediate[:text_limit] + "…"
|
| 212 |
+
|
| 213 |
+
if drop_analyses:
|
| 214 |
+
self.confusion_matrix = None
|
| 215 |
+
self.char_scores = None
|
| 216 |
+
self.taxonomy = None
|
| 217 |
+
self.structure = None
|
| 218 |
+
self.image_quality = None
|
| 219 |
+
self.line_metrics = None
|
| 220 |
+
self.hallucination_metrics = None
|
| 221 |
+
self.ner_metrics = None
|
| 222 |
+
self.calibration_metrics = None
|
| 223 |
+
self.philological_metrics = None
|
| 224 |
+
self.searchability_metrics = None
|
| 225 |
+
self.numerical_sequence_metrics = None
|
| 226 |
+
self.readability_metrics = None
|
| 227 |
|
| 228 |
|
| 229 |
@dataclass
|
|
@@ -104,9 +104,12 @@ def compute_metrics(
|
|
| 104 |
Objet contenant toutes les métriques calculées.
|
| 105 |
"""
|
| 106 |
if not _JIWER_AVAILABLE:
|
|
|
|
|
|
|
|
|
|
| 107 |
return MetricsResult(
|
| 108 |
-
cer=
|
| 109 |
-
wer=
|
| 110 |
reference_length=len(reference),
|
| 111 |
hypothesis_length=len(hypothesis),
|
| 112 |
error="jiwer n'est pas installé (pip install jiwer)",
|
|
@@ -177,9 +180,11 @@ def compute_metrics(
|
|
| 177 |
|
| 178 |
except Exception as exc: # noqa: BLE001
|
| 179 |
logger.warning("[metrics] calcul métriques échoué : %s", exc)
|
|
|
|
|
|
|
| 180 |
return MetricsResult(
|
| 181 |
-
cer=
|
| 182 |
-
wer=
|
| 183 |
reference_length=len(reference),
|
| 184 |
hypothesis_length=len(hypothesis),
|
| 185 |
error=str(exc),
|
|
|
|
| 104 |
Objet contenant toutes les métriques calculées.
|
| 105 |
"""
|
| 106 |
if not _JIWER_AVAILABLE:
|
| 107 |
+
# Sprint A14-S1 — A.I.0 P0 : ne pas retourner 0.0 en erreur
|
| 108 |
+
# (indistinguable d'un score parfait pour un lecteur qui ne
|
| 109 |
+
# vérifie pas ``error``). None = absence de mesure.
|
| 110 |
return MetricsResult(
|
| 111 |
+
cer=None, cer_nfc=None, cer_caseless=None,
|
| 112 |
+
wer=None, wer_normalized=None, mer=None, wil=None,
|
| 113 |
reference_length=len(reference),
|
| 114 |
hypothesis_length=len(hypothesis),
|
| 115 |
error="jiwer n'est pas installé (pip install jiwer)",
|
|
|
|
| 180 |
|
| 181 |
except Exception as exc: # noqa: BLE001
|
| 182 |
logger.warning("[metrics] calcul métriques échoué : %s", exc)
|
| 183 |
+
# Sprint A14-S1 — A.I.0 P0 : None plutôt que 0.0 (cf. cas
|
| 184 |
+
# ``not _JIWER_AVAILABLE`` plus haut pour le rationale).
|
| 185 |
return MetricsResult(
|
| 186 |
+
cer=None, cer_nfc=None, cer_caseless=None,
|
| 187 |
+
wer=None, wer_normalized=None, mer=None, wil=None,
|
| 188 |
reference_length=len(reference),
|
| 189 |
hypothesis_length=len(hypothesis),
|
| 190 |
error=str(exc),
|
|
@@ -42,6 +42,7 @@ def _compute_document_result(
|
|
| 42 |
char_exclude: Optional[frozenset],
|
| 43 |
corpus_lang: str = "fr",
|
| 44 |
profile: str = "standard",
|
|
|
|
| 45 |
) -> DocumentResult:
|
| 46 |
"""Calcule toutes les métriques pour un document et retourne un DocumentResult.
|
| 47 |
|
|
@@ -69,7 +70,15 @@ def _compute_document_result(
|
|
| 69 |
from picarones.core.metric_hooks import run_document_hooks
|
| 70 |
|
| 71 |
if ocr_result.success:
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
else:
|
| 74 |
metrics = MetricsResult(
|
| 75 |
cer=1.0, cer_nfc=1.0, cer_caseless=1.0,
|
|
|
|
| 42 |
char_exclude: Optional[frozenset],
|
| 43 |
corpus_lang: str = "fr",
|
| 44 |
profile: str = "standard",
|
| 45 |
+
normalization_profile: Optional[object] = None,
|
| 46 |
) -> DocumentResult:
|
| 47 |
"""Calcule toutes les métriques pour un document et retourne un DocumentResult.
|
| 48 |
|
|
|
|
| 70 |
from picarones.core.metric_hooks import run_document_hooks
|
| 71 |
|
| 72 |
if ocr_result.success:
|
| 73 |
+
# Sprint A14-S1 — A.I.0 P0 : propagation du profil de
|
| 74 |
+
# normalisation depuis le runner. ``normalization_profile``
|
| 75 |
+
# est un ``NormalizationProfile`` résolu en main process par
|
| 76 |
+
# ``run_benchmark`` (cf. orchestration.py).
|
| 77 |
+
metrics = compute_metrics(
|
| 78 |
+
ground_truth, ocr_result.text,
|
| 79 |
+
normalization_profile=normalization_profile, # type: ignore[arg-type]
|
| 80 |
+
char_exclude=char_exclude,
|
| 81 |
+
)
|
| 82 |
else:
|
| 83 |
metrics = MetricsResult(
|
| 84 |
cer=1.0, cer_nfc=1.0, cer_caseless=1.0,
|
|
@@ -64,6 +64,7 @@ def run_benchmark(
|
|
| 64 |
cancel_event: Optional[threading.Event] = None,
|
| 65 |
entity_extractor: Optional[callable] = None,
|
| 66 |
profile: str = "standard",
|
|
|
|
| 67 |
) -> BenchmarkResult:
|
| 68 |
"""Exécute le benchmark d'un ou plusieurs moteurs/pipelines sur un corpus.
|
| 69 |
|
|
@@ -119,6 +120,15 @@ def run_benchmark(
|
|
| 119 |
``"diagnostics"``, ``"economics"``, ``"pipeline"``, ``"full"``.
|
| 120 |
Le profil ``"standard"`` est strictement rétrocompatible avec
|
| 121 |
le runner pré-chantier-2.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
|
| 123 |
Returns
|
| 124 |
-------
|
|
@@ -135,6 +145,15 @@ def run_benchmark(
|
|
| 135 |
)
|
| 136 |
validate_profile(profile)
|
| 137 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
def _is_cancelled() -> bool:
|
| 139 |
return cancel_event is not None and cancel_event.is_set()
|
| 140 |
engine_reports: list[EngineReport] = []
|
|
@@ -225,12 +244,13 @@ def run_benchmark(
|
|
| 225 |
_cpu_doc_worker,
|
| 226 |
(engine_module, engine_class_name, engine.config,
|
| 227 |
doc.doc_id, str(doc.image_path), doc.ground_truth,
|
| 228 |
-
char_exclude_tuple, corpus_lang, profile
|
|
|
|
| 229 |
)
|
| 230 |
else:
|
| 231 |
future = executor.submit(
|
| 232 |
_io_doc_worker, engine, doc, char_exclude,
|
| 233 |
-
corpus_lang, profile,
|
| 234 |
)
|
| 235 |
future_to_doc[future] = doc
|
| 236 |
submitted_at[future] = time.monotonic()
|
|
@@ -397,9 +417,17 @@ def run_benchmark(
|
|
| 397 |
agg_ner = _aggregate_ner(document_results)
|
| 398 |
report.aggregated_ner = agg_ner
|
| 399 |
|
| 400 |
-
#
|
| 401 |
-
|
| 402 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 403 |
|
| 404 |
# Sprint 36 — analyse inter-moteurs (divergence taxonomique +
|
| 405 |
# complémentarité / oracle). N'est calculée qu'à partir de 2
|
|
|
|
| 64 |
cancel_event: Optional[threading.Event] = None,
|
| 65 |
entity_extractor: Optional[callable] = None,
|
| 66 |
profile: str = "standard",
|
| 67 |
+
normalization_profile: Optional[str] = None,
|
| 68 |
) -> BenchmarkResult:
|
| 69 |
"""Exécute le benchmark d'un ou plusieurs moteurs/pipelines sur un corpus.
|
| 70 |
|
|
|
|
| 120 |
``"diagnostics"``, ``"economics"``, ``"pipeline"``, ``"full"``.
|
| 121 |
Le profil ``"standard"`` est strictement rétrocompatible avec
|
| 122 |
le runner pré-chantier-2.
|
| 123 |
+
normalization_profile:
|
| 124 |
+
Identifiant d'un profil de normalisation diplomatique
|
| 125 |
+
(cf. ``measurements.normalization.NORMALIZATION_PROFILES``).
|
| 126 |
+
Sprint A14-S1 — A.I.0 P0 : auparavant l'API web exposait ce
|
| 127 |
+
paramètre mais il était silencieusement perdu avant
|
| 128 |
+
d'atteindre ``compute_metrics``, ce qui rendait
|
| 129 |
+
scientifiquement faux tout benchmark lancé via la web app.
|
| 130 |
+
Désormais propagé end-to-end : web → run_benchmark → workers
|
| 131 |
+
→ compute_metrics. ``None`` = profil par défaut (medieval_french).
|
| 132 |
|
| 133 |
Returns
|
| 134 |
-------
|
|
|
|
| 145 |
)
|
| 146 |
validate_profile(profile)
|
| 147 |
|
| 148 |
+
# Sprint A14-S1 — résolution one-shot du profil de normalisation.
|
| 149 |
+
# On le fait ici (main process) pour échouer rapidement sur un ID
|
| 150 |
+
# invalide avant de soumettre des futures aux pools, et pour
|
| 151 |
+
# éviter de re-résoudre N fois côté workers.
|
| 152 |
+
norm_profile_obj = None
|
| 153 |
+
if normalization_profile is not None:
|
| 154 |
+
from picarones.measurements.normalization import get_builtin_profile
|
| 155 |
+
norm_profile_obj = get_builtin_profile(normalization_profile)
|
| 156 |
+
|
| 157 |
def _is_cancelled() -> bool:
|
| 158 |
return cancel_event is not None and cancel_event.is_set()
|
| 159 |
engine_reports: list[EngineReport] = []
|
|
|
|
| 244 |
_cpu_doc_worker,
|
| 245 |
(engine_module, engine_class_name, engine.config,
|
| 246 |
doc.doc_id, str(doc.image_path), doc.ground_truth,
|
| 247 |
+
char_exclude_tuple, corpus_lang, profile,
|
| 248 |
+
norm_profile_obj),
|
| 249 |
)
|
| 250 |
else:
|
| 251 |
future = executor.submit(
|
| 252 |
_io_doc_worker, engine, doc, char_exclude,
|
| 253 |
+
corpus_lang, profile, norm_profile_obj,
|
| 254 |
)
|
| 255 |
future_to_doc[future] = doc
|
| 256 |
submitted_at[future] = time.monotonic()
|
|
|
|
| 417 |
agg_ner = _aggregate_ner(document_results)
|
| 418 |
report.aggregated_ner = agg_ner
|
| 419 |
|
| 420 |
+
# Sprint A14-S1 — A.I.0 P0 : la compaction inconditionnelle qui
|
| 421 |
+
# vivait ici amputait silencieusement le JSON exporté (et donc
|
| 422 |
+
# le rapport HTML qui le consomme) en supprimant 13 dicts
|
| 423 |
+
# d'analyse per-document et en tronquant les textes à 200 chars.
|
| 424 |
+
# ``DocumentResult.compact()`` est désormais opt-in (paramètres
|
| 425 |
+
# ``text_limit`` et ``drop_analyses``) ; le runner ne compacte
|
| 426 |
+
# plus par défaut afin que ``output_json`` contienne réellement
|
| 427 |
+
# toutes les analyses détaillées promises par le README.
|
| 428 |
+
# Un caller qui veut un JSON léger peut appeler
|
| 429 |
+
# ``dr.compact(text_limit=200, drop_analyses=True)`` lui-même
|
| 430 |
+
# après ``run_benchmark`` et avant la sérialisation finale.
|
| 431 |
|
| 432 |
# Sprint 36 — analyse inter-moteurs (divergence taxonomique +
|
| 433 |
# complémentarité / oracle). N'est calculée qu'à partir de 2
|
|
@@ -33,8 +33,14 @@ def _cpu_doc_worker(args: tuple) -> "DocumentResult":
|
|
| 33 |
- 7 éléments : legacy (Sprint 13)
|
| 34 |
- 8 éléments : + ``corpus_lang`` (Sprint 87)
|
| 35 |
- 9 éléments : + ``profile`` (chantier 2 post-Sprint 97)
|
|
|
|
| 36 |
"""
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
(engine_module, engine_class_name, engine_config, doc_id,
|
| 39 |
image_path, ground_truth, char_exclude_chars, corpus_lang,
|
| 40 |
profile) = args
|
|
@@ -61,6 +67,7 @@ def _cpu_doc_worker(args: tuple) -> "DocumentResult":
|
|
| 61 |
char_exclude=char_exclude,
|
| 62 |
corpus_lang=corpus_lang,
|
| 63 |
profile=profile,
|
|
|
|
| 64 |
)
|
| 65 |
|
| 66 |
|
|
@@ -70,6 +77,7 @@ def _io_doc_worker(
|
|
| 70 |
char_exclude: Optional[frozenset],
|
| 71 |
corpus_lang: str = "fr",
|
| 72 |
profile: str = "standard",
|
|
|
|
| 73 |
) -> "DocumentResult":
|
| 74 |
"""Worker pour ThreadPoolExecutor (moteurs IO-bound / API).
|
| 75 |
|
|
@@ -101,6 +109,7 @@ def _io_doc_worker(
|
|
| 101 |
char_exclude=char_exclude,
|
| 102 |
corpus_lang=corpus_lang,
|
| 103 |
profile=profile,
|
|
|
|
| 104 |
)
|
| 105 |
|
| 106 |
|
|
|
|
| 33 |
- 7 éléments : legacy (Sprint 13)
|
| 34 |
- 8 éléments : + ``corpus_lang`` (Sprint 87)
|
| 35 |
- 9 éléments : + ``profile`` (chantier 2 post-Sprint 97)
|
| 36 |
+
- 10 éléments : + ``normalization_profile`` (Sprint A14-S1, A.I.0 P0)
|
| 37 |
"""
|
| 38 |
+
norm_profile = None
|
| 39 |
+
if len(args) == 10:
|
| 40 |
+
(engine_module, engine_class_name, engine_config, doc_id,
|
| 41 |
+
image_path, ground_truth, char_exclude_chars, corpus_lang,
|
| 42 |
+
profile, norm_profile) = args
|
| 43 |
+
elif len(args) == 9:
|
| 44 |
(engine_module, engine_class_name, engine_config, doc_id,
|
| 45 |
image_path, ground_truth, char_exclude_chars, corpus_lang,
|
| 46 |
profile) = args
|
|
|
|
| 67 |
char_exclude=char_exclude,
|
| 68 |
corpus_lang=corpus_lang,
|
| 69 |
profile=profile,
|
| 70 |
+
normalization_profile=norm_profile,
|
| 71 |
)
|
| 72 |
|
| 73 |
|
|
|
|
| 77 |
char_exclude: Optional[frozenset],
|
| 78 |
corpus_lang: str = "fr",
|
| 79 |
profile: str = "standard",
|
| 80 |
+
normalization_profile: Optional[object] = None,
|
| 81 |
) -> "DocumentResult":
|
| 82 |
"""Worker pour ThreadPoolExecutor (moteurs IO-bound / API).
|
| 83 |
|
|
|
|
| 109 |
char_exclude=char_exclude,
|
| 110 |
corpus_lang=corpus_lang,
|
| 111 |
profile=profile,
|
| 112 |
+
normalization_profile=normalization_profile,
|
| 113 |
)
|
| 114 |
|
| 115 |
|
|
@@ -176,9 +176,15 @@ def run_benchmark_thread_v2(job: BenchmarkJob, req: BenchmarkRunRequest) -> None
|
|
| 176 |
if not engines:
|
| 177 |
raise ValueError("Aucun concurrent valide disponible.")
|
| 178 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
output_dir = Path(req.output_dir)
|
| 180 |
output_dir.mkdir(parents=True, exist_ok=True)
|
| 181 |
-
|
|
|
|
| 182 |
output_json = str(output_dir / f"{report_name}.json")
|
| 183 |
output_html = str(output_dir / f"{report_name}.html")
|
| 184 |
|
|
@@ -213,6 +219,7 @@ def run_benchmark_thread_v2(job: BenchmarkJob, req: BenchmarkRunRequest) -> None
|
|
| 213 |
progress_callback=_progress_callback,
|
| 214 |
char_exclude=char_excl,
|
| 215 |
cancel_event=job._cancel_event,
|
|
|
|
| 216 |
)
|
| 217 |
|
| 218 |
if job.status == "cancelled":
|
|
@@ -276,9 +283,15 @@ def run_benchmark_thread(job: BenchmarkJob, req: BenchmarkRequest) -> None:
|
|
| 276 |
raise ValueError("Aucun moteur valide disponible.")
|
| 277 |
|
| 278 |
# Répertoire de sortie
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
output_dir = Path(req.output_dir)
|
| 280 |
output_dir.mkdir(parents=True, exist_ok=True)
|
| 281 |
-
|
|
|
|
| 282 |
output_json = str(output_dir / f"{report_name}.json")
|
| 283 |
output_html = str(output_dir / f"{report_name}.html")
|
| 284 |
|
|
@@ -314,6 +327,7 @@ def run_benchmark_thread(job: BenchmarkJob, req: BenchmarkRequest) -> None:
|
|
| 314 |
progress_callback=_progress_callback,
|
| 315 |
char_exclude=char_excl,
|
| 316 |
cancel_event=job._cancel_event,
|
|
|
|
| 317 |
)
|
| 318 |
|
| 319 |
if job.status == "cancelled":
|
|
|
|
| 176 |
if not engines:
|
| 177 |
raise ValueError("Aucun concurrent valide disponible.")
|
| 178 |
|
| 179 |
+
# Sprint A14-S1 — A.I.0 P0 : ``output_dir`` a déjà été validé
|
| 180 |
+
# par le router (validated_path). ``report_name`` est sanitizé
|
| 181 |
+
# ici pour défense en profondeur (refuse ``../``, séparateurs,
|
| 182 |
+
# caractères de contrôle) avant concaténation à output_dir.
|
| 183 |
+
from picarones.web.security import safe_report_name
|
| 184 |
output_dir = Path(req.output_dir)
|
| 185 |
output_dir.mkdir(parents=True, exist_ok=True)
|
| 186 |
+
raw_name = req.report_name or f"rapport_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
| 187 |
+
report_name = safe_report_name(raw_name)
|
| 188 |
output_json = str(output_dir / f"{report_name}.json")
|
| 189 |
output_html = str(output_dir / f"{report_name}.html")
|
| 190 |
|
|
|
|
| 219 |
progress_callback=_progress_callback,
|
| 220 |
char_exclude=char_excl,
|
| 221 |
cancel_event=job._cancel_event,
|
| 222 |
+
normalization_profile=req.normalization_profile,
|
| 223 |
)
|
| 224 |
|
| 225 |
if job.status == "cancelled":
|
|
|
|
| 283 |
raise ValueError("Aucun moteur valide disponible.")
|
| 284 |
|
| 285 |
# Répertoire de sortie
|
| 286 |
+
# Sprint A14-S1 — A.I.0 P0 : ``output_dir`` a déjà été validé
|
| 287 |
+
# par le router (validated_path). ``report_name`` est sanitizé
|
| 288 |
+
# ici pour défense en profondeur (refuse ``../``, séparateurs,
|
| 289 |
+
# caractères de contrôle) avant concaténation à output_dir.
|
| 290 |
+
from picarones.web.security import safe_report_name
|
| 291 |
output_dir = Path(req.output_dir)
|
| 292 |
output_dir.mkdir(parents=True, exist_ok=True)
|
| 293 |
+
raw_name = req.report_name or f"rapport_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
| 294 |
+
report_name = safe_report_name(raw_name)
|
| 295 |
output_json = str(output_dir / f"{report_name}.json")
|
| 296 |
output_html = str(output_dir / f"{report_name}.html")
|
| 297 |
|
|
|
|
| 327 |
progress_callback=_progress_callback,
|
| 328 |
char_exclude=char_excl,
|
| 329 |
cancel_event=job._cancel_event,
|
| 330 |
+
normalization_profile=req.normalization_profile,
|
| 331 |
)
|
| 332 |
|
| 333 |
if job.status == "cancelled":
|
|
@@ -57,8 +57,15 @@ NormalizationProfileId = Literal[
|
|
| 57 |
"medieval_french", "early_modern_french",
|
| 58 |
"medieval_latin",
|
| 59 |
"early_modern_english", "medieval_english",
|
|
|
|
|
|
|
| 60 |
]
|
| 61 |
-
"""Identifiants des profils de normalisation Unicode disponibles.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
|
| 64 |
class BenchmarkRequest(BaseModel):
|
|
|
|
| 57 |
"medieval_french", "early_modern_french",
|
| 58 |
"medieval_latin",
|
| 59 |
"early_modern_english", "medieval_english",
|
| 60 |
+
"secretary_hand",
|
| 61 |
+
"sans_ponctuation", "sans_apostrophes",
|
| 62 |
]
|
| 63 |
+
"""Identifiants des profils de normalisation Unicode disponibles.
|
| 64 |
+
|
| 65 |
+
Liste alignée sur ``measurements.normalization.NORMALIZATION_PROFILES``
|
| 66 |
+
(11 profils). Toute addition côté ``normalization.py`` doit être
|
| 67 |
+
répercutée ici sous peine de rejet Pydantic au niveau API web.
|
| 68 |
+
Sprint A14-S1 — alignement README ↔ web models ↔ runtime."""
|
| 69 |
|
| 70 |
|
| 71 |
class BenchmarkRequest(BaseModel):
|
|
@@ -25,10 +25,15 @@ from picarones.web.benchmark_utils import (
|
|
| 25 |
)
|
| 26 |
from picarones.web.models import BenchmarkRequest, BenchmarkRunRequest
|
| 27 |
from picarones.web.security import (
|
|
|
|
| 28 |
assert_engines_allowed,
|
| 29 |
assert_llm_provider_allowed,
|
|
|
|
| 30 |
get_max_concurrent_jobs,
|
|
|
|
|
|
|
| 31 |
)
|
|
|
|
| 32 |
|
| 33 |
router = APIRouter()
|
| 34 |
|
|
@@ -61,18 +66,35 @@ def _start_job_thread(
|
|
| 61 |
@router.post("/api/benchmark/start")
|
| 62 |
async def api_benchmark_start(req: BenchmarkRequest, request: Request) -> dict:
|
| 63 |
"""Lance un benchmark sur une liste de moteurs OCR (mode legacy)."""
|
| 64 |
-
corpus_path = Path(req.corpus_path)
|
| 65 |
-
if not corpus_path.exists() or not corpus_path.is_dir():
|
| 66 |
-
raise HTTPException(
|
| 67 |
-
status_code=400, detail=f"Corpus non trouvé : {req.corpus_path}",
|
| 68 |
-
)
|
| 69 |
-
|
| 70 |
# Sprint 24 — mode public : refuse les moteurs OCR cloud mutualisés.
|
|
|
|
|
|
|
| 71 |
try:
|
| 72 |
assert_engines_allowed(req.engines)
|
| 73 |
except PermissionError as exc:
|
| 74 |
raise HTTPException(status_code=403, detail=str(exc))
|
| 75 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
# Sprint 24 — rate limit + sémaphore concurrents.
|
| 77 |
state.enforce_rate_limit(request)
|
| 78 |
if not state.JOBS_SEMAPHORE.acquire(blocking=False):
|
|
@@ -105,15 +127,12 @@ async def api_benchmark_run(req: BenchmarkRunRequest, request: Request) -> dict:
|
|
| 105 |
Chaque ``CompetitorConfig`` peut combiner un moteur OCR et un
|
| 106 |
provider LLM (mode post-correction, zero-shot, ou OCR seul).
|
| 107 |
"""
|
| 108 |
-
corpus_path = Path(req.corpus_path)
|
| 109 |
-
if not corpus_path.exists() or not corpus_path.is_dir():
|
| 110 |
-
raise HTTPException(
|
| 111 |
-
status_code=400, detail=f"Corpus non trouvé : {req.corpus_path}",
|
| 112 |
-
)
|
| 113 |
# ``competitors`` non vide est garanti par Pydantic ``min_length=1``.
|
| 114 |
|
| 115 |
# Mode public : refuse les pipelines LLM mutualisés et les moteurs
|
| 116 |
# OCR cloud sollicités par n'importe quel concurrent.
|
|
|
|
|
|
|
| 117 |
try:
|
| 118 |
for comp in req.competitors:
|
| 119 |
assert_engines_allowed([comp.ocr_engine] if comp.ocr_engine else [])
|
|
@@ -121,6 +140,31 @@ async def api_benchmark_run(req: BenchmarkRunRequest, request: Request) -> dict:
|
|
| 121 |
except PermissionError as exc:
|
| 122 |
raise HTTPException(status_code=403, detail=str(exc))
|
| 123 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
# Sprint 24 — rate limit + sémaphore concurrents.
|
| 125 |
state.enforce_rate_limit(request)
|
| 126 |
if not state.JOBS_SEMAPHORE.acquire(blocking=False):
|
|
|
|
| 25 |
)
|
| 26 |
from picarones.web.models import BenchmarkRequest, BenchmarkRunRequest
|
| 27 |
from picarones.web.security import (
|
| 28 |
+
PathValidationError,
|
| 29 |
assert_engines_allowed,
|
| 30 |
assert_llm_provider_allowed,
|
| 31 |
+
compute_workspace_roots,
|
| 32 |
get_max_concurrent_jobs,
|
| 33 |
+
validated_path,
|
| 34 |
+
validated_prompt_filename,
|
| 35 |
)
|
| 36 |
+
from picarones.web.state import UPLOADS_DIR
|
| 37 |
|
| 38 |
router = APIRouter()
|
| 39 |
|
|
|
|
| 66 |
@router.post("/api/benchmark/start")
|
| 67 |
async def api_benchmark_start(req: BenchmarkRequest, request: Request) -> dict:
|
| 68 |
"""Lance un benchmark sur une liste de moteurs OCR (mode legacy)."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
# Sprint 24 — mode public : refuse les moteurs OCR cloud mutualisés.
|
| 70 |
+
# Vérifié AVANT la validation des chemins pour que la réponse
|
| 71 |
+
# 403 mode public reste prioritaire (cf. tests sprint24).
|
| 72 |
try:
|
| 73 |
assert_engines_allowed(req.engines)
|
| 74 |
except PermissionError as exc:
|
| 75 |
raise HTTPException(status_code=403, detail=str(exc))
|
| 76 |
|
| 77 |
+
# Sprint A14-S1 — A.I.0 P0 : validation des chemins utilisateur
|
| 78 |
+
# contre les racines workspace autorisées. Bloque les chemins
|
| 79 |
+
# absolus arbitraires, la traversée (``..``), les liens symboliques
|
| 80 |
+
# vers l'extérieur, etc.
|
| 81 |
+
workspace_roots = compute_workspace_roots(UPLOADS_DIR)
|
| 82 |
+
try:
|
| 83 |
+
validated_path(
|
| 84 |
+
req.corpus_path,
|
| 85 |
+
allowed_roots=workspace_roots,
|
| 86 |
+
must_be_dir=True,
|
| 87 |
+
)
|
| 88 |
+
# ``output_dir`` peut ne pas encore exister, on valide juste
|
| 89 |
+
# qu'il sera créé dans une racine autorisée.
|
| 90 |
+
validated_path(
|
| 91 |
+
req.output_dir,
|
| 92 |
+
allowed_roots=workspace_roots,
|
| 93 |
+
must_exist=False,
|
| 94 |
+
)
|
| 95 |
+
except PathValidationError as exc:
|
| 96 |
+
raise HTTPException(status_code=400, detail=str(exc))
|
| 97 |
+
|
| 98 |
# Sprint 24 — rate limit + sémaphore concurrents.
|
| 99 |
state.enforce_rate_limit(request)
|
| 100 |
if not state.JOBS_SEMAPHORE.acquire(blocking=False):
|
|
|
|
| 127 |
Chaque ``CompetitorConfig`` peut combiner un moteur OCR et un
|
| 128 |
provider LLM (mode post-correction, zero-shot, ou OCR seul).
|
| 129 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
# ``competitors`` non vide est garanti par Pydantic ``min_length=1``.
|
| 131 |
|
| 132 |
# Mode public : refuse les pipelines LLM mutualisés et les moteurs
|
| 133 |
# OCR cloud sollicités par n'importe quel concurrent.
|
| 134 |
+
# Vérifié AVANT la validation des chemins (cf. /api/benchmark/start
|
| 135 |
+
# pour le rationale).
|
| 136 |
try:
|
| 137 |
for comp in req.competitors:
|
| 138 |
assert_engines_allowed([comp.ocr_engine] if comp.ocr_engine else [])
|
|
|
|
| 140 |
except PermissionError as exc:
|
| 141 |
raise HTTPException(status_code=403, detail=str(exc))
|
| 142 |
|
| 143 |
+
# Sprint A14-S1 — A.I.0 P0 : validation des chemins utilisateur
|
| 144 |
+
# (cf. /api/benchmark/start). Idempotent : refuse un corpus_path
|
| 145 |
+
# absolu hors workspaces, et refuse un output_dir qui s'évaderait
|
| 146 |
+
# via ``..`` ou symlinks.
|
| 147 |
+
workspace_roots = compute_workspace_roots(UPLOADS_DIR)
|
| 148 |
+
try:
|
| 149 |
+
validated_path(
|
| 150 |
+
req.corpus_path,
|
| 151 |
+
allowed_roots=workspace_roots,
|
| 152 |
+
must_be_dir=True,
|
| 153 |
+
)
|
| 154 |
+
validated_path(
|
| 155 |
+
req.output_dir,
|
| 156 |
+
allowed_roots=workspace_roots,
|
| 157 |
+
must_exist=False,
|
| 158 |
+
)
|
| 159 |
+
# Sprint A14-S1 — restriction des prompts à la bibliothèque
|
| 160 |
+
# intégrée (``picarones/prompts/``). Cf. validated_prompt_filename
|
| 161 |
+
# pour le rationale (vecteur d'exfiltration via LLM).
|
| 162 |
+
for comp in req.competitors:
|
| 163 |
+
if comp.prompt_file:
|
| 164 |
+
validated_prompt_filename(comp.prompt_file)
|
| 165 |
+
except PathValidationError as exc:
|
| 166 |
+
raise HTTPException(status_code=400, detail=str(exc))
|
| 167 |
+
|
| 168 |
# Sprint 24 — rate limit + sémaphore concurrents.
|
| 169 |
state.enforce_rate_limit(request)
|
| 170 |
if not state.JOBS_SEMAPHORE.acquire(blocking=False):
|
|
@@ -96,6 +96,188 @@ def assert_llm_provider_allowed(llm_provider: str) -> None:
|
|
| 96 |
)
|
| 97 |
|
| 98 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
# ---------------------------------------------------------------------------
|
| 100 |
# Browse roots
|
| 101 |
# ---------------------------------------------------------------------------
|
|
@@ -126,6 +308,43 @@ def compute_browse_roots(uploads_dir: Path) -> list[Path]:
|
|
| 126 |
]
|
| 127 |
|
| 128 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
# ---------------------------------------------------------------------------
|
| 130 |
# Validation des images uploadées
|
| 131 |
# ---------------------------------------------------------------------------
|
|
|
|
| 96 |
)
|
| 97 |
|
| 98 |
|
| 99 |
+
# ---------------------------------------------------------------------------
|
| 100 |
+
# Validation des chemins utilisateur (Sprint A14-S1, A.I.0 P0)
|
| 101 |
+
# ---------------------------------------------------------------------------
|
| 102 |
+
|
| 103 |
+
class PathValidationError(ValueError):
|
| 104 |
+
"""Levée quand un chemin utilisateur sort de la zone autorisée."""
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def validated_path(
|
| 108 |
+
user_path: str,
|
| 109 |
+
allowed_roots: list[Path],
|
| 110 |
+
must_exist: bool = False,
|
| 111 |
+
must_be_dir: bool = False,
|
| 112 |
+
) -> Path:
|
| 113 |
+
"""Résout un chemin utilisateur et vérifie qu'il reste dans une racine autorisée.
|
| 114 |
+
|
| 115 |
+
Garde-fou central contre la traversée de répertoires (path traversal)
|
| 116 |
+
et l'écriture/lecture arbitraire dans le système de fichiers du
|
| 117 |
+
serveur. Avant ce sprint, les endpoints ``/api/benchmark/*``
|
| 118 |
+
acceptaient n'importe quel ``corpus_path`` ou ``output_dir`` validé
|
| 119 |
+
uniquement par ``Path.exists()`` — ce qui permettait à un client
|
| 120 |
+
de pousser le serveur à lire/écrire en dehors de ses propres
|
| 121 |
+
workspaces, dans la limite des permissions du process.
|
| 122 |
+
|
| 123 |
+
Algorithme :
|
| 124 |
+
|
| 125 |
+
1. Refuse les chemins vides ou contenant des octets nuls.
|
| 126 |
+
2. Résout le chemin de manière absolue (``Path.resolve()``) — ça
|
| 127 |
+
écrase ``..``, les liens symboliques et les chemins relatifs.
|
| 128 |
+
3. Vérifie que le résultat est ``.is_relative_to(root)`` pour au
|
| 129 |
+
moins une des ``allowed_roots`` (elles aussi pré-résolues).
|
| 130 |
+
4. Optionnellement : vérifie l'existence et le type (dir).
|
| 131 |
+
|
| 132 |
+
Parameters
|
| 133 |
+
----------
|
| 134 |
+
user_path:
|
| 135 |
+
Chemin tel que reçu de l'utilisateur (str). Peut être absolu
|
| 136 |
+
ou relatif.
|
| 137 |
+
allowed_roots:
|
| 138 |
+
Liste de répertoires racines (``Path``) au sein desquels le
|
| 139 |
+
chemin résolu doit se trouver. Liste vide = tout refuser.
|
| 140 |
+
must_exist:
|
| 141 |
+
Si ``True``, exige que le chemin résolu existe sur le disque
|
| 142 |
+
après validation.
|
| 143 |
+
must_be_dir:
|
| 144 |
+
Si ``True``, exige que le chemin résolu existe ET soit un
|
| 145 |
+
répertoire. Implique ``must_exist=True``.
|
| 146 |
+
|
| 147 |
+
Returns
|
| 148 |
+
-------
|
| 149 |
+
Path
|
| 150 |
+
Chemin résolu absolu, garanti dans une des racines autorisées.
|
| 151 |
+
|
| 152 |
+
Raises
|
| 153 |
+
------
|
| 154 |
+
PathValidationError
|
| 155 |
+
Si le chemin est vide, contient un octet nul, sort des racines
|
| 156 |
+
autorisées, ou ne satisfait pas ``must_exist`` / ``must_be_dir``.
|
| 157 |
+
"""
|
| 158 |
+
if not user_path or not user_path.strip():
|
| 159 |
+
raise PathValidationError("Chemin vide.")
|
| 160 |
+
if "\x00" in user_path:
|
| 161 |
+
raise PathValidationError("Chemin contient un octet nul.")
|
| 162 |
+
if not allowed_roots:
|
| 163 |
+
raise PathValidationError(
|
| 164 |
+
"Aucune racine autorisée — refus de toute requête de chemin."
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
try:
|
| 168 |
+
resolved = Path(user_path).expanduser().resolve()
|
| 169 |
+
except (OSError, RuntimeError) as exc:
|
| 170 |
+
raise PathValidationError(f"Chemin invalide : {exc}") from exc
|
| 171 |
+
|
| 172 |
+
resolved_roots = [Path(r).expanduser().resolve() for r in allowed_roots]
|
| 173 |
+
if not any(_is_within(resolved, root) for root in resolved_roots):
|
| 174 |
+
raise PathValidationError(
|
| 175 |
+
f"Chemin hors zone autorisée : {user_path!r}. "
|
| 176 |
+
f"Racines acceptées : {[str(r) for r in resolved_roots]}."
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
if must_be_dir or must_exist:
|
| 180 |
+
if not resolved.exists():
|
| 181 |
+
raise PathValidationError(f"Chemin inexistant : {user_path!r}.")
|
| 182 |
+
if must_be_dir and not resolved.is_dir():
|
| 183 |
+
raise PathValidationError(f"Chemin n'est pas un répertoire : {user_path!r}.")
|
| 184 |
+
|
| 185 |
+
return resolved
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def _is_within(child: Path, parent: Path) -> bool:
|
| 189 |
+
"""Vrai si ``child`` est ``parent`` ou un descendant.
|
| 190 |
+
|
| 191 |
+
``Path.is_relative_to`` n'apparaît qu'en Python 3.9 — on l'utilise
|
| 192 |
+
via try/except pour rester explicite sur l'intention sans
|
| 193 |
+
dépendre du comportement exact de la stdlib selon la version.
|
| 194 |
+
"""
|
| 195 |
+
try:
|
| 196 |
+
child.relative_to(parent)
|
| 197 |
+
return True
|
| 198 |
+
except ValueError:
|
| 199 |
+
return False
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def validated_prompt_filename(name: str) -> str:
|
| 203 |
+
"""Valide qu'un ``prompt_file`` web est un simple nom de fichier sûr.
|
| 204 |
+
|
| 205 |
+
Sprint A14-S1 — A.I.0 P0 : le pipeline OCR+LLM lit un prompt
|
| 206 |
+
depuis le disque via ``picarones.pipelines.base._load_prompt``,
|
| 207 |
+
qui acceptait n'importe quel chemin absolu existant. En contexte
|
| 208 |
+
web, ça permettait à un utilisateur d'API de pousser le serveur à
|
| 209 |
+
lire un fichier arbitraire (``/etc/passwd``, ``.env``, etc.) puis
|
| 210 |
+
à l'envoyer comme prompt à un LLM externe — vecteur classique
|
| 211 |
+
d'exfiltration via tokens.
|
| 212 |
+
|
| 213 |
+
Cette fonction restreint la valeur reçue à un simple nom de
|
| 214 |
+
fichier de la **bibliothèque de prompts intégrée**
|
| 215 |
+
(``picarones/prompts/``). Pas de ``/``, pas de ``\\``, pas de
|
| 216 |
+
``..``, pas d'absolu.
|
| 217 |
+
|
| 218 |
+
Le caller (web layer) est responsable d'appeler cette fonction
|
| 219 |
+
AVANT de transmettre la valeur au pipeline.
|
| 220 |
+
|
| 221 |
+
Returns
|
| 222 |
+
-------
|
| 223 |
+
str
|
| 224 |
+
Nom de fichier validé (basename uniquement).
|
| 225 |
+
|
| 226 |
+
Raises
|
| 227 |
+
------
|
| 228 |
+
PathValidationError
|
| 229 |
+
Si la valeur contient un séparateur de chemin, un caractère de
|
| 230 |
+
contrôle, ou ressemble à un chemin absolu/relatif suspect.
|
| 231 |
+
"""
|
| 232 |
+
if not name:
|
| 233 |
+
raise PathValidationError("Nom de prompt vide.")
|
| 234 |
+
if "\x00" in name:
|
| 235 |
+
raise PathValidationError("Nom de prompt contient un octet nul.")
|
| 236 |
+
if any(c in name for c in ("/", "\\")):
|
| 237 |
+
raise PathValidationError(
|
| 238 |
+
f"Nom de prompt invalide (séparateur de chemin) : {name!r}. "
|
| 239 |
+
"Le web n'accepte que les prompts de la bibliothèque intégrée "
|
| 240 |
+
"(``picarones/prompts/``) — fournir le simple nom de fichier."
|
| 241 |
+
)
|
| 242 |
+
if name.startswith(".") or ".." in name:
|
| 243 |
+
raise PathValidationError(
|
| 244 |
+
f"Nom de prompt suspect : {name!r}. "
|
| 245 |
+
"Refus des préfixes ``.`` et des séquences ``..``."
|
| 246 |
+
)
|
| 247 |
+
if any(ord(c) < 0x20 for c in name):
|
| 248 |
+
raise PathValidationError("Nom de prompt contient un caractère de contrôle.")
|
| 249 |
+
return name
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
def safe_report_name(name: str, max_length: int = 128) -> str:
|
| 253 |
+
"""Sanitize un nom de rapport utilisateur en composant de chemin sûr.
|
| 254 |
+
|
| 255 |
+
Refuse les séparateurs de chemin (``/``, ``\\``), les caractères
|
| 256 |
+
de contrôle, les octets nuls. Tronque à ``max_length``. Si la
|
| 257 |
+
chaîne devient vide après nettoyage, lève ``PathValidationError``.
|
| 258 |
+
|
| 259 |
+
Cette fonction NE produit PAS un chemin — elle produit un nom
|
| 260 |
+
qu'un caller peut concaténer à un répertoire qu'il a déjà validé
|
| 261 |
+
avec ``validated_path``.
|
| 262 |
+
"""
|
| 263 |
+
if not name:
|
| 264 |
+
raise PathValidationError("Nom de rapport vide.")
|
| 265 |
+
if "\x00" in name:
|
| 266 |
+
raise PathValidationError("Nom de rapport contient un octet nul.")
|
| 267 |
+
# Refus explicite de tout séparateur de chemin et de caractères de contrôle.
|
| 268 |
+
bad = set("/\\")
|
| 269 |
+
cleaned = "".join(
|
| 270 |
+
c for c in name
|
| 271 |
+
if c not in bad and ord(c) >= 0x20
|
| 272 |
+
)
|
| 273 |
+
cleaned = cleaned.strip().strip(".") # pas de "." en début/fin (caché Unix, extension forçée)
|
| 274 |
+
if not cleaned:
|
| 275 |
+
raise PathValidationError(f"Nom de rapport invalide après nettoyage : {name!r}.")
|
| 276 |
+
if cleaned in (".", "..", ""):
|
| 277 |
+
raise PathValidationError(f"Nom de rapport réservé : {name!r}.")
|
| 278 |
+
return cleaned[:max_length]
|
| 279 |
+
|
| 280 |
+
|
| 281 |
# ---------------------------------------------------------------------------
|
| 282 |
# Browse roots
|
| 283 |
# ---------------------------------------------------------------------------
|
|
|
|
| 308 |
]
|
| 309 |
|
| 310 |
|
| 311 |
+
def compute_workspace_roots(uploads_dir: Path) -> list[Path]:
|
| 312 |
+
"""Retourne les racines autorisées pour les opérations de benchmark.
|
| 313 |
+
|
| 314 |
+
Sprint A14-S1 — A.I.0 P0 : utilisé par les endpoints
|
| 315 |
+
``/api/benchmark/start`` et ``/api/benchmark/run`` pour valider
|
| 316 |
+
``corpus_path`` et ``output_dir`` via :func:`validated_path`.
|
| 317 |
+
|
| 318 |
+
Sémantique :
|
| 319 |
+
|
| 320 |
+
- Si ``PICARONES_WORKSPACE_ROOTS`` est défini, prend précédence
|
| 321 |
+
absolue (admin sait ce qu'il fait).
|
| 322 |
+
- Sinon, en mode public : uniquement ``uploads_dir`` (lecture)
|
| 323 |
+
et ``./rapports`` (écriture des rapports générés).
|
| 324 |
+
- Sinon, mode dev : ``compute_browse_roots`` + ``./rapports`` +
|
| 325 |
+
``./corpus`` (corpus locaux des développeurs).
|
| 326 |
+
|
| 327 |
+
En production institutionnelle, exporter ``PICARONES_WORKSPACE_ROOTS``
|
| 328 |
+
pour épingler explicitement les répertoires autorisés.
|
| 329 |
+
"""
|
| 330 |
+
raw = os.environ.get("PICARONES_WORKSPACE_ROOTS")
|
| 331 |
+
if raw:
|
| 332 |
+
return [Path(p).expanduser().resolve() for p in raw.split(os.pathsep) if p.strip()]
|
| 333 |
+
|
| 334 |
+
base = compute_browse_roots(uploads_dir)
|
| 335 |
+
extras = [
|
| 336 |
+
Path("./rapports").resolve(),
|
| 337 |
+
Path("./corpus").resolve(),
|
| 338 |
+
]
|
| 339 |
+
seen: set[Path] = set()
|
| 340 |
+
out: list[Path] = []
|
| 341 |
+
for p in base + extras:
|
| 342 |
+
if p not in seen:
|
| 343 |
+
seen.add(p)
|
| 344 |
+
out.append(p)
|
| 345 |
+
return out
|
| 346 |
+
|
| 347 |
+
|
| 348 |
# ---------------------------------------------------------------------------
|
| 349 |
# Validation des images uploadées
|
| 350 |
# ---------------------------------------------------------------------------
|
|
@@ -63,7 +63,11 @@ FILE_BUDGETS: dict[str, int] = {
|
|
| 63 |
"picarones/extras/importers/gallica.py": 675, # actuel 563
|
| 64 |
"picarones/measurements/levers.py": 675, # actuel 561
|
| 65 |
"picarones/extras/importers/escriptorium.py": 650, # actuel 553
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
"picarones/core/corpus.py": 600, # actuel 511
|
| 68 |
"picarones/fixtures.py": 600, # actuel 510
|
| 69 |
"picarones/measurements/inter_engine.py": 575, # actuel 484
|
|
|
|
| 63 |
"picarones/extras/importers/gallica.py": 675, # actuel 563
|
| 64 |
"picarones/measurements/levers.py": 675, # actuel 561
|
| 65 |
"picarones/extras/importers/escriptorium.py": 650, # actuel 553
|
| 66 |
+
# Sprint A14-S1 — A.I.0 P0 : ajout de validated_path,
|
| 67 |
+
# validated_prompt_filename, safe_report_name et compute_workspace_roots.
|
| 68 |
+
# Ces helpers seront extraits dans ``picarones/web/path_security.py``
|
| 69 |
+
# lors du Sprint S20 du rewrite ciblé (création couche app/services/).
|
| 70 |
+
"picarones/web/security.py": 800, # actuel 751
|
| 71 |
"picarones/core/corpus.py": 600, # actuel 511
|
| 72 |
"picarones/fixtures.py": 600, # actuel 510
|
| 73 |
"picarones/measurements/inter_engine.py": 575, # actuel 484
|
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sprint A14-S1 — A.I.0 P0 : ``DocumentResult.compact()`` est opt-in.
|
| 2 |
+
|
| 3 |
+
Avant ce sprint, le runner appelait ``dr.compact()`` sans argument
|
| 4 |
+
avant de sérialiser le JSON, ce qui :
|
| 5 |
+
|
| 6 |
+
- tronquait ``ground_truth``, ``hypothesis`` et ``ocr_intermediate``
|
| 7 |
+
à 200 caractères ;
|
| 8 |
+
- effaçait 13 dicts d'analyse per-document (confusion, taxonomy,
|
| 9 |
+
philological, searchability, etc.).
|
| 10 |
+
|
| 11 |
+
Le rapport HTML — qui consomme ce JSON — recevait des données déjà
|
| 12 |
+
mutilées, contredisant la promesse "self-contained HTML report" du
|
| 13 |
+
README.
|
| 14 |
+
|
| 15 |
+
Désormais, ``compact()`` est no-op par défaut. Le caller doit
|
| 16 |
+
explicitement demander la troncature via ``text_limit`` et/ou la
|
| 17 |
+
suppression des analyses via ``drop_analyses=True``.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
from picarones.core.metrics import MetricsResult
|
| 23 |
+
from picarones.core.results import DocumentResult
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _make_dr(**kwargs) -> DocumentResult:
|
| 27 |
+
base = dict(
|
| 28 |
+
doc_id="d1",
|
| 29 |
+
image_path="x.png",
|
| 30 |
+
ground_truth="A" * 1000,
|
| 31 |
+
hypothesis="B" * 1000,
|
| 32 |
+
metrics=MetricsResult(cer=0.1, wer=0.1, error=None),
|
| 33 |
+
duration_seconds=0.1,
|
| 34 |
+
confusion_matrix={"k": "v"},
|
| 35 |
+
char_scores={"ligature": {"score": 0.9}},
|
| 36 |
+
taxonomy={"class": "v"},
|
| 37 |
+
structure={"k": "v"},
|
| 38 |
+
image_quality={"k": "v"},
|
| 39 |
+
line_metrics={"k": "v"},
|
| 40 |
+
hallucination_metrics={"k": "v"},
|
| 41 |
+
ner_metrics={"k": "v"},
|
| 42 |
+
calibration_metrics={"k": "v"},
|
| 43 |
+
philological_metrics={"k": "v"},
|
| 44 |
+
searchability_metrics={"k": "v"},
|
| 45 |
+
numerical_sequence_metrics={"k": "v"},
|
| 46 |
+
readability_metrics={"k": "v"},
|
| 47 |
+
ocr_intermediate="C" * 1000,
|
| 48 |
+
)
|
| 49 |
+
base.update(kwargs)
|
| 50 |
+
return DocumentResult(**base)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class TestCompactDefaultIsNoOp:
|
| 54 |
+
def test_default_call_does_not_truncate_text(self) -> None:
|
| 55 |
+
dr = _make_dr()
|
| 56 |
+
before_gt = dr.ground_truth
|
| 57 |
+
before_hyp = dr.hypothesis
|
| 58 |
+
before_ocr = dr.ocr_intermediate
|
| 59 |
+
dr.compact()
|
| 60 |
+
assert dr.ground_truth == before_gt
|
| 61 |
+
assert dr.hypothesis == before_hyp
|
| 62 |
+
assert dr.ocr_intermediate == before_ocr
|
| 63 |
+
|
| 64 |
+
def test_default_call_preserves_all_analyses(self) -> None:
|
| 65 |
+
dr = _make_dr()
|
| 66 |
+
dr.compact()
|
| 67 |
+
for field in (
|
| 68 |
+
"confusion_matrix", "char_scores", "taxonomy", "structure",
|
| 69 |
+
"image_quality", "line_metrics", "hallucination_metrics",
|
| 70 |
+
"ner_metrics", "calibration_metrics", "philological_metrics",
|
| 71 |
+
"searchability_metrics", "numerical_sequence_metrics",
|
| 72 |
+
"readability_metrics",
|
| 73 |
+
):
|
| 74 |
+
assert getattr(dr, field) is not None, (
|
| 75 |
+
f"{field} a été effacé alors que ``compact()`` est "
|
| 76 |
+
"censé être no-op par défaut depuis Sprint A14-S1."
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
class TestCompactTextLimit:
|
| 81 |
+
def test_text_limit_truncates_ground_truth(self) -> None:
|
| 82 |
+
dr = _make_dr()
|
| 83 |
+
dr.compact(text_limit=200)
|
| 84 |
+
assert len(dr.ground_truth) == 201 # 200 + ellipsis
|
| 85 |
+
|
| 86 |
+
def test_text_limit_truncates_hypothesis(self) -> None:
|
| 87 |
+
dr = _make_dr()
|
| 88 |
+
dr.compact(text_limit=50)
|
| 89 |
+
assert len(dr.hypothesis) == 51
|
| 90 |
+
|
| 91 |
+
def test_text_limit_truncates_ocr_intermediate(self) -> None:
|
| 92 |
+
dr = _make_dr()
|
| 93 |
+
dr.compact(text_limit=100)
|
| 94 |
+
assert len(dr.ocr_intermediate) == 101
|
| 95 |
+
|
| 96 |
+
def test_text_limit_zero_or_none_is_noop(self) -> None:
|
| 97 |
+
dr = _make_dr()
|
| 98 |
+
dr.compact(text_limit=0)
|
| 99 |
+
assert len(dr.ground_truth) == 1000
|
| 100 |
+
dr2 = _make_dr()
|
| 101 |
+
dr2.compact(text_limit=None)
|
| 102 |
+
assert len(dr2.ground_truth) == 1000
|
| 103 |
+
|
| 104 |
+
def test_text_limit_does_not_truncate_short_text(self) -> None:
|
| 105 |
+
dr = _make_dr(ground_truth="short", hypothesis="also short")
|
| 106 |
+
dr.compact(text_limit=200)
|
| 107 |
+
assert dr.ground_truth == "short"
|
| 108 |
+
assert dr.hypothesis == "also short"
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
class TestCompactDropAnalyses:
|
| 112 |
+
def test_drop_analyses_clears_all_thirteen_fields(self) -> None:
|
| 113 |
+
dr = _make_dr()
|
| 114 |
+
dr.compact(drop_analyses=True)
|
| 115 |
+
for field in (
|
| 116 |
+
"confusion_matrix", "char_scores", "taxonomy", "structure",
|
| 117 |
+
"image_quality", "line_metrics", "hallucination_metrics",
|
| 118 |
+
"ner_metrics", "calibration_metrics", "philological_metrics",
|
| 119 |
+
"searchability_metrics", "numerical_sequence_metrics",
|
| 120 |
+
"readability_metrics",
|
| 121 |
+
):
|
| 122 |
+
assert getattr(dr, field) is None, f"{field} aurait dû être effacé"
|
| 123 |
+
|
| 124 |
+
def test_drop_analyses_alone_preserves_text(self) -> None:
|
| 125 |
+
dr = _make_dr()
|
| 126 |
+
dr.compact(drop_analyses=True) # pas de text_limit
|
| 127 |
+
assert len(dr.ground_truth) == 1000
|
| 128 |
+
assert len(dr.hypothesis) == 1000
|
| 129 |
+
|
| 130 |
+
def test_combined_legacy_behavior(self) -> None:
|
| 131 |
+
"""``compact(text_limit=200, drop_analyses=True)`` reproduit
|
| 132 |
+
l'ancien comportement par défaut (avant Sprint A14-S1)."""
|
| 133 |
+
dr = _make_dr()
|
| 134 |
+
dr.compact(text_limit=200, drop_analyses=True)
|
| 135 |
+
assert len(dr.ground_truth) == 201
|
| 136 |
+
assert dr.confusion_matrix is None
|
| 137 |
+
assert dr.philological_metrics is None
|
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sprint A14-S1 — A.I.0 P0 : compute_metrics retourne None en cas d'erreur.
|
| 2 |
+
|
| 3 |
+
Avant ce sprint, ``compute_metrics`` retournait des ``MetricsResult``
|
| 4 |
+
avec ``cer=0.0, wer=0.0, ...`` quand jiwer était indisponible ou qu'une
|
| 5 |
+
exception était levée. Pour tout consommateur qui n'inspectait pas
|
| 6 |
+
``error``, ces zéros étaient indistinguables d'un score parfait — soit
|
| 7 |
+
l'inverse exact de la réalité (échec total = "100 % d'accord avec la
|
| 8 |
+
GT").
|
| 9 |
+
|
| 10 |
+
Désormais, en erreur, les champs métriques sont à ``None`` et ``error``
|
| 11 |
+
porte le message. Un accès direct à ``result.cer`` sur un résultat en
|
| 12 |
+
erreur lèvera désormais ``TypeError`` lors d'opérations numériques
|
| 13 |
+
(``cer * 100``), ce qui est l'effet voulu : un crash explicite plutôt
|
| 14 |
+
qu'une valeur factice.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
from unittest import mock
|
| 20 |
+
|
| 21 |
+
import pytest
|
| 22 |
+
|
| 23 |
+
from picarones.core.metrics import MetricsResult, aggregate_metrics
|
| 24 |
+
from picarones.measurements import metrics as metrics_module
|
| 25 |
+
from picarones.measurements.metrics import compute_metrics
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class TestComputeMetricsErrorPath:
|
| 29 |
+
def test_jiwer_missing_returns_none_metrics(self) -> None:
|
| 30 |
+
"""Si jiwer absent, tous les champs sont None et error est set."""
|
| 31 |
+
with mock.patch.object(metrics_module, "_JIWER_AVAILABLE", False):
|
| 32 |
+
result = compute_metrics("référence", "hypothèse")
|
| 33 |
+
assert result.cer is None
|
| 34 |
+
assert result.cer_nfc is None
|
| 35 |
+
assert result.cer_caseless is None
|
| 36 |
+
assert result.wer is None
|
| 37 |
+
assert result.wer_normalized is None
|
| 38 |
+
assert result.mer is None
|
| 39 |
+
assert result.wil is None
|
| 40 |
+
assert result.error is not None
|
| 41 |
+
assert "jiwer" in result.error.lower()
|
| 42 |
+
|
| 43 |
+
def test_jiwer_exception_returns_none_metrics(self) -> None:
|
| 44 |
+
"""Si jiwer lève, on retombe dans le bloc except et on retourne None."""
|
| 45 |
+
with mock.patch.object(
|
| 46 |
+
metrics_module, "_cer_from_strings",
|
| 47 |
+
side_effect=RuntimeError("simulated jiwer crash"),
|
| 48 |
+
):
|
| 49 |
+
result = compute_metrics("a", "b")
|
| 50 |
+
assert result.cer is None
|
| 51 |
+
assert result.wer is None
|
| 52 |
+
assert result.error is not None
|
| 53 |
+
assert "simulated jiwer crash" in result.error
|
| 54 |
+
|
| 55 |
+
def test_no_silent_zero_when_error_set(self) -> None:
|
| 56 |
+
"""Garde-fou : aucun champ ne doit être 0.0 si error est non-None.
|
| 57 |
+
|
| 58 |
+
Verrouille le bug exact que ce sprint corrige (0.0 indistinguable
|
| 59 |
+
d'un score parfait dans le JSON exporté).
|
| 60 |
+
"""
|
| 61 |
+
with mock.patch.object(metrics_module, "_JIWER_AVAILABLE", False):
|
| 62 |
+
result = compute_metrics("référence", "hypothèse")
|
| 63 |
+
assert result.error is not None
|
| 64 |
+
for field in ("cer", "cer_nfc", "cer_caseless", "wer",
|
| 65 |
+
"wer_normalized", "mer", "wil"):
|
| 66 |
+
assert getattr(result, field) is None, (
|
| 67 |
+
f"{field} = {getattr(result, field)!r} (devrait être None "
|
| 68 |
+
"puisque error est non-None)"
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class TestMetricsResultPropertiesHandleNone:
|
| 73 |
+
def test_cer_percent_handles_none(self) -> None:
|
| 74 |
+
r = MetricsResult(error="boom")
|
| 75 |
+
assert r.cer_percent is None
|
| 76 |
+
|
| 77 |
+
def test_wer_percent_handles_none(self) -> None:
|
| 78 |
+
r = MetricsResult(error="boom")
|
| 79 |
+
assert r.wer_percent is None
|
| 80 |
+
|
| 81 |
+
def test_as_dict_handles_none(self) -> None:
|
| 82 |
+
r = MetricsResult(error="boom")
|
| 83 |
+
d = r.as_dict()
|
| 84 |
+
assert d["cer"] is None
|
| 85 |
+
assert d["wer"] is None
|
| 86 |
+
assert d["error"] == "boom"
|
| 87 |
+
|
| 88 |
+
def test_as_dict_rounds_when_set(self) -> None:
|
| 89 |
+
r = MetricsResult(cer=0.123456789, wer=0.456789, error=None)
|
| 90 |
+
d = r.as_dict()
|
| 91 |
+
assert d["cer"] == 0.123457 # 6 décimales
|
| 92 |
+
assert d["wer"] == 0.456789
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
class TestAggregateMetricsFiltersNoneAndError:
|
| 96 |
+
def test_aggregator_excludes_results_with_error(self) -> None:
|
| 97 |
+
ok = MetricsResult(cer=0.1, wer=0.2, mer=0.15, wil=0.25, error=None)
|
| 98 |
+
ko = MetricsResult(error="boom") # cer/wer/etc tous None
|
| 99 |
+
agg = aggregate_metrics([ok, ko])
|
| 100 |
+
# Seul le résultat OK contribue à la moyenne.
|
| 101 |
+
assert agg["cer"]["mean"] == 0.1
|
| 102 |
+
assert agg["wer"]["mean"] == 0.2
|
| 103 |
+
assert agg["failed_count"] == 1
|
| 104 |
+
assert agg["document_count"] == 2
|
| 105 |
+
|
| 106 |
+
def test_aggregator_robust_to_partial_none(self) -> None:
|
| 107 |
+
"""Défense en profondeur : un caller pourrait construire un
|
| 108 |
+
MetricsResult avec des None sans avoir set ``error``. On ne
|
| 109 |
+
plante pas, on saute simplement les None."""
|
| 110 |
+
partial = MetricsResult(cer=0.05, wer=None, mer=None, wil=None, error=None)
|
| 111 |
+
agg = aggregate_metrics([partial])
|
| 112 |
+
assert agg["cer"]["mean"] == 0.05
|
| 113 |
+
# WER absent → stats vides plutôt que NaN.
|
| 114 |
+
assert agg["wer"] == {}
|
| 115 |
+
|
| 116 |
+
def test_aggregator_empty_when_all_errors(self) -> None:
|
| 117 |
+
errs = [MetricsResult(error="x"), MetricsResult(error="y")]
|
| 118 |
+
agg = aggregate_metrics(errs)
|
| 119 |
+
assert agg["cer"] == {}
|
| 120 |
+
assert agg["failed_count"] == 2
|
| 121 |
+
assert agg["document_count"] == 2
|
|
@@ -126,10 +126,20 @@ class TestModelSerialization:
|
|
| 126 |
assert d["ner_metrics"] == {"global": {"f1": 0.8}}
|
| 127 |
|
| 128 |
def test_compact_clears_ner_metrics(self) -> None:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
dr = _make_document_result(ner_metrics={"global": {"f1": 0.8}})
|
| 130 |
-
dr.compact()
|
| 131 |
assert dr.ner_metrics is None
|
| 132 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
def test_engine_report_aggregated_ner_omitted_when_none(self) -> None:
|
| 134 |
rep = EngineReport(
|
| 135 |
engine_name="t", engine_version="1", engine_config={},
|
|
|
|
| 126 |
assert d["ner_metrics"] == {"global": {"f1": 0.8}}
|
| 127 |
|
| 128 |
def test_compact_clears_ner_metrics(self) -> None:
|
| 129 |
+
# Sprint A14-S1 — A.I.0 P0 : ``compact()`` est désormais no-op
|
| 130 |
+
# par défaut (cf. core/results.py). Le comportement
|
| 131 |
+
# "efface les analyses" est explicitement opt-in via
|
| 132 |
+
# ``drop_analyses=True``.
|
| 133 |
dr = _make_document_result(ner_metrics={"global": {"f1": 0.8}})
|
| 134 |
+
dr.compact(drop_analyses=True)
|
| 135 |
assert dr.ner_metrics is None
|
| 136 |
|
| 137 |
+
def test_compact_default_is_noop(self) -> None:
|
| 138 |
+
"""Sprint A14-S1 — défaut sans argument ne touche à rien."""
|
| 139 |
+
dr = _make_document_result(ner_metrics={"global": {"f1": 0.8}})
|
| 140 |
+
dr.compact()
|
| 141 |
+
assert dr.ner_metrics == {"global": {"f1": 0.8}}
|
| 142 |
+
|
| 143 |
def test_engine_report_aggregated_ner_omitted_when_none(self) -> None:
|
| 144 |
rep = EngineReport(
|
| 145 |
engine_name="t", engine_version="1", engine_config={},
|
|
@@ -84,8 +84,9 @@ class TestModelsSerialization:
|
|
| 84 |
assert d["calibration_metrics"] == {"ece": 0.05, "mce": 0.1}
|
| 85 |
|
| 86 |
def test_compact_clears_calibration(self) -> None:
|
|
|
|
| 87 |
dr = _make_dr({"ece": 0.05})
|
| 88 |
-
dr.compact()
|
| 89 |
assert dr.calibration_metrics is None
|
| 90 |
|
| 91 |
def test_engine_report_aggregated_calibration_omitted_when_none(self) -> None:
|
|
|
|
| 84 |
assert d["calibration_metrics"] == {"ece": 0.05, "mce": 0.1}
|
| 85 |
|
| 86 |
def test_compact_clears_calibration(self) -> None:
|
| 87 |
+
# Sprint A14-S1 — ``compact()`` est désormais opt-in.
|
| 88 |
dr = _make_dr({"ece": 0.05})
|
| 89 |
+
dr.compact(drop_analyses=True)
|
| 90 |
assert dr.calibration_metrics is None
|
| 91 |
|
| 92 |
def test_engine_report_aggregated_calibration_omitted_when_none(self) -> None:
|
|
@@ -124,8 +124,9 @@ class TestSerialization:
|
|
| 124 |
|
| 125 |
class TestCompact:
|
| 126 |
def test_compact_clears_philological(self) -> None:
|
|
|
|
| 127 |
dr = _make_doc(philological={"mufi": {"coverage": 1.0}})
|
| 128 |
-
dr.compact()
|
| 129 |
assert dr.philological_metrics is None
|
| 130 |
|
| 131 |
|
|
|
|
| 124 |
|
| 125 |
class TestCompact:
|
| 126 |
def test_compact_clears_philological(self) -> None:
|
| 127 |
+
# Sprint A14-S1 — opt-in via drop_analyses=True.
|
| 128 |
dr = _make_doc(philological={"mufi": {"coverage": 1.0}})
|
| 129 |
+
dr.compact(drop_analyses=True)
|
| 130 |
assert dr.philological_metrics is None
|
| 131 |
|
| 132 |
|
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sprint A14-S1 — A.I.0 P0 : ``normalization_profile`` propagé end-to-end.
|
| 2 |
+
|
| 3 |
+
Avant ce sprint, le paramètre ``normalization_profile`` était :
|
| 4 |
+
|
| 5 |
+
- exposé par l'API web (``BenchmarkRequest`` / ``BenchmarkRunRequest``) ;
|
| 6 |
+
- transporté jusqu'à ``benchmark_utils.run_benchmark_thread*`` ;
|
| 7 |
+
- **silencieusement ignoré** : jamais transmis à ``run_benchmark`` ;
|
| 8 |
+
- ``run_benchmark`` n'avait même pas le paramètre dans sa signature.
|
| 9 |
+
|
| 10 |
+
Conséquence : tout benchmark lancé depuis l'API web utilisait le
|
| 11 |
+
profil par défaut (``medieval_french``) quel que soit le choix
|
| 12 |
+
utilisateur. L'option de l'UI était un faux bouton.
|
| 13 |
+
|
| 14 |
+
Ce module verrouille la propagation depuis la signature publique de
|
| 15 |
+
``run_benchmark`` jusqu'à ``compute_metrics`` via les workers.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import inspect
|
| 21 |
+
|
| 22 |
+
from picarones.measurements.normalization import (
|
| 23 |
+
NORMALIZATION_PROFILES,
|
| 24 |
+
get_builtin_profile,
|
| 25 |
+
)
|
| 26 |
+
from picarones.measurements.runner import run_benchmark
|
| 27 |
+
from picarones.measurements.runner.document import _compute_document_result
|
| 28 |
+
from picarones.measurements.runner.workers import (
|
| 29 |
+
_cpu_doc_worker,
|
| 30 |
+
_io_doc_worker,
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class TestRunBenchmarkSignature:
|
| 35 |
+
def test_run_benchmark_accepts_normalization_profile(self) -> None:
|
| 36 |
+
"""La signature publique doit exposer ``normalization_profile``."""
|
| 37 |
+
sig = inspect.signature(run_benchmark)
|
| 38 |
+
assert "normalization_profile" in sig.parameters
|
| 39 |
+
# Et avec une valeur par défaut sûre.
|
| 40 |
+
assert sig.parameters["normalization_profile"].default is None
|
| 41 |
+
|
| 42 |
+
def test_io_worker_accepts_normalization_profile(self) -> None:
|
| 43 |
+
sig = inspect.signature(_io_doc_worker)
|
| 44 |
+
assert "normalization_profile" in sig.parameters
|
| 45 |
+
|
| 46 |
+
def test_compute_document_result_accepts_normalization_profile(self) -> None:
|
| 47 |
+
sig = inspect.signature(_compute_document_result)
|
| 48 |
+
assert "normalization_profile" in sig.parameters
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class TestProfileResolution:
|
| 52 |
+
def test_all_eleven_profiles_resolvable(self) -> None:
|
| 53 |
+
"""Les 11 profils annoncés dans le README sont tous résolvables.
|
| 54 |
+
|
| 55 |
+
Verrouille la cohérence entre ``NORMALIZATION_PROFILES`` (table
|
| 56 |
+
runtime) et ``NormalizationProfileId`` (Literal Pydantic web).
|
| 57 |
+
"""
|
| 58 |
+
expected = {
|
| 59 |
+
"nfc", "caseless", "minimal",
|
| 60 |
+
"medieval_french", "early_modern_french",
|
| 61 |
+
"medieval_latin", "medieval_english", "early_modern_english",
|
| 62 |
+
"secretary_hand", "sans_ponctuation", "sans_apostrophes",
|
| 63 |
+
}
|
| 64 |
+
assert set(NORMALIZATION_PROFILES.keys()) >= expected
|
| 65 |
+
for name in expected:
|
| 66 |
+
profile = get_builtin_profile(name)
|
| 67 |
+
assert profile is not None
|
| 68 |
+
assert profile.name == name
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class TestWebModelProfileAlignment:
|
| 72 |
+
def test_web_literal_lists_all_eleven_profiles(self) -> None:
|
| 73 |
+
"""Le ``Literal`` Pydantic doit lister les 11 profils.
|
| 74 |
+
|
| 75 |
+
Avant S1, le Literal n'en exposait que 8 — Pydantic rejetait
|
| 76 |
+
donc 3 profils valides du runtime.
|
| 77 |
+
"""
|
| 78 |
+
from picarones.web.models import NormalizationProfileId
|
| 79 |
+
from typing import get_args
|
| 80 |
+
literals = set(get_args(NormalizationProfileId))
|
| 81 |
+
runtime = set(NORMALIZATION_PROFILES.keys())
|
| 82 |
+
# Le web peut être un sous-ensemble strict en théorie, mais
|
| 83 |
+
# l'alignement README ↔ web ↔ runtime exige égalité.
|
| 84 |
+
assert literals == runtime, (
|
| 85 |
+
f"Décalage README/web/runtime. Web a {literals}, "
|
| 86 |
+
f"runtime a {runtime}. Diff missing-from-web: "
|
| 87 |
+
f"{runtime - literals}, extra-in-web: {literals - runtime}."
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class TestNormalizationActuallyApplied:
|
| 92 |
+
"""Vérifie via une intégration unitaire que le profil arrive bien
|
| 93 |
+
jusqu'à ``compute_metrics`` et change le ``cer_diplomatic`` calculé."""
|
| 94 |
+
|
| 95 |
+
def test_cer_diplomatic_uses_specified_profile(self) -> None:
|
| 96 |
+
"""Avec deux profils différents, le ``cer_diplomatic`` est
|
| 97 |
+
différent sur la même paire de textes. Si le profil n'était
|
| 98 |
+
pas propagé, on aurait toujours la même valeur."""
|
| 99 |
+
from picarones.measurements.metrics import compute_metrics
|
| 100 |
+
|
| 101 |
+
# Texte avec un ſ médiéval + un v moderne (la GT a l'ancienne
|
| 102 |
+
# graphie, l'OCR la moderne).
|
| 103 |
+
gt = "ſuper aqua viuens"
|
| 104 |
+
hyp = "super aqua vivens"
|
| 105 |
+
|
| 106 |
+
# Profil "minimal" : seul ſ → s. v reste v de chaque côté.
|
| 107 |
+
prof_minimal = get_builtin_profile("minimal")
|
| 108 |
+
m_minimal = compute_metrics(gt, hyp, normalization_profile=prof_minimal)
|
| 109 |
+
|
| 110 |
+
# Profil "medieval_latin" : ſ → s, u → v, etc. Sera plus permissif.
|
| 111 |
+
prof_latin = get_builtin_profile("medieval_latin")
|
| 112 |
+
m_latin = compute_metrics(gt, hyp, normalization_profile=prof_latin)
|
| 113 |
+
|
| 114 |
+
# Les deux doivent être calculés.
|
| 115 |
+
assert m_minimal.cer_diplomatic is not None
|
| 116 |
+
assert m_latin.cer_diplomatic is not None
|
| 117 |
+
assert m_minimal.diplomatic_profile_name == "minimal"
|
| 118 |
+
assert m_latin.diplomatic_profile_name == "medieval_latin"
|
| 119 |
+
# Les profils diffèrent → le score change. S'ils étaient
|
| 120 |
+
# confondus (bug de propagation), ce serait égal.
|
| 121 |
+
assert m_minimal.diplomatic_profile_name != m_latin.diplomatic_profile_name
|
|
@@ -194,7 +194,8 @@ class TestResultsFields:
|
|
| 194 |
searchability_metrics={"recall": 0.9},
|
| 195 |
numerical_sequence_metrics={"n_total": 1},
|
| 196 |
)
|
| 197 |
-
|
|
|
|
| 198 |
assert dr.searchability_metrics is None
|
| 199 |
assert dr.numerical_sequence_metrics is None
|
| 200 |
|
|
|
|
| 194 |
searchability_metrics={"recall": 0.9},
|
| 195 |
numerical_sequence_metrics={"n_total": 1},
|
| 196 |
)
|
| 197 |
+
# Sprint A14-S1 — opt-in via drop_analyses=True.
|
| 198 |
+
dr.compact(drop_analyses=True)
|
| 199 |
assert dr.searchability_metrics is None
|
| 200 |
assert dr.numerical_sequence_metrics is None
|
| 201 |
|
|
@@ -140,13 +140,14 @@ class TestResultsFields:
|
|
| 140 |
assert "readability_metrics" not in d
|
| 141 |
|
| 142 |
def test_compact_clears(self) -> None:
|
|
|
|
| 143 |
dr = DocumentResult(
|
| 144 |
doc_id="d1", image_path="x.png",
|
| 145 |
ground_truth="x", hypothesis="x",
|
| 146 |
metrics=_stub_metrics(), duration_seconds=1.0,
|
| 147 |
readability_metrics={"flesch_delta": 5.0},
|
| 148 |
)
|
| 149 |
-
dr.compact()
|
| 150 |
assert dr.readability_metrics is None
|
| 151 |
|
| 152 |
def test_engine_report_serializes(self) -> None:
|
|
|
|
| 140 |
assert "readability_metrics" not in d
|
| 141 |
|
| 142 |
def test_compact_clears(self) -> None:
|
| 143 |
+
# Sprint A14-S1 — opt-in via drop_analyses=True.
|
| 144 |
dr = DocumentResult(
|
| 145 |
doc_id="d1", image_path="x.png",
|
| 146 |
ground_truth="x", hypothesis="x",
|
| 147 |
metrics=_stub_metrics(), duration_seconds=1.0,
|
| 148 |
readability_metrics={"flesch_delta": 5.0},
|
| 149 |
)
|
| 150 |
+
dr.compact(drop_analyses=True)
|
| 151 |
assert dr.readability_metrics is None
|
| 152 |
|
| 153 |
def test_engine_report_serializes(self) -> None:
|
|
File without changes
|
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sprint A14-S1 — A.I.0 P0 : validation des chemins utilisateur.
|
| 2 |
+
|
| 3 |
+
Tests sur ``picarones.web.security.validated_path``,
|
| 4 |
+
``validated_prompt_filename`` et ``safe_report_name`` : les helpers
|
| 5 |
+
introduits pour bloquer les chemins arbitraires reçus des endpoints
|
| 6 |
+
benchmark/run et benchmark/start.
|
| 7 |
+
|
| 8 |
+
Avant le sprint S1 du rewrite ciblé, l'API web acceptait :
|
| 9 |
+
|
| 10 |
+
- n'importe quel ``corpus_path`` validé uniquement par ``Path.exists()`` ;
|
| 11 |
+
- n'importe quel ``output_dir`` créé par ``Path(req.output_dir).mkdir()`` ;
|
| 12 |
+
- n'importe quel ``report_name`` concaténé directement (escape via ``../``) ;
|
| 13 |
+
- n'importe quel ``prompt_file`` absolu (vecteur d'exfiltration via LLM).
|
| 14 |
+
|
| 15 |
+
Les tests ci-dessous font office de filet de sécurité. Toute évolution
|
| 16 |
+
ultérieure de la couche security.py qui ferait régresser ces invariants
|
| 17 |
+
est bloquée par cette suite.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import os
|
| 23 |
+
import tempfile
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
|
| 26 |
+
import pytest
|
| 27 |
+
|
| 28 |
+
from picarones.web.security import (
|
| 29 |
+
PathValidationError,
|
| 30 |
+
safe_report_name,
|
| 31 |
+
validated_path,
|
| 32 |
+
validated_prompt_filename,
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 37 |
+
# validated_path
|
| 38 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class TestValidatedPath:
|
| 42 |
+
def test_accepts_path_within_allowed_root(self, tmp_path: Path) -> None:
|
| 43 |
+
sub = tmp_path / "corpus_a"
|
| 44 |
+
sub.mkdir()
|
| 45 |
+
result = validated_path(str(sub), allowed_roots=[tmp_path], must_be_dir=True)
|
| 46 |
+
assert result == sub.resolve()
|
| 47 |
+
|
| 48 |
+
def test_rejects_path_outside_allowed_roots(self, tmp_path: Path) -> None:
|
| 49 |
+
# /etc/passwd existe sur tout Linux et est clairement hors workspace.
|
| 50 |
+
with pytest.raises(PathValidationError, match="hors zone autorisée"):
|
| 51 |
+
validated_path("/etc/passwd", allowed_roots=[tmp_path])
|
| 52 |
+
|
| 53 |
+
def test_rejects_traversal_via_dot_dot(self, tmp_path: Path) -> None:
|
| 54 |
+
sub = tmp_path / "inside"
|
| 55 |
+
sub.mkdir()
|
| 56 |
+
# tmp_path/inside/../../../etc → résolu = /etc → hors zone
|
| 57 |
+
evasion = str(sub / ".." / ".." / ".." / "etc")
|
| 58 |
+
with pytest.raises(PathValidationError, match="hors zone autorisée"):
|
| 59 |
+
validated_path(evasion, allowed_roots=[tmp_path])
|
| 60 |
+
|
| 61 |
+
def test_rejects_empty_path(self, tmp_path: Path) -> None:
|
| 62 |
+
with pytest.raises(PathValidationError, match="vide"):
|
| 63 |
+
validated_path("", allowed_roots=[tmp_path])
|
| 64 |
+
|
| 65 |
+
def test_rejects_null_byte(self, tmp_path: Path) -> None:
|
| 66 |
+
with pytest.raises(PathValidationError, match="octet nul"):
|
| 67 |
+
validated_path("foo\x00bar", allowed_roots=[tmp_path])
|
| 68 |
+
|
| 69 |
+
def test_rejects_when_no_allowed_roots(self, tmp_path: Path) -> None:
|
| 70 |
+
with pytest.raises(PathValidationError, match="Aucune racine autorisée"):
|
| 71 |
+
validated_path(str(tmp_path), allowed_roots=[])
|
| 72 |
+
|
| 73 |
+
def test_must_exist_raises_on_missing(self, tmp_path: Path) -> None:
|
| 74 |
+
missing = tmp_path / "does_not_exist"
|
| 75 |
+
with pytest.raises(PathValidationError, match="inexistant"):
|
| 76 |
+
validated_path(str(missing), allowed_roots=[tmp_path], must_exist=True)
|
| 77 |
+
|
| 78 |
+
def test_must_be_dir_raises_on_file(self, tmp_path: Path) -> None:
|
| 79 |
+
f = tmp_path / "a_file.txt"
|
| 80 |
+
f.write_text("hello")
|
| 81 |
+
with pytest.raises(PathValidationError, match="n'est pas un répertoire"):
|
| 82 |
+
validated_path(str(f), allowed_roots=[tmp_path], must_be_dir=True)
|
| 83 |
+
|
| 84 |
+
def test_resolves_symlinks(self, tmp_path: Path) -> None:
|
| 85 |
+
# Si on crée un symlink dans tmp_path qui pointe vers /tmp/ailleurs,
|
| 86 |
+
# ``resolve()`` doit suivre le symlink. Si la cible est hors zone,
|
| 87 |
+
# on rejette.
|
| 88 |
+
outside = Path(tempfile.mkdtemp(prefix="picarones_outside_"))
|
| 89 |
+
try:
|
| 90 |
+
link = tmp_path / "tricky_link"
|
| 91 |
+
link.symlink_to(outside)
|
| 92 |
+
with pytest.raises(PathValidationError, match="hors zone autorisée"):
|
| 93 |
+
validated_path(str(link), allowed_roots=[tmp_path])
|
| 94 |
+
finally:
|
| 95 |
+
# cleanup
|
| 96 |
+
outside.rmdir()
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 100 |
+
# safe_report_name
|
| 101 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
class TestSafeReportName:
|
| 105 |
+
def test_accepts_simple_name(self) -> None:
|
| 106 |
+
assert safe_report_name("rapport_2026") == "rapport_2026"
|
| 107 |
+
|
| 108 |
+
def test_strips_path_separators(self) -> None:
|
| 109 |
+
# Les séparateurs sont supprimés silencieusement.
|
| 110 |
+
# ``../etc/passwd`` → ``..etcpasswd``, et ``..`` initial est strippé →
|
| 111 |
+
# ``etcpasswd`` (caractères neutres, pas de chemin).
|
| 112 |
+
result = safe_report_name("../etc/passwd")
|
| 113 |
+
assert "/" not in result
|
| 114 |
+
assert "\\" not in result
|
| 115 |
+
|
| 116 |
+
def test_rejects_empty(self) -> None:
|
| 117 |
+
with pytest.raises(PathValidationError, match="vide"):
|
| 118 |
+
safe_report_name("")
|
| 119 |
+
|
| 120 |
+
def test_rejects_null_byte(self) -> None:
|
| 121 |
+
with pytest.raises(PathValidationError, match="octet nul"):
|
| 122 |
+
safe_report_name("rapport\x00.html")
|
| 123 |
+
|
| 124 |
+
def test_rejects_pure_separators(self) -> None:
|
| 125 |
+
with pytest.raises(PathValidationError, match="invalide"):
|
| 126 |
+
safe_report_name("///")
|
| 127 |
+
|
| 128 |
+
def test_rejects_dot_only(self) -> None:
|
| 129 |
+
with pytest.raises(PathValidationError):
|
| 130 |
+
safe_report_name(".")
|
| 131 |
+
|
| 132 |
+
def test_truncates_to_max_length(self) -> None:
|
| 133 |
+
long_name = "a" * 500
|
| 134 |
+
assert len(safe_report_name(long_name, max_length=128)) == 128
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 138 |
+
# validated_prompt_filename
|
| 139 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
class TestValidatedPromptFilename:
|
| 143 |
+
def test_accepts_builtin_name(self) -> None:
|
| 144 |
+
assert (
|
| 145 |
+
validated_prompt_filename("correction_medieval_french.txt")
|
| 146 |
+
== "correction_medieval_french.txt"
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
def test_rejects_absolute_path(self) -> None:
|
| 150 |
+
with pytest.raises(PathValidationError, match="séparateur de chemin"):
|
| 151 |
+
validated_prompt_filename("/etc/passwd")
|
| 152 |
+
|
| 153 |
+
def test_rejects_relative_traversal(self) -> None:
|
| 154 |
+
with pytest.raises(PathValidationError):
|
| 155 |
+
validated_prompt_filename("../prompts/secret.txt")
|
| 156 |
+
|
| 157 |
+
def test_rejects_dot_dot_inline(self) -> None:
|
| 158 |
+
with pytest.raises(PathValidationError, match="suspect"):
|
| 159 |
+
validated_prompt_filename("foo..bar.txt")
|
| 160 |
+
|
| 161 |
+
def test_rejects_windows_separator(self) -> None:
|
| 162 |
+
with pytest.raises(PathValidationError, match="séparateur de chemin"):
|
| 163 |
+
validated_prompt_filename(r"C:\Users\victim\file.txt")
|
| 164 |
+
|
| 165 |
+
def test_rejects_dot_prefix(self) -> None:
|
| 166 |
+
with pytest.raises(PathValidationError, match="suspect"):
|
| 167 |
+
validated_prompt_filename(".env")
|
| 168 |
+
|
| 169 |
+
def test_rejects_null_byte(self) -> None:
|
| 170 |
+
with pytest.raises(PathValidationError, match="octet nul"):
|
| 171 |
+
validated_prompt_filename("file\x00.txt")
|
| 172 |
+
|
| 173 |
+
def test_rejects_control_characters(self) -> None:
|
| 174 |
+
with pytest.raises(PathValidationError, match="caractère de contrôle"):
|
| 175 |
+
validated_prompt_filename("file\x01.txt")
|
| 176 |
+
|
| 177 |
+
def test_rejects_empty(self) -> None:
|
| 178 |
+
with pytest.raises(PathValidationError, match="vide"):
|
| 179 |
+
validated_prompt_filename("")
|