Spaces:
Sleeping
Sleeping
Claude
cli(workflows): générer le HTML automatiquement (Phase 4.5 chantier post-rewrite)
de2327a unverified | """Commandes workflows benchmark : run, diagnose, economics, edition, compare. | |
| Sous-module CLI extrait de l'ancien ``picarones/cli.py`` (1519 lignes) | |
| lors du chantier 5 post-Sprint 97. Les commandes ici s'enregistrent | |
| automatiquement sur le groupe ``cli`` à l'import. | |
| Comportement et signatures inchangés — uniquement de la modularisation. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import sys | |
| import click | |
| from picarones.interfaces.cli import cli, _engine_from_name, _setup_logging | |
| def _validate_cer_threshold( | |
| ctx: click.Context, param: click.Parameter, value: float | None, | |
| ) -> float | None: | |
| """Callback Click qui valide ``--fail-if-cer-above`` à l'analyse. | |
| Sémantique : fraction ∈ [0, 1] (ex : 0.15 = 15 %), cohérent avec | |
| ``BenchmarkResult.ranking()[i]["mean_cer"]`` qui est aussi en | |
| fraction. | |
| Garde-fou migration : avant le fix de sémantique, le seuil était | |
| interprété comme un pourcentage (15.0 = 15 %). Tout caller qui | |
| passe encore une valeur > 1 vient de l'ancienne sémantique — on | |
| échoue bruyamment plutôt que de muter silencieusement le | |
| comportement (un seuil de 1500 % ne se déclencherait jamais et | |
| l'utilisateur croirait que son CI est sain). | |
| """ | |
| if value is None: | |
| return None | |
| if value < 0: | |
| raise click.BadParameter( | |
| f"doit être ≥ 0, reçu {value}.", | |
| ) | |
| if value > 1.0: | |
| raise click.BadParameter( | |
| f"doit être une fraction ∈ [0, 1] (ex : 0.15 = 15 %), " | |
| f"reçu {value}. Si vous utilisiez l'ancienne sémantique " | |
| "pourcentage, divisez par 100 (ex : 15.0 → 0.15).", | |
| ) | |
| return value | |
| # --------------------------------------------------------------------------- | |
| # picarones run | |
| # --------------------------------------------------------------------------- | |
| def run_cmd( | |
| corpus: str, | |
| engines: str, | |
| output: str, | |
| lang: str, | |
| psm: int, | |
| no_progress: bool, | |
| verbose: bool, | |
| fail_if_cer_above: float | None, | |
| profile: str, | |
| ) -> None: | |
| """Lance un benchmark OCR sur un corpus de documents. | |
| Le corpus doit être un dossier contenant des paires | |
| <image>.<ext> + <image>.gt.txt (vérité terrain). | |
| ``--fail-if-cer-above`` est validé à l'analyse Click (cf. | |
| ``_validate_cer_threshold``) — une valeur invalide est rejetée | |
| avant toute opération coûteuse. | |
| """ | |
| _setup_logging(verbose) | |
| from picarones.evaluation.corpus import load_corpus_from_directory | |
| from picarones.app.services.benchmark_runner import run_benchmark_via_service | |
| # Chargement du corpus | |
| try: | |
| corp = load_corpus_from_directory(corpus) | |
| except (FileNotFoundError, ValueError) as exc: | |
| click.echo(f"Erreur corpus : {exc}", err=True) | |
| sys.exit(1) | |
| click.echo(f"Corpus '{corp.name}' — {len(corp)} documents chargés.") | |
| # Instanciation des moteurs | |
| engine_names = [e.strip() for e in engines.split(",") if e.strip()] | |
| ocr_engines = [] | |
| for name in engine_names: | |
| try: | |
| engine = _engine_from_name(name, lang=lang, psm=psm) | |
| ocr_engines.append(engine) | |
| except click.BadParameter as exc: | |
| click.echo(f"Erreur moteur : {exc}", err=True) | |
| sys.exit(1) | |
| if not ocr_engines: | |
| click.echo("Aucun moteur valide spécifié.", err=True) | |
| sys.exit(1) | |
| click.echo(f"Moteurs : {', '.join(e.name for e in ocr_engines)}") | |
| click.echo(f"Profil de métriques : {profile}") | |
| # Lancement du benchmark | |
| result = run_benchmark_via_service( | |
| corpus=corp, | |
| engines=ocr_engines, | |
| output_json=output, | |
| show_progress=not no_progress, | |
| profile=profile, | |
| ) | |
| # Affichage du classement | |
| click.echo("\n── Classement ──────────────────────────────────") | |
| for rank, entry in enumerate(result.ranking(), 1): | |
| cer_pct = f"{entry['mean_cer'] * 100:.2f}%" if entry["mean_cer"] is not None else "N/A" | |
| wer_pct = f"{entry['mean_wer'] * 100:.2f}%" if entry["mean_wer"] is not None else "N/A" | |
| failed = entry["failed"] | |
| failed_str = f" ({failed} erreur(s))" if failed else "" | |
| click.echo(f" {rank}. {entry['engine']:<20} CER={cer_pct:<8} WER={wer_pct}{failed_str}") | |
| click.echo(f"\nRésultats écrits dans : {output}") | |
| # Mode CI/CD : exit code non-zero si CER > seuil. | |
| # ``fail_if_cer_above`` est déjà validé en tête de fonction (∈ [0, 1]). | |
| if fail_if_cer_above is not None: | |
| for entry in result.ranking(): | |
| if ( | |
| entry["mean_cer"] is not None | |
| and entry["mean_cer"] > fail_if_cer_above | |
| ): | |
| click.echo( | |
| f"\nECHEC : {entry['engine']} " | |
| f"CER={entry['mean_cer']*100:.2f}% " | |
| f"> seuil {fail_if_cer_above*100:.2f}%", | |
| err=True, | |
| ) | |
| sys.exit(1) | |
| # --------------------------------------------------------------------------- | |
| # Workflows CLI dédiés (chantier 4 post-Sprint 97) | |
| # --------------------------------------------------------------------------- | |
| # | |
| # Chaque commande spécialisée fixe un profil de calcul (chantier 2) et | |
| # émet un message identifiant la famille avant de déléguer au runner. | |
| # L'option ``--profile`` reste disponible mais le défaut change pour | |
| # chaque commande. | |
| def _html_path_from_json(json_path: str) -> str: | |
| """Convertit un chemin ``results.json`` en chemin ``results.html``. | |
| Utilisé par les workflows pour générer automatiquement le rapport | |
| HTML à côté du JSON (Phase 4.5 du chantier post-rewrite — auparavant | |
| chaque workflow imprimait juste le chemin JSON et l'utilisateur | |
| devait relancer ``picarones report --results …`` manuellement, | |
| contre-intuitif vu que le workflow vendait un livrable HTML). | |
| """ | |
| from pathlib import Path | |
| p = Path(json_path) | |
| return str(p.with_suffix(".html")) | |
| def _run_workflow( | |
| *, | |
| corpus: str, | |
| engines: str, | |
| output: str, | |
| lang: str, | |
| psm: int, | |
| no_progress: bool, | |
| verbose: bool, | |
| profile: str, | |
| workflow_label: str, | |
| generate_html: bool = True, | |
| html_lang: str = "fr", | |
| ) -> None: | |
| """Implémentation commune des commandes ``run``, ``diagnose``, | |
| ``economics`` et ``edition``. | |
| Les 4 commandes partagent le squelette : chargement corpus → | |
| instanciation moteurs → ``run_benchmark_via_service(profile=...)`` → affichage | |
| classement → génération automatique du rapport HTML. Seul le profil | |
| par défaut et le message d'en-tête diffèrent. | |
| Phase 4.5 du chantier post-rewrite : ``generate_html=True`` par | |
| défaut. Auparavant les workflows ne produisaient que du JSON, ce | |
| qui forçait l'utilisateur à ré-exécuter ``picarones report`` | |
| manuellement — contre-intuitif (les docstrings vendaient une vue | |
| HTML "Diagnostic", "Coût et performance", "Taxonomie avancée" | |
| qui n'était jamais générée). Passer ``generate_html=False`` | |
| permet de désactiver pour les usages CI/scripts qui ne veulent | |
| que le JSON. | |
| """ | |
| _setup_logging(verbose) | |
| from picarones.evaluation.corpus import load_corpus_from_directory | |
| from picarones.app.services.benchmark_runner import run_benchmark_via_service | |
| try: | |
| corp = load_corpus_from_directory(corpus) | |
| except (FileNotFoundError, ValueError) as exc: | |
| click.echo(f"Erreur corpus : {exc}", err=True) | |
| sys.exit(1) | |
| click.echo(f"[{workflow_label}] Corpus '{corp.name}' — " | |
| f"{len(corp)} documents chargés.") | |
| engine_names = [e.strip() for e in engines.split(",") if e.strip()] | |
| ocr_engines = [] | |
| for name in engine_names: | |
| try: | |
| engine = _engine_from_name(name, lang=lang, psm=psm) | |
| ocr_engines.append(engine) | |
| except click.BadParameter as exc: | |
| click.echo(f"Erreur moteur : {exc}", err=True) | |
| sys.exit(1) | |
| if not ocr_engines: | |
| click.echo("Aucun moteur valide spécifié.", err=True) | |
| sys.exit(1) | |
| click.echo(f"Moteurs : {', '.join(e.name for e in ocr_engines)}") | |
| click.echo(f"Profil de métriques : {profile}") | |
| result = run_benchmark_via_service( | |
| corpus=corp, | |
| engines=ocr_engines, | |
| output_json=output, | |
| show_progress=not no_progress, | |
| profile=profile, | |
| ) | |
| click.echo("\n── Classement ──────────────────────────────────") | |
| for rank, entry in enumerate(result.ranking(), 1): | |
| cer_pct = ( | |
| f"{entry['mean_cer'] * 100:.2f}%" | |
| if entry["mean_cer"] is not None else "N/A" | |
| ) | |
| wer_pct = ( | |
| f"{entry['mean_wer'] * 100:.2f}%" | |
| if entry["mean_wer"] is not None else "N/A" | |
| ) | |
| failed = entry["failed"] | |
| failed_str = f" ({failed} erreur(s))" if failed else "" | |
| click.echo( | |
| f" {rank}. {entry['engine']:<20} " | |
| f"CER={cer_pct:<8} WER={wer_pct}{failed_str}" | |
| ) | |
| click.echo(f"\nRésultats JSON écrits dans : {output}") | |
| if generate_html: | |
| html_output = _html_path_from_json(output) | |
| try: | |
| from picarones.reports.html.generator import ReportGenerator | |
| gen = ReportGenerator(result, lang=html_lang) | |
| gen.generate(html_output) | |
| click.echo(f"Rapport HTML généré : {html_output}") | |
| except Exception as exc: # noqa: BLE001 | |
| # Le JSON est déjà écrit ; on logue l'échec HTML sans | |
| # quitter avec un code d'erreur (l'utilisateur peut | |
| # relancer ``picarones report`` manuellement). | |
| click.echo( | |
| f"Avertissement : génération HTML échouée ({exc}). " | |
| f"Relancer ``picarones report --results {output}`` " | |
| "pour réessayer.", | |
| err=True, | |
| ) | |
| def diagnose_cmd( | |
| corpus: str, engines: str, output: str, lang: str, psm: int, | |
| no_progress: bool, verbose: bool, no_html: bool, html_lang: str, | |
| ) -> None: | |
| """Workflow diagnostic : bench + leviers d'amélioration + image_predictive. | |
| Active le profil ``diagnostics`` (chantier 2) qui calcule les | |
| métriques nécessaires à la vue HTML « Diagnostic approfondi » | |
| (chantier 3) : leviers, profil d'image, baseline, longitudinal. | |
| Idéal pour comprendre *pourquoi* un moteur produit ces résultats | |
| sur ce corpus, pas seulement *quel CER*. | |
| Phase 4.5 du chantier post-rewrite : génère désormais le HTML | |
| automatiquement à côté du JSON (``--no-html`` pour skipper). | |
| """ | |
| _run_workflow( | |
| corpus=corpus, engines=engines, output=output, | |
| lang=lang, psm=psm, | |
| no_progress=no_progress, verbose=verbose, | |
| profile="diagnostics", | |
| workflow_label="diagnose", | |
| generate_html=not no_html, | |
| html_lang=html_lang, | |
| ) | |
| def economics_cmd( | |
| corpus: str, engines: str, output: str, lang: str, psm: int, | |
| no_progress: bool, verbose: bool, no_html: bool, html_lang: str, | |
| ) -> None: | |
| """Workflow économique : bench + throughput effectif + (cost projection). | |
| Active le profil ``economics`` (chantier 2) qui se concentre sur | |
| les métriques de décision budget : pages/h utilisable (intégrant | |
| la correction humaine HTR-United à 5 s/erreur), coût marginal par | |
| erreur évitée. La vue HTML « Coût et performance » (chantier 3) | |
| est désormais générée automatiquement (Phase 4.5 chantier | |
| post-rewrite — ``--no-html`` pour skipper). | |
| """ | |
| _run_workflow( | |
| corpus=corpus, engines=engines, output=output, | |
| lang=lang, psm=psm, | |
| no_progress=no_progress, verbose=verbose, | |
| profile="economics", | |
| workflow_label="economics", | |
| generate_html=not no_html, | |
| html_lang=html_lang, | |
| ) | |
| def edition_cmd( | |
| corpus: str, engines: str, output: str, lang: str, psm: int, | |
| no_progress: bool, verbose: bool, no_html: bool, html_lang: str, | |
| ) -> None: | |
| """Workflow édition critique : bench + métriques philologiques. | |
| Active le profil ``philological`` (chantier 2) qui inclut les | |
| modules philologiques (unicode_blocks, abbreviations, MUFI, | |
| early_modern_typography, modern_archives, roman_numerals) et la | |
| vue HTML « Taxonomie avancée » (chantier 3) avec comparaison | |
| miroir leader vs runner-up. Cible : éditeurs de chartes, | |
| paléographes, archivistes. | |
| Phase 4.5 du chantier post-rewrite : génère le HTML | |
| automatiquement (``--no-html`` pour skipper). | |
| """ | |
| _run_workflow( | |
| corpus=corpus, engines=engines, output=output, | |
| lang=lang, psm=psm, | |
| no_progress=no_progress, verbose=verbose, | |
| profile="philological", | |
| workflow_label="edition", | |
| generate_html=not no_html, | |
| html_lang=html_lang, | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # picarones compare (Sprint 28) | |
| # --------------------------------------------------------------------------- | |
| def compare_cmd( | |
| run_a: str, | |
| run_b: str, | |
| output: str, | |
| threshold: float, | |
| label_a: str, | |
| label_b: str, | |
| json_only: bool, | |
| verbose: bool, | |
| ) -> None: | |
| """Compare deux runs de benchmark JSON et signale les régressions. | |
| Convention : un Δ CER positif signifie que ``B`` est moins bon que | |
| ``A``. Un moteur dont |Δ CER| > ``--threshold`` est marqué comme | |
| régression ou amélioration. | |
| \b | |
| Exemples : | |
| picarones compare run_v1.json run_v2.json -o diff.html | |
| picarones compare run_v1.json run_v2.json --json | |
| picarones compare run_v1.json run_v2.json --threshold 0.01 --label-a v1 --label-b v2 | |
| """ | |
| _setup_logging(verbose) | |
| from picarones.reports.html.comparison import ( | |
| compare_benchmarks, | |
| detect_regressions, | |
| render_comparison_html, | |
| ) | |
| diff = compare_benchmarks( | |
| run_a, run_b, | |
| threshold=threshold, | |
| label_a=label_a, | |
| label_b=label_b, | |
| ) | |
| regressions = detect_regressions(diff) | |
| if json_only: | |
| click.echo(json.dumps(diff.as_dict(), ensure_ascii=False, indent=2)) | |
| if regressions: | |
| sys.exit(2) # exit code 2 → régression détectée (utile en CI) | |
| return | |
| out = render_comparison_html(diff, output) | |
| click.echo(f"Rapport de comparaison : {out}") | |
| click.echo(f"Moteurs comparés : {len(diff.deltas)}") | |
| click.echo(f"Régressions : {len(regressions)}") | |
| click.echo(f"Améliorations : {sum(1 for d in diff.deltas if d.is_improvement)}") | |
| if regressions: | |
| click.echo("\n— Régressions détectées —") | |
| for d in regressions: | |
| click.echo( | |
| f" ⚠ {d.engine} : " | |
| f"{d.cer_a:.3f} → {d.cer_b:.3f} (Δ +{d.delta_cer:.3f})" | |
| ) | |
| sys.exit(2) | |