Claude commited on
Commit
0580de5
·
unverified ·
1 Parent(s): 240c946

Sprint 9: complete provider system — runtimes, adapters, registry, resolver

Browse files

Three runtimes (skeleton for V1, actual execution deferred):
- local_runtime.py: for locally installed models
- hub_runtime.py: for HF Hub models (detects huggingface_hub availability)
- api_runtime.py: for external HTTP endpoints (configurable timeout/retries)

Two new adapters:
- line_box_json: handles providers returning lines with bboxes but no word
segmentation. Converts xyxy→xywh. Each line becomes one word. Block inferred
- text_only: handles mLLM providers (Qwen, etc.) returning text without
geometry. Geometry marked 'unknown' — honest about data absence. Splits
text into paragraphs and lines. ALTO will be refused, PAGE may be partial

Provider infrastructure:
- registry.py: central index of adapters (3 families) and runtimes (3 types)
with get_adapter(), get_runtime(), list_*() functions
- resolver.py: ResolvedProvider — resolves ProviderProfile into concrete
runtime + adapter pair. No magic: profile declares family + runtime_type
- normalization/pipeline.py: updated to use registry instead of local map

28 new tests (line_box adapter: 8, text_only adapter: 8, registry: 9,
resolver: 3). 460 total passing.

https://claude.ai/code/session_01Cuzvc9Pjfo5u46eT3ta2Cg

src/app/normalization/pipeline.py CHANGED
@@ -1,7 +1,7 @@
1
  """Normalization pipeline — orchestrates raw → canonical conversion.
2
 
3
  The pipeline:
4
- 1. Resolves the adapter from the provider profile
5
  2. Runs the adapter to produce a CanonicalDocument
6
  3. Returns the document (enrichers and validators are separate steps)
7
  """
@@ -10,27 +10,7 @@ from __future__ import annotations
10
 
11
  from src.app.domain.models import CanonicalDocument, RawProviderPayload
12
  from src.app.domain.models.geometry import GeometryContext
13
- from src.app.providers.adapters.base import BaseAdapter
14
- from src.app.providers.adapters.word_box_json import WordBoxJsonAdapter
15
-
16
- # Registry of available adapters by family name
17
- _ADAPTERS: dict[str, type[BaseAdapter]] = {
18
- "word_box_json": WordBoxJsonAdapter,
19
- }
20
-
21
-
22
- def get_adapter(family: str) -> BaseAdapter:
23
- """Instantiate an adapter for the given provider family.
24
-
25
- Raises KeyError if the family is not registered.
26
- """
27
- adapter_cls = _ADAPTERS.get(family)
28
- if adapter_cls is None:
29
- raise KeyError(
30
- f"No adapter registered for family '{family}'. "
31
- f"Available: {list(_ADAPTERS.keys())}"
32
- )
33
- return adapter_cls()
34
 
35
 
36
  def normalize(
 
1
  """Normalization pipeline — orchestrates raw → canonical conversion.
2
 
3
  The pipeline:
4
+ 1. Resolves the adapter from the provider registry
5
  2. Runs the adapter to produce a CanonicalDocument
6
  3. Returns the document (enrichers and validators are separate steps)
7
  """
 
10
 
11
  from src.app.domain.models import CanonicalDocument, RawProviderPayload
12
  from src.app.domain.models.geometry import GeometryContext
13
+ from src.app.providers.registry import get_adapter
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
 
15
 
16
  def normalize(
src/app/providers/adapters/line_box_json.py CHANGED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """line_box_json adapter — handles providers that return lines with bboxes but no word segmentation.
2
+
3
+ Expected payload format:
4
+ [
5
+ {"text": "line text", "bbox": [x1, y1, x2, y2], "confidence": 0.95},
6
+ ...
7
+ ]
8
+ or:
9
+ [
10
+ {"text": "line text", "bbox": [x, y, w, h], "confidence": 0.95, "format": "xywh"},
11
+ ...
12
+ ]
13
+
14
+ Each item is treated as a text line. A single word per line is created
15
+ since the provider doesn't segment words. The block is inferred.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from src.app.domain.models import (
21
+ CanonicalDocument,
22
+ Geometry,
23
+ Provenance,
24
+ RawProviderPayload,
25
+ )
26
+ from src.app.domain.models.geometry import GeometryContext
27
+ from src.app.domain.models.status import EvidenceType, GeometryStatus, InputType
28
+ from src.app.geometry.bbox import union_all
29
+ from src.app.geometry.normalization import xyxy_to_xywh
30
+ from src.app.normalization.canonical_builder import CanonicalBuilder
31
+ from src.app.providers.adapters.base import BaseAdapter
32
+
33
+
34
+ class LineBoxJsonAdapter(BaseAdapter):
35
+ """Adapter for the line_box_json family."""
36
+
37
+ @property
38
+ def family(self) -> str:
39
+ return "line_box_json"
40
+
41
+ @property
42
+ def version(self) -> str:
43
+ return "adapter.line_box_json.v1"
44
+
45
+ def normalize(
46
+ self,
47
+ raw: RawProviderPayload,
48
+ geometry_context: GeometryContext,
49
+ *,
50
+ document_id: str,
51
+ source_filename: str | None = None,
52
+ ) -> CanonicalDocument:
53
+ payload = raw.payload
54
+ if not isinstance(payload, list):
55
+ raise ValueError(
56
+ f"line_box_json expects a list payload, got {type(payload).__name__}"
57
+ )
58
+ if not payload:
59
+ raise ValueError("line_box_json payload contains no items")
60
+
61
+ builder = CanonicalBuilder(
62
+ document_id=document_id,
63
+ input_type=InputType.IMAGE,
64
+ filename=source_filename,
65
+ )
66
+
67
+ page = builder.add_page(
68
+ page_id="p1",
69
+ page_index=0,
70
+ width=geometry_context.source_width,
71
+ height=geometry_context.source_height,
72
+ )
73
+
74
+ line_bboxes: list[tuple[float, float, float, float]] = []
75
+ line_data: list[dict] = []
76
+
77
+ for idx, item in enumerate(payload):
78
+ if not isinstance(item, dict):
79
+ raise ValueError(f"Item {idx}: expected dict, got {type(item).__name__}")
80
+
81
+ text = str(item.get("text", ""))
82
+ if not text:
83
+ continue
84
+
85
+ raw_bbox = item.get("bbox")
86
+ if not raw_bbox or len(raw_bbox) != 4:
87
+ raise ValueError(f"Item {idx}: missing or invalid bbox")
88
+
89
+ # Determine format: default is xyxy, "xywh" if specified
90
+ fmt = item.get("format", "xyxy")
91
+ if fmt == "xywh":
92
+ bbox = (float(raw_bbox[0]), float(raw_bbox[1]),
93
+ float(raw_bbox[2]), float(raw_bbox[3]))
94
+ else:
95
+ bbox = xyxy_to_xywh(tuple(float(v) for v in raw_bbox))
96
+
97
+ confidence = item.get("confidence")
98
+ if confidence is not None:
99
+ confidence = max(0.0, min(1.0, float(confidence)))
100
+
101
+ line_bboxes.append(bbox)
102
+ line_data.append({
103
+ "bbox": bbox,
104
+ "text": text,
105
+ "confidence": confidence,
106
+ "source_idx": idx,
107
+ })
108
+
109
+ if not line_data:
110
+ raise ValueError("line_box_json payload contains no valid items")
111
+
112
+ block_bbox = union_all(line_bboxes)
113
+ block_prov = Provenance(
114
+ provider=raw.provider_id,
115
+ adapter=self.version,
116
+ source_ref="$",
117
+ evidence_type=EvidenceType.DERIVED,
118
+ derived_from=[f"tl{i+1}" for i in range(len(line_data))],
119
+ )
120
+
121
+ region = page.add_text_region(
122
+ region_id="tb1",
123
+ geometry=Geometry(bbox=block_bbox, status=GeometryStatus.INFERRED),
124
+ provenance=block_prov,
125
+ )
126
+
127
+ for i, ld in enumerate(line_data):
128
+ line_id = f"tl{i + 1}"
129
+ word_id = f"w{i + 1}"
130
+
131
+ prov = Provenance(
132
+ provider=raw.provider_id,
133
+ adapter=self.version,
134
+ source_ref=f"$[{ld['source_idx']}]",
135
+ evidence_type=EvidenceType.PROVIDER_NATIVE,
136
+ )
137
+
138
+ geo = Geometry(bbox=ld["bbox"], status=GeometryStatus.EXACT)
139
+
140
+ line = region.add_line(line_id, geometry=geo, provenance=prov)
141
+ line.add_word(
142
+ word_id,
143
+ text=ld["text"],
144
+ geometry=geo,
145
+ provenance=prov,
146
+ confidence=ld["confidence"],
147
+ )
148
+
149
+ return builder.build()
src/app/providers/adapters/text_only.py CHANGED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """text_only adapter — handles providers that return structured text without geometry.
2
+
3
+ Expected payload format:
4
+ {
5
+ "text": "full page text",
6
+ "blocks": [ # optional
7
+ {"text": "block text"},
8
+ ...
9
+ ]
10
+ }
11
+ or simply:
12
+ {"text": "full text"}
13
+
14
+ This adapter is honest: geometry is marked as 'unknown' since the
15
+ provider doesn't supply coordinates. ALTO export will be refused
16
+ (no word geometry), but PAGE export may be partial and the viewer
17
+ will show text without positioned overlays.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from src.app.domain.models import (
23
+ CanonicalDocument,
24
+ Geometry,
25
+ Provenance,
26
+ RawProviderPayload,
27
+ )
28
+ from src.app.domain.models.geometry import GeometryContext
29
+ from src.app.domain.models.status import EvidenceType, GeometryStatus, InputType
30
+ from src.app.normalization.canonical_builder import CanonicalBuilder
31
+ from src.app.providers.adapters.base import BaseAdapter
32
+
33
+
34
+ class TextOnlyAdapter(BaseAdapter):
35
+ """Adapter for the text_only family (mLLM without geometry)."""
36
+
37
+ @property
38
+ def family(self) -> str:
39
+ return "text_only"
40
+
41
+ @property
42
+ def version(self) -> str:
43
+ return "adapter.text_only.v1"
44
+
45
+ def normalize(
46
+ self,
47
+ raw: RawProviderPayload,
48
+ geometry_context: GeometryContext,
49
+ *,
50
+ document_id: str,
51
+ source_filename: str | None = None,
52
+ ) -> CanonicalDocument:
53
+ payload = raw.payload
54
+ if not isinstance(payload, dict):
55
+ raise ValueError(
56
+ f"text_only expects a dict payload, got {type(payload).__name__}"
57
+ )
58
+
59
+ builder = CanonicalBuilder(
60
+ document_id=document_id,
61
+ input_type=InputType.IMAGE,
62
+ filename=source_filename,
63
+ )
64
+
65
+ page_w = geometry_context.source_width
66
+ page_h = geometry_context.source_height
67
+
68
+ page = builder.add_page("p1", 0, page_w, page_h)
69
+
70
+ # Placeholder bbox covering the full page — marked unknown
71
+ full_page_geo = Geometry(
72
+ bbox=(0, 0, page_w, page_h),
73
+ status=GeometryStatus.UNKNOWN,
74
+ )
75
+
76
+ # Extract text blocks
77
+ blocks = payload.get("blocks")
78
+ if blocks and isinstance(blocks, list):
79
+ texts = [str(b.get("text", "")) for b in blocks if isinstance(b, dict) and b.get("text")]
80
+ else:
81
+ # Single text blob — split into paragraphs
82
+ full_text = str(payload.get("text", ""))
83
+ if not full_text.strip():
84
+ raise ValueError("text_only payload has no text content")
85
+ texts = [p.strip() for p in full_text.split("\n\n") if p.strip()]
86
+ if not texts:
87
+ texts = [full_text.strip()]
88
+
89
+ for bi, block_text in enumerate(texts):
90
+ block_id = f"tb{bi + 1}"
91
+ prov = Provenance(
92
+ provider=raw.provider_id,
93
+ adapter=self.version,
94
+ source_ref=f"$.blocks[{bi}]" if blocks else f"$.text.paragraph[{bi}]",
95
+ evidence_type=EvidenceType.PROVIDER_NATIVE,
96
+ )
97
+
98
+ region = page.add_text_region(
99
+ region_id=block_id,
100
+ geometry=full_page_geo,
101
+ provenance=prov,
102
+ )
103
+
104
+ # Split block into lines
105
+ lines = [l.strip() for l in block_text.split("\n") if l.strip()]
106
+ if not lines:
107
+ lines = [block_text]
108
+
109
+ for li, line_text in enumerate(lines):
110
+ line_id = f"tl{bi + 1}_{li + 1}"
111
+ word_id = f"w{bi + 1}_{li + 1}"
112
+
113
+ line = region.add_line(
114
+ line_id, geometry=full_page_geo, provenance=prov,
115
+ )
116
+ # Each line becomes a single word (no word segmentation available)
117
+ line.add_word(
118
+ word_id,
119
+ text=line_text,
120
+ geometry=full_page_geo,
121
+ provenance=prov,
122
+ )
123
+
124
+ return builder.build()
src/app/providers/registry.py CHANGED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Provider registry — central index of available adapters and runtimes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from src.app.providers.adapters.base import BaseAdapter
6
+ from src.app.providers.adapters.line_box_json import LineBoxJsonAdapter
7
+ from src.app.providers.adapters.text_only import TextOnlyAdapter
8
+ from src.app.providers.adapters.word_box_json import WordBoxJsonAdapter
9
+ from src.app.providers.profiles import ProviderFamily, RuntimeType
10
+ from src.app.providers.runtimes.api_runtime import ApiRuntime
11
+ from src.app.providers.runtimes.base import BaseRuntime
12
+ from src.app.providers.runtimes.hub_runtime import HubRuntime
13
+ from src.app.providers.runtimes.local_runtime import LocalRuntime
14
+
15
+ # -- Adapter registry ---------------------------------------------------------
16
+
17
+ _ADAPTER_REGISTRY: dict[str, type[BaseAdapter]] = {
18
+ ProviderFamily.WORD_BOX_JSON.value: WordBoxJsonAdapter,
19
+ ProviderFamily.LINE_BOX_JSON.value: LineBoxJsonAdapter,
20
+ ProviderFamily.TEXT_ONLY.value: TextOnlyAdapter,
21
+ }
22
+
23
+
24
+ def get_adapter(family: str) -> BaseAdapter:
25
+ """Instantiate an adapter for the given provider family."""
26
+ cls = _ADAPTER_REGISTRY.get(family)
27
+ if cls is None:
28
+ raise KeyError(
29
+ f"No adapter for family '{family}'. "
30
+ f"Available: {list(_ADAPTER_REGISTRY.keys())}"
31
+ )
32
+ return cls()
33
+
34
+
35
+ def list_adapter_families() -> list[str]:
36
+ return list(_ADAPTER_REGISTRY.keys())
37
+
38
+
39
+ # -- Runtime registry ---------------------------------------------------------
40
+
41
+ _RUNTIME_REGISTRY: dict[str, type[BaseRuntime]] = {
42
+ RuntimeType.LOCAL.value: LocalRuntime,
43
+ RuntimeType.HUB.value: HubRuntime,
44
+ RuntimeType.API.value: ApiRuntime,
45
+ }
46
+
47
+
48
+ def get_runtime(runtime_type: str, **kwargs: object) -> BaseRuntime:
49
+ """Instantiate a runtime for the given type."""
50
+ cls = _RUNTIME_REGISTRY.get(runtime_type)
51
+ if cls is None:
52
+ raise KeyError(
53
+ f"No runtime for type '{runtime_type}'. "
54
+ f"Available: {list(_RUNTIME_REGISTRY.keys())}"
55
+ )
56
+ return cls(**kwargs) # type: ignore[arg-type]
57
+
58
+
59
+ def list_runtime_types() -> list[str]:
60
+ return list(_RUNTIME_REGISTRY.keys())
src/app/providers/resolver.py CHANGED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Provider resolver — resolves a ProviderProfile into runtime + adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from src.app.providers.adapters.base import BaseAdapter
6
+ from src.app.providers.profiles import ProviderProfile
7
+ from src.app.providers.registry import get_adapter, get_runtime
8
+ from src.app.providers.runtimes.base import BaseRuntime
9
+
10
+
11
+ class ResolvedProvider:
12
+ """A fully resolved provider: runtime + adapter, ready to execute."""
13
+
14
+ def __init__(self, profile: ProviderProfile, runtime: BaseRuntime, adapter: BaseAdapter) -> None:
15
+ self.profile = profile
16
+ self.runtime = runtime
17
+ self.adapter = adapter
18
+
19
+ @property
20
+ def provider_id(self) -> str:
21
+ return self.profile.provider_id
22
+
23
+ @property
24
+ def family(self) -> str:
25
+ return self.profile.family.value
26
+
27
+
28
+ def resolve_provider(profile: ProviderProfile) -> ResolvedProvider:
29
+ """Resolve a provider profile into a runtime + adapter pair.
30
+
31
+ No magic: the profile explicitly declares its runtime_type and family.
32
+ We look them up in the registries.
33
+
34
+ Raises KeyError if the runtime or family is not registered.
35
+ """
36
+ runtime = get_runtime(profile.runtime_type.value)
37
+ adapter = get_adapter(profile.family.value)
38
+ return ResolvedProvider(profile=profile, runtime=runtime, adapter=adapter)
src/app/providers/runtimes/api_runtime.py CHANGED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """API runtime — calls an external HTTP endpoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from src.app.domain.models import RawProviderPayload
9
+ from src.app.providers.runtimes.base import BaseRuntime
10
+
11
+
12
+ class ApiRuntime(BaseRuntime):
13
+ """Runtime that calls an external API endpoint.
14
+
15
+ Supports OpenAI-compatible and custom endpoints.
16
+ In V1 this is a skeleton — actual HTTP calls will be added
17
+ when API-based providers are integrated.
18
+ """
19
+
20
+ def __init__(self, timeout: int = 60, max_retries: int = 2) -> None:
21
+ self._timeout = timeout
22
+ self._max_retries = max_retries
23
+
24
+ def execute(
25
+ self,
26
+ image_path: Path,
27
+ model_id: str,
28
+ *,
29
+ options: dict[str, Any] | None = None,
30
+ ) -> RawProviderPayload:
31
+ raise NotImplementedError(
32
+ "ApiRuntime.execute is not yet implemented. "
33
+ "In V1, provide raw payloads directly to the job service."
34
+ )
35
+
36
+ def is_available(self) -> bool:
37
+ return True
src/app/providers/runtimes/hub_runtime.py CHANGED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hub runtime — loads a model from the Hugging Face Hub."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from src.app.domain.models import RawProviderPayload
9
+ from src.app.providers.runtimes.base import BaseRuntime
10
+
11
+
12
+ class HubRuntime(BaseRuntime):
13
+ """Runtime for models loaded from the Hugging Face Hub.
14
+
15
+ Uses HF_HOME for caching. In V1 this is a skeleton.
16
+ """
17
+
18
+ def execute(
19
+ self,
20
+ image_path: Path,
21
+ model_id: str,
22
+ *,
23
+ options: dict[str, Any] | None = None,
24
+ ) -> RawProviderPayload:
25
+ raise NotImplementedError(
26
+ "HubRuntime.execute is not yet implemented. "
27
+ "In V1, provide raw payloads directly to the job service."
28
+ )
29
+
30
+ def is_available(self) -> bool:
31
+ try:
32
+ import huggingface_hub # noqa: F401
33
+ return True
34
+ except ImportError:
35
+ return False
src/app/providers/runtimes/local_runtime.py CHANGED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Local runtime — loads and runs a model from a local directory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from src.app.domain.models import RawProviderPayload
9
+ from src.app.providers.runtimes.base import BaseRuntime
10
+
11
+
12
+ class LocalRuntime(BaseRuntime):
13
+ """Runtime for locally installed models.
14
+
15
+ In V1 this is a skeleton — actual model loading will be integrated
16
+ when specific providers (PaddleOCR, etc.) are wired in.
17
+ """
18
+
19
+ def execute(
20
+ self,
21
+ image_path: Path,
22
+ model_id: str,
23
+ *,
24
+ options: dict[str, Any] | None = None,
25
+ ) -> RawProviderPayload:
26
+ raise NotImplementedError(
27
+ "LocalRuntime.execute is not yet implemented. "
28
+ "In V1, provide raw payloads directly to the job service."
29
+ )
30
+
31
+ def is_available(self) -> bool:
32
+ return True
tests/fixtures/line_box_sample.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ [
2
+ {"text": "Bonjour le monde", "bbox": [100, 200, 600, 240], "confidence": 0.95},
3
+ {"text": "Ceci est une ligne", "bbox": [100, 260, 550, 300], "confidence": 0.92},
4
+ {"text": "Troisième ligne ici", "bbox": [100, 320, 520, 360], "confidence": 0.88}
5
+ ]
tests/fixtures/text_only_sample.json ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ {
2
+ "text": "Bonjour le monde.\nCeci est la première ligne.\n\nDeuxième paragraphe.\nAvec deux lignes.",
3
+ "model": "qwen3-vl"
4
+ }
tests/unit/test_provider_system.py ADDED
@@ -0,0 +1,282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the provider system: adapters, registry, resolver."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ from src.app.domain.models import GeometryStatus, RawProviderPayload
11
+ from src.app.domain.models.geometry import GeometryContext
12
+ from src.app.providers.adapters.line_box_json import LineBoxJsonAdapter
13
+ from src.app.providers.adapters.text_only import TextOnlyAdapter
14
+ from src.app.providers.capabilities import CapabilityMatrix
15
+ from src.app.providers.profiles import ProviderFamily, ProviderProfile, RuntimeType
16
+ from src.app.providers.registry import (
17
+ get_adapter,
18
+ get_runtime,
19
+ list_adapter_families,
20
+ list_runtime_types,
21
+ )
22
+ from src.app.providers.resolver import resolve_provider
23
+
24
+
25
+ @pytest.fixture
26
+ def geo_ctx() -> GeometryContext:
27
+ return GeometryContext(source_width=2480, source_height=3508)
28
+
29
+
30
+ # -- LineBoxJsonAdapter -------------------------------------------------------
31
+
32
+
33
+ class TestLineBoxJsonAdapter:
34
+ @pytest.fixture
35
+ def adapter(self) -> LineBoxJsonAdapter:
36
+ return LineBoxJsonAdapter()
37
+
38
+ @pytest.fixture
39
+ def raw(self, fixtures_dir: Path) -> RawProviderPayload:
40
+ with open(fixtures_dir / "line_box_sample.json") as f:
41
+ payload = json.load(f)
42
+ return RawProviderPayload(
43
+ provider_id="line_model", adapter_id="v1", runtime_type="local",
44
+ payload=payload, image_width=2480, image_height=3508,
45
+ )
46
+
47
+ def test_family(self, adapter: LineBoxJsonAdapter) -> None:
48
+ assert adapter.family == "line_box_json"
49
+
50
+ def test_normalize_produces_document(
51
+ self, adapter: LineBoxJsonAdapter, raw: RawProviderPayload, geo_ctx: GeometryContext,
52
+ ) -> None:
53
+ doc = adapter.normalize(raw, geo_ctx, document_id="test_line")
54
+ assert doc.document_id == "test_line"
55
+ assert len(doc.pages) == 1
56
+
57
+ def test_correct_line_count(
58
+ self, adapter: LineBoxJsonAdapter, raw: RawProviderPayload, geo_ctx: GeometryContext,
59
+ ) -> None:
60
+ doc = adapter.normalize(raw, geo_ctx, document_id="test")
61
+ total_lines = sum(
62
+ len(r.lines) for r in doc.pages[0].text_regions
63
+ )
64
+ assert total_lines == 3
65
+
66
+ def test_bbox_converted_from_xyxy(
67
+ self, adapter: LineBoxJsonAdapter, raw: RawProviderPayload, geo_ctx: GeometryContext,
68
+ ) -> None:
69
+ doc = adapter.normalize(raw, geo_ctx, document_id="test")
70
+ first_line = doc.pages[0].text_regions[0].lines[0]
71
+ x, y, w, h = first_line.geometry.bbox
72
+ # From fixture: [100, 200, 600, 240] → xyxy → xywh: (100, 200, 500, 40)
73
+ assert x == pytest.approx(100)
74
+ assert y == pytest.approx(200)
75
+ assert w == pytest.approx(500)
76
+ assert h == pytest.approx(40)
77
+
78
+ def test_text_preserved(
79
+ self, adapter: LineBoxJsonAdapter, raw: RawProviderPayload, geo_ctx: GeometryContext,
80
+ ) -> None:
81
+ doc = adapter.normalize(raw, geo_ctx, document_id="test")
82
+ word = doc.pages[0].text_regions[0].lines[0].words[0]
83
+ assert word.text == "Bonjour le monde"
84
+
85
+ def test_geometry_status_exact(
86
+ self, adapter: LineBoxJsonAdapter, raw: RawProviderPayload, geo_ctx: GeometryContext,
87
+ ) -> None:
88
+ doc = adapter.normalize(raw, geo_ctx, document_id="test")
89
+ line = doc.pages[0].text_regions[0].lines[0]
90
+ assert line.geometry.status == GeometryStatus.EXACT
91
+
92
+ def test_empty_payload_rejected(
93
+ self, adapter: LineBoxJsonAdapter, geo_ctx: GeometryContext,
94
+ ) -> None:
95
+ raw = RawProviderPayload(
96
+ provider_id="t", adapter_id="v1", runtime_type="local", payload=[],
97
+ )
98
+ with pytest.raises(ValueError, match="no items"):
99
+ adapter.normalize(raw, geo_ctx, document_id="test")
100
+
101
+ def test_dict_payload_rejected(
102
+ self, adapter: LineBoxJsonAdapter, geo_ctx: GeometryContext,
103
+ ) -> None:
104
+ raw = RawProviderPayload(
105
+ provider_id="t", adapter_id="v1", runtime_type="local",
106
+ payload={"not": "a list"},
107
+ )
108
+ with pytest.raises(ValueError, match="list payload"):
109
+ adapter.normalize(raw, geo_ctx, document_id="test")
110
+
111
+
112
+ # -- TextOnlyAdapter ----------------------------------------------------------
113
+
114
+
115
+ class TestTextOnlyAdapter:
116
+ @pytest.fixture
117
+ def adapter(self) -> TextOnlyAdapter:
118
+ return TextOnlyAdapter()
119
+
120
+ @pytest.fixture
121
+ def raw(self, fixtures_dir: Path) -> RawProviderPayload:
122
+ with open(fixtures_dir / "text_only_sample.json") as f:
123
+ payload = json.load(f)
124
+ return RawProviderPayload(
125
+ provider_id="qwen3_vl", adapter_id="v1", runtime_type="api",
126
+ payload=payload, image_width=2480, image_height=3508,
127
+ )
128
+
129
+ def test_family(self, adapter: TextOnlyAdapter) -> None:
130
+ assert adapter.family == "text_only"
131
+
132
+ def test_normalize_produces_document(
133
+ self, adapter: TextOnlyAdapter, raw: RawProviderPayload, geo_ctx: GeometryContext,
134
+ ) -> None:
135
+ doc = adapter.normalize(raw, geo_ctx, document_id="test_text")
136
+ assert doc.document_id == "test_text"
137
+ assert len(doc.pages) == 1
138
+
139
+ def test_splits_paragraphs(
140
+ self, adapter: TextOnlyAdapter, raw: RawProviderPayload, geo_ctx: GeometryContext,
141
+ ) -> None:
142
+ doc = adapter.normalize(raw, geo_ctx, document_id="test")
143
+ # Fixture has 2 paragraphs separated by \n\n
144
+ assert len(doc.pages[0].text_regions) == 2
145
+
146
+ def test_geometry_is_unknown(
147
+ self, adapter: TextOnlyAdapter, raw: RawProviderPayload, geo_ctx: GeometryContext,
148
+ ) -> None:
149
+ doc = adapter.normalize(raw, geo_ctx, document_id="test")
150
+ word = doc.pages[0].text_regions[0].lines[0].words[0]
151
+ assert word.geometry.status == GeometryStatus.UNKNOWN
152
+
153
+ def test_text_preserved(
154
+ self, adapter: TextOnlyAdapter, raw: RawProviderPayload, geo_ctx: GeometryContext,
155
+ ) -> None:
156
+ doc = adapter.normalize(raw, geo_ctx, document_id="test")
157
+ first_word = doc.pages[0].text_regions[0].lines[0].words[0]
158
+ assert "Bonjour" in first_word.text
159
+
160
+ def test_no_text_rejected(
161
+ self, adapter: TextOnlyAdapter, geo_ctx: GeometryContext,
162
+ ) -> None:
163
+ raw = RawProviderPayload(
164
+ provider_id="t", adapter_id="v1", runtime_type="api",
165
+ payload={"text": ""},
166
+ )
167
+ with pytest.raises(ValueError, match="no text"):
168
+ adapter.normalize(raw, geo_ctx, document_id="test")
169
+
170
+ def test_list_payload_rejected(
171
+ self, adapter: TextOnlyAdapter, geo_ctx: GeometryContext,
172
+ ) -> None:
173
+ raw = RawProviderPayload(
174
+ provider_id="t", adapter_id="v1", runtime_type="api",
175
+ payload=[1, 2, 3],
176
+ )
177
+ with pytest.raises(ValueError, match="dict payload"):
178
+ adapter.normalize(raw, geo_ctx, document_id="test")
179
+
180
+ def test_with_blocks(
181
+ self, adapter: TextOnlyAdapter, geo_ctx: GeometryContext,
182
+ ) -> None:
183
+ raw = RawProviderPayload(
184
+ provider_id="t", adapter_id="v1", runtime_type="api",
185
+ payload={
186
+ "text": "ignored",
187
+ "blocks": [
188
+ {"text": "Block one line one\nBlock one line two"},
189
+ {"text": "Block two"},
190
+ ],
191
+ },
192
+ )
193
+ doc = adapter.normalize(raw, geo_ctx, document_id="test")
194
+ assert len(doc.pages[0].text_regions) == 2
195
+ assert len(doc.pages[0].text_regions[0].lines) == 2
196
+
197
+
198
+ # -- Registry -----------------------------------------------------------------
199
+
200
+
201
+ class TestRegistry:
202
+ def test_list_families(self) -> None:
203
+ families = list_adapter_families()
204
+ assert "word_box_json" in families
205
+ assert "line_box_json" in families
206
+ assert "text_only" in families
207
+
208
+ def test_list_runtimes(self) -> None:
209
+ types = list_runtime_types()
210
+ assert "local" in types
211
+ assert "hub" in types
212
+ assert "api" in types
213
+
214
+ def test_get_adapter_word_box(self) -> None:
215
+ adapter = get_adapter("word_box_json")
216
+ assert adapter.family == "word_box_json"
217
+
218
+ def test_get_adapter_line_box(self) -> None:
219
+ adapter = get_adapter("line_box_json")
220
+ assert adapter.family == "line_box_json"
221
+
222
+ def test_get_adapter_text_only(self) -> None:
223
+ adapter = get_adapter("text_only")
224
+ assert adapter.family == "text_only"
225
+
226
+ def test_get_adapter_unknown(self) -> None:
227
+ with pytest.raises(KeyError, match="No adapter"):
228
+ get_adapter("unknown_family")
229
+
230
+ def test_get_runtime_local(self) -> None:
231
+ rt = get_runtime("local")
232
+ assert rt.is_available()
233
+
234
+ def test_get_runtime_api(self) -> None:
235
+ rt = get_runtime("api")
236
+ assert rt.is_available()
237
+
238
+ def test_get_runtime_unknown(self) -> None:
239
+ with pytest.raises(KeyError, match="No runtime"):
240
+ get_runtime("unknown_type")
241
+
242
+
243
+ # -- Resolver -----------------------------------------------------------------
244
+
245
+
246
+ class TestResolver:
247
+ def test_resolve_word_box_local(self) -> None:
248
+ profile = ProviderProfile(
249
+ provider_id="paddle_test",
250
+ display_name="PaddleOCR Test",
251
+ runtime_type=RuntimeType.LOCAL,
252
+ model_id_or_path="/models/paddle",
253
+ family=ProviderFamily.WORD_BOX_JSON,
254
+ )
255
+ resolved = resolve_provider(profile)
256
+ assert resolved.provider_id == "paddle_test"
257
+ assert resolved.family == "word_box_json"
258
+ assert resolved.adapter.family == "word_box_json"
259
+
260
+ def test_resolve_text_only_api(self) -> None:
261
+ profile = ProviderProfile(
262
+ provider_id="qwen_test",
263
+ display_name="Qwen Test",
264
+ runtime_type=RuntimeType.API,
265
+ model_id_or_path="qwen3-vl",
266
+ family=ProviderFamily.TEXT_ONLY,
267
+ endpoint="https://api.example.com/v1",
268
+ )
269
+ resolved = resolve_provider(profile)
270
+ assert resolved.family == "text_only"
271
+ assert resolved.runtime.is_available()
272
+
273
+ def test_resolve_line_box_hub(self) -> None:
274
+ profile = ProviderProfile(
275
+ provider_id="hub_test",
276
+ display_name="Hub Model",
277
+ runtime_type=RuntimeType.HUB,
278
+ model_id_or_path="user/model",
279
+ family=ProviderFamily.LINE_BOX_JSON,
280
+ )
281
+ resolved = resolve_provider(profile)
282
+ assert resolved.family == "line_box_json"