Spaces:
Sleeping
Sprint 1: complete domain models — canonical document, geometry, provenance, readiness
Browse filesDefine all Pydantic v2 domain models for the canonical-first architecture:
- status.py: 12 enums (GeometryStatus, EvidenceType, BlockRole, NonTextKind,
InputType, ReadinessLevel, MissingCapability, OverlayLevel, etc.)
- geometry.py: Point, BBox, Baseline, ClipRect, Geometry (bbox + polygon +
status), GeometryContext — bbox convention is [x, y, width, height]
- provenance.py: Provenance with conditional validation (provider_native
cannot have derived_from)
- readiness.py: AltoReadiness, PageXmlReadiness, ExportEligibility,
DocumentReadiness with consistency validators
- canonical_document.py: Source, Hyphenation, Word, TextLine, TextRegion,
NonTextRegion, Page, Audit, CanonicalDocument — full hierarchy with
metadata field at every level for future extensibility
- raw_payload.py: RawProviderPayload (opaque container for provider output)
- viewer_projection.py: OverlayItem, InspectionData, ViewerProjection
- __init__.py: public API re-exporting all models
All models are frozen (immutable). 117 tests covering validation, rejection
of invalid data, conditional logic, JSON round-trips, and JSON Schema
generation. The spec example from the architecture document validates.
https://claude.ai/code/session_01Cuzvc9Pjfo5u46eT3ta2Cg
- src/app/domain/models/__init__.py +97 -0
- src/app/domain/models/canonical_document.py +275 -0
- src/app/domain/models/geometry.py +128 -0
- src/app/domain/models/provenance.py +33 -0
- src/app/domain/models/raw_payload.py +34 -0
- src/app/domain/models/readiness.py +68 -0
- src/app/domain/models/status.py +126 -0
- src/app/domain/models/viewer_projection.py +79 -0
- tests/unit/test_canonical_document.py +627 -0
- tests/unit/test_geometry.py +143 -0
- tests/unit/test_provenance.py +106 -0
- tests/unit/test_raw_payload.py +82 -0
- tests/unit/test_readiness.py +95 -0
- tests/unit/test_viewer_projection.py +156 -0
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Domain models — public API.
|
| 2 |
+
|
| 3 |
+
Import from here rather than from individual modules:
|
| 4 |
+
|
| 5 |
+
from src.app.domain.models import CanonicalDocument, Word, Page, Geometry
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from src.app.domain.models.canonical_document import (
|
| 9 |
+
Audit,
|
| 10 |
+
CanonicalDocument,
|
| 11 |
+
Hyphenation,
|
| 12 |
+
NonTextRegion,
|
| 13 |
+
Page,
|
| 14 |
+
Source,
|
| 15 |
+
TextLine,
|
| 16 |
+
TextRegion,
|
| 17 |
+
Word,
|
| 18 |
+
)
|
| 19 |
+
from src.app.domain.models.geometry import (
|
| 20 |
+
BBox,
|
| 21 |
+
Baseline,
|
| 22 |
+
ClipRect,
|
| 23 |
+
Geometry,
|
| 24 |
+
GeometryContext,
|
| 25 |
+
Point,
|
| 26 |
+
PolygonPoints,
|
| 27 |
+
)
|
| 28 |
+
from src.app.domain.models.provenance import Provenance
|
| 29 |
+
from src.app.domain.models.raw_payload import RawProviderPayload
|
| 30 |
+
from src.app.domain.models.readiness import (
|
| 31 |
+
AltoReadiness,
|
| 32 |
+
DocumentReadiness,
|
| 33 |
+
ExportEligibility,
|
| 34 |
+
PageXmlReadiness,
|
| 35 |
+
)
|
| 36 |
+
from src.app.domain.models.status import (
|
| 37 |
+
BlockRole,
|
| 38 |
+
CoordinateOrigin,
|
| 39 |
+
EvidenceType,
|
| 40 |
+
GeometryStatus,
|
| 41 |
+
InputType,
|
| 42 |
+
MissingCapability,
|
| 43 |
+
NonTextKind,
|
| 44 |
+
OverlayLevel,
|
| 45 |
+
ReadinessLevel,
|
| 46 |
+
Unit,
|
| 47 |
+
)
|
| 48 |
+
from src.app.domain.models.viewer_projection import (
|
| 49 |
+
InspectionData,
|
| 50 |
+
OverlayItem,
|
| 51 |
+
ViewerProjection,
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
__all__ = [
|
| 55 |
+
# Enums
|
| 56 |
+
"BlockRole",
|
| 57 |
+
"CoordinateOrigin",
|
| 58 |
+
"EvidenceType",
|
| 59 |
+
"GeometryStatus",
|
| 60 |
+
"InputType",
|
| 61 |
+
"MissingCapability",
|
| 62 |
+
"NonTextKind",
|
| 63 |
+
"OverlayLevel",
|
| 64 |
+
"ReadinessLevel",
|
| 65 |
+
"Unit",
|
| 66 |
+
# Geometry
|
| 67 |
+
"BBox",
|
| 68 |
+
"Baseline",
|
| 69 |
+
"ClipRect",
|
| 70 |
+
"Geometry",
|
| 71 |
+
"GeometryContext",
|
| 72 |
+
"Point",
|
| 73 |
+
"PolygonPoints",
|
| 74 |
+
# Provenance
|
| 75 |
+
"Provenance",
|
| 76 |
+
# Readiness
|
| 77 |
+
"AltoReadiness",
|
| 78 |
+
"DocumentReadiness",
|
| 79 |
+
"ExportEligibility",
|
| 80 |
+
"PageXmlReadiness",
|
| 81 |
+
# Canonical document
|
| 82 |
+
"Audit",
|
| 83 |
+
"CanonicalDocument",
|
| 84 |
+
"Hyphenation",
|
| 85 |
+
"NonTextRegion",
|
| 86 |
+
"Page",
|
| 87 |
+
"Source",
|
| 88 |
+
"TextLine",
|
| 89 |
+
"TextRegion",
|
| 90 |
+
"Word",
|
| 91 |
+
# Raw payload
|
| 92 |
+
"RawProviderPayload",
|
| 93 |
+
# Viewer
|
| 94 |
+
"InspectionData",
|
| 95 |
+
"OverlayItem",
|
| 96 |
+
"ViewerProjection",
|
| 97 |
+
]
|
|
@@ -0,0 +1,275 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CanonicalDocument — the business truth of the system.
|
| 2 |
+
|
| 3 |
+
This is the central model. Everything flows through it:
|
| 4 |
+
Provider output → Adapter → CanonicalDocument → Validators → Serializers
|
| 5 |
+
|
| 6 |
+
The hierarchy is: Document → Page → TextRegion (block) → TextLine → Word
|
| 7 |
+
→ NonTextRegion
|
| 8 |
+
|
| 9 |
+
Every node carries geometry + provenance. No exceptions.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
from datetime import datetime, timezone
|
| 15 |
+
from typing import Any
|
| 16 |
+
|
| 17 |
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
| 18 |
+
|
| 19 |
+
from src.app.domain.models.geometry import Geometry
|
| 20 |
+
from src.app.domain.models.provenance import Provenance
|
| 21 |
+
from src.app.domain.models.readiness import (
|
| 22 |
+
AltoReadiness,
|
| 23 |
+
DocumentReadiness,
|
| 24 |
+
ExportEligibility,
|
| 25 |
+
PageXmlReadiness,
|
| 26 |
+
)
|
| 27 |
+
from src.app.domain.models.status import (
|
| 28 |
+
BlockRole,
|
| 29 |
+
CoordinateOrigin,
|
| 30 |
+
InputType,
|
| 31 |
+
NonTextKind,
|
| 32 |
+
ReadinessLevel,
|
| 33 |
+
Unit,
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# -- Source ------------------------------------------------------------------
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class Source(BaseModel):
|
| 41 |
+
"""Metadata about the original input document."""
|
| 42 |
+
|
| 43 |
+
model_config = ConfigDict(frozen=True)
|
| 44 |
+
|
| 45 |
+
input_type: InputType
|
| 46 |
+
filename: str | None = None
|
| 47 |
+
mime_type: str | None = None
|
| 48 |
+
checksum: str | None = None
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# -- Hyphenation -------------------------------------------------------------
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class Hyphenation(BaseModel):
|
| 55 |
+
"""Word-level hyphenation info for split words across lines."""
|
| 56 |
+
|
| 57 |
+
model_config = ConfigDict(frozen=True)
|
| 58 |
+
|
| 59 |
+
is_hyphenated: bool
|
| 60 |
+
part: int | None = None
|
| 61 |
+
full_form: str | None = None
|
| 62 |
+
|
| 63 |
+
@model_validator(mode="after")
|
| 64 |
+
def _validate_consistency(self) -> Hyphenation:
|
| 65 |
+
if not self.is_hyphenated:
|
| 66 |
+
if self.part is not None or self.full_form is not None:
|
| 67 |
+
raise ValueError(
|
| 68 |
+
"When is_hyphenated is False, part and full_form must be None"
|
| 69 |
+
)
|
| 70 |
+
else:
|
| 71 |
+
if self.part not in (1, 2):
|
| 72 |
+
raise ValueError(f"Hyphenation part must be 1 or 2, got {self.part}")
|
| 73 |
+
if not self.full_form:
|
| 74 |
+
raise ValueError("Hyphenation full_form is required when is_hyphenated is True")
|
| 75 |
+
return self
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# -- Word --------------------------------------------------------------------
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
class Word(BaseModel):
|
| 82 |
+
"""A single word — the leaf level of the canonical hierarchy.
|
| 83 |
+
|
| 84 |
+
This is the primary unit for ALTO export (maps to <String>).
|
| 85 |
+
"""
|
| 86 |
+
|
| 87 |
+
model_config = ConfigDict(frozen=True)
|
| 88 |
+
|
| 89 |
+
id: str = Field(min_length=1)
|
| 90 |
+
text: str = Field(min_length=1)
|
| 91 |
+
normalized_text: str | None = None
|
| 92 |
+
geometry: Geometry
|
| 93 |
+
lang: str | None = Field(default=None, pattern=r"^[a-z]{3}$")
|
| 94 |
+
confidence: float | None = Field(default=None, ge=0, le=1)
|
| 95 |
+
style_refs: list[str] = Field(default_factory=list)
|
| 96 |
+
hyphenation: Hyphenation | None = None
|
| 97 |
+
provenance: Provenance
|
| 98 |
+
metadata: dict[str, Any] | None = None
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
# -- TextLine ----------------------------------------------------------------
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
class TextLine(BaseModel):
|
| 105 |
+
"""A line of text — contains an ordered list of Words.
|
| 106 |
+
|
| 107 |
+
Maps to <TextLine> in ALTO and PAGE XML.
|
| 108 |
+
"""
|
| 109 |
+
|
| 110 |
+
model_config = ConfigDict(frozen=True)
|
| 111 |
+
|
| 112 |
+
id: str = Field(min_length=1)
|
| 113 |
+
geometry: Geometry
|
| 114 |
+
confidence: float | None = Field(default=None, ge=0, le=1)
|
| 115 |
+
lang: str | None = Field(default=None, pattern=r"^[a-z]{3}$")
|
| 116 |
+
provenance: Provenance
|
| 117 |
+
words: list[Word] = Field(min_length=1)
|
| 118 |
+
metadata: dict[str, Any] | None = None
|
| 119 |
+
|
| 120 |
+
@property
|
| 121 |
+
def text(self) -> str:
|
| 122 |
+
"""Concatenated text of all words in this line."""
|
| 123 |
+
return " ".join(w.text for w in self.words)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
# -- TextRegion (block) ------------------------------------------------------
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
class TextRegion(BaseModel):
|
| 130 |
+
"""A text block / region — contains an ordered list of TextLines.
|
| 131 |
+
|
| 132 |
+
Maps to <TextBlock> in ALTO and <TextRegion> in PAGE XML.
|
| 133 |
+
"""
|
| 134 |
+
|
| 135 |
+
model_config = ConfigDict(frozen=True)
|
| 136 |
+
|
| 137 |
+
id: str = Field(min_length=1)
|
| 138 |
+
role: BlockRole | None = None
|
| 139 |
+
geometry: Geometry
|
| 140 |
+
confidence: float | None = Field(default=None, ge=0, le=1)
|
| 141 |
+
lang: str | None = Field(default=None, pattern=r"^[a-z]{3}$")
|
| 142 |
+
provenance: Provenance
|
| 143 |
+
lines: list[TextLine] = Field(min_length=1)
|
| 144 |
+
metadata: dict[str, Any] | None = None
|
| 145 |
+
|
| 146 |
+
@property
|
| 147 |
+
def text(self) -> str:
|
| 148 |
+
"""Concatenated text of all lines in this region."""
|
| 149 |
+
return "\n".join(line.text for line in self.lines)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
# -- NonTextRegion -----------------------------------------------------------
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
class NonTextRegion(BaseModel):
|
| 156 |
+
"""A non-textual region (illustration, table, separator, etc.)."""
|
| 157 |
+
|
| 158 |
+
model_config = ConfigDict(frozen=True)
|
| 159 |
+
|
| 160 |
+
id: str = Field(min_length=1)
|
| 161 |
+
kind: NonTextKind
|
| 162 |
+
geometry: Geometry
|
| 163 |
+
confidence: float | None = Field(default=None, ge=0, le=1)
|
| 164 |
+
provenance: Provenance
|
| 165 |
+
metadata: dict[str, Any] | None = None
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
# -- Page --------------------------------------------------------------------
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
class Page(BaseModel):
|
| 172 |
+
"""A single page in the document.
|
| 173 |
+
|
| 174 |
+
Contains text regions (blocks), non-text regions, reading order,
|
| 175 |
+
and per-page readiness assessments.
|
| 176 |
+
"""
|
| 177 |
+
|
| 178 |
+
model_config = ConfigDict(frozen=True)
|
| 179 |
+
|
| 180 |
+
id: str = Field(min_length=1)
|
| 181 |
+
page_index: int = Field(ge=0)
|
| 182 |
+
width: float = Field(gt=0)
|
| 183 |
+
height: float = Field(gt=0)
|
| 184 |
+
unit: Unit = Unit.PX
|
| 185 |
+
rotation: float = 0.0
|
| 186 |
+
coordinate_origin: CoordinateOrigin = CoordinateOrigin.TOP_LEFT
|
| 187 |
+
|
| 188 |
+
alto_readiness: AltoReadiness = Field(
|
| 189 |
+
default_factory=lambda: AltoReadiness(level=ReadinessLevel.NONE, missing=["word_text"])
|
| 190 |
+
)
|
| 191 |
+
page_readiness: PageXmlReadiness = Field(
|
| 192 |
+
default_factory=lambda: PageXmlReadiness(level=ReadinessLevel.NONE, missing=["word_text"])
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
reading_order: list[str] = Field(default_factory=list)
|
| 196 |
+
|
| 197 |
+
text_regions: list[TextRegion] = Field(default_factory=list)
|
| 198 |
+
non_text_regions: list[NonTextRegion] = Field(default_factory=list)
|
| 199 |
+
|
| 200 |
+
warnings: list[str] = Field(default_factory=list)
|
| 201 |
+
metadata: dict[str, Any] | None = None
|
| 202 |
+
|
| 203 |
+
@property
|
| 204 |
+
def text(self) -> str:
|
| 205 |
+
"""Concatenated text of all text regions."""
|
| 206 |
+
return "\n\n".join(r.text for r in self.text_regions)
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
# -- Audit -------------------------------------------------------------------
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
class Audit(BaseModel):
|
| 213 |
+
"""Audit trail for the document processing pipeline."""
|
| 214 |
+
|
| 215 |
+
model_config = ConfigDict(frozen=True)
|
| 216 |
+
|
| 217 |
+
provider_id: str | None = None
|
| 218 |
+
runtime_type: str | None = None
|
| 219 |
+
adapter_version: str | None = None
|
| 220 |
+
enrichers_applied: list[str] = Field(default_factory=list)
|
| 221 |
+
validators_run: list[str] = Field(default_factory=list)
|
| 222 |
+
processing_duration_ms: float | None = None
|
| 223 |
+
warnings: list[str] = Field(default_factory=list)
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
# -- CanonicalDocument -------------------------------------------------------
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
class CanonicalDocument(BaseModel):
|
| 230 |
+
"""The canonical document — business truth of the system.
|
| 231 |
+
|
| 232 |
+
This is the single representation that all validators, enrichers,
|
| 233 |
+
and serializers operate on.
|
| 234 |
+
"""
|
| 235 |
+
|
| 236 |
+
model_config = ConfigDict(frozen=True)
|
| 237 |
+
|
| 238 |
+
schema_version: str = Field(default="1.0.0", pattern=r"^\d+\.\d+\.\d+$")
|
| 239 |
+
document_id: str = Field(min_length=1)
|
| 240 |
+
source: Source
|
| 241 |
+
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
| 242 |
+
|
| 243 |
+
pages: list[Page] = Field(min_length=1)
|
| 244 |
+
|
| 245 |
+
audit: Audit = Field(default_factory=Audit)
|
| 246 |
+
global_readiness: DocumentReadiness = Field(default_factory=DocumentReadiness)
|
| 247 |
+
export_eligibility: ExportEligibility = Field(default_factory=ExportEligibility)
|
| 248 |
+
|
| 249 |
+
metadata: dict[str, Any] | None = None
|
| 250 |
+
|
| 251 |
+
@property
|
| 252 |
+
def text(self) -> str:
|
| 253 |
+
"""Concatenated text of all pages."""
|
| 254 |
+
return "\n\n---\n\n".join(p.text for p in self.pages)
|
| 255 |
+
|
| 256 |
+
@property
|
| 257 |
+
def all_text_region_ids(self) -> set[str]:
|
| 258 |
+
"""All text region IDs across all pages."""
|
| 259 |
+
return {r.id for p in self.pages for r in p.text_regions}
|
| 260 |
+
|
| 261 |
+
@property
|
| 262 |
+
def all_ids(self) -> set[str]:
|
| 263 |
+
"""Every node ID in the document (pages, regions, lines, words)."""
|
| 264 |
+
ids: set[str] = set()
|
| 265 |
+
for page in self.pages:
|
| 266 |
+
ids.add(page.id)
|
| 267 |
+
for region in page.text_regions:
|
| 268 |
+
ids.add(region.id)
|
| 269 |
+
for line in region.lines:
|
| 270 |
+
ids.add(line.id)
|
| 271 |
+
for word in line.words:
|
| 272 |
+
ids.add(word.id)
|
| 273 |
+
for ntr in page.non_text_regions:
|
| 274 |
+
ids.add(ntr.id)
|
| 275 |
+
return ids
|
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Geometry primitives and context for the canonical model.
|
| 2 |
+
|
| 3 |
+
Convention (non-negotiable, see AGENTS.md):
|
| 4 |
+
- bbox: [x, y, width, height] — x = left edge, y = top edge
|
| 5 |
+
- coordinate origin: top_left
|
| 6 |
+
- unit: px
|
| 7 |
+
- polygon: list of (x, y) pairs, or None
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from typing import Annotated
|
| 13 |
+
|
| 14 |
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
| 15 |
+
|
| 16 |
+
from src.app.domain.models.status import CoordinateOrigin, GeometryStatus, Unit
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
# -- Primitives --------------------------------------------------------------
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class Point(BaseModel):
|
| 23 |
+
"""A 2D point in image space."""
|
| 24 |
+
|
| 25 |
+
model_config = ConfigDict(frozen=True)
|
| 26 |
+
|
| 27 |
+
x: float = Field(ge=0)
|
| 28 |
+
y: float = Field(ge=0)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class BBox(BaseModel):
|
| 32 |
+
"""Axis-aligned bounding box: [x, y, width, height].
|
| 33 |
+
|
| 34 |
+
x, y = top-left corner. width, height > 0.
|
| 35 |
+
"""
|
| 36 |
+
|
| 37 |
+
model_config = ConfigDict(frozen=True)
|
| 38 |
+
|
| 39 |
+
x: float = Field(ge=0)
|
| 40 |
+
y: float = Field(ge=0)
|
| 41 |
+
width: float = Field(gt=0)
|
| 42 |
+
height: float = Field(gt=0)
|
| 43 |
+
|
| 44 |
+
@property
|
| 45 |
+
def x2(self) -> float:
|
| 46 |
+
return self.x + self.width
|
| 47 |
+
|
| 48 |
+
@property
|
| 49 |
+
def y2(self) -> float:
|
| 50 |
+
return self.y + self.height
|
| 51 |
+
|
| 52 |
+
def as_tuple(self) -> tuple[float, float, float, float]:
|
| 53 |
+
return (self.x, self.y, self.width, self.height)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
PolygonPoints = list[tuple[float, float]]
|
| 57 |
+
"""Ordered list of (x, y) vertex pairs forming a closed polygon."""
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class Baseline(BaseModel):
|
| 61 |
+
"""A baseline defined by two endpoints."""
|
| 62 |
+
|
| 63 |
+
model_config = ConfigDict(frozen=True)
|
| 64 |
+
|
| 65 |
+
start: Point
|
| 66 |
+
end: Point
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class ClipRect(BaseModel):
|
| 70 |
+
"""Clipping rectangle — same semantics as BBox but used for clipping ops."""
|
| 71 |
+
|
| 72 |
+
model_config = ConfigDict(frozen=True)
|
| 73 |
+
|
| 74 |
+
x: float = Field(ge=0)
|
| 75 |
+
y: float = Field(ge=0)
|
| 76 |
+
width: float = Field(gt=0)
|
| 77 |
+
height: float = Field(gt=0)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
# -- Geometry (the canonical geometry object on every node) ------------------
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
class Geometry(BaseModel):
|
| 84 |
+
"""Standardised geometry attached to every canonical node.
|
| 85 |
+
|
| 86 |
+
bbox is always required (at least for ALTO export).
|
| 87 |
+
polygon is optional — preserved when the provider supplies it.
|
| 88 |
+
status indicates how the geometry was obtained.
|
| 89 |
+
"""
|
| 90 |
+
|
| 91 |
+
model_config = ConfigDict(frozen=True)
|
| 92 |
+
|
| 93 |
+
bbox: Annotated[
|
| 94 |
+
tuple[float, float, float, float],
|
| 95 |
+
Field(description="(x, y, width, height)"),
|
| 96 |
+
]
|
| 97 |
+
polygon: PolygonPoints | None = None
|
| 98 |
+
status: GeometryStatus
|
| 99 |
+
|
| 100 |
+
@model_validator(mode="after")
|
| 101 |
+
def _validate_bbox_dimensions(self) -> Geometry:
|
| 102 |
+
_x, _y, w, h = self.bbox
|
| 103 |
+
if w <= 0 or h <= 0:
|
| 104 |
+
raise ValueError(f"bbox width and height must be > 0, got w={w}, h={h}")
|
| 105 |
+
if _x < 0 or _y < 0:
|
| 106 |
+
raise ValueError(f"bbox x and y must be >= 0, got x={_x}, y={_y}")
|
| 107 |
+
return self
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
# -- GeometryContext (describes the coordinate space of a provider output) ---
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
class GeometryContext(BaseModel):
|
| 114 |
+
"""Describes the geometric context of a provider's output.
|
| 115 |
+
|
| 116 |
+
Used by adapters to normalise provider coordinates into canonical space.
|
| 117 |
+
"""
|
| 118 |
+
|
| 119 |
+
model_config = ConfigDict(frozen=True)
|
| 120 |
+
|
| 121 |
+
source_width: int = Field(gt=0)
|
| 122 |
+
source_height: int = Field(gt=0)
|
| 123 |
+
provided_width: int | None = Field(default=None, gt=0)
|
| 124 |
+
provided_height: int | None = Field(default=None, gt=0)
|
| 125 |
+
resize_factor: float | None = Field(default=None, gt=0)
|
| 126 |
+
rotation: float = 0.0
|
| 127 |
+
coordinate_origin: CoordinateOrigin = CoordinateOrigin.TOP_LEFT
|
| 128 |
+
unit: Unit = Unit.PX
|
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Provenance model — tracks how every piece of data was produced.
|
| 2 |
+
|
| 3 |
+
Every node in the CanonicalDocument must carry a Provenance. This is
|
| 4 |
+
the foundation of the system's explainability and auditability.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
| 10 |
+
|
| 11 |
+
from src.app.domain.models.status import EvidenceType
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class Provenance(BaseModel):
|
| 15 |
+
"""Tracks the origin and derivation chain of a canonical node."""
|
| 16 |
+
|
| 17 |
+
model_config = ConfigDict(frozen=True)
|
| 18 |
+
|
| 19 |
+
provider: str = Field(min_length=1)
|
| 20 |
+
adapter: str = Field(min_length=1)
|
| 21 |
+
source_ref: str = Field(min_length=1)
|
| 22 |
+
evidence_type: EvidenceType
|
| 23 |
+
derived_from: list[str] = Field(default_factory=list)
|
| 24 |
+
|
| 25 |
+
@model_validator(mode="after")
|
| 26 |
+
def _validate_derivation_consistency(self) -> Provenance:
|
| 27 |
+
"""provider_native data cannot have derivation parents."""
|
| 28 |
+
if self.evidence_type == EvidenceType.PROVIDER_NATIVE and self.derived_from:
|
| 29 |
+
raise ValueError(
|
| 30 |
+
"provider_native evidence must have an empty derived_from list, "
|
| 31 |
+
f"got {self.derived_from}"
|
| 32 |
+
)
|
| 33 |
+
return self
|
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""RawProviderPayload — the source truth.
|
| 2 |
+
|
| 3 |
+
This is an opaque wrapper around whatever the provider returned.
|
| 4 |
+
It is stored for audit, debug, comparison, and reproducibility.
|
| 5 |
+
It is never used for export or rendering.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from datetime import datetime, timezone
|
| 11 |
+
from typing import Any
|
| 12 |
+
|
| 13 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class RawProviderPayload(BaseModel):
|
| 17 |
+
"""Opaque container for the raw output of a provider."""
|
| 18 |
+
|
| 19 |
+
model_config = ConfigDict(frozen=True)
|
| 20 |
+
|
| 21 |
+
provider_id: str = Field(min_length=1)
|
| 22 |
+
adapter_id: str = Field(min_length=1)
|
| 23 |
+
runtime_type: str = Field(min_length=1)
|
| 24 |
+
model_id: str | None = None
|
| 25 |
+
|
| 26 |
+
payload: dict[str, Any] | list[Any]
|
| 27 |
+
"""The raw JSON-serialisable output from the provider."""
|
| 28 |
+
|
| 29 |
+
received_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
| 30 |
+
|
| 31 |
+
image_width: int | None = Field(default=None, gt=0)
|
| 32 |
+
image_height: int | None = Field(default=None, gt=0)
|
| 33 |
+
|
| 34 |
+
metadata: dict[str, Any] | None = None
|
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Readiness and export eligibility models.
|
| 2 |
+
|
| 3 |
+
These models answer: "can we export this document, and how completely?"
|
| 4 |
+
They are computed by validators, never set manually.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
| 10 |
+
|
| 11 |
+
from src.app.domain.models.status import MissingCapability, ReadinessLevel
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class AltoReadiness(BaseModel):
|
| 15 |
+
"""ALTO export readiness for a single page."""
|
| 16 |
+
|
| 17 |
+
model_config = ConfigDict(frozen=True)
|
| 18 |
+
|
| 19 |
+
level: ReadinessLevel
|
| 20 |
+
missing: list[MissingCapability] = Field(default_factory=list)
|
| 21 |
+
|
| 22 |
+
@model_validator(mode="after")
|
| 23 |
+
def _validate_consistency(self) -> AltoReadiness:
|
| 24 |
+
if self.level == ReadinessLevel.FULL and self.missing:
|
| 25 |
+
raise ValueError(
|
| 26 |
+
f"level is 'full' but missing capabilities listed: {self.missing}"
|
| 27 |
+
)
|
| 28 |
+
if self.level == ReadinessLevel.NONE and not self.missing:
|
| 29 |
+
raise ValueError("level is 'none' but no missing capabilities listed")
|
| 30 |
+
return self
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class PageXmlReadiness(BaseModel):
|
| 34 |
+
"""PAGE XML export readiness for a single page."""
|
| 35 |
+
|
| 36 |
+
model_config = ConfigDict(frozen=True)
|
| 37 |
+
|
| 38 |
+
level: ReadinessLevel
|
| 39 |
+
missing: list[MissingCapability] = Field(default_factory=list)
|
| 40 |
+
|
| 41 |
+
@model_validator(mode="after")
|
| 42 |
+
def _validate_consistency(self) -> PageXmlReadiness:
|
| 43 |
+
if self.level == ReadinessLevel.FULL and self.missing:
|
| 44 |
+
raise ValueError(
|
| 45 |
+
f"level is 'full' but missing capabilities listed: {self.missing}"
|
| 46 |
+
)
|
| 47 |
+
if self.level == ReadinessLevel.NONE and not self.missing:
|
| 48 |
+
raise ValueError("level is 'none' but no missing capabilities listed")
|
| 49 |
+
return self
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class ExportEligibility(BaseModel):
|
| 53 |
+
"""Aggregated export eligibility for the whole document."""
|
| 54 |
+
|
| 55 |
+
model_config = ConfigDict(frozen=True)
|
| 56 |
+
|
| 57 |
+
alto_export: ReadinessLevel = ReadinessLevel.NONE
|
| 58 |
+
page_export: ReadinessLevel = ReadinessLevel.NONE
|
| 59 |
+
viewer_render: ReadinessLevel = ReadinessLevel.NONE
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class DocumentReadiness(BaseModel):
|
| 63 |
+
"""Global readiness summary for the document."""
|
| 64 |
+
|
| 65 |
+
model_config = ConfigDict(frozen=True)
|
| 66 |
+
|
| 67 |
+
level: ReadinessLevel = ReadinessLevel.NONE
|
| 68 |
+
page_readiness: list[ReadinessLevel] = Field(default_factory=list)
|
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Domain enums — single source of truth for all status and classification values.
|
| 2 |
+
|
| 3 |
+
These enums are used across the canonical document, provenance, readiness,
|
| 4 |
+
and viewer projection models. They carry no logic — only values.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from enum import Enum
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
# -- Geometry ----------------------------------------------------------------
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class GeometryStatus(str, Enum):
|
| 16 |
+
"""How a piece of geometry was obtained."""
|
| 17 |
+
|
| 18 |
+
EXACT = "exact"
|
| 19 |
+
INFERRED = "inferred"
|
| 20 |
+
REPAIRED = "repaired"
|
| 21 |
+
UNKNOWN = "unknown"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class CoordinateOrigin(str, Enum):
|
| 25 |
+
"""Origin of the coordinate system. Always top_left in canonical model."""
|
| 26 |
+
|
| 27 |
+
TOP_LEFT = "top_left"
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class Unit(str, Enum):
|
| 31 |
+
"""Measurement unit. Always px in canonical model."""
|
| 32 |
+
|
| 33 |
+
PX = "px"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# -- Provenance --------------------------------------------------------------
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class EvidenceType(str, Enum):
|
| 40 |
+
"""How a piece of data was produced."""
|
| 41 |
+
|
| 42 |
+
PROVIDER_NATIVE = "provider_native"
|
| 43 |
+
DERIVED = "derived"
|
| 44 |
+
REPAIRED = "repaired"
|
| 45 |
+
MANUAL = "manual"
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# -- Document structure ------------------------------------------------------
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class BlockRole(str, Enum):
|
| 52 |
+
"""Semantic role of a text block within the page."""
|
| 53 |
+
|
| 54 |
+
BODY = "body"
|
| 55 |
+
HEADING = "heading"
|
| 56 |
+
FOOTNOTE = "footnote"
|
| 57 |
+
CAPTION = "caption"
|
| 58 |
+
MARGIN = "margin"
|
| 59 |
+
PAGE_NUMBER = "page_number"
|
| 60 |
+
HEADER = "header"
|
| 61 |
+
FOOTER = "footer"
|
| 62 |
+
OTHER = "other"
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
class NonTextKind(str, Enum):
|
| 66 |
+
"""Type of non-textual region."""
|
| 67 |
+
|
| 68 |
+
ILLUSTRATION = "illustration"
|
| 69 |
+
TABLE = "table"
|
| 70 |
+
SEPARATOR = "separator"
|
| 71 |
+
ORNAMENT = "ornament"
|
| 72 |
+
GRAPHIC = "graphic"
|
| 73 |
+
OTHER = "other"
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
# -- Source ------------------------------------------------------------------
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
class InputType(str, Enum):
|
| 80 |
+
"""Type of the original input document."""
|
| 81 |
+
|
| 82 |
+
IMAGE = "image"
|
| 83 |
+
PDF = "pdf"
|
| 84 |
+
OCR_JSON = "ocr_json"
|
| 85 |
+
MARKDOWN = "markdown"
|
| 86 |
+
HTML = "html"
|
| 87 |
+
XML = "xml"
|
| 88 |
+
TEXT = "text"
|
| 89 |
+
OTHER = "other"
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
# -- Readiness ---------------------------------------------------------------
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
class ReadinessLevel(str, Enum):
|
| 96 |
+
"""How ready a document / page / element is for export."""
|
| 97 |
+
|
| 98 |
+
FULL = "full"
|
| 99 |
+
PARTIAL = "partial"
|
| 100 |
+
DEGRADED = "degraded"
|
| 101 |
+
NONE = "none"
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
class MissingCapability(str, Enum):
|
| 105 |
+
"""Specific capabilities that may be missing for export readiness."""
|
| 106 |
+
|
| 107 |
+
PAGE_DIMENSIONS = "page_dimensions"
|
| 108 |
+
BLOCK_GEOMETRY = "block_geometry"
|
| 109 |
+
LINE_GEOMETRY = "line_geometry"
|
| 110 |
+
WORD_GEOMETRY = "word_geometry"
|
| 111 |
+
WORD_TEXT = "word_text"
|
| 112 |
+
READING_ORDER = "reading_order"
|
| 113 |
+
LANGUAGE = "language"
|
| 114 |
+
CONFIDENCE = "confidence"
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
# -- Overlay (viewer) --------------------------------------------------------
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
class OverlayLevel(str, Enum):
|
| 121 |
+
"""Granularity level for viewer overlays."""
|
| 122 |
+
|
| 123 |
+
BLOCK = "block"
|
| 124 |
+
LINE = "line"
|
| 125 |
+
WORD = "word"
|
| 126 |
+
NON_TEXT = "non_text"
|
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ViewerProjection — the rendering truth.
|
| 2 |
+
|
| 3 |
+
This is the lightweight structure consumed by the front-end viewer.
|
| 4 |
+
It is derived from the CanonicalDocument by the projection builder.
|
| 5 |
+
It never parses XML. It never calls providers.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 13 |
+
|
| 14 |
+
from src.app.domain.models.readiness import ExportEligibility
|
| 15 |
+
from src.app.domain.models.status import (
|
| 16 |
+
EvidenceType,
|
| 17 |
+
GeometryStatus,
|
| 18 |
+
OverlayLevel,
|
| 19 |
+
ReadinessLevel,
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class OverlayItem(BaseModel):
|
| 24 |
+
"""A single visual overlay on the page image."""
|
| 25 |
+
|
| 26 |
+
model_config = ConfigDict(frozen=True)
|
| 27 |
+
|
| 28 |
+
id: str = Field(min_length=1)
|
| 29 |
+
level: OverlayLevel
|
| 30 |
+
bbox: tuple[float, float, float, float] = Field(
|
| 31 |
+
description="(x, y, width, height)"
|
| 32 |
+
)
|
| 33 |
+
polygon: list[tuple[float, float]] | None = None
|
| 34 |
+
label: str | None = None
|
| 35 |
+
text: str | None = None
|
| 36 |
+
confidence: float | None = Field(default=None, ge=0, le=1)
|
| 37 |
+
provenance_type: EvidenceType | None = None
|
| 38 |
+
geometry_status: GeometryStatus | None = None
|
| 39 |
+
click_payload: dict[str, Any] | None = None
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class InspectionData(BaseModel):
|
| 43 |
+
"""Detailed data for the inspection panel — shown on click."""
|
| 44 |
+
|
| 45 |
+
model_config = ConfigDict(frozen=True)
|
| 46 |
+
|
| 47 |
+
id: str = Field(min_length=1)
|
| 48 |
+
level: OverlayLevel
|
| 49 |
+
text: str | None = None
|
| 50 |
+
bbox: tuple[float, float, float, float]
|
| 51 |
+
polygon: list[tuple[float, float]] | None = None
|
| 52 |
+
confidence: float | None = None
|
| 53 |
+
lang: str | None = None
|
| 54 |
+
provenance_type: EvidenceType | None = None
|
| 55 |
+
provenance_provider: str | None = None
|
| 56 |
+
geometry_status: GeometryStatus | None = None
|
| 57 |
+
readiness: ReadinessLevel | None = None
|
| 58 |
+
role: str | None = None
|
| 59 |
+
metadata: dict[str, Any] | None = None
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class ViewerProjection(BaseModel):
|
| 63 |
+
"""Complete projection for the viewer — one per page."""
|
| 64 |
+
|
| 65 |
+
model_config = ConfigDict(frozen=True)
|
| 66 |
+
|
| 67 |
+
image_ref: str
|
| 68 |
+
image_width: int = Field(gt=0)
|
| 69 |
+
image_height: int = Field(gt=0)
|
| 70 |
+
|
| 71 |
+
block_overlays: list[OverlayItem] = Field(default_factory=list)
|
| 72 |
+
line_overlays: list[OverlayItem] = Field(default_factory=list)
|
| 73 |
+
word_overlays: list[OverlayItem] = Field(default_factory=list)
|
| 74 |
+
non_text_overlays: list[OverlayItem] = Field(default_factory=list)
|
| 75 |
+
|
| 76 |
+
inspection_index: dict[str, InspectionData] = Field(default_factory=dict)
|
| 77 |
+
|
| 78 |
+
validation_flags: list[str] = Field(default_factory=list)
|
| 79 |
+
export_status: ExportEligibility = Field(default_factory=ExportEligibility)
|
|
@@ -0,0 +1,627 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the CanonicalDocument model — the heart of the system."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
|
| 7 |
+
import pytest
|
| 8 |
+
from pydantic import ValidationError
|
| 9 |
+
|
| 10 |
+
from src.app.domain.models import (
|
| 11 |
+
AltoReadiness,
|
| 12 |
+
Audit,
|
| 13 |
+
BlockRole,
|
| 14 |
+
CanonicalDocument,
|
| 15 |
+
EvidenceType,
|
| 16 |
+
ExportEligibility,
|
| 17 |
+
Geometry,
|
| 18 |
+
GeometryStatus,
|
| 19 |
+
Hyphenation,
|
| 20 |
+
InputType,
|
| 21 |
+
MissingCapability,
|
| 22 |
+
NonTextKind,
|
| 23 |
+
NonTextRegion,
|
| 24 |
+
Page,
|
| 25 |
+
PageXmlReadiness,
|
| 26 |
+
Provenance,
|
| 27 |
+
ReadinessLevel,
|
| 28 |
+
Source,
|
| 29 |
+
TextLine,
|
| 30 |
+
TextRegion,
|
| 31 |
+
Word,
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# -- Helpers (reusable fixture builders) -------------------------------------
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _prov(ref: str = "$.test", evidence: EvidenceType = EvidenceType.PROVIDER_NATIVE) -> Provenance:
|
| 39 |
+
return Provenance(
|
| 40 |
+
provider="test-provider",
|
| 41 |
+
adapter="adapter.test.v1",
|
| 42 |
+
source_ref=ref,
|
| 43 |
+
evidence_type=evidence,
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _geo(
|
| 48 |
+
x: float = 100,
|
| 49 |
+
y: float = 200,
|
| 50 |
+
w: float = 300,
|
| 51 |
+
h: float = 50,
|
| 52 |
+
status: GeometryStatus = GeometryStatus.EXACT,
|
| 53 |
+
) -> Geometry:
|
| 54 |
+
return Geometry(bbox=(x, y, w, h), status=status)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _word(
|
| 58 |
+
id: str = "w1",
|
| 59 |
+
text: str = "Bonjour",
|
| 60 |
+
x: float = 100,
|
| 61 |
+
y: float = 200,
|
| 62 |
+
w: float = 90,
|
| 63 |
+
h: float = 40,
|
| 64 |
+
) -> Word:
|
| 65 |
+
return Word(
|
| 66 |
+
id=id,
|
| 67 |
+
text=text,
|
| 68 |
+
geometry=_geo(x, y, w, h),
|
| 69 |
+
lang="fra",
|
| 70 |
+
confidence=0.96,
|
| 71 |
+
provenance=_prov(f"$.words.{id}"),
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _line(id: str = "tl1", words: list[Word] | None = None) -> TextLine:
|
| 76 |
+
return TextLine(
|
| 77 |
+
id=id,
|
| 78 |
+
geometry=_geo(100, 200, 1100, 42),
|
| 79 |
+
lang="fra",
|
| 80 |
+
provenance=_prov(f"$.lines.{id}"),
|
| 81 |
+
words=words or [_word()],
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _region(id: str = "tb1", lines: list[TextLine] | None = None) -> TextRegion:
|
| 86 |
+
return TextRegion(
|
| 87 |
+
id=id,
|
| 88 |
+
role=BlockRole.BODY,
|
| 89 |
+
geometry=_geo(100, 200, 1200, 900),
|
| 90 |
+
lang="fra",
|
| 91 |
+
provenance=_prov(f"$.blocks.{id}"),
|
| 92 |
+
lines=lines or [_line()],
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _page(
|
| 97 |
+
id: str = "p1",
|
| 98 |
+
text_regions: list[TextRegion] | None = None,
|
| 99 |
+
) -> Page:
|
| 100 |
+
return Page(
|
| 101 |
+
id=id,
|
| 102 |
+
page_index=0,
|
| 103 |
+
width=2480,
|
| 104 |
+
height=3508,
|
| 105 |
+
alto_readiness=AltoReadiness(level=ReadinessLevel.FULL),
|
| 106 |
+
page_readiness=PageXmlReadiness(level=ReadinessLevel.FULL),
|
| 107 |
+
reading_order=["tb1"],
|
| 108 |
+
text_regions=text_regions or [_region()],
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def _doc(pages: list[Page] | None = None) -> CanonicalDocument:
|
| 113 |
+
return CanonicalDocument(
|
| 114 |
+
document_id="doc_test_001",
|
| 115 |
+
source=Source(input_type=InputType.IMAGE, filename="test.png"),
|
| 116 |
+
pages=pages or [_page()],
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
# -- Source ------------------------------------------------------------------
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
class TestSource:
|
| 124 |
+
def test_minimal(self) -> None:
|
| 125 |
+
s = Source(input_type=InputType.IMAGE)
|
| 126 |
+
assert s.filename is None
|
| 127 |
+
assert s.checksum is None
|
| 128 |
+
|
| 129 |
+
def test_full(self) -> None:
|
| 130 |
+
s = Source(
|
| 131 |
+
input_type=InputType.IMAGE,
|
| 132 |
+
filename="page.tif",
|
| 133 |
+
mime_type="image/tiff",
|
| 134 |
+
checksum="abc123",
|
| 135 |
+
)
|
| 136 |
+
assert s.filename == "page.tif"
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
# -- Hyphenation -------------------------------------------------------------
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
class TestHyphenation:
|
| 143 |
+
def test_not_hyphenated(self) -> None:
|
| 144 |
+
h = Hyphenation(is_hyphenated=False)
|
| 145 |
+
assert h.part is None
|
| 146 |
+
assert h.full_form is None
|
| 147 |
+
|
| 148 |
+
def test_part_1(self) -> None:
|
| 149 |
+
h = Hyphenation(is_hyphenated=True, part=1, full_form="patrimoine")
|
| 150 |
+
assert h.part == 1
|
| 151 |
+
|
| 152 |
+
def test_part_2(self) -> None:
|
| 153 |
+
h = Hyphenation(is_hyphenated=True, part=2, full_form="patrimoine")
|
| 154 |
+
assert h.part == 2
|
| 155 |
+
|
| 156 |
+
def test_false_with_part_rejected(self) -> None:
|
| 157 |
+
with pytest.raises(ValidationError, match="is_hyphenated is False"):
|
| 158 |
+
Hyphenation(is_hyphenated=False, part=1, full_form="test")
|
| 159 |
+
|
| 160 |
+
def test_true_without_part_rejected(self) -> None:
|
| 161 |
+
with pytest.raises(ValidationError, match="part must be 1 or 2"):
|
| 162 |
+
Hyphenation(is_hyphenated=True, part=None, full_form="test")
|
| 163 |
+
|
| 164 |
+
def test_true_without_full_form_rejected(self) -> None:
|
| 165 |
+
with pytest.raises(ValidationError, match="full_form is required"):
|
| 166 |
+
Hyphenation(is_hyphenated=True, part=1, full_form=None)
|
| 167 |
+
|
| 168 |
+
def test_invalid_part_rejected(self) -> None:
|
| 169 |
+
with pytest.raises(ValidationError, match="part must be 1 or 2"):
|
| 170 |
+
Hyphenation(is_hyphenated=True, part=3, full_form="test")
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
# -- Word --------------------------------------------------------------------
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
class TestWord:
|
| 177 |
+
def test_valid(self) -> None:
|
| 178 |
+
w = _word()
|
| 179 |
+
assert w.text == "Bonjour"
|
| 180 |
+
assert w.confidence == 0.96
|
| 181 |
+
|
| 182 |
+
def test_empty_text_rejected(self) -> None:
|
| 183 |
+
with pytest.raises(ValidationError):
|
| 184 |
+
Word(
|
| 185 |
+
id="w1",
|
| 186 |
+
text="",
|
| 187 |
+
geometry=_geo(),
|
| 188 |
+
provenance=_prov(),
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
def test_empty_id_rejected(self) -> None:
|
| 192 |
+
with pytest.raises(ValidationError):
|
| 193 |
+
Word(
|
| 194 |
+
id="",
|
| 195 |
+
text="hello",
|
| 196 |
+
geometry=_geo(),
|
| 197 |
+
provenance=_prov(),
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
def test_confidence_out_of_range_rejected(self) -> None:
|
| 201 |
+
with pytest.raises(ValidationError):
|
| 202 |
+
Word(
|
| 203 |
+
id="w1",
|
| 204 |
+
text="hello",
|
| 205 |
+
geometry=_geo(),
|
| 206 |
+
confidence=1.5,
|
| 207 |
+
provenance=_prov(),
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
def test_invalid_lang_rejected(self) -> None:
|
| 211 |
+
with pytest.raises(ValidationError):
|
| 212 |
+
Word(
|
| 213 |
+
id="w1",
|
| 214 |
+
text="hello",
|
| 215 |
+
geometry=_geo(),
|
| 216 |
+
lang="french", # must be 3-letter ISO
|
| 217 |
+
provenance=_prov(),
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
def test_valid_lang(self) -> None:
|
| 221 |
+
w = Word(
|
| 222 |
+
id="w1",
|
| 223 |
+
text="hello",
|
| 224 |
+
geometry=_geo(),
|
| 225 |
+
lang="eng",
|
| 226 |
+
provenance=_prov(),
|
| 227 |
+
)
|
| 228 |
+
assert w.lang == "eng"
|
| 229 |
+
|
| 230 |
+
def test_null_confidence_ok(self) -> None:
|
| 231 |
+
w = Word(
|
| 232 |
+
id="w1",
|
| 233 |
+
text="hello",
|
| 234 |
+
geometry=_geo(),
|
| 235 |
+
confidence=None,
|
| 236 |
+
provenance=_prov(),
|
| 237 |
+
)
|
| 238 |
+
assert w.confidence is None
|
| 239 |
+
|
| 240 |
+
def test_with_hyphenation(self) -> None:
|
| 241 |
+
w = Word(
|
| 242 |
+
id="w1",
|
| 243 |
+
text="patri-",
|
| 244 |
+
geometry=_geo(),
|
| 245 |
+
provenance=_prov(),
|
| 246 |
+
hyphenation=Hyphenation(is_hyphenated=True, part=1, full_form="patrimoine"),
|
| 247 |
+
)
|
| 248 |
+
assert w.hyphenation is not None
|
| 249 |
+
assert w.hyphenation.full_form == "patrimoine"
|
| 250 |
+
|
| 251 |
+
def test_with_metadata(self) -> None:
|
| 252 |
+
w = Word(
|
| 253 |
+
id="w1",
|
| 254 |
+
text="hello",
|
| 255 |
+
geometry=_geo(),
|
| 256 |
+
provenance=_prov(),
|
| 257 |
+
metadata={"custom_field": "value"},
|
| 258 |
+
)
|
| 259 |
+
assert w.metadata == {"custom_field": "value"}
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
# -- TextLine ----------------------------------------------------------------
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
class TestTextLine:
|
| 266 |
+
def test_valid(self) -> None:
|
| 267 |
+
line = _line()
|
| 268 |
+
assert line.text == "Bonjour"
|
| 269 |
+
|
| 270 |
+
def test_empty_words_rejected(self) -> None:
|
| 271 |
+
with pytest.raises(ValidationError):
|
| 272 |
+
TextLine(
|
| 273 |
+
id="tl1",
|
| 274 |
+
geometry=_geo(),
|
| 275 |
+
provenance=_prov(),
|
| 276 |
+
words=[],
|
| 277 |
+
)
|
| 278 |
+
|
| 279 |
+
def test_multiple_words(self) -> None:
|
| 280 |
+
line = _line(words=[_word("w1", "Hello"), _word("w2", "world")])
|
| 281 |
+
assert line.text == "Hello world"
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
# -- TextRegion --------------------------------------------------------------
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
class TestTextRegion:
|
| 288 |
+
def test_valid(self) -> None:
|
| 289 |
+
r = _region()
|
| 290 |
+
assert r.role == BlockRole.BODY
|
| 291 |
+
assert "Bonjour" in r.text
|
| 292 |
+
|
| 293 |
+
def test_empty_lines_rejected(self) -> None:
|
| 294 |
+
with pytest.raises(ValidationError):
|
| 295 |
+
TextRegion(
|
| 296 |
+
id="tb1",
|
| 297 |
+
geometry=_geo(),
|
| 298 |
+
provenance=_prov(),
|
| 299 |
+
lines=[],
|
| 300 |
+
)
|
| 301 |
+
|
| 302 |
+
def test_multiple_lines(self) -> None:
|
| 303 |
+
r = _region(
|
| 304 |
+
lines=[
|
| 305 |
+
_line("tl1", [_word("w1", "First")]),
|
| 306 |
+
_line("tl2", [_word("w2", "Second")]),
|
| 307 |
+
]
|
| 308 |
+
)
|
| 309 |
+
assert r.text == "First\nSecond"
|
| 310 |
+
|
| 311 |
+
def test_null_role(self) -> None:
|
| 312 |
+
r = TextRegion(
|
| 313 |
+
id="tb1",
|
| 314 |
+
role=None,
|
| 315 |
+
geometry=_geo(),
|
| 316 |
+
provenance=_prov(),
|
| 317 |
+
lines=[_line()],
|
| 318 |
+
)
|
| 319 |
+
assert r.role is None
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
# -- NonTextRegion -----------------------------------------------------------
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
class TestNonTextRegion:
|
| 326 |
+
def test_valid(self) -> None:
|
| 327 |
+
ntr = NonTextRegion(
|
| 328 |
+
id="ntr1",
|
| 329 |
+
kind=NonTextKind.ILLUSTRATION,
|
| 330 |
+
geometry=_geo(),
|
| 331 |
+
provenance=_prov(),
|
| 332 |
+
)
|
| 333 |
+
assert ntr.kind == NonTextKind.ILLUSTRATION
|
| 334 |
+
|
| 335 |
+
def test_all_kinds(self) -> None:
|
| 336 |
+
for kind in NonTextKind:
|
| 337 |
+
ntr = NonTextRegion(
|
| 338 |
+
id=f"ntr_{kind.value}",
|
| 339 |
+
kind=kind,
|
| 340 |
+
geometry=_geo(),
|
| 341 |
+
provenance=_prov(),
|
| 342 |
+
)
|
| 343 |
+
assert ntr.kind == kind
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
# -- Page --------------------------------------------------------------------
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
class TestPage:
|
| 350 |
+
def test_valid(self) -> None:
|
| 351 |
+
p = _page()
|
| 352 |
+
assert p.width == 2480
|
| 353 |
+
assert p.height == 3508
|
| 354 |
+
assert "Bonjour" in p.text
|
| 355 |
+
|
| 356 |
+
def test_zero_width_rejected(self) -> None:
|
| 357 |
+
with pytest.raises(ValidationError):
|
| 358 |
+
Page(
|
| 359 |
+
id="p1",
|
| 360 |
+
page_index=0,
|
| 361 |
+
width=0,
|
| 362 |
+
height=100,
|
| 363 |
+
)
|
| 364 |
+
|
| 365 |
+
def test_negative_index_rejected(self) -> None:
|
| 366 |
+
with pytest.raises(ValidationError):
|
| 367 |
+
Page(
|
| 368 |
+
id="p1",
|
| 369 |
+
page_index=-1,
|
| 370 |
+
width=100,
|
| 371 |
+
height=100,
|
| 372 |
+
)
|
| 373 |
+
|
| 374 |
+
def test_empty_page_ok(self) -> None:
|
| 375 |
+
"""A page with no regions is valid (e.g. blank page)."""
|
| 376 |
+
p = Page(
|
| 377 |
+
id="p1",
|
| 378 |
+
page_index=0,
|
| 379 |
+
width=2480,
|
| 380 |
+
height=3508,
|
| 381 |
+
)
|
| 382 |
+
assert p.text_regions == []
|
| 383 |
+
assert p.text == ""
|
| 384 |
+
|
| 385 |
+
def test_with_non_text_region(self) -> None:
|
| 386 |
+
ntr = NonTextRegion(
|
| 387 |
+
id="ntr1",
|
| 388 |
+
kind=NonTextKind.SEPARATOR,
|
| 389 |
+
geometry=_geo(),
|
| 390 |
+
provenance=_prov(),
|
| 391 |
+
)
|
| 392 |
+
p = Page(
|
| 393 |
+
id="p1",
|
| 394 |
+
page_index=0,
|
| 395 |
+
width=2480,
|
| 396 |
+
height=3508,
|
| 397 |
+
non_text_regions=[ntr],
|
| 398 |
+
)
|
| 399 |
+
assert len(p.non_text_regions) == 1
|
| 400 |
+
|
| 401 |
+
|
| 402 |
+
# -- CanonicalDocument -------------------------------------------------------
|
| 403 |
+
|
| 404 |
+
|
| 405 |
+
class TestCanonicalDocument:
|
| 406 |
+
def test_valid_minimal(self) -> None:
|
| 407 |
+
doc = _doc()
|
| 408 |
+
assert doc.document_id == "doc_test_001"
|
| 409 |
+
assert doc.schema_version == "1.0.0"
|
| 410 |
+
assert len(doc.pages) == 1
|
| 411 |
+
|
| 412 |
+
def test_empty_pages_rejected(self) -> None:
|
| 413 |
+
with pytest.raises(ValidationError):
|
| 414 |
+
CanonicalDocument(
|
| 415 |
+
document_id="doc1",
|
| 416 |
+
source=Source(input_type=InputType.IMAGE),
|
| 417 |
+
pages=[],
|
| 418 |
+
)
|
| 419 |
+
|
| 420 |
+
def test_text_property(self) -> None:
|
| 421 |
+
doc = _doc()
|
| 422 |
+
assert "Bonjour" in doc.text
|
| 423 |
+
|
| 424 |
+
def test_all_ids(self) -> None:
|
| 425 |
+
doc = _doc()
|
| 426 |
+
ids = doc.all_ids
|
| 427 |
+
assert "p1" in ids
|
| 428 |
+
assert "tb1" in ids
|
| 429 |
+
assert "tl1" in ids
|
| 430 |
+
assert "w1" in ids
|
| 431 |
+
|
| 432 |
+
def test_all_text_region_ids(self) -> None:
|
| 433 |
+
doc = _doc()
|
| 434 |
+
assert doc.all_text_region_ids == {"tb1"}
|
| 435 |
+
|
| 436 |
+
def test_invalid_schema_version_rejected(self) -> None:
|
| 437 |
+
with pytest.raises(ValidationError):
|
| 438 |
+
CanonicalDocument(
|
| 439 |
+
schema_version="v1",
|
| 440 |
+
document_id="doc1",
|
| 441 |
+
source=Source(input_type=InputType.IMAGE),
|
| 442 |
+
pages=[_page()],
|
| 443 |
+
)
|
| 444 |
+
|
| 445 |
+
def test_empty_document_id_rejected(self) -> None:
|
| 446 |
+
with pytest.raises(ValidationError):
|
| 447 |
+
CanonicalDocument(
|
| 448 |
+
document_id="",
|
| 449 |
+
source=Source(input_type=InputType.IMAGE),
|
| 450 |
+
pages=[_page()],
|
| 451 |
+
)
|
| 452 |
+
|
| 453 |
+
def test_default_audit(self) -> None:
|
| 454 |
+
doc = _doc()
|
| 455 |
+
assert doc.audit.enrichers_applied == []
|
| 456 |
+
assert doc.audit.validators_run == []
|
| 457 |
+
|
| 458 |
+
def test_with_audit(self) -> None:
|
| 459 |
+
doc = CanonicalDocument(
|
| 460 |
+
document_id="doc1",
|
| 461 |
+
source=Source(input_type=InputType.IMAGE),
|
| 462 |
+
pages=[_page()],
|
| 463 |
+
audit=Audit(
|
| 464 |
+
provider_id="paddle",
|
| 465 |
+
runtime_type="local",
|
| 466 |
+
enrichers_applied=["polygon_to_bbox", "lang_propagation"],
|
| 467 |
+
),
|
| 468 |
+
)
|
| 469 |
+
assert len(doc.audit.enrichers_applied) == 2
|
| 470 |
+
|
| 471 |
+
def test_default_export_eligibility(self) -> None:
|
| 472 |
+
doc = _doc()
|
| 473 |
+
assert doc.export_eligibility.alto_export == ReadinessLevel.NONE
|
| 474 |
+
|
| 475 |
+
def test_with_metadata(self) -> None:
|
| 476 |
+
doc = CanonicalDocument(
|
| 477 |
+
document_id="doc1",
|
| 478 |
+
source=Source(input_type=InputType.IMAGE),
|
| 479 |
+
pages=[_page()],
|
| 480 |
+
metadata={"project": "test", "version": 2},
|
| 481 |
+
)
|
| 482 |
+
assert doc.metadata["project"] == "test"
|
| 483 |
+
|
| 484 |
+
def test_json_roundtrip(self) -> None:
|
| 485 |
+
"""Full serialization → deserialization cycle."""
|
| 486 |
+
doc = _doc()
|
| 487 |
+
json_str = doc.model_dump_json()
|
| 488 |
+
parsed = json.loads(json_str)
|
| 489 |
+
doc2 = CanonicalDocument.model_validate(parsed)
|
| 490 |
+
assert doc2.document_id == doc.document_id
|
| 491 |
+
assert len(doc2.pages) == len(doc.pages)
|
| 492 |
+
assert doc2.pages[0].text_regions[0].lines[0].words[0].text == "Bonjour"
|
| 493 |
+
|
| 494 |
+
def test_json_schema_generation(self) -> None:
|
| 495 |
+
"""Verify we can generate a JSON Schema from the Pydantic model."""
|
| 496 |
+
schema = CanonicalDocument.model_json_schema()
|
| 497 |
+
assert schema["title"] == "CanonicalDocument"
|
| 498 |
+
assert "pages" in schema["properties"]
|
| 499 |
+
# Verify nested definitions exist
|
| 500 |
+
assert "$defs" in schema
|
| 501 |
+
defs = schema["$defs"]
|
| 502 |
+
assert "Word" in defs
|
| 503 |
+
assert "TextLine" in defs
|
| 504 |
+
assert "TextRegion" in defs
|
| 505 |
+
assert "Page" in defs
|
| 506 |
+
assert "Geometry" in defs
|
| 507 |
+
assert "Provenance" in defs
|
| 508 |
+
|
| 509 |
+
|
| 510 |
+
# -- Full example from spec --------------------------------------------------
|
| 511 |
+
|
| 512 |
+
|
| 513 |
+
class TestSpecExample:
|
| 514 |
+
"""Validate the example from the architecture document §14."""
|
| 515 |
+
|
| 516 |
+
def test_spec_example_validates(self) -> None:
|
| 517 |
+
doc = CanonicalDocument(
|
| 518 |
+
schema_version="1.0.0",
|
| 519 |
+
document_id="doc_0001",
|
| 520 |
+
source=Source(
|
| 521 |
+
input_type=InputType.IMAGE,
|
| 522 |
+
filename="page_001.tif",
|
| 523 |
+
mime_type="image/tiff",
|
| 524 |
+
checksum=None,
|
| 525 |
+
),
|
| 526 |
+
pages=[
|
| 527 |
+
Page(
|
| 528 |
+
id="p1",
|
| 529 |
+
page_index=0,
|
| 530 |
+
width=2480,
|
| 531 |
+
height=3508,
|
| 532 |
+
alto_readiness=AltoReadiness(level=ReadinessLevel.FULL),
|
| 533 |
+
page_readiness=PageXmlReadiness(level=ReadinessLevel.FULL),
|
| 534 |
+
reading_order=["tb1"],
|
| 535 |
+
text_regions=[
|
| 536 |
+
TextRegion(
|
| 537 |
+
id="tb1",
|
| 538 |
+
role=BlockRole.BODY,
|
| 539 |
+
geometry=Geometry(
|
| 540 |
+
bbox=(100, 200, 1200, 900),
|
| 541 |
+
polygon=None,
|
| 542 |
+
status=GeometryStatus.EXACT,
|
| 543 |
+
),
|
| 544 |
+
confidence=None,
|
| 545 |
+
lang="fra",
|
| 546 |
+
provenance=Provenance(
|
| 547 |
+
provider="paddleocr-vl",
|
| 548 |
+
adapter="adapter.paddle.v1",
|
| 549 |
+
source_ref="$.pages[0].blocks[0]",
|
| 550 |
+
evidence_type=EvidenceType.PROVIDER_NATIVE,
|
| 551 |
+
derived_from=[],
|
| 552 |
+
),
|
| 553 |
+
lines=[
|
| 554 |
+
TextLine(
|
| 555 |
+
id="tl1",
|
| 556 |
+
geometry=Geometry(
|
| 557 |
+
bbox=(110, 220, 1180, 42),
|
| 558 |
+
polygon=None,
|
| 559 |
+
status=GeometryStatus.EXACT,
|
| 560 |
+
),
|
| 561 |
+
confidence=None,
|
| 562 |
+
lang="fra",
|
| 563 |
+
provenance=Provenance(
|
| 564 |
+
provider="paddleocr-vl",
|
| 565 |
+
adapter="adapter.paddle.v1",
|
| 566 |
+
source_ref="$.pages[0].blocks[0].lines[0]",
|
| 567 |
+
evidence_type=EvidenceType.PROVIDER_NATIVE,
|
| 568 |
+
derived_from=[],
|
| 569 |
+
),
|
| 570 |
+
words=[
|
| 571 |
+
Word(
|
| 572 |
+
id="w1",
|
| 573 |
+
text="Bonjour",
|
| 574 |
+
normalized_text=None,
|
| 575 |
+
geometry=Geometry(
|
| 576 |
+
bbox=(110, 220, 90, 40),
|
| 577 |
+
polygon=None,
|
| 578 |
+
status=GeometryStatus.EXACT,
|
| 579 |
+
),
|
| 580 |
+
lang="fra",
|
| 581 |
+
confidence=0.96,
|
| 582 |
+
style_refs=[],
|
| 583 |
+
hyphenation=None,
|
| 584 |
+
provenance=Provenance(
|
| 585 |
+
provider="paddleocr-vl",
|
| 586 |
+
adapter="adapter.paddle.v1",
|
| 587 |
+
source_ref="$.pages[0].blocks[0].lines[0].words[0]",
|
| 588 |
+
evidence_type=EvidenceType.PROVIDER_NATIVE,
|
| 589 |
+
derived_from=[],
|
| 590 |
+
),
|
| 591 |
+
),
|
| 592 |
+
],
|
| 593 |
+
),
|
| 594 |
+
],
|
| 595 |
+
),
|
| 596 |
+
],
|
| 597 |
+
non_text_regions=[],
|
| 598 |
+
),
|
| 599 |
+
],
|
| 600 |
+
)
|
| 601 |
+
assert doc.document_id == "doc_0001"
|
| 602 |
+
assert doc.pages[0].text_regions[0].lines[0].words[0].text == "Bonjour"
|
| 603 |
+
assert doc.pages[0].text_regions[0].lines[0].words[0].confidence == 0.96
|
| 604 |
+
|
| 605 |
+
def test_hyphenation_example(self) -> None:
|
| 606 |
+
"""Validate the hyphenation example from §8."""
|
| 607 |
+
w1 = Word(
|
| 608 |
+
id="w18",
|
| 609 |
+
text="patri-",
|
| 610 |
+
geometry=_geo(500, 800, 95, 30),
|
| 611 |
+
confidence=0.89,
|
| 612 |
+
lang="fra",
|
| 613 |
+
provenance=_prov("$.blocks[2].lines[5].words[7]"),
|
| 614 |
+
hyphenation=Hyphenation(is_hyphenated=True, part=1, full_form="patrimoine"),
|
| 615 |
+
)
|
| 616 |
+
w2 = Word(
|
| 617 |
+
id="w19",
|
| 618 |
+
text="moine",
|
| 619 |
+
geometry=_geo(120, 840, 70, 30),
|
| 620 |
+
confidence=0.90,
|
| 621 |
+
lang="fra",
|
| 622 |
+
provenance=_prov("$.blocks[2].lines[6].words[0]"),
|
| 623 |
+
hyphenation=Hyphenation(is_hyphenated=True, part=2, full_form="patrimoine"),
|
| 624 |
+
)
|
| 625 |
+
assert w1.hyphenation.full_form == w2.hyphenation.full_form == "patrimoine"
|
| 626 |
+
assert w1.hyphenation.part == 1
|
| 627 |
+
assert w2.hyphenation.part == 2
|
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for geometry primitives and Geometry model."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
from pydantic import ValidationError
|
| 7 |
+
|
| 8 |
+
from src.app.domain.models import (
|
| 9 |
+
BBox,
|
| 10 |
+
Baseline,
|
| 11 |
+
ClipRect,
|
| 12 |
+
Geometry,
|
| 13 |
+
GeometryContext,
|
| 14 |
+
GeometryStatus,
|
| 15 |
+
Point,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class TestPoint:
|
| 20 |
+
def test_valid(self) -> None:
|
| 21 |
+
p = Point(x=10.0, y=20.0)
|
| 22 |
+
assert p.x == 10.0
|
| 23 |
+
assert p.y == 20.0
|
| 24 |
+
|
| 25 |
+
def test_origin(self) -> None:
|
| 26 |
+
p = Point(x=0, y=0)
|
| 27 |
+
assert p.x == 0.0
|
| 28 |
+
|
| 29 |
+
def test_negative_x_rejected(self) -> None:
|
| 30 |
+
with pytest.raises(ValidationError):
|
| 31 |
+
Point(x=-1, y=0)
|
| 32 |
+
|
| 33 |
+
def test_negative_y_rejected(self) -> None:
|
| 34 |
+
with pytest.raises(ValidationError):
|
| 35 |
+
Point(x=0, y=-1)
|
| 36 |
+
|
| 37 |
+
def test_frozen(self) -> None:
|
| 38 |
+
p = Point(x=1, y=2)
|
| 39 |
+
with pytest.raises(ValidationError):
|
| 40 |
+
p.x = 5 # type: ignore[misc]
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class TestBBox:
|
| 44 |
+
def test_valid(self) -> None:
|
| 45 |
+
b = BBox(x=10, y=20, width=100, height=50)
|
| 46 |
+
assert b.x2 == 110.0
|
| 47 |
+
assert b.y2 == 70.0
|
| 48 |
+
|
| 49 |
+
def test_as_tuple(self) -> None:
|
| 50 |
+
b = BBox(x=10, y=20, width=100, height=50)
|
| 51 |
+
assert b.as_tuple() == (10.0, 20.0, 100.0, 50.0)
|
| 52 |
+
|
| 53 |
+
def test_zero_width_rejected(self) -> None:
|
| 54 |
+
with pytest.raises(ValidationError):
|
| 55 |
+
BBox(x=0, y=0, width=0, height=10)
|
| 56 |
+
|
| 57 |
+
def test_zero_height_rejected(self) -> None:
|
| 58 |
+
with pytest.raises(ValidationError):
|
| 59 |
+
BBox(x=0, y=0, width=10, height=0)
|
| 60 |
+
|
| 61 |
+
def test_negative_x_rejected(self) -> None:
|
| 62 |
+
with pytest.raises(ValidationError):
|
| 63 |
+
BBox(x=-1, y=0, width=10, height=10)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class TestBaseline:
|
| 67 |
+
def test_valid(self) -> None:
|
| 68 |
+
bl = Baseline(start=Point(x=0, y=100), end=Point(x=200, y=100))
|
| 69 |
+
assert bl.start.x == 0
|
| 70 |
+
assert bl.end.x == 200
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
class TestClipRect:
|
| 74 |
+
def test_valid(self) -> None:
|
| 75 |
+
cr = ClipRect(x=0, y=0, width=100, height=100)
|
| 76 |
+
assert cr.width == 100
|
| 77 |
+
|
| 78 |
+
def test_zero_dimension_rejected(self) -> None:
|
| 79 |
+
with pytest.raises(ValidationError):
|
| 80 |
+
ClipRect(x=0, y=0, width=0, height=100)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
class TestGeometry:
|
| 84 |
+
def test_valid_exact(self) -> None:
|
| 85 |
+
g = Geometry(bbox=(100, 200, 300, 50), status=GeometryStatus.EXACT)
|
| 86 |
+
assert g.bbox == (100, 200, 300, 50)
|
| 87 |
+
assert g.polygon is None
|
| 88 |
+
|
| 89 |
+
def test_valid_with_polygon(self) -> None:
|
| 90 |
+
g = Geometry(
|
| 91 |
+
bbox=(100, 200, 300, 50),
|
| 92 |
+
polygon=[(100, 200), (400, 200), (400, 250), (100, 250)],
|
| 93 |
+
status=GeometryStatus.EXACT,
|
| 94 |
+
)
|
| 95 |
+
assert len(g.polygon) == 4
|
| 96 |
+
|
| 97 |
+
def test_zero_width_rejected(self) -> None:
|
| 98 |
+
with pytest.raises(ValidationError, match="width and height must be > 0"):
|
| 99 |
+
Geometry(bbox=(100, 200, 0, 50), status=GeometryStatus.EXACT)
|
| 100 |
+
|
| 101 |
+
def test_zero_height_rejected(self) -> None:
|
| 102 |
+
with pytest.raises(ValidationError, match="width and height must be > 0"):
|
| 103 |
+
Geometry(bbox=(100, 200, 300, 0), status=GeometryStatus.EXACT)
|
| 104 |
+
|
| 105 |
+
def test_negative_x_rejected(self) -> None:
|
| 106 |
+
with pytest.raises(ValidationError, match="x and y must be >= 0"):
|
| 107 |
+
Geometry(bbox=(-1, 200, 300, 50), status=GeometryStatus.EXACT)
|
| 108 |
+
|
| 109 |
+
def test_negative_y_rejected(self) -> None:
|
| 110 |
+
with pytest.raises(ValidationError, match="x and y must be >= 0"):
|
| 111 |
+
Geometry(bbox=(100, -1, 300, 50), status=GeometryStatus.EXACT)
|
| 112 |
+
|
| 113 |
+
def test_all_statuses_accepted(self) -> None:
|
| 114 |
+
for status in GeometryStatus:
|
| 115 |
+
g = Geometry(bbox=(10, 10, 10, 10), status=status)
|
| 116 |
+
assert g.status == status
|
| 117 |
+
|
| 118 |
+
def test_frozen(self) -> None:
|
| 119 |
+
g = Geometry(bbox=(10, 20, 30, 40), status=GeometryStatus.EXACT)
|
| 120 |
+
with pytest.raises(ValidationError):
|
| 121 |
+
g.status = GeometryStatus.INFERRED # type: ignore[misc]
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
class TestGeometryContext:
|
| 125 |
+
def test_valid_minimal(self) -> None:
|
| 126 |
+
gc = GeometryContext(source_width=2480, source_height=3508)
|
| 127 |
+
assert gc.source_width == 2480
|
| 128 |
+
assert gc.resize_factor is None
|
| 129 |
+
|
| 130 |
+
def test_valid_full(self) -> None:
|
| 131 |
+
gc = GeometryContext(
|
| 132 |
+
source_width=2480,
|
| 133 |
+
source_height=3508,
|
| 134 |
+
provided_width=1240,
|
| 135 |
+
provided_height=1754,
|
| 136 |
+
resize_factor=0.5,
|
| 137 |
+
rotation=90.0,
|
| 138 |
+
)
|
| 139 |
+
assert gc.resize_factor == 0.5
|
| 140 |
+
|
| 141 |
+
def test_zero_source_rejected(self) -> None:
|
| 142 |
+
with pytest.raises(ValidationError):
|
| 143 |
+
GeometryContext(source_width=0, source_height=100)
|
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the Provenance model."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
from pydantic import ValidationError
|
| 7 |
+
|
| 8 |
+
from src.app.domain.models import EvidenceType, Provenance
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class TestProvenance:
|
| 12 |
+
def test_valid_native(self) -> None:
|
| 13 |
+
p = Provenance(
|
| 14 |
+
provider="paddleocr-vl",
|
| 15 |
+
adapter="adapter.paddle.v1",
|
| 16 |
+
source_ref="$.pages[0].blocks[0]",
|
| 17 |
+
evidence_type=EvidenceType.PROVIDER_NATIVE,
|
| 18 |
+
)
|
| 19 |
+
assert p.derived_from == []
|
| 20 |
+
|
| 21 |
+
def test_valid_derived(self) -> None:
|
| 22 |
+
p = Provenance(
|
| 23 |
+
provider="enricher.polygon_to_bbox",
|
| 24 |
+
adapter="enricher.v1",
|
| 25 |
+
source_ref="tl1",
|
| 26 |
+
evidence_type=EvidenceType.DERIVED,
|
| 27 |
+
derived_from=["tl1"],
|
| 28 |
+
)
|
| 29 |
+
assert p.derived_from == ["tl1"]
|
| 30 |
+
|
| 31 |
+
def test_native_with_derived_from_rejected(self) -> None:
|
| 32 |
+
with pytest.raises(ValidationError, match="provider_native.*empty derived_from"):
|
| 33 |
+
Provenance(
|
| 34 |
+
provider="paddle",
|
| 35 |
+
adapter="adapter.v1",
|
| 36 |
+
source_ref="$.x",
|
| 37 |
+
evidence_type=EvidenceType.PROVIDER_NATIVE,
|
| 38 |
+
derived_from=["some_id"],
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
def test_empty_provider_rejected(self) -> None:
|
| 42 |
+
with pytest.raises(ValidationError):
|
| 43 |
+
Provenance(
|
| 44 |
+
provider="",
|
| 45 |
+
adapter="adapter.v1",
|
| 46 |
+
source_ref="$.x",
|
| 47 |
+
evidence_type=EvidenceType.PROVIDER_NATIVE,
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
def test_empty_adapter_rejected(self) -> None:
|
| 51 |
+
with pytest.raises(ValidationError):
|
| 52 |
+
Provenance(
|
| 53 |
+
provider="paddle",
|
| 54 |
+
adapter="",
|
| 55 |
+
source_ref="$.x",
|
| 56 |
+
evidence_type=EvidenceType.PROVIDER_NATIVE,
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
def test_empty_source_ref_rejected(self) -> None:
|
| 60 |
+
with pytest.raises(ValidationError):
|
| 61 |
+
Provenance(
|
| 62 |
+
provider="paddle",
|
| 63 |
+
adapter="adapter.v1",
|
| 64 |
+
source_ref="",
|
| 65 |
+
evidence_type=EvidenceType.PROVIDER_NATIVE,
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
def test_repaired_evidence_type(self) -> None:
|
| 69 |
+
p = Provenance(
|
| 70 |
+
provider="bbox_repair",
|
| 71 |
+
adapter="enricher.v1",
|
| 72 |
+
source_ref="w1",
|
| 73 |
+
evidence_type=EvidenceType.REPAIRED,
|
| 74 |
+
derived_from=["w1"],
|
| 75 |
+
)
|
| 76 |
+
assert p.evidence_type == EvidenceType.REPAIRED
|
| 77 |
+
|
| 78 |
+
def test_manual_evidence_type(self) -> None:
|
| 79 |
+
p = Provenance(
|
| 80 |
+
provider="manual",
|
| 81 |
+
adapter="manual.v1",
|
| 82 |
+
source_ref="user_correction",
|
| 83 |
+
evidence_type=EvidenceType.MANUAL,
|
| 84 |
+
)
|
| 85 |
+
assert p.evidence_type == EvidenceType.MANUAL
|
| 86 |
+
|
| 87 |
+
def test_frozen(self) -> None:
|
| 88 |
+
p = Provenance(
|
| 89 |
+
provider="paddle",
|
| 90 |
+
adapter="v1",
|
| 91 |
+
source_ref="$.x",
|
| 92 |
+
evidence_type=EvidenceType.PROVIDER_NATIVE,
|
| 93 |
+
)
|
| 94 |
+
with pytest.raises(ValidationError):
|
| 95 |
+
p.provider = "other" # type: ignore[misc]
|
| 96 |
+
|
| 97 |
+
def test_json_roundtrip(self) -> None:
|
| 98 |
+
p = Provenance(
|
| 99 |
+
provider="paddleocr-vl",
|
| 100 |
+
adapter="adapter.paddle.v1",
|
| 101 |
+
source_ref="$.pages[0]",
|
| 102 |
+
evidence_type=EvidenceType.PROVIDER_NATIVE,
|
| 103 |
+
)
|
| 104 |
+
data = p.model_dump()
|
| 105 |
+
p2 = Provenance.model_validate(data)
|
| 106 |
+
assert p == p2
|
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the RawProviderPayload model."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
from pydantic import ValidationError
|
| 7 |
+
|
| 8 |
+
from src.app.domain.models import RawProviderPayload
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class TestRawProviderPayload:
|
| 12 |
+
def test_valid_dict_payload(self) -> None:
|
| 13 |
+
rp = RawProviderPayload(
|
| 14 |
+
provider_id="paddleocr",
|
| 15 |
+
adapter_id="adapter.paddle.v1",
|
| 16 |
+
runtime_type="local",
|
| 17 |
+
payload={"pages": [{"blocks": []}]},
|
| 18 |
+
)
|
| 19 |
+
assert rp.provider_id == "paddleocr"
|
| 20 |
+
|
| 21 |
+
def test_valid_list_payload(self) -> None:
|
| 22 |
+
rp = RawProviderPayload(
|
| 23 |
+
provider_id="paddleocr",
|
| 24 |
+
adapter_id="adapter.paddle.v1",
|
| 25 |
+
runtime_type="local",
|
| 26 |
+
payload=[[[100, 200], [300, 200], [300, 250], [100, 250]], ["text", 0.95]],
|
| 27 |
+
)
|
| 28 |
+
assert isinstance(rp.payload, list)
|
| 29 |
+
|
| 30 |
+
def test_empty_provider_rejected(self) -> None:
|
| 31 |
+
with pytest.raises(ValidationError):
|
| 32 |
+
RawProviderPayload(
|
| 33 |
+
provider_id="",
|
| 34 |
+
adapter_id="v1",
|
| 35 |
+
runtime_type="local",
|
| 36 |
+
payload={},
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
def test_with_image_dimensions(self) -> None:
|
| 40 |
+
rp = RawProviderPayload(
|
| 41 |
+
provider_id="paddle",
|
| 42 |
+
adapter_id="v1",
|
| 43 |
+
runtime_type="local",
|
| 44 |
+
payload={},
|
| 45 |
+
image_width=2480,
|
| 46 |
+
image_height=3508,
|
| 47 |
+
)
|
| 48 |
+
assert rp.image_width == 2480
|
| 49 |
+
|
| 50 |
+
def test_zero_dimension_rejected(self) -> None:
|
| 51 |
+
with pytest.raises(ValidationError):
|
| 52 |
+
RawProviderPayload(
|
| 53 |
+
provider_id="paddle",
|
| 54 |
+
adapter_id="v1",
|
| 55 |
+
runtime_type="local",
|
| 56 |
+
payload={},
|
| 57 |
+
image_width=0,
|
| 58 |
+
image_height=100,
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
def test_with_metadata(self) -> None:
|
| 62 |
+
rp = RawProviderPayload(
|
| 63 |
+
provider_id="paddle",
|
| 64 |
+
adapter_id="v1",
|
| 65 |
+
runtime_type="local",
|
| 66 |
+
payload={"data": "test"},
|
| 67 |
+
metadata={"processing_time_ms": 1234},
|
| 68 |
+
)
|
| 69 |
+
assert rp.metadata["processing_time_ms"] == 1234
|
| 70 |
+
|
| 71 |
+
def test_json_roundtrip(self) -> None:
|
| 72 |
+
rp = RawProviderPayload(
|
| 73 |
+
provider_id="paddle",
|
| 74 |
+
adapter_id="v1",
|
| 75 |
+
runtime_type="local",
|
| 76 |
+
model_id="PP-OCRv4",
|
| 77 |
+
payload={"blocks": [{"text": "hello"}]},
|
| 78 |
+
)
|
| 79 |
+
data = rp.model_dump(mode="json")
|
| 80 |
+
rp2 = RawProviderPayload.model_validate(data)
|
| 81 |
+
assert rp2.provider_id == rp.provider_id
|
| 82 |
+
assert rp2.model_id == "PP-OCRv4"
|
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for readiness and export eligibility models."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
from pydantic import ValidationError
|
| 7 |
+
|
| 8 |
+
from src.app.domain.models import (
|
| 9 |
+
AltoReadiness,
|
| 10 |
+
DocumentReadiness,
|
| 11 |
+
ExportEligibility,
|
| 12 |
+
MissingCapability,
|
| 13 |
+
PageXmlReadiness,
|
| 14 |
+
ReadinessLevel,
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class TestAltoReadiness:
|
| 19 |
+
def test_full_no_missing(self) -> None:
|
| 20 |
+
r = AltoReadiness(level=ReadinessLevel.FULL)
|
| 21 |
+
assert r.missing == []
|
| 22 |
+
|
| 23 |
+
def test_partial_with_missing(self) -> None:
|
| 24 |
+
r = AltoReadiness(
|
| 25 |
+
level=ReadinessLevel.PARTIAL,
|
| 26 |
+
missing=[MissingCapability.WORD_GEOMETRY, MissingCapability.CONFIDENCE],
|
| 27 |
+
)
|
| 28 |
+
assert len(r.missing) == 2
|
| 29 |
+
|
| 30 |
+
def test_none_with_missing(self) -> None:
|
| 31 |
+
r = AltoReadiness(
|
| 32 |
+
level=ReadinessLevel.NONE,
|
| 33 |
+
missing=[MissingCapability.PAGE_DIMENSIONS],
|
| 34 |
+
)
|
| 35 |
+
assert r.level == ReadinessLevel.NONE
|
| 36 |
+
|
| 37 |
+
def test_full_with_missing_rejected(self) -> None:
|
| 38 |
+
with pytest.raises(ValidationError, match="full.*missing"):
|
| 39 |
+
AltoReadiness(
|
| 40 |
+
level=ReadinessLevel.FULL,
|
| 41 |
+
missing=[MissingCapability.WORD_GEOMETRY],
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
def test_none_without_missing_rejected(self) -> None:
|
| 45 |
+
with pytest.raises(ValidationError, match="none.*no missing"):
|
| 46 |
+
AltoReadiness(level=ReadinessLevel.NONE, missing=[])
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class TestPageXmlReadiness:
|
| 50 |
+
def test_full_no_missing(self) -> None:
|
| 51 |
+
r = PageXmlReadiness(level=ReadinessLevel.FULL)
|
| 52 |
+
assert r.missing == []
|
| 53 |
+
|
| 54 |
+
def test_full_with_missing_rejected(self) -> None:
|
| 55 |
+
with pytest.raises(ValidationError, match="full.*missing"):
|
| 56 |
+
PageXmlReadiness(
|
| 57 |
+
level=ReadinessLevel.FULL,
|
| 58 |
+
missing=[MissingCapability.LINE_GEOMETRY],
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
def test_none_without_missing_rejected(self) -> None:
|
| 62 |
+
with pytest.raises(ValidationError, match="none.*no missing"):
|
| 63 |
+
PageXmlReadiness(level=ReadinessLevel.NONE, missing=[])
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class TestExportEligibility:
|
| 67 |
+
def test_defaults_to_none(self) -> None:
|
| 68 |
+
e = ExportEligibility()
|
| 69 |
+
assert e.alto_export == ReadinessLevel.NONE
|
| 70 |
+
assert e.page_export == ReadinessLevel.NONE
|
| 71 |
+
assert e.viewer_render == ReadinessLevel.NONE
|
| 72 |
+
|
| 73 |
+
def test_independent_levels(self) -> None:
|
| 74 |
+
e = ExportEligibility(
|
| 75 |
+
alto_export=ReadinessLevel.FULL,
|
| 76 |
+
page_export=ReadinessLevel.PARTIAL,
|
| 77 |
+
viewer_render=ReadinessLevel.DEGRADED,
|
| 78 |
+
)
|
| 79 |
+
assert e.alto_export == ReadinessLevel.FULL
|
| 80 |
+
assert e.page_export == ReadinessLevel.PARTIAL
|
| 81 |
+
assert e.viewer_render == ReadinessLevel.DEGRADED
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
class TestDocumentReadiness:
|
| 85 |
+
def test_defaults(self) -> None:
|
| 86 |
+
dr = DocumentReadiness()
|
| 87 |
+
assert dr.level == ReadinessLevel.NONE
|
| 88 |
+
assert dr.page_readiness == []
|
| 89 |
+
|
| 90 |
+
def test_with_pages(self) -> None:
|
| 91 |
+
dr = DocumentReadiness(
|
| 92 |
+
level=ReadinessLevel.PARTIAL,
|
| 93 |
+
page_readiness=[ReadinessLevel.FULL, ReadinessLevel.PARTIAL],
|
| 94 |
+
)
|
| 95 |
+
assert len(dr.page_readiness) == 2
|
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the ViewerProjection model."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
from pydantic import ValidationError
|
| 7 |
+
|
| 8 |
+
from src.app.domain.models import (
|
| 9 |
+
EvidenceType,
|
| 10 |
+
ExportEligibility,
|
| 11 |
+
GeometryStatus,
|
| 12 |
+
OverlayLevel,
|
| 13 |
+
ReadinessLevel,
|
| 14 |
+
)
|
| 15 |
+
from src.app.domain.models.viewer_projection import (
|
| 16 |
+
InspectionData,
|
| 17 |
+
OverlayItem,
|
| 18 |
+
ViewerProjection,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class TestOverlayItem:
|
| 23 |
+
def test_valid_word_overlay(self) -> None:
|
| 24 |
+
o = OverlayItem(
|
| 25 |
+
id="w1",
|
| 26 |
+
level=OverlayLevel.WORD,
|
| 27 |
+
bbox=(110, 220, 90, 40),
|
| 28 |
+
text="Bonjour",
|
| 29 |
+
confidence=0.96,
|
| 30 |
+
provenance_type=EvidenceType.PROVIDER_NATIVE,
|
| 31 |
+
geometry_status=GeometryStatus.EXACT,
|
| 32 |
+
)
|
| 33 |
+
assert o.level == OverlayLevel.WORD
|
| 34 |
+
assert o.text == "Bonjour"
|
| 35 |
+
|
| 36 |
+
def test_valid_block_overlay(self) -> None:
|
| 37 |
+
o = OverlayItem(
|
| 38 |
+
id="tb1",
|
| 39 |
+
level=OverlayLevel.BLOCK,
|
| 40 |
+
bbox=(100, 200, 1200, 900),
|
| 41 |
+
label="body",
|
| 42 |
+
)
|
| 43 |
+
assert o.level == OverlayLevel.BLOCK
|
| 44 |
+
|
| 45 |
+
def test_with_polygon(self) -> None:
|
| 46 |
+
o = OverlayItem(
|
| 47 |
+
id="w1",
|
| 48 |
+
level=OverlayLevel.WORD,
|
| 49 |
+
bbox=(100, 200, 50, 30),
|
| 50 |
+
polygon=[(100, 200), (150, 200), (150, 230), (100, 230)],
|
| 51 |
+
)
|
| 52 |
+
assert len(o.polygon) == 4
|
| 53 |
+
|
| 54 |
+
def test_empty_id_rejected(self) -> None:
|
| 55 |
+
with pytest.raises(ValidationError):
|
| 56 |
+
OverlayItem(id="", level=OverlayLevel.WORD, bbox=(0, 0, 10, 10))
|
| 57 |
+
|
| 58 |
+
def test_confidence_out_of_range(self) -> None:
|
| 59 |
+
with pytest.raises(ValidationError):
|
| 60 |
+
OverlayItem(
|
| 61 |
+
id="w1",
|
| 62 |
+
level=OverlayLevel.WORD,
|
| 63 |
+
bbox=(0, 0, 10, 10),
|
| 64 |
+
confidence=2.0,
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
class TestInspectionData:
|
| 69 |
+
def test_valid(self) -> None:
|
| 70 |
+
insp = InspectionData(
|
| 71 |
+
id="w1",
|
| 72 |
+
level=OverlayLevel.WORD,
|
| 73 |
+
text="Bonjour",
|
| 74 |
+
bbox=(110, 220, 90, 40),
|
| 75 |
+
confidence=0.96,
|
| 76 |
+
lang="fra",
|
| 77 |
+
provenance_type=EvidenceType.PROVIDER_NATIVE,
|
| 78 |
+
provenance_provider="paddleocr",
|
| 79 |
+
geometry_status=GeometryStatus.EXACT,
|
| 80 |
+
readiness=ReadinessLevel.FULL,
|
| 81 |
+
)
|
| 82 |
+
assert insp.lang == "fra"
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
class TestViewerProjection:
|
| 86 |
+
def test_valid_minimal(self) -> None:
|
| 87 |
+
vp = ViewerProjection(
|
| 88 |
+
image_ref="test.png",
|
| 89 |
+
image_width=2480,
|
| 90 |
+
image_height=3508,
|
| 91 |
+
)
|
| 92 |
+
assert vp.block_overlays == []
|
| 93 |
+
assert vp.word_overlays == []
|
| 94 |
+
|
| 95 |
+
def test_valid_with_overlays(self) -> None:
|
| 96 |
+
block = OverlayItem(
|
| 97 |
+
id="tb1",
|
| 98 |
+
level=OverlayLevel.BLOCK,
|
| 99 |
+
bbox=(100, 200, 1200, 900),
|
| 100 |
+
label="body",
|
| 101 |
+
)
|
| 102 |
+
word = OverlayItem(
|
| 103 |
+
id="w1",
|
| 104 |
+
level=OverlayLevel.WORD,
|
| 105 |
+
bbox=(110, 220, 90, 40),
|
| 106 |
+
text="Bonjour",
|
| 107 |
+
)
|
| 108 |
+
vp = ViewerProjection(
|
| 109 |
+
image_ref="test.png",
|
| 110 |
+
image_width=2480,
|
| 111 |
+
image_height=3508,
|
| 112 |
+
block_overlays=[block],
|
| 113 |
+
word_overlays=[word],
|
| 114 |
+
inspection_index={
|
| 115 |
+
"w1": InspectionData(
|
| 116 |
+
id="w1",
|
| 117 |
+
level=OverlayLevel.WORD,
|
| 118 |
+
text="Bonjour",
|
| 119 |
+
bbox=(110, 220, 90, 40),
|
| 120 |
+
)
|
| 121 |
+
},
|
| 122 |
+
)
|
| 123 |
+
assert len(vp.block_overlays) == 1
|
| 124 |
+
assert len(vp.word_overlays) == 1
|
| 125 |
+
assert "w1" in vp.inspection_index
|
| 126 |
+
|
| 127 |
+
def test_zero_dimensions_rejected(self) -> None:
|
| 128 |
+
with pytest.raises(ValidationError):
|
| 129 |
+
ViewerProjection(
|
| 130 |
+
image_ref="test.png",
|
| 131 |
+
image_width=0,
|
| 132 |
+
image_height=100,
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
def test_default_export_status(self) -> None:
|
| 136 |
+
vp = ViewerProjection(
|
| 137 |
+
image_ref="test.png",
|
| 138 |
+
image_width=100,
|
| 139 |
+
image_height=100,
|
| 140 |
+
)
|
| 141 |
+
assert vp.export_status.alto_export == ReadinessLevel.NONE
|
| 142 |
+
|
| 143 |
+
def test_json_roundtrip(self) -> None:
|
| 144 |
+
vp = ViewerProjection(
|
| 145 |
+
image_ref="test.png",
|
| 146 |
+
image_width=2480,
|
| 147 |
+
image_height=3508,
|
| 148 |
+
export_status=ExportEligibility(
|
| 149 |
+
alto_export=ReadinessLevel.FULL,
|
| 150 |
+
page_export=ReadinessLevel.PARTIAL,
|
| 151 |
+
viewer_render=ReadinessLevel.FULL,
|
| 152 |
+
),
|
| 153 |
+
)
|
| 154 |
+
data = vp.model_dump(mode="json")
|
| 155 |
+
vp2 = ViewerProjection.model_validate(data)
|
| 156 |
+
assert vp2.export_status.alto_export == ReadinessLevel.FULL
|