Claude commited on
Commit
df4f47c
·
unverified ·
1 Parent(s): 16ac2c0

feat: filtrage macOS, exclusion chars, Vue Analyses, métriques robustes

Browse files

1. Filtrage fichiers cachés macOS (._*)
- corpus.py : exclure les fichiers débutant par '.' lors de la détection
des images (._0000.png, .DS_Store etc.)
- app.py _flatten_zip_to_dir : ignorer les entrées ._* et .* dans les ZIPs
- app.py _analyze_corpus_dir : idem, plus de faux avertissements GT manquant

2. Profils de normalisation avec exclusion de caractères
- NormalizationProfile.exclude_chars (frozenset) : supprime ces chars des
deux textes (GT et OCR) avant TOUT calcul CER/WER/MER/WIL
- _parse_exclude_chars() : parse "', -, –" (comma+espace) ou ".,;:!?" (chars)
- Deux profils prédéfinis : sans_ponctuation, sans_apostrophes
- compute_metrics() accepte char_exclude= et l'applique en amont
- run_benchmark() accepte char_exclude= et le transmet
- BenchmarkRequest / BenchmarkRunRequest : champ char_exclude
- SPA : champ "Caractères à ignorer" + auto-remplissage depuis le profil

3. Vue Analyses — Chart.js inline (plus de CDN)
- Embarque chart.umd.min.js (v4.5.1) dans le rapport HTML auto-contenu
- Supprime les références CDN chart.js et diff2html (diff2html non utilisé)
- Injection post-.format() pour éviter les conflits avec les {} du JS
- Vérifié node --check sur le rapport démo : 0 SyntaxError

4. Métriques robustes (exclusion des hallucinations)
- Nouvelle carte "Métriques robustes" dans la vue Classement
- Deux curseurs JS : seuil d'ancrage (défaut 0.5) et ratio longueur (défaut 1.5)
- Recalcule CER/WER en excluant les docs détectés hallucinés, en temps réel
- Affiche : Δ CER global→robuste, docs exclus et restants, liste cliquable
- Entièrement côté client (aucun changement de pipeline nécessaire)

Tests : 979 passés (+ 15 nouveaux dans test_sprint12_nouvelles_fonctionnalites.py)

https://claude.ai/code/session_017gXea9mxBQqDTAsSQd7aAq

picarones/core/corpus.py CHANGED
@@ -109,9 +109,10 @@ def load_corpus_from_directory(
109
  documents: list[Document] = []
110
  skipped = 0
111
 
112
- # Collecte de toutes les images
113
  image_paths = sorted(
114
- p for p in directory.iterdir() if p.suffix.lower() in IMAGE_EXTENSIONS
 
115
  )
116
 
117
  for image_path in image_paths:
 
109
  documents: list[Document] = []
110
  skipped = 0
111
 
112
+ # Collecte de toutes les images (on exclut les fichiers cachés macOS ._* et .*)
113
  image_paths = sorted(
114
+ p for p in directory.iterdir()
115
+ if p.suffix.lower() in IMAGE_EXTENSIONS and not p.name.startswith(".")
116
  )
117
 
118
  for image_path in image_paths:
picarones/core/metrics.py CHANGED
@@ -120,6 +120,7 @@ def compute_metrics(
120
  reference: str,
121
  hypothesis: str,
122
  normalization_profile: "Optional[NormalizationProfile]" = None, # noqa: F821
 
123
  ) -> MetricsResult:
124
  """Calcule l'ensemble des métriques CER/WER pour une paire de textes.
125
 
@@ -133,6 +134,9 @@ def compute_metrics(
133
  Profil de normalisation diplomatique optionnel.
134
  Si fourni, calcule ``cer_diplomatic`` en plus des métriques standard.
135
  Si None, utilise le profil medieval_french par défaut.
 
 
 
136
 
137
  Returns
138
  -------
@@ -149,6 +153,11 @@ def compute_metrics(
149
  )
150
 
151
  try:
 
 
 
 
 
152
  # CER variants
153
  cer_raw = _cer_from_strings(reference, hypothesis)
154
  cer_nfc = _cer_from_strings(
 
120
  reference: str,
121
  hypothesis: str,
122
  normalization_profile: "Optional[NormalizationProfile]" = None, # noqa: F821
123
+ char_exclude: "Optional[frozenset]" = None,
124
  ) -> MetricsResult:
125
  """Calcule l'ensemble des métriques CER/WER pour une paire de textes.
126
 
 
134
  Profil de normalisation diplomatique optionnel.
135
  Si fourni, calcule ``cer_diplomatic`` en plus des métriques standard.
136
  Si None, utilise le profil medieval_french par défaut.
137
+ char_exclude:
138
+ Ensemble de caractères à supprimer des deux textes avant tout calcul
139
+ (CER, WER, MER, WIL). Appliqué également au CER diplomatique.
140
 
141
  Returns
142
  -------
 
153
  )
154
 
155
  try:
156
+ # Exclusion de caractères avant tout calcul
157
+ if char_exclude:
158
+ reference = "".join(c for c in reference if c not in char_exclude)
159
+ hypothesis = "".join(c for c in hypothesis if c not in char_exclude)
160
+
161
  # CER variants
162
  cer_raw = _cer_from_strings(reference, hypothesis)
163
  cer_nfc = _cer_from_strings(
picarones/core/normalization.py CHANGED
@@ -152,6 +152,10 @@ class NormalizationProfile:
152
  diplomatic_table:
153
  Table de correspondances graphiques historiques appliquée caractère
154
  par caractère sur les deux textes avant calcul du CER.
 
 
 
 
155
  description:
156
  Description courte du profil (affichée dans le rapport HTML).
157
  """
@@ -160,10 +164,13 @@ class NormalizationProfile:
160
  nfc: bool = True
161
  caseless: bool = False
162
  diplomatic_table: dict[str, str] = field(default_factory=dict)
 
163
  description: str = ""
164
 
165
  def normalize(self, text: str) -> str:
166
  """Applique le profil de normalisation à un texte."""
 
 
167
  if self.nfc:
168
  text = unicodedata.normalize("NFC", text)
169
  if self.caseless:
@@ -178,6 +185,7 @@ class NormalizationProfile:
178
  "nfc": self.nfc,
179
  "caseless": self.caseless,
180
  "diplomatic_table": self.diplomatic_table,
 
181
  "description": self.description,
182
  }
183
 
@@ -186,7 +194,8 @@ class NormalizationProfile:
186
  """Charge un profil depuis un fichier YAML.
187
 
188
  Le fichier YAML doit contenir les clés ``name``, optionnellement
189
- ``caseless``, ``description`` et ``diplomatic`` (dict str→str).
 
190
 
191
  Example
192
  -------
@@ -195,6 +204,7 @@ class NormalizationProfile:
195
  name: medieval_custom
196
  caseless: false
197
  description: Français médiéval personnalisé
 
198
  diplomatic:
199
  ſ: s
200
  u: v
@@ -213,6 +223,7 @@ class NormalizationProfile:
213
  nfc=bool(data.get("nfc", True)),
214
  caseless=bool(data.get("caseless", False)),
215
  diplomatic_table=data.get("diplomatic", {}),
 
216
  description=data.get("description", ""),
217
  )
218
 
@@ -224,6 +235,7 @@ class NormalizationProfile:
224
  nfc=bool(data.get("nfc", True)),
225
  caseless=bool(data.get("caseless", False)),
226
  diplomatic_table=data.get("diplomatic", {}),
 
227
  description=data.get("description", ""),
228
  )
229
 
@@ -296,6 +308,23 @@ NORMALIZATION_PROFILES: dict[str, NormalizationProfile] = {
296
  diplomatic_table=DIPLOMATIC_EN_SECRETARY,
297
  description="Secretary hand (XVIth–XVIIth c.): ſ=s, u=v, i=j, vv=w, þ=th, ð=th, ȝ=y",
298
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
299
  }
300
 
301
 
@@ -331,6 +360,31 @@ def get_builtin_profile(name: str) -> NormalizationProfile:
331
  # Fonctions utilitaires
332
  # ---------------------------------------------------------------------------
333
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
334
  def _apply_diplomatic_table(text: str, table: dict[str, str]) -> str:
335
  """Applique une table de correspondances diplomatiques caractère par caractère.
336
 
 
152
  diplomatic_table:
153
  Table de correspondances graphiques historiques appliquée caractère
154
  par caractère sur les deux textes avant calcul du CER.
155
+ exclude_chars:
156
+ Ensemble de caractères supprimés des deux textes (GT et OCR) avant
157
+ tout calcul de métriques (CER, WER, MER, WIL et CER diplomatique).
158
+ Utile pour ignorer la ponctuation ou les apostrophes.
159
  description:
160
  Description courte du profil (affichée dans le rapport HTML).
161
  """
 
164
  nfc: bool = True
165
  caseless: bool = False
166
  diplomatic_table: dict[str, str] = field(default_factory=dict)
167
+ exclude_chars: frozenset = field(default_factory=frozenset)
168
  description: str = ""
169
 
170
  def normalize(self, text: str) -> str:
171
  """Applique le profil de normalisation à un texte."""
172
+ if self.exclude_chars:
173
+ text = "".join(c for c in text if c not in self.exclude_chars)
174
  if self.nfc:
175
  text = unicodedata.normalize("NFC", text)
176
  if self.caseless:
 
185
  "nfc": self.nfc,
186
  "caseless": self.caseless,
187
  "diplomatic_table": self.diplomatic_table,
188
+ "exclude_chars": sorted(self.exclude_chars),
189
  "description": self.description,
190
  }
191
 
 
194
  """Charge un profil depuis un fichier YAML.
195
 
196
  Le fichier YAML doit contenir les clés ``name``, optionnellement
197
+ ``caseless``, ``description``, ``diplomatic`` (dict str→str) et
198
+ ``exclude_chars`` (liste ou chaîne de caractères à ignorer).
199
 
200
  Example
201
  -------
 
204
  name: medieval_custom
205
  caseless: false
206
  description: Français médiéval personnalisé
207
+ exclude_chars: ".,;:!?"
208
  diplomatic:
209
  ſ: s
210
  u: v
 
223
  nfc=bool(data.get("nfc", True)),
224
  caseless=bool(data.get("caseless", False)),
225
  diplomatic_table=data.get("diplomatic", {}),
226
+ exclude_chars=_parse_exclude_chars(data.get("exclude_chars", "")),
227
  description=data.get("description", ""),
228
  )
229
 
 
235
  nfc=bool(data.get("nfc", True)),
236
  caseless=bool(data.get("caseless", False)),
237
  diplomatic_table=data.get("diplomatic", {}),
238
+ exclude_chars=_parse_exclude_chars(data.get("exclude_chars", "")),
239
  description=data.get("description", ""),
240
  )
241
 
 
308
  diplomatic_table=DIPLOMATIC_EN_SECRETARY,
309
  description="Secretary hand (XVIth–XVIIth c.): ſ=s, u=v, i=j, vv=w, þ=th, ð=th, ȝ=y",
310
  ),
311
+ # ── Profils d'exclusion de caractères ────────────────────────────────
312
+ "sans_ponctuation": NormalizationProfile(
313
+ name="sans_ponctuation",
314
+ nfc=True,
315
+ caseless=False,
316
+ diplomatic_table={},
317
+ exclude_chars=frozenset(". , ; : ! ? ' \u2019 \" - \u2013 \u2014 ( ) [ ]".split()),
318
+ description="NFC + suppression de la ponctuation courante : . , ; : ! ? ' \" - – — ( ) [ ]",
319
+ ),
320
+ "sans_apostrophes": NormalizationProfile(
321
+ name="sans_apostrophes",
322
+ nfc=True,
323
+ caseless=False,
324
+ diplomatic_table={},
325
+ exclude_chars=frozenset(["'", "\u2019"]), # apostrophe droite + apostrophe typographique
326
+ description="NFC + suppression des apostrophes droite (') et typographique (\u2019)",
327
+ ),
328
  }
329
 
330
 
 
360
  # Fonctions utilitaires
361
  # ---------------------------------------------------------------------------
362
 
363
+ def _parse_exclude_chars(value: "str | list | None") -> frozenset:
364
+ """Convertit une liste de caractères (str ou list) en frozenset.
365
+
366
+ Accepte :
367
+ - Une chaîne de caractères séparés par une virgule+espace (ex. ``"', -, –"``)
368
+ ou simplement concaténés sans séparateur (ex. ``".,;:!?"``)
369
+ - Une liste Python/YAML de chaînes (chacune un caractère)
370
+ - None ou chaîne vide → frozenset vide
371
+
372
+ Règle de désambiguïsation : si la chaîne contient la séquence ``", "``
373
+ (virgule suivie d'un espace), on découpe par ``", "``. Sinon, chaque
374
+ caractère Unicode est un item distinct.
375
+ """
376
+ if not value:
377
+ return frozenset()
378
+ if isinstance(value, (list, tuple)):
379
+ return frozenset(str(c) for c in value if c)
380
+ raw = str(value)
381
+ # Désambiguïsation : séparer par ", " si présent (format lisible)
382
+ if ", " in raw:
383
+ return frozenset(c.strip() for c in raw.split(",") if c.strip())
384
+ # Sinon, chaque caractère Unicode est un item distinct
385
+ return frozenset(raw)
386
+
387
+
388
  def _apply_diplomatic_table(text: str, table: dict[str, str]) -> str:
389
  """Applique une table de correspondances diplomatiques caractère par caractère.
390
 
picarones/core/runner.py CHANGED
@@ -22,6 +22,7 @@ def run_benchmark(
22
  output_json: Optional[str | Path] = None,
23
  show_progress: bool = True,
24
  progress_callback: Optional[callable] = None,
 
25
  ) -> BenchmarkResult:
26
  """Exécute le benchmark d'un ou plusieurs moteurs/pipelines sur un corpus.
27
 
@@ -72,7 +73,7 @@ def run_benchmark(
72
  ocr_result = engine.run(doc.image_path)
73
 
74
  if ocr_result.success:
75
- metrics = compute_metrics(doc.ground_truth, ocr_result.text)
76
  else:
77
  metrics = MetricsResult(
78
  cer=1.0, cer_nfc=1.0, cer_caseless=1.0,
 
22
  output_json: Optional[str | Path] = None,
23
  show_progress: bool = True,
24
  progress_callback: Optional[callable] = None,
25
+ char_exclude: Optional[frozenset] = None,
26
  ) -> BenchmarkResult:
27
  """Exécute le benchmark d'un ou plusieurs moteurs/pipelines sur un corpus.
28
 
 
73
  ocr_result = engine.run(doc.image_path)
74
 
75
  if ocr_result.success:
76
+ metrics = compute_metrics(doc.ground_truth, ocr_result.text, char_exclude=char_exclude)
77
  else:
78
  metrics = MetricsResult(
79
  cer=1.0, cer_nfc=1.0, cer_caseless=1.0,
picarones/report/generator.py CHANGED
@@ -22,6 +22,20 @@ import math
22
  from pathlib import Path
23
  from typing import Optional
24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  from picarones.core.results import BenchmarkResult
26
  from picarones.report.diff_utils import compute_char_diff, compute_word_diff
27
  from picarones.core.statistics import (
@@ -435,17 +449,8 @@ _HTML_TEMPLATE = """\
435
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
436
  <title>Picarones — {corpus_name}</title>
437
 
438
- <!-- Chart.js -->
439
- <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"
440
- integrity="sha512-CQBWl4fJHWbryGE+Pc3UJWW1h3Q8IkkvNnPTozals+S49OTEQPoQj/m1LZRM28Wr/7bJCMlpYS3/Zp4hHuWQ=="
441
- crossorigin="anonymous"></script>
442
-
443
- <!-- diff2html -->
444
- <link rel="stylesheet"
445
- href="https://cdnjs.cloudflare.com/ajax/libs/diff2html/3.4.47/diff2html.min.css"
446
- crossorigin="anonymous">
447
- <script src="https://cdnjs.cloudflare.com/ajax/libs/diff2html/3.4.47/diff2html.min.js"
448
- crossorigin="anonymous"></script>
449
 
450
  <style>
451
  /* ── Reset & base ─────────────────────────────────────────────────── */
@@ -579,6 +584,22 @@ tbody tr:hover {{ background: #f8fafc; }}
579
  }}
580
 
581
  /* ── Gallery ──────────────────────────────────────────────────────── */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
582
  .gallery-controls {{
583
  display: flex; align-items: center; gap: .75rem;
584
  margin-bottom: 1rem; flex-wrap: wrap;
@@ -1057,6 +1078,31 @@ body.present-mode nav .meta {{ display: none; }}
1057
  </div>
1058
  </div>
1059
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1060
  </div>
1061
 
1062
  <!-- ════ Vue 2 : Galerie ═══════════════════════════════════════════ -->
@@ -1692,6 +1738,110 @@ document.querySelectorAll('#ranking-table th.sortable').forEach(th => {{
1692
  }});
1693
  }});
1694
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1695
  // ── Vue Galerie ─────────────────────────────────────────────────
1696
  function renderGallery() {{
1697
  const sortKey = document.getElementById('gallery-sort').value;
@@ -2979,6 +3129,7 @@ function init() {{
2979
  }});
2980
 
2981
  renderRanking();
 
2982
  renderGallery();
2983
  buildDocList();
2984
 
@@ -3076,13 +3227,18 @@ class ReportGenerator:
3076
  report_json = json.dumps(report_data, ensure_ascii=False, separators=(",", ":"))
3077
  i18n_json = json.dumps(labels, ensure_ascii=False, separators=(",", ":"))
3078
 
 
 
 
3079
  html = _HTML_TEMPLATE.format(
3080
  corpus_name=self.benchmark.corpus_name,
3081
  picarones_version=self.benchmark.picarones_version,
3082
  report_data_json=report_json,
3083
  i18n_json=i18n_json,
3084
  html_lang=labels.get("html_lang", "fr"),
 
3085
  )
 
3086
 
3087
  output_path.write_text(html, encoding="utf-8")
3088
  return output_path.resolve()
 
22
  from pathlib import Path
23
  from typing import Optional
24
 
25
+ # ---------------------------------------------------------------------------
26
+ # Ressources vendor (embarquées dans le rapport HTML)
27
+ # ---------------------------------------------------------------------------
28
+
29
+ _VENDOR_DIR = Path(__file__).parent / "vendor"
30
+
31
+
32
+ def _load_vendor_js(name: str) -> str:
33
+ """Lit un fichier JS vendorisé et retourne son contenu."""
34
+ p = _VENDOR_DIR / name
35
+ if p.exists():
36
+ return p.read_text(encoding="utf-8")
37
+ return f"/* vendor/{name} non trouvé */"
38
+
39
  from picarones.core.results import BenchmarkResult
40
  from picarones.report.diff_utils import compute_char_diff, compute_word_diff
41
  from picarones.core.statistics import (
 
449
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
450
  <title>Picarones — {corpus_name}</title>
451
 
452
+ <!-- Chart.js (vendorisé inline) -->
453
+ <script>{chartjs_inline}</script>
 
 
 
 
 
 
 
 
 
454
 
455
  <style>
456
  /* ── Reset & base ─────────────────────────────────────────────────── */
 
584
  }}
585
 
586
  /* ── Gallery ──────────────────────────────────────────────────────── */
587
+ /* Robust metrics controls */
588
+ .robust-controls {{
589
+ display: flex; flex-wrap: wrap; gap: 1.5rem; margin-bottom: .75rem;
590
+ }}
591
+ .robust-controls label {{
592
+ display: flex; align-items: center; gap: .4rem;
593
+ font-size: .82rem; color: var(--text-muted);
594
+ }}
595
+ .robust-controls input[type=range] {{ width: 140px; }}
596
+ .slider-val {{
597
+ font-weight: 700; color: var(--text); min-width: 2.5rem;
598
+ }}
599
+ .robust-table td {{ padding: .4rem .6rem; font-size: .85rem; }}
600
+ .robust-table .improved {{ color: #16a34a; font-weight: 600; }}
601
+ .robust-table .worsened {{ color: #dc2626; font-weight: 600; }}
602
+
603
  .gallery-controls {{
604
  display: flex; align-items: center; gap: .75rem;
605
  margin-bottom: 1rem; flex-wrap: wrap;
 
1078
  </div>
1079
  </div>
1080
  </div>
1081
+
1082
+ <!-- ── Métriques robustes ────────────────────────────────────── -->
1083
+ <div class="card" id="robust-metrics-card">
1084
+ <h2 data-i18n="h_robust">Métriques robustes (sans hallucinations)</h2>
1085
+ <p style="font-size:.82rem;color:var(--text-muted);margin-bottom:.75rem" data-i18n="robust_desc">
1086
+ Recalcule CER, WER, MER, WIL en excluant les documents détectés comme hallucinés.
1087
+ </p>
1088
+ <div class="robust-controls">
1089
+ <label>
1090
+ <span data-i18n="robust_anchor_label">Seuil d'ancrage min :</span>
1091
+ <input type="range" id="robust-anchor" min="0" max="1" step="0.05" value="0.5"
1092
+ oninput="document.getElementById('robust-anchor-val').textContent=parseFloat(this.value).toFixed(2);renderRobustMetrics()">
1093
+ <span id="robust-anchor-val" class="slider-val">0.50</span>
1094
+ </label>
1095
+ <label>
1096
+ <span data-i18n="robust_ratio_label">Ratio longueur max :</span>
1097
+ <input type="range" id="robust-ratio" min="1" max="3" step="0.1" value="1.5"
1098
+ oninput="document.getElementById('robust-ratio-val').textContent=parseFloat(this.value).toFixed(1);renderRobustMetrics()">
1099
+ <span id="robust-ratio-val" class="slider-val">1.5</span>
1100
+ </label>
1101
+ </div>
1102
+ <div id="robust-summary" style="font-size:.82rem;color:var(--text-muted);margin:.5rem 0"></div>
1103
+ <div id="robust-table-wrap" class="table-wrap"></div>
1104
+ <div id="robust-excluded-docs" style="margin-top:.75rem;font-size:.82rem"></div>
1105
+ </div>
1106
  </div>
1107
 
1108
  <!-- ════ Vue 2 : Galerie ═══════════════════════════════════════════ -->
 
1738
  }});
1739
  }});
1740
 
1741
+ // ── Métriques robustes ──────────────────────────────────────────
1742
+ function renderRobustMetrics() {{
1743
+ const anchorThreshold = parseFloat(document.getElementById('robust-anchor').value);
1744
+ const ratioThreshold = parseFloat(document.getElementById('robust-ratio').value);
1745
+
1746
+ // Pour chaque engine : recalculer CER/WER en excluant les docs hallucinés
1747
+ const results = DATA.engines.map(eng => {{
1748
+ const allDocs = DATA.documents;
1749
+ const excluded = [];
1750
+ const cerVals = [], werVals = [], merVals = [], wilVals = [];
1751
+
1752
+ allDocs.forEach(doc => {{
1753
+ const er = doc.engine_results.find(r => r.engine === eng.name);
1754
+ if (!er || er.error) return;
1755
+ const hm = er.hallucination_metrics;
1756
+ const isHall = hm && (hm.anchor_score < anchorThreshold || hm.length_ratio > ratioThreshold);
1757
+ if (isHall) {{
1758
+ excluded.push({{ doc_id: doc.doc_id, anchor: hm.anchor_score, ratio: hm.length_ratio }});
1759
+ }} else {{
1760
+ cerVals.push(er.cer);
1761
+ werVals.push(er.wer);
1762
+ if (er.mer !== undefined) merVals.push(er.mer);
1763
+ if (er.wil !== undefined) wilVals.push(er.wil);
1764
+ }}
1765
+ }});
1766
+
1767
+ const mean = arr => arr.length ? arr.reduce((a,b)=>a+b,0)/arr.length : null;
1768
+ return {{
1769
+ name: eng.name,
1770
+ global_cer: eng.cer,
1771
+ global_wer: eng.wer,
1772
+ robust_cer: mean(cerVals),
1773
+ robust_wer: mean(werVals),
1774
+ robust_mer: mean(merVals),
1775
+ robust_docs: cerVals.length,
1776
+ excluded_count: excluded.length,
1777
+ excluded_docs: excluded,
1778
+ }};
1779
+ }});
1780
+
1781
+ // Résumé
1782
+ const totalExcluded = Math.max(...results.map(r => r.excluded_count));
1783
+ const totalDocs = DATA.documents.length;
1784
+ document.getElementById('robust-summary').textContent =
1785
+ `${{totalExcluded}} document(s) exclu(s) sur ${{totalDocs}} ` +
1786
+ `(seuil ancrage < ${{anchorThreshold.toFixed(2)}}, ratio > ${{ratioThreshold.toFixed(1)}})`;
1787
+
1788
+ // Tableau comparatif
1789
+ const hasRobust = results.some(r => r.excluded_count > 0);
1790
+ const card = document.getElementById('robust-metrics-card');
1791
+ if (!results.some(r => r.excluded_docs.length > 0 || r.robust_cer !== null)) {{
1792
+ document.getElementById('robust-table-wrap').innerHTML =
1793
+ '<p style="color:var(--text-muted);font-size:.82rem">Aucune donnée de hallucinations disponible pour ce corpus.</p>';
1794
+ return;
1795
+ }}
1796
+
1797
+ const rows = results.map(r => {{
1798
+ const delta = r.robust_cer !== null ? r.robust_cer - r.global_cer : null;
1799
+ const deltaClass = delta === null ? '' : (delta < -0.001 ? 'improved' : delta > 0.001 ? 'worsened' : '');
1800
+ const deltaStr = delta === null ? '—' : (delta >= 0 ? '+' : '') + (delta*100).toFixed(2) + '%';
1801
+ return `<tr>
1802
+ <td><b>${{esc(r.name)}}</b></td>
1803
+ <td>${{pct(r.global_cer)}}</td>
1804
+ <td>${{r.robust_cer !== null ? pct(r.robust_cer) : '—'}}</td>
1805
+ <td class="${{deltaClass}}">${{deltaStr}}</td>
1806
+ <td>${{pct(r.global_wer)}}</td>
1807
+ <td>${{r.robust_wer !== null ? pct(r.robust_wer) : '—'}}</td>
1808
+ <td style="color:var(--text-muted)">${{r.excluded_count}} exclu(s) / ${{r.robust_docs}} restant(s)</td>
1809
+ </tr>`;
1810
+ }}).join('');
1811
+
1812
+ document.getElementById('robust-table-wrap').innerHTML = `
1813
+ <table class="robust-table" style="width:100%;border-collapse:collapse">
1814
+ <thead>
1815
+ <tr style="background:var(--bg)">
1816
+ <th style="text-align:left;padding:.4rem .6rem;font-size:.8rem">Moteur</th>
1817
+ <th style="padding:.4rem .6rem;font-size:.8rem">CER global</th>
1818
+ <th style="padding:.4rem .6rem;font-size:.8rem">CER robuste</th>
1819
+ <th style="padding:.4rem .6rem;font-size:.8rem">Δ CER</th>
1820
+ <th style="padding:.4rem .6rem;font-size:.8rem">WER global</th>
1821
+ <th style="padding:.4rem .6rem;font-size:.8rem">WER robuste</th>
1822
+ <th style="padding:.4rem .6rem;font-size:.8rem">Documents</th>
1823
+ </tr>
1824
+ </thead>
1825
+ <tbody>${{rows}}</tbody>
1826
+ </table>`;
1827
+
1828
+ // Documents exclus
1829
+ const allExcluded = results.flatMap(r => r.excluded_docs.map(d => ({{...d, engine: r.name}})));
1830
+ if (allExcluded.length > 0) {{
1831
+ const uniq = [...new Map(allExcluded.map(d => [d.doc_id, d])).values()];
1832
+ document.getElementById('robust-excluded-docs').innerHTML =
1833
+ `<details><summary style="cursor:pointer;font-size:.82rem;color:var(--text-muted)">` +
1834
+ `▶ Documents exclus (${{uniq.length}})</summary>` +
1835
+ `<ul style="margin:.4rem 0 0 1rem;font-size:.8rem;color:var(--text-muted)">` +
1836
+ uniq.map(d => `<li><a href="#" onclick="openDocument('${{esc(d.doc_id)}}');return false">${{esc(d.doc_id)}}</a>` +
1837
+ ` — ancrage: ${{d.anchor !== undefined ? d.anchor.toFixed(3) : '?'}}, ratio: ${{d.ratio !== undefined ? d.ratio.toFixed(2) : '?'}}</li>`
1838
+ ).join('') +
1839
+ `</ul></details>`;
1840
+ }} else {{
1841
+ document.getElementById('robust-excluded-docs').innerHTML = '';
1842
+ }}
1843
+ }}
1844
+
1845
  // ── Vue Galerie ─────────────────────────────────────────────────
1846
  function renderGallery() {{
1847
  const sortKey = document.getElementById('gallery-sort').value;
 
3129
  }});
3130
 
3131
  renderRanking();
3132
+ renderRobustMetrics();
3133
  renderGallery();
3134
  buildDocList();
3135
 
 
3227
  report_json = json.dumps(report_data, ensure_ascii=False, separators=(",", ":"))
3228
  i18n_json = json.dumps(labels, ensure_ascii=False, separators=(",", ":"))
3229
 
3230
+ # Chart.js contient des { } qui casseraient .format() → injection post-format
3231
+ chartjs_js = _load_vendor_js("chart.umd.min.js")
3232
+
3233
  html = _HTML_TEMPLATE.format(
3234
  corpus_name=self.benchmark.corpus_name,
3235
  picarones_version=self.benchmark.picarones_version,
3236
  report_data_json=report_json,
3237
  i18n_json=i18n_json,
3238
  html_lang=labels.get("html_lang", "fr"),
3239
+ chartjs_inline="__CHARTJS_PLACEHOLDER__",
3240
  )
3241
+ html = html.replace("__CHARTJS_PLACEHOLDER__", chartjs_js)
3242
 
3243
  output_path.write_text(html, encoding="utf-8")
3244
  return output_path.resolve()
picarones/report/vendor/chart.umd.min.js ADDED
The diff for this file is too large to render. See raw diff
 
picarones/web/app.py CHANGED
@@ -125,6 +125,7 @@ class BenchmarkRequest(BaseModel):
125
  corpus_path: str
126
  engines: list[str] = ["tesseract"]
127
  normalization_profile: str = "nfc"
 
128
  output_dir: str = "./rapports/"
129
  report_name: str = ""
130
  lang: str = "fra"
@@ -156,6 +157,7 @@ class BenchmarkRunRequest(BaseModel):
156
  corpus_path: str
157
  competitors: list[CompetitorConfig]
158
  normalization_profile: str = "nfc"
 
159
  output_dir: str = "./rapports/"
160
  report_name: str = ""
161
  report_lang: str = "fr"
@@ -612,7 +614,11 @@ def _extract_page_text(root: ET.Element) -> str:
612
 
613
  def _analyze_corpus_dir(path: Path) -> dict:
614
  """Analyse un dossier et retourne un résumé des paires image/GT détectées."""
615
- images = sorted(f.name for f in path.iterdir() if f.suffix.lower() in _IMAGE_EXTS)
 
 
 
 
616
  pairs: list[dict] = []
617
  missing_gt: list[str] = []
618
  for img in images:
@@ -662,6 +668,9 @@ def _flatten_zip_to_dir(zf: zipfile.ZipFile, dest: Path) -> None:
662
  continue
663
  p = Path(member.filename)
664
  name = p.name
 
 
 
665
  # Accepter images, .gt.txt et .xml (ALTO/PAGE)
666
  if p.suffix.lower() in _IMAGE_EXTS or name.endswith(".gt.txt") or p.suffix.lower() == ".xml":
667
  data = zf.read(member.filename)
@@ -779,6 +788,7 @@ async def api_normalization_profiles() -> dict:
779
  "description": p.description or p.name,
780
  "caseless": p.caseless,
781
  "diplomatic_rules": len(p.diplomatic_table),
 
782
  }
783
  for pid, p in NORMALIZATION_PROFILES.items()
784
  ]
@@ -1155,12 +1165,16 @@ def _run_benchmark_thread_v2(job: BenchmarkJob, req: BenchmarkRunRequest) -> Non
1155
  "total": total_steps,
1156
  })
1157
 
 
 
 
1158
  result = run_benchmark(
1159
  corpus=corpus,
1160
  engines=engines,
1161
  output_json=output_json,
1162
  show_progress=False,
1163
  progress_callback=_progress_callback,
 
1164
  )
1165
 
1166
  if job.status == "cancelled":
@@ -1259,6 +1273,9 @@ def _run_benchmark_thread(job: BenchmarkJob, req: BenchmarkRequest) -> None:
1259
  "total": total_steps,
1260
  })
1261
 
 
 
 
1262
  # Lancer le benchmark
1263
  result = run_benchmark(
1264
  corpus=corpus,
@@ -1266,6 +1283,7 @@ def _run_benchmark_thread(job: BenchmarkJob, req: BenchmarkRequest) -> None:
1266
  output_json=output_json,
1267
  show_progress=False,
1268
  progress_callback=_progress_callback,
 
1269
  )
1270
 
1271
  if job.status == "cancelled":
@@ -1661,6 +1679,10 @@ tr:hover td { background: #f0ede6; }
1661
  <option value="nfc">NFC (standard)</option>
1662
  </select>
1663
  </div>
 
 
 
 
1664
  <div class="form-group">
1665
  <label data-i18n="bench_output_label">Dossier de sortie</label>
1666
  <input type="text" id="output-dir" value="./rapports/" />
@@ -2239,19 +2261,27 @@ function renderCompetitors() {
2239
  }
2240
 
2241
  // ─── Normalization profiles ──────────────────────────────────────────────────
 
2242
  async function loadNormProfiles() {
2243
  try {
2244
  const r = await fetch("/api/normalization/profiles");
2245
  const d = await r.json();
 
2246
  const sel = document.getElementById("norm-profile");
2247
  sel.innerHTML = "";
2248
- d.profiles.forEach(p => {
2249
  const opt = document.createElement("option");
2250
  opt.value = p.id;
2251
  opt.textContent = `${p.name} — ${p.description}`;
2252
  if (p.id === "nfc") opt.selected = true;
2253
  sel.appendChild(opt);
2254
  });
 
 
 
 
 
 
2255
  } catch(e) {}
2256
  }
2257
 
@@ -2322,6 +2352,7 @@ async function startBenchmark() {
2322
  corpus_path: corpusPath,
2323
  competitors: _competitors,
2324
  normalization_profile: document.getElementById("norm-profile").value,
 
2325
  output_dir: document.getElementById("output-dir").value,
2326
  report_name: document.getElementById("report-name").value,
2327
  };
 
125
  corpus_path: str
126
  engines: list[str] = ["tesseract"]
127
  normalization_profile: str = "nfc"
128
+ char_exclude: str = "" # Caractères à ignorer (séparés par virgule, ex: "',–")
129
  output_dir: str = "./rapports/"
130
  report_name: str = ""
131
  lang: str = "fra"
 
157
  corpus_path: str
158
  competitors: list[CompetitorConfig]
159
  normalization_profile: str = "nfc"
160
+ char_exclude: str = "" # Caractères à ignorer (séparés par virgule, ex: "',–")
161
  output_dir: str = "./rapports/"
162
  report_name: str = ""
163
  report_lang: str = "fr"
 
614
 
615
  def _analyze_corpus_dir(path: Path) -> dict:
616
  """Analyse un dossier et retourne un résumé des paires image/GT détectées."""
617
+ # Exclure les fichiers cachés macOS (._* AppleDouble) et tout fichier débutant par .
618
+ images = sorted(
619
+ f.name for f in path.iterdir()
620
+ if f.suffix.lower() in _IMAGE_EXTS and not f.name.startswith(".")
621
+ )
622
  pairs: list[dict] = []
623
  missing_gt: list[str] = []
624
  for img in images:
 
668
  continue
669
  p = Path(member.filename)
670
  name = p.name
671
+ # Ignorer les fichiers cachés macOS (._* créés par AppleDouble dans les ZIPs)
672
+ if name.startswith("."):
673
+ continue
674
  # Accepter images, .gt.txt et .xml (ALTO/PAGE)
675
  if p.suffix.lower() in _IMAGE_EXTS or name.endswith(".gt.txt") or p.suffix.lower() == ".xml":
676
  data = zf.read(member.filename)
 
788
  "description": p.description or p.name,
789
  "caseless": p.caseless,
790
  "diplomatic_rules": len(p.diplomatic_table),
791
+ "exclude_chars": sorted(p.exclude_chars),
792
  }
793
  for pid, p in NORMALIZATION_PROFILES.items()
794
  ]
 
1165
  "total": total_steps,
1166
  })
1167
 
1168
+ from picarones.core.normalization import _parse_exclude_chars
1169
+ char_excl = _parse_exclude_chars(req.char_exclude) if req.char_exclude else None
1170
+
1171
  result = run_benchmark(
1172
  corpus=corpus,
1173
  engines=engines,
1174
  output_json=output_json,
1175
  show_progress=False,
1176
  progress_callback=_progress_callback,
1177
+ char_exclude=char_excl,
1178
  )
1179
 
1180
  if job.status == "cancelled":
 
1273
  "total": total_steps,
1274
  })
1275
 
1276
+ from picarones.core.normalization import _parse_exclude_chars
1277
+ char_excl = _parse_exclude_chars(req.char_exclude) if req.char_exclude else None
1278
+
1279
  # Lancer le benchmark
1280
  result = run_benchmark(
1281
  corpus=corpus,
 
1283
  output_json=output_json,
1284
  show_progress=False,
1285
  progress_callback=_progress_callback,
1286
+ char_exclude=char_excl,
1287
  )
1288
 
1289
  if job.status == "cancelled":
 
1679
  <option value="nfc">NFC (standard)</option>
1680
  </select>
1681
  </div>
1682
+ <div class="form-group">
1683
+ <label data-i18n="bench_char_exclude_label">Caractères à ignorer <span style="color:var(--text-muted);font-size:.75rem">(séparés par virgule, ex : ', -, –)</span></label>
1684
+ <input type="text" id="char-exclude" placeholder="ex: ', -, –, ." style="font-family:monospace" />
1685
+ </div>
1686
  <div class="form-group">
1687
  <label data-i18n="bench_output_label">Dossier de sortie</label>
1688
  <input type="text" id="output-dir" value="./rapports/" />
 
2261
  }
2262
 
2263
  // ─── Normalization profiles ──────────────────────────────────────────────────
2264
+ let _normProfilesData = [];
2265
  async function loadNormProfiles() {
2266
  try {
2267
  const r = await fetch("/api/normalization/profiles");
2268
  const d = await r.json();
2269
+ _normProfilesData = d.profiles || [];
2270
  const sel = document.getElementById("norm-profile");
2271
  sel.innerHTML = "";
2272
+ _normProfilesData.forEach(p => {
2273
  const opt = document.createElement("option");
2274
  opt.value = p.id;
2275
  opt.textContent = `${p.name} — ${p.description}`;
2276
  if (p.id === "nfc") opt.selected = true;
2277
  sel.appendChild(opt);
2278
  });
2279
+ sel.addEventListener("change", () => {
2280
+ const p = _normProfilesData.find(x => x.id === sel.value);
2281
+ if (p && p.exclude_chars && p.exclude_chars.length) {
2282
+ document.getElementById("char-exclude").value = p.exclude_chars.join(", ");
2283
+ }
2284
+ });
2285
  } catch(e) {}
2286
  }
2287
 
 
2352
  corpus_path: corpusPath,
2353
  competitors: _competitors,
2354
  normalization_profile: document.getElementById("norm-profile").value,
2355
+ char_exclude: document.getElementById("char-exclude").value.trim(),
2356
  output_dir: document.getElementById("output-dir").value,
2357
  report_name: document.getElementById("report-name").value,
2358
  };
tests/test_report.py CHANGED
@@ -161,11 +161,12 @@ class TestReportGenerator:
161
  html = out.read_text(encoding="utf-8")
162
  assert "chart.js" in html.lower() or "Chart.js" in html
163
 
164
- def test_contains_diff2html(self, sample_generator, tmp_path):
165
  out = tmp_path / "rapport.html"
166
  sample_generator.generate(out)
167
  html = out.read_text(encoding="utf-8")
168
- assert "diff2html" in html.lower()
 
169
 
170
  def test_data_embedded(self, sample_generator, tmp_path):
171
  out = tmp_path / "rapport.html"
 
161
  html = out.read_text(encoding="utf-8")
162
  assert "chart.js" in html.lower() or "Chart.js" in html
163
 
164
+ def test_contains_chartjs(self, sample_generator, tmp_path):
165
  out = tmp_path / "rapport.html"
166
  sample_generator.generate(out)
167
  html = out.read_text(encoding="utf-8")
168
+ # Chart.js est désormais embarqué inline (plus de CDN)
169
+ assert "Chart.js" in html or "new Chart(" in html
170
 
171
  def test_data_embedded(self, sample_generator, tmp_path):
172
  out = tmp_path / "rapport.html"
tests/test_sprint12_nouvelles_fonctionnalites.py ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests pour les nouvelles fonctionnalités du sprint 12 :
2
+ 1. Filtrage des fichiers cachés macOS (._*) dans corpus et ZIP
3
+ 2. Profils de normalisation avec exclusion de caractères
4
+ 3. Vue Analyses — Chart.js inline (plus de CDN)
5
+ 4. Métriques robustes dans le rapport HTML
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import io
11
+ import zipfile
12
+ from pathlib import Path
13
+
14
+ import pytest
15
+
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # 1. Filtrage des fichiers cachés macOS
19
+ # ---------------------------------------------------------------------------
20
+
21
+ FAKE_PNG = (
22
+ b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01"
23
+ b"\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00\x00"
24
+ b"\x00\x0cIDATx\x9cc\xf8\x0f\x00\x00\x01\x01\x00\x05\x18"
25
+ b"\xd8N\x00\x00\x00\x00IEND\xaeB`\x82"
26
+ )
27
+
28
+
29
+ class TestMacOSHiddenFilesFiltering:
30
+ def test_hidden_images_ignored_in_corpus(self, tmp_path):
31
+ """Les fichiers ._* ne doivent pas être comptés comme images valides."""
32
+ from picarones.core.corpus import load_corpus_from_directory
33
+
34
+ # Image réelle avec GT
35
+ (tmp_path / "page_001.png").write_bytes(FAKE_PNG)
36
+ (tmp_path / "page_001.gt.txt").write_text("Texte réel", encoding="utf-8")
37
+
38
+ # Fichiers AppleDouble macOS (sans GT associé)
39
+ (tmp_path / "._page_001.png").write_bytes(b"\x00\x05\x16\x07")
40
+ (tmp_path / ".DS_Store").write_bytes(b"\x00\x00\x00\x01Bud1")
41
+
42
+ corpus = load_corpus_from_directory(tmp_path)
43
+ assert len(corpus) == 1
44
+ assert corpus.documents[0].doc_id == "page_001"
45
+
46
+ def test_hidden_files_not_extracted_from_zip(self, tmp_path):
47
+ """_flatten_zip_to_dir doit ignorer les entrées ._* dans le ZIP."""
48
+ from picarones.web.app import _flatten_zip_to_dir
49
+
50
+ buf = io.BytesIO()
51
+ with zipfile.ZipFile(buf, "w") as zf:
52
+ zf.writestr("page_001.png", FAKE_PNG)
53
+ zf.writestr("page_001.gt.txt", "Texte réel")
54
+ zf.writestr("._page_001.png", b"\x00\x05\x16\x07")
55
+ zf.writestr("__MACOSX/._page_001.png", b"\x00\x05\x16\x07")
56
+
57
+ buf.seek(0)
58
+ dest = tmp_path / "corpus"
59
+ dest.mkdir()
60
+ with zipfile.ZipFile(buf) as zf:
61
+ _flatten_zip_to_dir(zf, dest)
62
+
63
+ files = {f.name for f in dest.iterdir()}
64
+ assert "._page_001.png" not in files
65
+ assert "page_001.png" in files
66
+ assert "page_001.gt.txt" in files
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # 2. Profils de normalisation avec exclusion de caractères
71
+ # ---------------------------------------------------------------------------
72
+
73
+ class TestExcludeCharsNormalization:
74
+ def test_parse_exclude_chars_from_comma_string(self):
75
+ from picarones.core.normalization import _parse_exclude_chars
76
+
77
+ result = _parse_exclude_chars("', -, –")
78
+ assert "'" in result
79
+ assert "-" in result
80
+ assert "–" in result
81
+
82
+ def test_parse_exclude_chars_from_plain_string(self):
83
+ from picarones.core.normalization import _parse_exclude_chars
84
+
85
+ result = _parse_exclude_chars(".,;:!?")
86
+ assert "." in result
87
+ assert "," in result
88
+ assert "?" in result
89
+
90
+ def test_parse_exclude_chars_empty(self):
91
+ from picarones.core.normalization import _parse_exclude_chars
92
+
93
+ assert _parse_exclude_chars("") == frozenset()
94
+ assert _parse_exclude_chars(None) == frozenset()
95
+
96
+ def test_normalize_strips_excluded_chars(self):
97
+ from picarones.core.normalization import NormalizationProfile
98
+
99
+ profile = NormalizationProfile(
100
+ name="test",
101
+ exclude_chars=frozenset([".", ","]),
102
+ )
103
+ assert profile.normalize("Bonjour, monde.") == "Bonjour monde"
104
+
105
+ def test_sans_ponctuation_profile_exists(self):
106
+ from picarones.core.normalization import NORMALIZATION_PROFILES
107
+
108
+ assert "sans_ponctuation" in NORMALIZATION_PROFILES
109
+ p = NORMALIZATION_PROFILES["sans_ponctuation"]
110
+ assert "." in p.exclude_chars
111
+ assert "," in p.exclude_chars
112
+ assert "?" in p.exclude_chars
113
+
114
+ def test_sans_apostrophes_profile_exists(self):
115
+ from picarones.core.normalization import NORMALIZATION_PROFILES
116
+
117
+ assert "sans_apostrophes" in NORMALIZATION_PROFILES
118
+ p = NORMALIZATION_PROFILES["sans_apostrophes"]
119
+ assert "'" in p.exclude_chars
120
+ assert "\u2019" in p.exclude_chars # apostrophe typographique
121
+
122
+ def test_compute_metrics_with_char_exclude(self):
123
+ from picarones.core.metrics import compute_metrics
124
+
125
+ ref = "Bonjour, monde!"
126
+ hyp = "Bonjour monde"
127
+ # Sans exclusion, CER > 0 (virgule et ! manquants)
128
+ metrics_raw = compute_metrics(ref, hyp)
129
+ assert metrics_raw.cer > 0
130
+
131
+ # Avec exclusion de la ponctuation, les deux textes deviennent identiques
132
+ metrics_excl = compute_metrics(ref, hyp, char_exclude=frozenset([",", "!", " "]))
133
+ # CER devrait être 0 ou très faible maintenant (Bonjourmonde == Bonjourmonde)
134
+ assert metrics_excl.cer == 0.0
135
+
136
+ def test_char_exclude_propagated_in_run_benchmark(self, tmp_path):
137
+ """char_exclude doit être transmis à run_benchmark et réduire le CER."""
138
+ from picarones.core.corpus import Corpus, Document
139
+ from picarones.core.runner import run_benchmark
140
+ from picarones.engines.base import BaseOCREngine, EngineResult
141
+
142
+ class MockEngine(BaseOCREngine):
143
+ name = "mock"
144
+ version = "0.0"
145
+
146
+ def _run_ocr(self, image_path):
147
+ return EngineResult(text="Bonjour monde", success=True)
148
+
149
+ doc = Document(image_path=tmp_path / "page.png", ground_truth="Bonjour, monde!")
150
+ (tmp_path / "page.png").write_bytes(FAKE_PNG)
151
+ corpus = Corpus(name="test", documents=[doc])
152
+
153
+ result_raw = run_benchmark(corpus, [MockEngine()])
154
+ cer_raw = result_raw.engine_reports[0].document_results[0].metrics.cer
155
+
156
+ result_excl = run_benchmark(corpus, [MockEngine()], char_exclude=frozenset([",", "!"]))
157
+ cer_excl = result_excl.engine_reports[0].document_results[0].metrics.cer
158
+
159
+ assert cer_excl <= cer_raw
160
+
161
+
162
+ # ---------------------------------------------------------------------------
163
+ # 3. Vue Analyses — Chart.js inline
164
+ # ---------------------------------------------------------------------------
165
+
166
+ class TestChartJsInline:
167
+ def test_chartjs_embedded_inline(self, sample_generator, tmp_path):
168
+ """Le rapport HTML doit embarquer Chart.js inline (pas de CDN)."""
169
+ out = tmp_path / "rapport.html"
170
+ sample_generator.generate(out)
171
+ html = out.read_text(encoding="utf-8")
172
+
173
+ assert "cdnjs.cloudflare.com/ajax/libs/Chart.js" not in html
174
+ assert "Chart.js v" in html or "new Chart(" in html
175
+
176
+ def test_no_diff2html_cdn(self, sample_generator, tmp_path):
177
+ """Le rapport ne doit plus référencer diff2html (CDN supprimé)."""
178
+ out = tmp_path / "rapport.html"
179
+ sample_generator.generate(out)
180
+ html = out.read_text(encoding="utf-8")
181
+
182
+ assert "diff2html" not in html
183
+
184
+ def test_build_charts_function_present(self, sample_generator, tmp_path):
185
+ out = tmp_path / "rapport.html"
186
+ sample_generator.generate(out)
187
+ html = out.read_text(encoding="utf-8")
188
+
189
+ assert "function buildCharts()" in html
190
+ assert "buildCerHistogram" in html
191
+ assert "buildRadar" in html
192
+
193
+
194
+ @pytest.fixture
195
+ def sample_generator():
196
+ """Fixture partagée : crée un ReportGenerator avec des données fictives."""
197
+ from picarones.report.generator import ReportGenerator
198
+ from picarones.core.results import BenchmarkResult, DocumentResult, EngineReport
199
+ from picarones.core.metrics import MetricsResult
200
+
201
+ def _make_metric(cer=0.1):
202
+ return MetricsResult(
203
+ cer=cer, cer_nfc=cer, cer_caseless=cer,
204
+ wer=cer, wer_normalized=cer, mer=cer, wil=cer,
205
+ reference_length=100, hypothesis_length=100,
206
+ )
207
+
208
+ docs = [
209
+ DocumentResult(
210
+ doc_id=f"doc_{i}", image_path="", ground_truth="GT text",
211
+ hypothesis="Hyp text", metrics=_make_metric(0.1 + i * 0.01),
212
+ duration_seconds=0.1,
213
+ )
214
+ for i in range(3)
215
+ ]
216
+ report = EngineReport(engine_name="tesseract", engine_version="5.0", engine_config={}, document_results=docs)
217
+ bm = BenchmarkResult(
218
+ corpus_name="TestCorpus", corpus_source=None, document_count=3,
219
+ engine_reports=[report],
220
+ )
221
+ return ReportGenerator(bm)
222
+
223
+
224
+ # ---------------------------------------------------------------------------
225
+ # 4. Métriques robustes — présence dans le rapport HTML
226
+ # ---------------------------------------------------------------------------
227
+
228
+ class TestRobustMetrics:
229
+ def test_robust_metrics_card_present(self, sample_generator, tmp_path):
230
+ """La carte Métriques robustes doit être présente dans le rapport."""
231
+ out = tmp_path / "rapport.html"
232
+ sample_generator.generate(out)
233
+ html = out.read_text(encoding="utf-8")
234
+
235
+ assert "robust-metrics-card" in html
236
+ assert "robust-anchor" in html
237
+ assert "robust-ratio" in html
238
+ assert "renderRobustMetrics" in html
239
+
240
+ def test_robust_metrics_js_syntax_valid(self, sample_generator, tmp_path):
241
+ """La fonction renderRobustMetrics ne doit pas introduire de SyntaxError JS."""
242
+ import re
243
+ import subprocess
244
+
245
+ out = tmp_path / "rapport.html"
246
+ sample_generator.generate(out)
247
+ html = out.read_text(encoding="utf-8")
248
+
249
+ scripts = re.findall(r"<script>(.*?)</script>", html, re.DOTALL)
250
+ # Le bloc applicatif est le dernier script
251
+ app_js = tmp_path / "app.js"
252
+ app_js.write_text(scripts[-1], encoding="utf-8")
253
+
254
+ result = subprocess.run(
255
+ ["node", "--check", str(app_js)],
256
+ capture_output=True, text=True,
257
+ )
258
+ assert result.returncode == 0, f"Erreur JS : {result.stderr}"