"""``Artifact`` et ``ArtifactType``. Toute sortie d'une étape de pipeline est un **artefact traçable** : identifiant stable, type explicite, hash du contenu, provenance. L'enum ``ArtifactType`` a 9 valeurs canoniques + 3 aliases courts pour les types texte/ALTO/PAGE. Distinctions clés pour les vues d'évaluation : - **``RAW_TEXT`` vs ``CORRECTED_TEXT``** — un OCR brut et un texte corrigé par un LLM ont la même structure (string) mais des contrats différents : seul le second peut être projeté vers ``ALTO_XML`` via reconstruction. Cette distinction permet à ``TextView`` de comparer honnêtement les deux types dans la même vue tout en signalant à l'utilisateur que la projection a un sens différent. - **``ALTO_XML`` vs ``PAGE_XML`` vs ``CANONICAL_DOCUMENT``** — les trois formats spatiaux sont conceptuellement distincts ; un ``CANONICAL_DOCUMENT`` (markdown ou JSON canonique produit par un VLM) n'a pas de coordonnées et ne peut pas être projeté vers ``ALTO_XML`` sans étape de reconstruction. Anti-sur-ingénierie ------------------- ``Artifact`` ne porte que les champs nécessaires aux vues actuelles. Champs reportés (à ajouter quand un caller en a concrètement besoin) : ``media_type``, ``cost``, ``latency``, ``warnings``, ``model_version``, ``parent_artifact_ids`` (DAG d'origine). """ from __future__ import annotations import hashlib import re from enum import Enum from pydantic import BaseModel, ConfigDict, Field, field_validator class ArtifactType(str, Enum): """Type d'un artefact produit ou consommé par une étape de pipeline. Volontairement extensible : si une nouvelle vue (post-livraison) nécessite un type supplémentaire (ex : ``LAYOUT_HEATMAP``), on l'ajoute ici avec un commentaire indiquant la vue qui le consomme. Convention de nommage : ``UPPER_SNAKE_CASE`` pour le nom Python, ``lower_snake_case`` pour la valeur string sérialisée (utilisée dans les YAML de pipeline et dans les exports JSON). """ #: Image source (PNG, TIFF, JPEG). Entrée typique d'un OCR. IMAGE = "image" #: Texte brut produit par un OCR (avant correction LLM). RAW_TEXT = "raw_text" #: Texte corrigé par un LLM ou un module de post-correction. #: Distinct de ``RAW_TEXT`` parce que les vues d'évaluation #: doivent pouvoir signaler "ce texte a été modifié par un #: modèle après l'OCR" (impact sur over-normalisation, #: hallucination, fidélité philologique). CORRECTED_TEXT = "corrected_text" #: ALTO XML 4.x avec lignes, mots, coordonnées, ordre de lecture. ALTO_XML = "alto_xml" #: PAGE XML (PRIMA / Transkribus). PAGE_XML = "page_xml" #: Représentation canonique structurée sans coordonnées. #: Typique d'une sortie VLM (markdown, JSON canonique). Peut #: être reconstruit en ALTO via un module dédié, mais n'a pas #: nativement les coordonnées spatiales. CANONICAL_DOCUMENT = "canonical_document" #: Liste d'entités nommées (PER, LOC, ORG, DATE, MISC...). ENTITIES = "entities" #: Liste ordonnée d'IDs de régions documentaires définissant #: l'ordre de lecture (essentiel pour les manuscrits glosés et #: les journaux multi-colonnes). READING_ORDER = "reading_order" #: Alignement entre deux artefacts (typiquement ``RAW_TEXT`` → #: ``CORRECTED_TEXT`` produit par un module de post-correction #: ou de remapping ALTO). Utilisé par ``HallucinationView`` et #: ``error_absorption``. ALIGNMENT = "alignment" #: Confidences OCR au niveau token. Sidecar JSON produit par les #: adapters OCR qui exposent des scores natifs (Tesseract #: image_to_data, Pero transcription_confidence, Mistral OCR API #: confidences, Google Vision Word.confidence, Azure DI #: Word.confidence). #: #: Schéma JSON : ``{"tokens": [{"text": str, "confidence": #: float ∈ [0, 1]}], "extractor": str, "model_version": str | #: null}``. Consommé par les vues de calibration (ECE/MCE, #: reliability diagram). CONFIDENCES = "confidences" #: Aliases courts pour les types texte/ALTO/PAGE. Le mécanisme #: natif d'Enum Python rend ces noms équivalents aux canoniques : #: #: >>> ArtifactType.TEXT is ArtifactType.RAW_TEXT #: True #: #: Utilisés par les ``@register_metric(...)`` qui déclarent leurs #: signatures de manière concise. TEXT = "raw_text" ALTO = "alto_xml" PAGE = "page_xml" @classmethod def _missing_(cls, value: object) -> "ArtifactType | None": """Accepte les chaînes courtes (``"text"``, ``"alto"``, ``"page"``) en plus des valeurs canoniques. Permet aux specs YAML d'utiliser indifféremment l'un ou l'autre nom. """ short_map: dict[str, "ArtifactType"] = { "text": cls.RAW_TEXT, "alto": cls.ALTO_XML, "page": cls.PAGE_XML, } if not isinstance(value, str): return None return short_map.get(value) #: Map valeur canonique → valeur string courte. Permet aux dicts #: indexés par ``ArtifactType.value`` de présenter les **deux** clés : #: un caller qui cherche ``["raw_text"]`` et un caller qui cherche #: ``["text"]`` voient le même résultat. LEGACY_VALUE_ALIASES: dict[str, str] = { "raw_text": "text", "alto_xml": "alto", "page_xml": "page", } def expand_legacy_keys(d: dict) -> dict: """Pour chaque clé canonique de ``d`` qui a un alias legacy (cf. :data:`LEGACY_VALUE_ALIASES`), copie la valeur sous l'alias. Mute le dict en place ET le retourne (chainable). Idempotent : si la clé legacy existe déjà avec une valeur différente, on ne l'écrase pas (un override explicite gagne). """ for canonical, legacy in LEGACY_VALUE_ALIASES.items(): if canonical in d and legacy not in d: d[legacy] = d[canonical] return d def compute_content_hash(payload: bytes) -> str: """SHA-256 hex (64 chars) d'un payload binaire. Helper exposé au domain pour que les adapters puissent calculer un hash compatible avec ``Artifact.content_hash`` sans dépendre d'un détail d'implémentation. """ return hashlib.sha256(payload).hexdigest() # Validation des identifiants. On veut un ``id`` stable et # filesystem-safe (utilisable comme nom de fichier dans # ``ArtifactStore``) sans imposer un format trop restrictif. _ID_RE = re.compile(r"^[A-Za-z0-9_.\-:/]+$") class Artifact(BaseModel): """Une sortie traçable d'une étape de pipeline. Immuable (``frozen=True``) : un artefact ne change pas après création. Pour produire un artefact "modifié", une étape produit un nouvel ``Artifact`` distinct. Sérialisation déterministe : ``model_dump_json()`` produit les mêmes octets pour le même contenu (champs Pydantic ordonnés). Indispensable pour le cache d'artefacts. Attributs --------- id: Identifiant unique de l'artefact dans le contexte d'un run. Convention : ``"::"``, mais le caller est libre du format tant que c'est unique et que ``_ID_RE`` matche. document_id: ``DocumentRef.id`` du document auquel cet artefact appartient. type: Type de l'artefact (cf. ``ArtifactType``). uri: Chemin filesystem ou URI distant vers le contenu. ``None`` si l'artefact est stocké inline (cas des petits artefacts comme un texte court produit en mémoire). Le caller (typiquement ``ArtifactStore``, S7) est responsable de la résolution. content_hash: SHA-256 hex (64 chars) du contenu. ``None`` autorisé seulement pour les artefacts initiaux fournis par l'utilisateur (image, GT) qui n'ont pas encore été lus. Une fois calculé, immuable. produced_by_step: Nom de l'étape de pipeline qui a produit l'artefact. ``None`` pour les artefacts initiaux (entrées du pipeline, GT). provenance: ``ProvenanceRecord`` portant ``code_version`` et ``parameters_hash``. ``None`` pour les artefacts initiaux. """ model_config = ConfigDict(frozen=True, extra="forbid") id: str = Field(min_length=1, max_length=512) document_id: str = Field(min_length=1, max_length=256) type: ArtifactType uri: str | None = Field(default=None, max_length=2048) content_hash: str | None = Field(default=None, min_length=64, max_length=64) produced_by_step: str | None = Field(default=None, max_length=256) # ``provenance`` typé en str pour éviter import croisé pydantic # avec ProvenanceRecord ; remplacé par le vrai type via __init__ # plus bas. provenance: "ProvenanceRecord | None" = Field(default=None) @field_validator("id", "document_id") @classmethod def _validate_filesystem_safe_id(cls, v: str) -> str: if not _ID_RE.match(v): from picarones.domain.errors import ArtifactValidationError raise ArtifactValidationError( f"id invalide : {v!r}. " f"Doit matcher {_ID_RE.pattern!r} (alphanum + ``_.-:/``)." ) return v @field_validator("content_hash") @classmethod def _validate_hex_hash(cls, v: str | None) -> str | None: if v is None: return v try: int(v, 16) except ValueError: from picarones.domain.errors import ArtifactValidationError raise ArtifactValidationError( f"content_hash doit être hex SHA-256 64 chars : {v!r}" ) return v.lower() # Forward reference pour ``provenance``. from picarones.domain.provenance import ProvenanceRecord # noqa: E402 Artifact.model_rebuild() __all__ = [ "Artifact", "ArtifactType", "compute_content_hash", ]