Spaces:
Sleeping
feat: filtrage macOS, exclusion chars, Vue Analyses, métriques robustes
Browse files1. 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 +3 -2
- picarones/core/metrics.py +9 -0
- picarones/core/normalization.py +55 -1
- picarones/core/runner.py +2 -1
- picarones/report/generator.py +167 -11
- picarones/report/vendor/chart.umd.min.js +0 -0
- picarones/web/app.py +33 -2
- tests/test_report.py +3 -2
- tests/test_sprint12_nouvelles_fonctionnalites.py +258 -0
|
@@ -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()
|
|
|
|
| 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:
|
|
@@ -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(
|
|
@@ -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``
|
|
|
|
| 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 |
|
|
@@ -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,
|
|
@@ -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
|
| 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()
|
|
The diff for this file is too large to render.
See raw diff
|
|
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
};
|
|
@@ -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
|
| 165 |
out = tmp_path / "rapport.html"
|
| 166 |
sample_generator.generate(out)
|
| 167 |
html = out.read_text(encoding="utf-8")
|
| 168 |
-
|
|
|
|
| 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"
|
|
@@ -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}"
|