Claude commited on
Commit
a2bea75
·
unverified ·
1 Parent(s): 9993409

fix(security,metrics): Sprint A14-S1 — boucher les 6 P0 du rewrite ciblé

Browse files

Sprint 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 CHANGED
@@ -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
- cer: float
25
- cer_nfc: float
26
- cer_caseless: float
27
- wer: float
28
- wer_normalized: float
29
- mer: float
30
- wil: float
31
- reference_length: int
32
- hypothesis_length: int
 
 
 
 
 
 
 
 
 
 
 
 
 
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": round(self.cer, 6),
44
- "cer_nfc": round(self.cer_nfc, 6),
45
- "cer_caseless": round(self.cer_caseless, 6),
46
- "wer": round(self.wer, 6),
47
- "wer_normalized": round(self.wer_normalized, 6),
48
- "mer": round(self.mer, 6),
49
- "wil": round(self.wil, 6),
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
- values = [getattr(r, metric) for r in results if r.error is None]
 
 
 
 
 
 
 
 
 
 
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é)
picarones/core/results.py CHANGED
@@ -160,35 +160,70 @@ class DocumentResult:
160
  d["readability_metrics"] = self.readability_metrics
161
  return d
162
 
163
- def compact(self) -> None:
 
 
 
 
164
  """Libère les champs lourds pour réduire l'empreinte mémoire.
165
 
166
- Appelé après que les données ont été sérialisées dans le fichier
167
- partiel et que les agrégations ont été calculées. Les champs
168
- ``ground_truth`` et ``hypothesis`` sont tronqués et les analyses
169
- détaillées (confusion, taxonomy…) sont supprimées.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  """
171
- # Garder un extrait pour le rapport, libérer le texte complet
172
- if len(self.ground_truth) > 200:
173
- self.ground_truth = self.ground_truth[:200] + "…"
174
- if len(self.hypothesis) > 200:
175
- self.hypothesis = self.hypothesis[:200] + "…"
176
- if self.ocr_intermediate and len(self.ocr_intermediate) > 200:
177
- self.ocr_intermediate = self.ocr_intermediate[:200] + "…"
178
- # Les analyses per-document ne sont plus nécessaires après agrégation
179
- self.confusion_matrix = None
180
- self.char_scores = None
181
- self.taxonomy = None
182
- self.structure = None
183
- self.image_quality = None
184
- self.line_metrics = None
185
- self.hallucination_metrics = None
186
- self.ner_metrics = None
187
- self.calibration_metrics = None
188
- self.philological_metrics = None
189
- self.searchability_metrics = None
190
- self.numerical_sequence_metrics = None
191
- self.readability_metrics = None
 
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
picarones/measurements/metrics.py CHANGED
@@ -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=0.0, cer_nfc=0.0, cer_caseless=0.0,
109
- wer=0.0, wer_normalized=0.0, mer=0.0, wil=0.0,
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=0.0, cer_nfc=0.0, cer_caseless=0.0,
182
- wer=0.0, wer_normalized=0.0, mer=0.0, wil=0.0,
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),
picarones/measurements/runner/document.py CHANGED
@@ -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
- metrics = compute_metrics(ground_truth, ocr_result.text, char_exclude=char_exclude)
 
 
 
 
 
 
 
 
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,
picarones/measurements/runner/orchestration.py CHANGED
@@ -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
- # Libérer la mémoire des analyses per-document après agrégation
401
- for dr in document_results:
402
- dr.compact()
 
 
 
 
 
 
 
 
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
picarones/measurements/runner/workers.py CHANGED
@@ -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
- if len(args) == 9:
 
 
 
 
 
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
 
picarones/web/benchmark_utils.py CHANGED
@@ -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
- report_name = req.report_name or f"rapport_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
 
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
- report_name = req.report_name or f"rapport_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
 
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":
picarones/web/models.py CHANGED
@@ -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):
picarones/web/routers/benchmark.py CHANGED
@@ -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):
picarones/web/security.py CHANGED
@@ -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
  # ---------------------------------------------------------------------------
tests/architecture/test_file_budgets.py CHANGED
@@ -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
- "picarones/web/security.py": 625, # actuel 532
 
 
 
 
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
tests/core/test_sprint_a14_s1_compact_optin.py ADDED
@@ -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
tests/core/test_sprint_a14_s1_metrics_error_returns_none.py ADDED
@@ -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
tests/measurements/test_sprint40_ner_runner.py CHANGED
@@ -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={},
tests/measurements/test_sprint42_calibration_runner.py CHANGED
@@ -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:
tests/measurements/test_sprint61_philological_runner.py CHANGED
@@ -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
 
tests/measurements/test_sprint_a14_s1_normalization_propagation.py ADDED
@@ -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
tests/report/test_sprint86_aii5_html.py CHANGED
@@ -194,7 +194,8 @@ class TestResultsFields:
194
  searchability_metrics={"recall": 0.9},
195
  numerical_sequence_metrics={"n_total": 1},
196
  )
197
- dr.compact()
 
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
 
tests/report/test_sprint87_readability_html.py CHANGED
@@ -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:
tests/security/__init__.py ADDED
File without changes
tests/security/test_sprint_a14_s1_path_validation.py ADDED
@@ -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("")