Spaces:
Sleeping
Sleeping
| """Registre typΓ© des hooks de mΓ©triques document-level et corpus-level. | |
| Chantier 2 du plan d'Γ©volution post-Sprint 97. | |
| Pourquoi ce module | |
| ------------------ | |
| Avant ce chantier, ``picarones.core.runner._compute_document_result`` | |
| contenait **11 imports tardifs codΓ©s en dur** vers | |
| ``picarones.core.confusion``, ``char_scores``, ``taxonomy``, ``structure``, | |
| ``image_quality``, ``line_metrics``, ``hallucination``, | |
| ``philological_runner``, ``searchability_runner``, | |
| ``numerical_sequences_runner``, ``readability_runner`` β chacun enrobΓ© | |
| dans un ``try/except Exception`` qui logue un warning. SymΓ©triquement, | |
| la phase d'agrΓ©gation contenait 11 fonctions ``_aggregate_*`` ou | |
| ``aggregate_*``. Ajouter une nouvelle mΓ©trique exigeait de patcher | |
| ``runner.py`` Γ deux endroits, ce qui rendait le fichier monolithique | |
| (1322 lignes) et fragile. | |
| Ce module centralise le mΓ©canisme : | |
| - **Profils** (``minimal`` / ``standard`` / ``philological`` / | |
| ``diagnostics`` / ``pipeline`` / ``full``) β l'utilisateur choisit | |
| quel sous-ensemble de mΓ©triques calculer selon son use case. | |
| - **Hooks document-level** (:class:`DocumentMetricHook`) enregistrΓ©s via | |
| :func:`register_document_metric` β fonctions appelΓ©es pour chaque | |
| document, leur retour remplit un attribut nommΓ© du ``DocumentResult``. | |
| - **AgrΓ©gateurs corpus-level** (:class:`CorpusMetricAggregator`) | |
| enregistrΓ©s via :func:`register_corpus_aggregator` β fonctions | |
| appelΓ©es une fois par moteur pour synthΓ©tiser les | |
| ``DocumentResult`` en attributs ``aggregated_*`` du ``EngineReport``. | |
| RΓ©trocompat stricte | |
| ------------------- | |
| Le profil ``standard`` (dΓ©faut) active exactement les 11 hooks et 11 | |
| agrΓ©gateurs historiques. Comportement, ordre d'exΓ©cution, gestion | |
| d'erreurs et octets de sortie : strictement identiques Γ avant le | |
| chantier 2. La preuve est dans ``tests/test_metric_hooks.py`` | |
| (cas-tests qui comparent profil ``standard`` vs comportement legacy | |
| sur fixtures). | |
| Comment ajouter un hook | |
| ----------------------- | |
| Pour ajouter une mΓ©trique document-level : | |
| from picarones.core.metric_hooks import ( | |
| register_document_metric, PROFILE_STANDARD, PROFILE_FULL, | |
| ) | |
| >>> | |
| @register_document_metric( | |
| name="my_metric", | |
| attribute="my_metric", # nom du champ dans DocumentResult | |
| profiles=(PROFILE_STANDARD, PROFILE_FULL), | |
| requires_success=True, | |
| ) | |
| def my_hook(*, ground_truth, hypothesis, image_path, corpus_lang, | |
| ocr_result): | |
| # Imports tardifs OK ici β le coΓ»t n'est payΓ© que si le hook | |
| # est dans le profil actif. | |
| from my_pkg import compute_my_metric | |
| return compute_my_metric(ground_truth, hypothesis) | |
| Pour un nouveau profil, l'ajouter Γ :data:`KNOWN_PROFILES` (et | |
| rΓ©fΓ©rencer dans la doc utilisateur ``docs/profiles/``). | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| from dataclasses import dataclass, field | |
| from typing import Any, Callable, Iterable, Optional | |
| logger = logging.getLogger(__name__) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Profils | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| PROFILE_MINIMAL = "minimal" | |
| """Profil le plus lΓ©ger : juste CER/WER de ``compute_metrics``. Aucun | |
| hook document-level ni agrΓ©gateur ne s'exΓ©cute. Cible : tests rapides | |
| ou bench massif oΓΉ seul le CER global compte.""" | |
| PROFILE_STANDARD = "standard" | |
| """Profil par dΓ©faut. Active tous les hooks et agrΓ©gateurs historiques | |
| de Picarones (Sprints 5 + 10 + 39+42 + 55-60 + 84-87). Comportement | |
| strictement identique au runner prΓ©-chantier-2.""" | |
| PROFILE_PHILOLOGICAL = "philological" | |
| """Profil orientΓ© Γ©dition critique : standard + emphase philologique. | |
| Aujourd'hui Γ©quivalent Γ standard ; rΓ©servΓ© pour des hooks futurs | |
| spΓ©cifiques aux corpus mΓ©diΓ©vaux et imprimΓ©s anciens.""" | |
| PROFILE_DIAGNOSTICS = "diagnostics" | |
| """Profil orientΓ© diagnostic : standard + leviers d'amΓ©lioration, | |
| prΓ©diction de complexitΓ©, baseline historique. RΓ©servΓ© pour des | |
| hooks futurs (chantiers 3-4).""" | |
| PROFILE_ECONOMICS = "economics" | |
| """Profil orientΓ© dΓ©cision budget : minimal + mΓ©triques Γ©conomiques | |
| (throughput effectif, coΓ»t marginal). RΓ©servΓ© pour des hooks futurs.""" | |
| PROFILE_PIPELINE = "pipeline" | |
| """Profil pour les benchmarks de pipelines composΓ©es (axe B). Active | |
| les hooks pertinents aux jonctions du DAG. RΓ©servΓ© pour des hooks | |
| futurs spΓ©cifiques aux pipelines.""" | |
| PROFILE_FULL = "full" | |
| """Profil exhaustif : tous les hooks de tous les profils. CoΓ»t | |
| maximal mais reproductibilitΓ© scientifique maximale.""" | |
| KNOWN_PROFILES: frozenset[str] = frozenset({ | |
| PROFILE_MINIMAL, | |
| PROFILE_STANDARD, | |
| PROFILE_PHILOLOGICAL, | |
| PROFILE_DIAGNOSTICS, | |
| PROFILE_ECONOMICS, | |
| PROFILE_PIPELINE, | |
| PROFILE_FULL, | |
| }) | |
| def validate_profile(profile: str) -> None: | |
| """Lève ``ValueError`` si ``profile`` n'est pas connu. | |
| Le runner appelle cette fonction au dΓ©marrage pour rejeter | |
| rapidement une faute de frappe utilisateur (``--profile philolagic``). | |
| """ | |
| if profile not in KNOWN_PROFILES: | |
| raise ValueError( | |
| f"profil inconnu : {profile!r}. " | |
| f"Profils valides : {sorted(KNOWN_PROFILES)}" | |
| ) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Modèles de hook | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class DocumentMetricHook: | |
| """Hook calculΓ© pour chaque document. | |
| Attributs | |
| --------- | |
| name: | |
| Identifiant lisible utilisΓ© dans les logs et les warnings. | |
| attribute: | |
| Nom du champ du :class:`DocumentResult` Γ remplir (par | |
| exemple ``"confusion_matrix"`` ou ``"taxonomy"``). Doit | |
| correspondre exactement Γ un attribut existant β le runner | |
| passe le rΓ©sultat via ``setattr``. | |
| profiles: | |
| Ensemble des profils dans lesquels ce hook s'active. | |
| func: | |
| Fonction calculant la mΓ©trique. Signature attendue : | |
| ``func(*, ground_truth, hypothesis, image_path, corpus_lang, | |
| ocr_result) -> Any``. Tous les arguments sont passΓ©s en | |
| keyword pour que les hooks puissent ignorer ceux qu'ils | |
| n'utilisent pas avec ``**_``. | |
| requires_success: | |
| Si ``True``, le hook n'est appelΓ© que quand | |
| ``ocr_result.success`` (texte hyp non-vide). Γvite de gaspiller | |
| du temps sur des documents en erreur OCR. | |
| requires_token_confidences: | |
| Si ``True``, le hook n'est appelΓ© que quand | |
| ``ocr_result.token_confidences`` est non-vide. RΓ©servΓ© Γ la | |
| calibration (Sprint 42). | |
| """ | |
| name: str | |
| attribute: str | |
| profiles: frozenset[str] | |
| func: Callable[..., Any] | |
| requires_success: bool = False | |
| requires_token_confidences: bool = False | |
| class CorpusMetricAggregator: | |
| """AgrΓ©gateur calculΓ© une fois par moteur sur tous les documents. | |
| Attributs | |
| --------- | |
| name: | |
| Identifiant lisible. | |
| attribute: | |
| Nom du champ du :class:`EngineReport` Γ remplir (par | |
| exemple ``"aggregated_confusion"``). | |
| profiles: | |
| Profils dans lesquels l'agrΓ©gateur s'active. | |
| func: | |
| ``func(document_results: list[DocumentResult]) -> Any``. | |
| """ | |
| name: str | |
| attribute: str | |
| profiles: frozenset[str] | |
| func: Callable[..., Any] | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Registres globaux | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _DOCUMENT_HOOKS: list[DocumentMetricHook] = [] | |
| _CORPUS_AGGREGATORS: list[CorpusMetricAggregator] = [] | |
| def _check_profiles(profiles: Iterable[str]) -> frozenset[str]: | |
| frozen = frozenset(profiles) | |
| unknown = frozen - KNOWN_PROFILES | |
| if unknown: | |
| raise ValueError( | |
| f"profils inconnus : {sorted(unknown)}. " | |
| f"Profils valides : {sorted(KNOWN_PROFILES)}" | |
| ) | |
| return frozen | |
| def register_document_metric( | |
| *, | |
| name: str, | |
| attribute: str, | |
| profiles: Iterable[str], | |
| requires_success: bool = False, | |
| requires_token_confidences: bool = False, | |
| ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: | |
| """DΓ©corateur d'enregistrement d'un hook document-level.""" | |
| profiles_set = _check_profiles(profiles) | |
| def decorator(func: Callable[..., Any]) -> Callable[..., Any]: | |
| # Idempotence : si le mΓͺme func est rΓ©-enregistrΓ© (rΓ©-import | |
| # du module en test), on ignore silencieusement. Si un autre | |
| # func tente le mΓͺme ``name``, on lΓ¨ve. | |
| for existing in _DOCUMENT_HOOKS: | |
| if existing.name == name: | |
| if existing.func is func: | |
| return func | |
| raise ValueError( | |
| f"hook document '{name}' dΓ©jΓ enregistrΓ© par " | |
| f"{existing.func.__module__}.{existing.func.__qualname__}" | |
| ) | |
| _DOCUMENT_HOOKS.append(DocumentMetricHook( | |
| name=name, | |
| attribute=attribute, | |
| profiles=profiles_set, | |
| func=func, | |
| requires_success=requires_success, | |
| requires_token_confidences=requires_token_confidences, | |
| )) | |
| return func | |
| return decorator | |
| def register_corpus_aggregator( | |
| *, | |
| name: str, | |
| attribute: str, | |
| profiles: Iterable[str], | |
| ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: | |
| """DΓ©corateur d'enregistrement d'un agrΓ©gateur corpus-level.""" | |
| profiles_set = _check_profiles(profiles) | |
| def decorator(func: Callable[..., Any]) -> Callable[..., Any]: | |
| for existing in _CORPUS_AGGREGATORS: | |
| if existing.name == name: | |
| if existing.func is func: | |
| return func | |
| raise ValueError( | |
| f"agrΓ©gateur corpus '{name}' dΓ©jΓ enregistrΓ© par " | |
| f"{existing.func.__module__}.{existing.func.__qualname__}" | |
| ) | |
| _CORPUS_AGGREGATORS.append(CorpusMetricAggregator( | |
| name=name, | |
| attribute=attribute, | |
| profiles=profiles_set, | |
| func=func, | |
| )) | |
| return func | |
| return decorator | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # SΓ©lection + exΓ©cution selon profil | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def select_document_hooks(profile: str) -> list[DocumentMetricHook]: | |
| """Retourne les hooks document-level actifs pour ``profile``. | |
| L'ordre d'enregistrement est prΓ©servΓ© pour garantir que les | |
| warnings et logs apparaissent dans le mΓͺme ordre qu'avant le | |
| chantier 2 (cf. test de rΓ©trocompat). | |
| """ | |
| validate_profile(profile) | |
| return [h for h in _DOCUMENT_HOOKS if profile in h.profiles] | |
| def select_corpus_aggregators(profile: str) -> list[CorpusMetricAggregator]: | |
| """Retourne les agrΓ©gateurs corpus-level actifs pour ``profile``.""" | |
| validate_profile(profile) | |
| return [a for a in _CORPUS_AGGREGATORS if profile in a.profiles] | |
| def run_document_hooks( | |
| profile: str, | |
| *, | |
| ground_truth: str, | |
| hypothesis: str, | |
| image_path: str, | |
| corpus_lang: str, | |
| ocr_result: Any, | |
| ) -> dict[str, Any]: | |
| """ExΓ©cute tous les hooks document-level actifs pour ``profile``. | |
| Retourne un dict ``{attribute_name: value}`` que le runner peut | |
| appliquer au ``DocumentResult`` via ``setattr`` ou ``**kwargs``. | |
| PrΓ©-conditions : | |
| - les hooks Γ ``requires_success=True`` ne tournent que si | |
| ``ocr_result.success`` ; | |
| - les hooks Γ ``requires_token_confidences=True`` ne tournent | |
| que si ``ocr_result.token_confidences`` est non vide. | |
| Toute exception levΓ©e par un hook est loggΓ©e en warning et | |
| le hook est sautΓ© (``attribute`` absent du dict retournΓ©). Aucun | |
| hook ne fait jamais Γ©chouer le calcul des autres β discipline | |
| historique prΓ©servΓ©e. | |
| """ | |
| out: dict[str, Any] = {} | |
| for hook in select_document_hooks(profile): | |
| if hook.requires_success and not getattr(ocr_result, "success", False): | |
| continue | |
| if hook.requires_token_confidences and not getattr( | |
| ocr_result, "token_confidences", None, | |
| ): | |
| continue | |
| try: | |
| value = hook.func( | |
| ground_truth=ground_truth, | |
| hypothesis=hypothesis, | |
| image_path=image_path, | |
| corpus_lang=corpus_lang, | |
| ocr_result=ocr_result, | |
| ) | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning( | |
| "[%s] fonctionnalitΓ© dΓ©gradΓ©e : %s", hook.name, exc, | |
| ) | |
| continue | |
| if value is not None: | |
| out[hook.attribute] = value | |
| return out | |
| def run_corpus_aggregators( | |
| profile: str, | |
| document_results: list, | |
| ) -> dict[str, Any]: | |
| """ExΓ©cute tous les agrΓ©gateurs corpus-level pour ``profile``. | |
| Retourne un dict ``{attribute_name: value}`` Γ appliquer au | |
| ``EngineReport``. Comme pour les hooks doc-level, une exception | |
| dans un agrΓ©gateur est loggΓ©e et l'agrΓ©gateur sautΓ©. | |
| """ | |
| out: dict[str, Any] = {} | |
| for agg in select_corpus_aggregators(profile): | |
| try: | |
| value = agg.func(document_results) | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning( | |
| "[aggregate_%s] fonctionnalitΓ© dΓ©gradΓ©e : %s", agg.name, exc, | |
| ) | |
| continue | |
| if value is not None: | |
| out[agg.attribute] = value | |
| return out | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Helpers test-only | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _reset_for_tests() -> None: | |
| """Vide les registres globaux. **RΓ©servΓ© aux tests** β dΓ©sactive | |
| toutes les mΓ©triques en production.""" | |
| _DOCUMENT_HOOKS.clear() | |
| _CORPUS_AGGREGATORS.clear() | |
| def _all_document_hook_names() -> list[str]: | |
| return [h.name for h in _DOCUMENT_HOOKS] | |
| def _all_corpus_aggregator_names() -> list[str]: | |
| return [a.name for a in _CORPUS_AGGREGATORS] | |
| __all__ = [ | |
| "PROFILE_MINIMAL", | |
| "PROFILE_STANDARD", | |
| "PROFILE_PHILOLOGICAL", | |
| "PROFILE_DIAGNOSTICS", | |
| "PROFILE_ECONOMICS", | |
| "PROFILE_PIPELINE", | |
| "PROFILE_FULL", | |
| "KNOWN_PROFILES", | |
| "validate_profile", | |
| "DocumentMetricHook", | |
| "CorpusMetricAggregator", | |
| "register_document_metric", | |
| "register_corpus_aggregator", | |
| "select_document_hooks", | |
| "select_corpus_aggregators", | |
| "run_document_hooks", | |
| "run_corpus_aggregators", | |
| ] | |