Claude commited on
Commit
41fae03
·
unverified ·
1 Parent(s): 96f8c0a

Sprint 1: complete domain models — canonical document, geometry, provenance, readiness

Browse files

Define 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 CHANGED
@@ -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
+ ]
src/app/domain/models/canonical_document.py CHANGED
@@ -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
src/app/domain/models/geometry.py CHANGED
@@ -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
src/app/domain/models/provenance.py CHANGED
@@ -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
src/app/domain/models/raw_payload.py CHANGED
@@ -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
src/app/domain/models/readiness.py CHANGED
@@ -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)
src/app/domain/models/status.py CHANGED
@@ -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"
src/app/domain/models/viewer_projection.py CHANGED
@@ -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)
tests/unit/test_canonical_document.py ADDED
@@ -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
tests/unit/test_geometry.py ADDED
@@ -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)
tests/unit/test_provenance.py ADDED
@@ -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
tests/unit/test_raw_payload.py ADDED
@@ -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"
tests/unit/test_readiness.py ADDED
@@ -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
tests/unit/test_viewer_projection.py ADDED
@@ -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