Picarones / picarones /cli /_workflows.py
Claude
refactor(core): faire de core/ un cercle 1 strict, déplacer cercle 2 vers measurements/
979f3c3 unverified
Raw
History Blame
16.3 kB
"""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.cli import cli, _engine_from_name, _setup_logging
# ---------------------------------------------------------------------------
# picarones run
# ---------------------------------------------------------------------------
@cli.command("run")
@click.option(
"--corpus", "-c",
required=True,
type=click.Path(exists=True, file_okay=False, resolve_path=True),
help="Dossier contenant les paires image / .gt.txt",
)
@click.option(
"--engines", "-e",
default="tesseract",
show_default=True,
help="Liste de moteurs séparés par des virgules (ex : tesseract,pero_ocr)",
)
@click.option(
"--output", "-o",
default="results.json",
show_default=True,
type=click.Path(resolve_path=True),
help="Fichier JSON de sortie",
)
@click.option(
"--lang", "-l",
default="fra",
show_default=True,
help="Code langue Tesseract (fra, lat, eng…)",
)
@click.option("--psm", default=6, show_default=True, help="Page Segmentation Mode Tesseract (0-13)")
@click.option("--no-progress", is_flag=True, default=False, help="Désactive la barre de progression")
@click.option("--verbose", "-v", is_flag=True, default=False, help="Mode verbeux")
@click.option(
"--fail-if-cer-above",
default=None,
type=float,
metavar="THRESHOLD",
help="Quitte avec code 1 si CER moyen > THRESHOLD (usage CI/CD)",
)
@click.option(
"--profile",
default="standard",
show_default=True,
type=click.Choice([
"minimal", "standard", "philological", "diagnostics",
"economics", "pipeline", "full",
]),
help=(
"Profil de calcul des métriques (chantier 2 post-Sprint 97). "
"'minimal' calcule uniquement CER/WER (rapide, bench massif). "
"'standard' active les 12 hooks historiques (défaut, rétrocompat). "
"Voir docs/profiles/ pour le détail."
),
)
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).
"""
_setup_logging(verbose)
from picarones.core.corpus import load_corpus_from_directory
from picarones.measurements.runner import run_benchmark
# 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(
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
if fail_if_cer_above is not None:
for entry in result.ranking():
if entry["mean_cer"] is not None and entry["mean_cer"] * 100 > fail_if_cer_above:
click.echo(
f"\nECHEC : {entry['engine']} CER={entry['mean_cer']*100:.2f}% "
f"> seuil {fail_if_cer_above:.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 _run_workflow(
*,
corpus: str,
engines: str,
output: str,
lang: str,
psm: int,
no_progress: bool,
verbose: bool,
profile: str,
workflow_label: str,
) -> None:
"""Implémentation commune des commandes ``run``, ``diagnose``,
``economics`` et ``edition``.
Les 4 commandes partagent le squelette : chargement corpus →
instanciation moteurs → ``run_benchmark(profile=...)`` → affichage
classement. Seul le profil par défaut et le message d'en-tête
diffèrent.
"""
_setup_logging(verbose)
from picarones.core.corpus import load_corpus_from_directory
from picarones.measurements.runner import run_benchmark
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(
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 écrits dans : {output}")
@cli.command("diagnose")
@click.option(
"--corpus", "-c", required=True,
type=click.Path(exists=True, file_okay=False, resolve_path=True),
help="Dossier contenant les paires image / .gt.txt",
)
@click.option(
"--engines", "-e", default="tesseract", show_default=True,
help="Liste de moteurs séparés par des virgules",
)
@click.option(
"--output", "-o", default="results_diagnose.json", show_default=True,
type=click.Path(resolve_path=True),
help="Fichier JSON de sortie",
)
@click.option("--lang", "-l", default="fra", show_default=True,
help="Code langue Tesseract")
@click.option("--psm", default=6, show_default=True,
help="Page Segmentation Mode Tesseract")
@click.option("--no-progress", is_flag=True, default=False,
help="Désactive la barre de progression")
@click.option("--verbose", "-v", is_flag=True, default=False,
help="Mode verbeux")
def diagnose_cmd(
corpus: str, engines: str, output: str, lang: str, psm: int,
no_progress: bool, verbose: bool,
) -> 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*.
"""
_run_workflow(
corpus=corpus, engines=engines, output=output,
lang=lang, psm=psm,
no_progress=no_progress, verbose=verbose,
profile="diagnostics",
workflow_label="diagnose",
)
@cli.command("economics")
@click.option(
"--corpus", "-c", required=True,
type=click.Path(exists=True, file_okay=False, resolve_path=True),
help="Dossier contenant les paires image / .gt.txt",
)
@click.option(
"--engines", "-e", default="tesseract", show_default=True,
help="Liste de moteurs séparés par des virgules",
)
@click.option(
"--output", "-o", default="results_economics.json", show_default=True,
type=click.Path(resolve_path=True),
help="Fichier JSON de sortie",
)
@click.option("--lang", "-l", default="fra", show_default=True,
help="Code langue Tesseract")
@click.option("--psm", default=6, show_default=True,
help="Page Segmentation Mode Tesseract")
@click.option("--no-progress", is_flag=True, default=False,
help="Désactive la barre de progression")
@click.option("--verbose", "-v", is_flag=True, default=False,
help="Mode verbeux")
def economics_cmd(
corpus: str, engines: str, output: str, lang: str, psm: int,
no_progress: bool, verbose: bool,
) -> 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 ensuite branchée.
"""
_run_workflow(
corpus=corpus, engines=engines, output=output,
lang=lang, psm=psm,
no_progress=no_progress, verbose=verbose,
profile="economics",
workflow_label="economics",
)
@cli.command("edition")
@click.option(
"--corpus", "-c", required=True,
type=click.Path(exists=True, file_okay=False, resolve_path=True),
help="Dossier contenant les paires image / .gt.txt",
)
@click.option(
"--engines", "-e", default="tesseract", show_default=True,
help="Liste de moteurs séparés par des virgules",
)
@click.option(
"--output", "-o", default="results_edition.json", show_default=True,
type=click.Path(resolve_path=True),
help="Fichier JSON de sortie",
)
@click.option("--lang", "-l", default="fra", show_default=True,
help="Code langue Tesseract")
@click.option("--psm", default=6, show_default=True,
help="Page Segmentation Mode Tesseract")
@click.option("--no-progress", is_flag=True, default=False,
help="Désactive la barre de progression")
@click.option("--verbose", "-v", is_flag=True, default=False,
help="Mode verbeux")
def edition_cmd(
corpus: str, engines: str, output: str, lang: str, psm: int,
no_progress: bool, verbose: bool,
) -> 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.
"""
_run_workflow(
corpus=corpus, engines=engines, output=output,
lang=lang, psm=psm,
no_progress=no_progress, verbose=verbose,
profile="philological",
workflow_label="edition",
)
# ---------------------------------------------------------------------------
# picarones compare (Sprint 28)
# ---------------------------------------------------------------------------
@cli.command("compare")
@click.argument("run_a", type=click.Path(exists=True, dir_okay=False, resolve_path=True))
@click.argument("run_b", type=click.Path(exists=True, dir_okay=False, resolve_path=True))
@click.option(
"--output", "-o",
default="comparaison.html",
show_default=True,
type=click.Path(resolve_path=True),
help="Fichier HTML de sortie pour le rapport de comparaison",
)
@click.option(
"--threshold",
default=0.005,
show_default=True,
type=float,
help="Seuil régression / amélioration (CER absolu, ex. 0.005 = 0,5 pp)",
)
@click.option(
"--label-a",
default="A",
show_default=True,
help="Étiquette du premier run dans le rapport",
)
@click.option(
"--label-b",
default="B",
show_default=True,
help="Étiquette du second run dans le rapport",
)
@click.option(
"--json", "json_only", is_flag=True, default=False,
help="Sortie JSON sur stdout au lieu du rapport HTML",
)
@click.option("--verbose", "-v", is_flag=True, default=False, help="Mode verbeux")
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.report.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)