Spaces:
Sleeping
Sleeping
File size: 15,278 Bytes
51baf52 d19d9b9 51baf52 d19d9b9 51baf52 d19d9b9 51baf52 d19d9b9 51baf52 d19d9b9 51baf52 162c559 51baf52 d19d9b9 51baf52 fb1d823 51baf52 162c559 51baf52 d19d9b9 51baf52 d19d9b9 7d68969 d19d9b9 51baf52 162c559 51baf52 fb1d823 51baf52 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 | """``RunSpec`` — déclaration YAML d'un run benchmark.
Sprint A14-S24 / S39 du rewrite ciblé.
Format qui décrit un run complet en YAML : corpus, pipelines
hétérogènes (potentiellement avec DAG branchant), vues canoniques à
appliquer, sortie HTML. Permet à l'utilisateur BnF de lancer un
benchmark via la CLI sans écrire de Python.
Format
------
::
corpus_zip: ./bnf.zip # OU corpus_dir
corpus_dir: ./extracted/ # mutuellement exclusif
corpus_name: bnf_xviiie # optionnel (défaut : stem)
corpus_metadata:
language: fr
period: early_modern
pipelines:
- name: ocr_then_correct
initial_inputs: [image]
# Sprint S39 : output symbolique préféré pour le texte.
# Référence un (step_id).(output_type) qui sera utilisé par
# les vues TextView / SearchView quand plusieurs steps
# produisent du RAW_TEXT. Optionnel.
preferred_text_output: corrector.corrected_text
steps:
- id: ocr
adapter_class: my_pkg.adapters.TesseractAdapter
adapter_kwargs: {lang: fra}
input_types: [image]
output_types: [raw_text]
- id: corrector
adapter_class: my_pkg.adapters.LLMCorrector
adapter_kwargs: {model: gpt-4o}
input_types: [raw_text]
output_types: [corrected_text]
# Sprint S39 : DAG branchant. Si plusieurs steps
# produisent le même type, on désigne explicitement la
# source. Sans inputs_from : dernier producteur.
inputs_from:
raw_text: ocr
views: [text_final, searchability] # noms canoniques
output_dir: ./runs/r1
report_html: ./runs/r1/rapport.html # optionnel
report_lang: fr
code_version: "1.0.0-rewrite"
Conventions
-----------
- ``corpus_zip`` ou ``corpus_dir`` est requis (pas les deux).
- ``views`` accepte uniquement les noms canoniques :
``text_final``, ``alto_documentary``, ``searchability``. Le
caller qui veut des vues custom passe par l'API Python directe.
- ``adapter_class`` est un dotted path Python. La classe doit être
importable au moment du run (l'utilisateur installe ses propres
packages dans le venv courant).
- ``adapter_kwargs`` est passé tel quel au constructeur.
- ``inputs_from`` (S39) : map ``ArtifactType → step_id`` qui désigne
explicitement la source d'un input. ``__initial__`` désigne les
entrées initiales du runner. Sans ``inputs_from``, l'executor
prend le dernier producteur de chaque type.
- ``preferred_text_output`` (S39) : référence symbolique
``step_id.output_type`` qui désigne quelle sortie de pipeline est
préférée pour les vues textuelles (utile quand plusieurs steps
produisent du RAW_TEXT ou du CORRECTED_TEXT). Optionnel.
Anti-sur-ingénierie
-------------------
- Pas de templating Jinja2 dans le YAML (variables d'env, includes).
Si un caller veut composer plusieurs YAMLs, il les concatène en
Python.
- Pas de schéma JSON publié — pydantic est l'autorité. Le format
évoluera avec le rewrite ; la stabilité sera tagguée à la
livraison BnF.
- Pas de validation des dépendances de package — si la classe n'est
pas importable au runtime, on échoue lisiblement.
"""
from __future__ import annotations
import importlib
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, model_validator
from picarones.domain.artifacts import ArtifactType
from picarones.domain.errors import PicaronesError
#: Vues canoniques supportées par la CLI.
CANONICAL_VIEW_NAMES: frozenset[str] = frozenset({
"text_final",
"alto_documentary",
"searchability",
})
# ──────────────────────────────────────────────────────────────────────
# Schéma pydantic
# ──────────────────────────────────────────────────────────────────────
class StepSpec(BaseModel):
"""Description d'un step de pipeline dans la spec YAML."""
model_config = ConfigDict(extra="forbid")
id: str = Field(min_length=1, max_length=128)
adapter_class: str = Field(
min_length=1, max_length=512,
description="Dotted path Python vers la classe adapter.",
)
adapter_kwargs: dict[str, Any] = Field(default_factory=dict)
input_types: tuple[ArtifactType, ...] = Field(...)
output_types: tuple[ArtifactType, ...] = Field(...)
inputs_from: dict[ArtifactType, str] = Field(
default_factory=dict,
description=(
"Sprint S39 — DAG branchant : map ``ArtifactType → step_id`` "
"qui désigne explicitement la source d'un input. "
"``__initial__`` pour les entrées initiales du runner. "
"Sans ``inputs_from``, l'executor prend le dernier producteur."
),
)
class PipelineSpecYaml(BaseModel):
"""Description d'une pipeline dans la spec YAML."""
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=128)
initial_inputs: tuple[ArtifactType, ...] = Field(...)
steps: tuple[StepSpec, ...] = Field(min_length=1)
preferred_text_output: str | None = Field(
default=None,
max_length=256,
description=(
"Sprint S39 — référence ``step_id.output_type`` qui désigne "
"quelle sortie de la pipeline est préférée pour les vues "
"textuelles (utile quand plusieurs steps produisent du "
"RAW_TEXT ou CORRECTED_TEXT). Format ``<step_id>.<artifact_type>`` "
"(ex : ``corrector.corrected_text``). Optionnel — sans, les "
"vues prennent la dernière sortie textuelle observée."
),
)
@model_validator(mode="after")
def _validate_preferred_text_output(self) -> "PipelineSpecYaml":
"""Vérifie que ``preferred_text_output`` (si défini) référence
un step existant dont les ``output_types`` contiennent le
type cité."""
ref = self.preferred_text_output
if ref is None:
return self
if "." not in ref:
raise ValueError(
f"preferred_text_output {ref!r} : format attendu "
"``step_id.output_type`` (ex : ``corrector.corrected_text``).",
)
step_id, _, output_type_value = ref.partition(".")
if not step_id or not output_type_value:
raise ValueError(
f"preferred_text_output {ref!r} : step_id ou output_type vide.",
)
# Vérifier que le step existe.
target_step = next(
(s for s in self.steps if s.id == step_id), None,
)
if target_step is None:
raise ValueError(
f"preferred_text_output {ref!r} : step "
f"{step_id!r} introuvable dans la pipeline "
f"{self.name!r}.",
)
# Vérifier que le step produit bien ce type.
try:
output_enum = ArtifactType(output_type_value)
except ValueError as exc:
raise ValueError(
f"preferred_text_output {ref!r} : "
f"output_type {output_type_value!r} inconnu.",
) from exc
if output_enum not in target_step.output_types:
raise ValueError(
f"preferred_text_output {ref!r} : step {step_id!r} "
f"ne produit pas {output_type_value!r} "
f"(produit : {[t.value for t in target_step.output_types]}).",
)
return self
@model_validator(mode="after")
def _validate_inputs_from(self) -> "PipelineSpecYaml":
"""Vérifie que chaque ``inputs_from[type] = ref`` désigne soit
``__initial__``, soit un step antérieur qui produit le type."""
from picarones.domain.pipeline_spec import INITIAL_STEP_ID
# Set des steps déjà vus pour vérifier l'antériorité.
seen_step_ids: set[str] = set()
# Map des outputs produits par chaque step (pour vérification
# des types).
outputs_by_step: dict[str, set[ArtifactType]] = {}
for step in self.steps:
for input_type, source in step.inputs_from.items():
if source == INITIAL_STEP_ID:
if input_type not in self.initial_inputs:
raise ValueError(
f"step {step.id!r} : inputs_from[{input_type.value!r}] "
f"= {INITIAL_STEP_ID!r} mais ce type n'est pas dans "
f"initial_inputs (= {[t.value for t in self.initial_inputs]}).",
)
continue
if source not in seen_step_ids:
raise ValueError(
f"step {step.id!r} : inputs_from[{input_type.value!r}] "
f"= {source!r} ne désigne pas une étape antérieure "
f"connue (déjà vues : {sorted(seen_step_ids)}).",
)
if input_type not in outputs_by_step.get(source, set()):
raise ValueError(
f"step {step.id!r} : inputs_from[{input_type.value!r}] "
f"= {source!r} mais cette étape ne produit pas ce type.",
)
seen_step_ids.add(step.id)
outputs_by_step[step.id] = set(step.output_types)
return self
class RunSpec(BaseModel):
"""Déclaration complète d'un run benchmark.
Tous les chemins (``corpus_zip``, ``corpus_dir``, ``output_dir``,
``report_html``) sont relatifs au répertoire courant au moment de
l'invocation CLI, ou absolus. Pas de résolution magique
(``$HOME``, env vars) — le caller passe ce qu'il veut voir.
"""
model_config = ConfigDict(extra="forbid")
corpus_zip: str | None = Field(default=None, max_length=2048)
corpus_dir: str | None = Field(default=None, max_length=2048)
corpus_name: str | None = Field(default=None, max_length=128)
corpus_metadata: dict[str, str] = Field(default_factory=dict)
pipelines: tuple[PipelineSpecYaml, ...] = Field(min_length=1)
views: tuple[str, ...] = Field(min_length=1)
output_dir: str = Field(min_length=1, max_length=2048)
report_html: str | None = Field(default=None, max_length=2048)
report_lang: str = Field(default="fr")
code_version: str = Field(default="0.0.0-unset", max_length=128)
@model_validator(mode="after")
def _validate_corpus_source(self) -> "RunSpec":
if (self.corpus_zip is None) == (self.corpus_dir is None):
raise ValueError(
"RunSpec : il faut renseigner exactement l'un de "
"``corpus_zip`` ou ``corpus_dir`` (pas les deux, pas "
"aucun).",
)
return self
@model_validator(mode="after")
def _validate_views_are_canonical(self) -> "RunSpec":
unknown = [v for v in self.views if v not in CANONICAL_VIEW_NAMES]
if unknown:
raise ValueError(
f"RunSpec : vue(s) inconnue(s) {unknown!r}. "
f"Seules les vues canoniques sont supportées par la "
f"CLI : {sorted(CANONICAL_VIEW_NAMES)}.",
)
return self
@model_validator(mode="after")
def _validate_unique_pipeline_names(self) -> "RunSpec":
names = [p.name for p in self.pipelines]
if len(set(names)) != len(names):
raise ValueError(
f"RunSpec : noms de pipeline dupliqués dans {names!r}.",
)
return self
# ──────────────────────────────────────────────────────────────────────
# Loader YAML + résolution dotted path
# ──────────────────────────────────────────────────────────────────────
class RunSpecLoadError(PicaronesError):
"""Échec de chargement / validation d'une spec YAML."""
def load_run_spec_from_yaml(yaml_text: str) -> RunSpec:
"""Parse + valide une chaîne YAML.
Raises
------
RunSpecLoadError
Si le YAML est mal formé, si pydantic rejette le schéma, ou
si une contrainte du model_validator échoue.
"""
import yaml
try:
data = yaml.safe_load(yaml_text)
except yaml.YAMLError as exc:
raise RunSpecLoadError(f"YAML mal formé : {exc}") from exc
if data is None:
raise RunSpecLoadError(
"RunSpec : YAML vide (attendu un mapping racine).",
)
if not isinstance(data, dict):
raise RunSpecLoadError(
f"RunSpec : YAML racine doit être un mapping, reçu "
f"{type(data).__name__}.",
)
try:
return RunSpec.model_validate(data)
except Exception as exc: # noqa: BLE001 — re-typer en exception métier
raise RunSpecLoadError(f"RunSpec invalide : {exc}") from exc
def resolve_adapter_class(dotted_path: str) -> type:
"""Importe et retourne la classe désignée par un dotted path.
Format attendu : ``module.sub.ClassName``. ``module.sub:ClassName``
accepté aussi (séparateur ``:`` style entry-point).
Raises
------
RunSpecLoadError
Si le module est introuvable, si l'attribut n'existe pas,
ou si l'attribut n'est pas une classe instanciable.
"""
if not dotted_path or "." not in dotted_path and ":" not in dotted_path:
raise RunSpecLoadError(
f"adapter_class invalide : {dotted_path!r} — attendu "
f"``module.sub.ClassName`` ou ``module.sub:ClassName``.",
)
if ":" in dotted_path:
module_path, _, class_name = dotted_path.rpartition(":")
else:
module_path, _, class_name = dotted_path.rpartition(".")
if not module_path or not class_name:
raise RunSpecLoadError(
f"adapter_class mal formé : {dotted_path!r}.",
)
try:
module = importlib.import_module(module_path)
except ImportError as exc:
raise RunSpecLoadError(
f"Module introuvable pour {dotted_path!r} : {exc}",
) from exc
try:
cls = getattr(module, class_name)
except AttributeError as exc:
raise RunSpecLoadError(
f"Attribut {class_name!r} absent du module "
f"{module_path!r}.",
) from exc
if not isinstance(cls, type):
raise RunSpecLoadError(
f"adapter_class {dotted_path!r} n'est pas une classe "
f"(c'est un {type(cls).__name__}).",
)
return cls
__all__ = [
"CANONICAL_VIEW_NAMES",
"PipelineSpecYaml",
"RunSpec",
"RunSpecLoadError",
"StepSpec",
"load_run_spec_from_yaml",
"resolve_adapter_class",
]
|