Source code for cadrumo.domain.calculations.registry.schema_extraction
"""Extraction-profile schema contracts for registry revisions.
These :class:`~domain.calculations.registry.schema_base.RegistryModel`
definitions describe how a registry revision maps declaration PDFs, submitted
files, justificantes, and official workbooks into target casillas. The contract
records parser identity, accepted artefact kind, match strategy, confidence,
coverage, legal grounding, source grounding, and the explicit specimen /
round-trip flags that prevent silent provisional extraction.
The schema remains declarative authority only: it does not import parser
adapters, read artefacts, or run extraction. Registry validation checks local
reference closure and evidence sufficiency before
:class:`~domain.calculations.registry.schema.RegistrySnapshot` exposes the profiles to
application and adapter consumers.
Registry-build validation checks casilla, export-field, legal, and source
closure for each profile, together with artefact kind, dotted parser, bbox
anchor, specimen, and round-trip conformance for these contracts.
See Also:
:class:`~domain.calculations.registry.schema.ModeloRevision`
Revision record that owns committed extraction-profile rows.
:class:`~domain.calculations.registry.schema.RegistrySnapshot`
Validated filing-context view that exposes extraction profiles by id.
:class:`~domain.calculations.registry.schema_surfaces.CasillaDefinition`
Target casilla metadata each extraction target must reference.
:class:`~domain.calculations.registry.schema_references.LegalReference`
Legal evidence rows cited through ``legal_refs``.
:class:`~domain.calculations.registry.schema_references.SourceReference`
Source evidence rows cited through ``source_refs``.
"""
from __future__ import annotations
from decimal import Decimal
from enum import StrEnum
from typing import Annotated, Literal
from pydantic import BeforeValidator, Field, field_validator, model_validator
from ....core.casilla_id import CasillaId
from ....core.errors.hierarchy import pydantic_validation_boundary
from .errors import RegistryValidationError
from .ids import ExtractionProfileId
from .schema_base import LegalRefs, RegistryModel, SourceRefs, coerce_enum_member
from .schema_scalars import DecimalValue
[docs]
class ExtractionSurface(StrEnum):
"""The artefact an extraction profile reads casilla values out of."""
BORRADOR_PDF = "borrador_pdf"
DECLARACION_PDF = "declaracion_pdf"
JUSTIFICANTE_PDF = "justificante_pdf"
EXPORT_RECORD = "export_record"
OFFICIAL_WORKBOOK = "official_workbook"
ExtractionSurfaceField = Annotated[ExtractionSurface, BeforeValidator(coerce_enum_member(ExtractionSurface))]
"""Registry ``surface`` token hydrated into a member."""
[docs]
class ExtractionArtefactKind(StrEnum):
"""A stored artefact an extraction profile will accept as its input.
Deliberately NOT the same vocabulary as :class:`ExtractionSurface`, despite the
overlap: a surface says what the profile was designed to read, an artefact kind says
what a caller may hand it. ``submitted_file`` is an artefact and never a surface, and
``borrador_pdf`` is a surface with no artefact kind of its own.
"""
SUBMITTED_FILE = "submitted_file"
DECLARATION_PDF = "declaration_pdf"
JUSTIFICANTE_PDF = "justificante_pdf"
OFFICIAL_WORKBOOK = "official_workbook"
ExtractionArtefactKindField = Annotated[
ExtractionArtefactKind, BeforeValidator(coerce_enum_member(ExtractionArtefactKind))
]
"""Registry ``accepted_artefact_kinds`` token hydrated into a member."""
[docs]
class ExtractionMatchStrategy(StrEnum):
"""How a target casilla is located on the surface."""
NUMERIC_CASILLA = "numeric_casilla"
NAMED_LABEL = "named_label"
BBOX_ANCHORED = "bbox_anchored"
ExtractionMatchStrategyField = Annotated[
ExtractionMatchStrategy, BeforeValidator(coerce_enum_member(ExtractionMatchStrategy))
]
"""Registry ``match_strategy`` token hydrated into a member."""
[docs]
class ExtractionValueKind(StrEnum):
"""What kind of value the extractor expects to read."""
AMOUNT = "amount"
TEXT = "text"
ENUM = "enum"
ExtractionValueKindField = Annotated[ExtractionValueKind, BeforeValidator(coerce_enum_member(ExtractionValueKind))]
"""Registry ``value_kind`` token hydrated into a member."""
[docs]
class BboxValueOffset(StrEnum):
"""Where the value sits relative to its anchoring box number."""
LEFT_OF_NUMBER = "left_of_number"
ABOVE_NUMBER = "above_number"
RIGHT_OF_NUMBER = "right_of_number"
BboxValueOffsetField = Annotated[BboxValueOffset, BeforeValidator(coerce_enum_member(BboxValueOffset))]
"""Registry ``value_offset`` token hydrated into a member."""
[docs]
class ExtractionConfidence(StrEnum):
"""How far a profile's output may be trusted without review."""
STRICT = "strict"
REVIEW_REQUIRED = "review_required"
ExtractionConfidenceField = Annotated[ExtractionConfidence, BeforeValidator(coerce_enum_member(ExtractionConfidence))]
"""Registry ``confidence`` token hydrated into a member."""
[docs]
class ExtractionVerificationSource(StrEnum):
"""What evidence backs a profile's extraction accuracy.
``NOT_APPLICABLE`` and ``HISTORICAL_SUPPRESSION`` are distinct on purpose: the first
says verification does not apply, the second says it was deliberately withheld for a
historical revision. Collapsing them would report a suppression as an absence.
"""
REAL_AEAT_CORPUS_PDF = "real_aeat_corpus_pdf"
SYNTHETIC_FROM_AEAT_PUBLISHED_TEXT = "synthetic_from_aeat_published_text"
HISTORICAL_SUPPRESSION = "historical_suppression"
NOT_APPLICABLE = "not_applicable"
ExtractionVerificationSourceField = Annotated[
ExtractionVerificationSource,
BeforeValidator(coerce_enum_member(ExtractionVerificationSource)),
]
"""Registry ``verification_source`` token hydrated into a member."""
[docs]
class BboxAnchorSpec(RegistryModel):
r"""Spatial anchor configuration for the ``bbox_anchored`` extraction strategy."""
box_number_pattern: str
value_offset: BboxValueOffsetField
anchor_x_min: float | None = None
anchor_x_max: float | None = None
value_x_max: float | None = None
column_anchor: str | None = None
@model_validator(mode="after")
@pydantic_validation_boundary
def _anchor_x_range_is_not_inverted(self) -> BboxAnchorSpec:
if self.anchor_x_min is not None and self.anchor_x_max is not None and self.anchor_x_min > self.anchor_x_max:
raise RegistryValidationError("bbox anchor_x_min must not exceed anchor_x_max")
return self
[docs]
class ExtractionTargetDefinition(RegistryModel):
"""Per-target descriptor for a registry extraction profile.
``value_kind`` is a PARSE DIRECTIVE, not a declaration of the target's type.
It answers only "how is the captured token turned into a value?", and the
parser branches on it exactly one way: ``amount`` gets Spanish-decimal
parsing, the blank-box guard and the word-level positional pass, while
everything else carries the raw token through unchanged.
``enum`` is therefore a documentation refinement of ``text`` and makes no
enforceable claim about the value space. Nothing in this schema declares
the permitted members of any target's enumeration, so there is no set to
validate against and no consumer that would consult one. A reader meeting
``value_kind = "enum"`` should read "expected to be one of a closed set,
and nothing checks that", not "constrained".
The consequence is that ``value_kind`` and ``data_type`` answer different
questions and cannot be compared for coherence. A target declaring ``enum``
over a casilla whose ``data_type`` is ``text`` or ``integer`` is not
incoherent -- the first says how to read the printed token, the second says
what the casilla holds. Under this reading such targets are documentation
rather than defects, which is why none of them is "corrected" to ``text``.
Measured 2026-07-28 by walking every profile on every revision through the
authority, eight targets declare ``enum`` and all eight sit over ``text`` or
``integer``. Four are on the ``declaracion_pdf`` surface and are the ones
this ruling was raised against (Modelo 036 ``decl.event-kind``, Modelo 232
``decl.tipo-ejercicio`` in both revisions, Modelo 840
``decl.tipo-declaracion``). The other four are ``export_record`` targets on
Modelo 180, and they are if anything a stronger case for the same reading:
no consumer reads ``value_kind`` on that surface at all.
Making ``enum`` load-bearing later is a real option, but it requires
declaring the value space first -- a member list per target, grounded in
AEAT's published values -- because a constraint no one can enumerate cannot
be enforced. Until then the parser must treat ``enum`` and ``text``
identically, which is pinned by
``adapters.inbound.declaracion.tests.test_extraction_value_kind_contract``.
"""
casilla_id: CasillaId
match_strategy: ExtractionMatchStrategyField
value_kind: ExtractionValueKindField
label_pattern: str | None = None
bbox_anchor: BboxAnchorSpec | None = None
@model_validator(mode="after")
@pydantic_validation_boundary
def _field_strategy_consistency(self) -> ExtractionTargetDefinition:
if self.match_strategy == "named_label" and not self.label_pattern:
raise RegistryValidationError("named_label extraction targets require label_pattern")
if self.match_strategy == "numeric_casilla" and self.label_pattern is not None:
raise RegistryValidationError("numeric_casilla extraction targets must not define label_pattern")
if self.match_strategy == "bbox_anchored" and self.bbox_anchor is None:
raise RegistryValidationError("bbox_anchored extraction targets require bbox_anchor")
if self.match_strategy != "bbox_anchored" and self.bbox_anchor is not None:
raise RegistryValidationError("bbox_anchor must be None for non-bbox_anchored strategies")
return self
[docs]
class ExtractionProfileDefinition(RegistryModel):
"""Registry extraction profile for declaration/borrador/workbook artefacts."""
id: ExtractionProfileId
surface: ExtractionSurfaceField
artefact_kind: str
accepted_artefact_kinds: tuple[ExtractionArtefactKindField, ...] = Field(min_length=1)
parser: str
target_casillas: tuple[ExtractionTargetDefinition, ...] = Field(min_length=1)
confidence: ExtractionConfidenceField
provisional_pending_specimen: bool = False
corpus_round_trip_verified: bool = False
verification_source: ExtractionVerificationSourceField | None = None
min_coverage: DecimalValue = Field(gt=Decimal("0"), le=Decimal("1"))
failure_semantics: Literal["fail_hard"]
legal_refs: LegalRefs
source_refs: SourceRefs
@field_validator("accepted_artefact_kinds")
@classmethod
@pydantic_validation_boundary
def _accepted_artefact_kinds_unique(cls, value: tuple[str, ...]) -> tuple[str, ...]:
if len(set(value)) != len(value):
raise RegistryValidationError("extraction profile accepted_artefact_kinds entries must be unique")
return value
@field_validator("target_casillas")
@classmethod
@pydantic_validation_boundary
def _target_casillas_unique(
cls,
value: tuple[ExtractionTargetDefinition, ...],
) -> tuple[ExtractionTargetDefinition, ...]:
casilla_ids = [target.casilla_id for target in value]
if len(set(casilla_ids)) != len(casilla_ids):
raise RegistryValidationError("extraction profile target_casillas casilla_id entries must be unique")
return value
__all__ = ["BboxAnchorSpec", "ExtractionProfileDefinition", "ExtractionTargetDefinition"]