acquivela 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
acquivela/__init__.py ADDED
@@ -0,0 +1,28 @@
1
+ """AcquiVela — Document Acquisition Framework.
2
+
3
+ Public exports per frozen architecture (ADR-0001 through ADR-0006).
4
+ """
5
+
6
+ from acquivela.cdm import CanonicalDocument, Page, TextBlock
7
+ from acquivela.capabilities import Capabilities
8
+ from acquivela.models import AcquireRequest
9
+ from acquivela.plugin import Plugin
10
+ from acquivela.public_result import AcquireResult, Diagnostics
11
+ from acquivela.facade import acquire
12
+ from acquivela.pipeline import Pipeline
13
+ from acquivela.registry import Registry
14
+
15
+ __version__ = "0.2.0"
16
+ __all__ = [
17
+ "CanonicalDocument",
18
+ "Page",
19
+ "TextBlock",
20
+ "Capabilities",
21
+ "AcquireRequest",
22
+ "Plugin",
23
+ "AcquireResult",
24
+ "Diagnostics",
25
+ "Pipeline",
26
+ "Registry",
27
+ "acquire",
28
+ ]
@@ -0,0 +1,19 @@
1
+ """Capability Vocabulary — Canonical capability constants for plugin selection.
2
+
3
+ Per ADR-0014: Plugins declare capabilities; consumers request them via
4
+ AcquireRequest.required_capability. Vocabulary is validated against this
5
+ module's constants before plugin lookup.
6
+ """
7
+
8
+
9
+ class Capabilities:
10
+ """Canonical capability constants.
11
+
12
+ Plugins declare capabilities as strings matching these constants.
13
+ Consumers request capabilities via AcquireRequest.required_capability.
14
+
15
+ Unknown plugin capabilities are tolerated at registration but inert
16
+ for selection — they cannot satisfy a required_capability request.
17
+ """
18
+
19
+ DOCUMENT_TEXT = "document.text"
acquivela/cdm.py ADDED
@@ -0,0 +1,106 @@
1
+ """Canonical Document Model (CDM) v0.2 — Frozen per ADR-0003, extended per ADR-0011."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import List
6
+
7
+
8
+ class CanonicalDocument:
9
+ """CanonicalDocument — frozen public contract.
10
+
11
+ Attributes:
12
+ cdm_version: Version string, e.g., "0.2"
13
+ pages: List of Page objects (at least one required)
14
+ title: Document title, or None if unavailable (ADR-0011)
15
+ """
16
+
17
+ def __init__(
18
+ self,
19
+ pages: List["Page"],
20
+ cdm_version: str = "0.2",
21
+ title: str | None = None,
22
+ ):
23
+ if not isinstance(pages, list):
24
+ raise TypeError(f"pages must be a list, got {type(pages).__name__}")
25
+ if len(pages) < 1:
26
+ raise ValueError("CanonicalDocument must contain at least one Page")
27
+ for i, page in enumerate(pages):
28
+ if not isinstance(page, Page):
29
+ raise TypeError(f"pages[{i}] must be a Page, got {type(page).__name__}")
30
+ self.cdm_version = cdm_version
31
+ self.pages = pages
32
+ self.title = title
33
+
34
+ def __repr__(self) -> str:
35
+ return (
36
+ f"CanonicalDocument(cdm_version={self.cdm_version!r}, "
37
+ f"pages={len(self.pages)}, title={self.title!r})"
38
+ )
39
+
40
+ def __eq__(self, other: object) -> bool:
41
+ if not isinstance(other, CanonicalDocument):
42
+ return NotImplemented
43
+ return (
44
+ self.cdm_version == other.cdm_version
45
+ and self.pages == other.pages
46
+ and self.title == other.title
47
+ )
48
+
49
+
50
+ class Page:
51
+ """Page within CanonicalDocument — frozen public contract.
52
+
53
+ Attributes:
54
+ text_blocks: List of TextBlock objects (at least one required)
55
+ page_number: 1-based physical page number, or None if unknown (ADR-0011)
56
+ """
57
+
58
+ def __init__(
59
+ self,
60
+ text_blocks: List["TextBlock"],
61
+ page_number: int | None = None,
62
+ ):
63
+ if not isinstance(text_blocks, list):
64
+ raise TypeError(f"text_blocks must be a list, got {type(text_blocks).__name__}")
65
+ if len(text_blocks) < 1:
66
+ raise ValueError("Page must contain at least one TextBlock")
67
+ for i, block in enumerate(text_blocks):
68
+ if not isinstance(block, TextBlock):
69
+ raise TypeError(f"text_blocks[{i}] must be a TextBlock, got {type(block).__name__}")
70
+ self.text_blocks = text_blocks
71
+ self.page_number = page_number
72
+
73
+ def __repr__(self) -> str:
74
+ return f"Page(text_blocks={len(self.text_blocks)}, page_number={self.page_number!r})"
75
+
76
+ def __eq__(self, other: object) -> bool:
77
+ if not isinstance(other, Page):
78
+ return NotImplemented
79
+ return (
80
+ self.text_blocks == other.text_blocks
81
+ and self.page_number == other.page_number
82
+ )
83
+
84
+
85
+ class TextBlock:
86
+ """TextBlock within Page — frozen public contract.
87
+
88
+ Attributes:
89
+ text: Text content string
90
+ text_type: Structural text classification, or None if unknown (ADR-0011).
91
+ Allowed values: "title", "paragraph", "list_item", "code", "caption"
92
+ """
93
+
94
+ def __init__(self, text: str, text_type: str | None = None):
95
+ if not isinstance(text, str):
96
+ raise TypeError(f"text must be a string, got {type(text).__name__}")
97
+ self.text = text
98
+ self.text_type = text_type
99
+
100
+ def __repr__(self) -> str:
101
+ return f"TextBlock(text={self.text!r}, text_type={self.text_type!r})"
102
+
103
+ def __eq__(self, other: object) -> bool:
104
+ if not isinstance(other, TextBlock):
105
+ return NotImplemented
106
+ return self.text == other.text and self.text_type == other.text_type
@@ -0,0 +1,27 @@
1
+ """ExecutionContext — Internal component per ADR-0005."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class ExecutionContext:
7
+ """Internal execution context — NOT a frozen contract.
8
+
9
+ This is an internal mechanism for carrying execution metadata
10
+ that does not belong in the frozen public contracts.
11
+ It is internal to the pipeline and carries no compatibility guarantee.
12
+
13
+ Per ADR-0005: Pipeline propagates ExecutionContext.selection_reason
14
+ into Diagnostics.selection_reason.
15
+ """
16
+
17
+ def __init__(self):
18
+ self.selection_reason: str | None = None
19
+ self.selected_plugin_id: str | None = None
20
+ self.available_plugins: list[str] = []
21
+ self.selection_method: str | None = None
22
+
23
+ def __repr__(self) -> str:
24
+ return (
25
+ f"ExecutionContext(reason={self.selection_reason!r}, "
26
+ f"plugin={self.selected_plugin_id!r}, method={self.selection_method!r})"
27
+ )
acquivela/facade.py ADDED
@@ -0,0 +1,47 @@
1
+ """AcquiVela high-level acquisition facade.
2
+
3
+ Provides a governed public execution entry point that allows consumers to perform
4
+ document acquisition using AcquiVela's stable public contracts without depending
5
+ directly on INTERNAL Pipeline or Registry APIs.
6
+
7
+ The facade delegates to existing internal orchestration (Pipeline/Registry/plugin
8
+ selection/health/fallback) while hiding those components from the consumer.
9
+
10
+ Do NOT promote Pipeline or Registry to PUBLIC/STABLE contracts.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+ from acquivela import AcquireRequest
18
+ from acquivela.public_result import AcquireResult
19
+ from acquivela.pipeline import Pipeline
20
+ from acquivela.registry import Registry
21
+ from acquivela.plugins.docling import DoclingPlugin
22
+ from acquivela.plugins.mineru import MinerUPlugin
23
+
24
+
25
+ def acquire(request: AcquireRequest) -> AcquireResult:
26
+ """Acquire a document using stable public contracts.
27
+
28
+ Consumers do not need to import Pipeline or Registry.
29
+ Concrete plugin instantiation is hidden behind the facade.
30
+
31
+ Args:
32
+ request: Acquisition request specifying file path, engine hint,
33
+ and/or required capability.
34
+
35
+ Returns:
36
+ AcquireResult with status, canonical document, diagnostics, and error.
37
+
38
+ Normal consumer usage requires no direct Pipeline, Registry, or plugin
39
+ class construction.
40
+ """
41
+ # Internal bootstrap: create Registry, auto-register built-in plugins,
42
+ # then delegate to existing Pipeline orchestration.
43
+ registry = Registry()
44
+ registry.register(DoclingPlugin())
45
+ registry.register(MinerUPlugin())
46
+ pipeline = Pipeline(registry=registry)
47
+ return pipeline.execute_with_registry(request)
acquivela/models.py ADDED
@@ -0,0 +1,49 @@
1
+ """AcquiVela request/response models — Frozen per ADR-0003."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class AcquireRequest:
7
+ """Input to the Acquisition Pipeline — data model per ADR-0001.
8
+
9
+ Attributes:
10
+ file_path: Input file to process.
11
+ engine_hint: Optional specific plugin to use (ADR-0005 §3.1).
12
+ required_capability: Optional capability the plugin must provide (ADR-0014).
13
+ If set, validated against canonical vocabulary; plugins are filtered
14
+ to those declaring the capability. If None, no capability filter is applied.
15
+ """
16
+
17
+ def __init__(
18
+ self,
19
+ file_path: str,
20
+ engine_hint: str | None = None,
21
+ required_capability: str | None = None,
22
+ ):
23
+ if not isinstance(file_path, str):
24
+ raise TypeError("file_path must be a string")
25
+ if not file_path:
26
+ raise ValueError("file_path must not be empty")
27
+ if engine_hint is not None and not isinstance(engine_hint, str):
28
+ raise TypeError("engine_hint must be a string or None")
29
+ if required_capability is not None and not isinstance(required_capability, str):
30
+ raise TypeError("required_capability must be a string or None")
31
+ self.file_path = file_path
32
+ self.engine_hint = engine_hint
33
+ self.required_capability = required_capability
34
+
35
+ def __repr__(self) -> str:
36
+ return (
37
+ f"AcquireRequest(file_path={self.file_path!r}, "
38
+ f"engine_hint={self.engine_hint!r}, "
39
+ f"required_capability={self.required_capability!r})"
40
+ )
41
+
42
+ def __eq__(self, other: object) -> bool:
43
+ if not isinstance(other, AcquireRequest):
44
+ return NotImplemented
45
+ return (
46
+ self.file_path == other.file_path
47
+ and self.engine_hint == other.engine_hint
48
+ and self.required_capability == other.required_capability
49
+ )
acquivela/pipeline.py ADDED
@@ -0,0 +1,317 @@
1
+ """Pipeline — Internal orchestration component per ADR-0004, ADR-0005, ADR-0006.
2
+
3
+ Pipeline orchestrates plugin selection, execution, and result construction.
4
+ Pipeline is internal infrastructure, not a frozen public contract. Its API
5
+ may change before a future stable interface decision.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from typing import Optional
12
+
13
+ from acquivela.capabilities import Capabilities
14
+ from acquivela.plugin import Plugin
15
+ from acquivela.models import AcquireRequest
16
+ from acquivela.result import PluginResult
17
+ from acquivela.public_result import AcquireResult, Diagnostics
18
+ from acquivela.registry import Registry
19
+ from acquivela.execution_context import ExecutionContext
20
+
21
+
22
+ def _validate_capability(requested: str) -> None:
23
+ """Validate that requested capability is in the canonical vocabulary.
24
+
25
+ Raises ValueError if the capability is not a canonical constant.
26
+ Per ADR-0014 §6.7: vocabulary validation is the first selection step.
27
+ """
28
+ vocabulary = {
29
+ v for v in Capabilities.__dict__.values()
30
+ if isinstance(v, str)
31
+ }
32
+ if requested not in vocabulary:
33
+ raise ValueError(f"Unknown capability: {requested}")
34
+
35
+
36
+ class Pipeline:
37
+ """Pipeline layer — converts PluginResult (internal) to AcquireResult (public).
38
+
39
+ Per ADR-0004: Pipeline is the exception boundary. All plugin exceptions
40
+ are caught and converted to AcquireResult(status="failure").
41
+
42
+ Per ADR-0006: Pipeline performs Selection; Registry supplies candidates.
43
+ """
44
+
45
+ def __init__(self, registry: Optional[Registry] = None):
46
+ if registry is not None and not isinstance(registry, Registry):
47
+ raise TypeError(f"registry must be a Registry or None, got {type(registry).__name__}")
48
+ self.registry = registry
49
+
50
+ def execute(
51
+ self,
52
+ plugin: Plugin,
53
+ request: AcquireRequest,
54
+ execution_context: Optional[ExecutionContext] = None,
55
+ ) -> AcquireResult:
56
+ """Execute a specific plugin (bypasses Registry selection).
57
+
58
+ Args:
59
+ plugin: The plugin to execute
60
+ request: The acquisition request
61
+ execution_context: Optional internal context for selection_reason
62
+
63
+ Returns:
64
+ AcquireResult with status, canonical_document, diagnostics, error
65
+ """
66
+ if not isinstance(plugin, Plugin):
67
+ raise TypeError(f"plugin must be a Plugin instance, got {type(plugin).__name__}")
68
+ if not isinstance(request, AcquireRequest):
69
+ raise TypeError(f"request must be an AcquireRequest, got {type(request).__name__}")
70
+ if execution_context is not None and not isinstance(execution_context, ExecutionContext):
71
+ raise TypeError(f"execution_context must be an ExecutionContext or None, got {type(execution_context).__name__}")
72
+
73
+ start_time = time.time()
74
+
75
+ try:
76
+ plugin_result = plugin.acquire(request)
77
+ duration_ms = int((time.time() - start_time) * 1000)
78
+
79
+ meta = plugin.metadata()
80
+
81
+ # Populate selection_reason if execution_context provided
82
+ selection_reason = None
83
+ if execution_context is not None:
84
+ selection_reason = execution_context.selection_reason
85
+
86
+ diagnostics = Diagnostics(
87
+ plugin_id=meta["id"],
88
+ plugin_version=meta["version"],
89
+ engine_used=plugin_result.internal_diagnostics.get("engine", "unknown"),
90
+ duration_ms=duration_ms,
91
+ warning=None,
92
+ selection_reason=selection_reason,
93
+ )
94
+
95
+ return AcquireResult(
96
+ status="success",
97
+ canonical_document=plugin_result.canonical_document,
98
+ diagnostics=[diagnostics],
99
+ error=None,
100
+ )
101
+
102
+ except Exception as e:
103
+ duration_ms = int((time.time() - start_time) * 1000)
104
+ meta = plugin.metadata()
105
+
106
+ selection_reason = None
107
+ if execution_context is not None:
108
+ selection_reason = execution_context.selection_reason
109
+
110
+ diagnostics = Diagnostics(
111
+ plugin_id=meta["id"],
112
+ plugin_version=meta["version"],
113
+ engine_used="unknown",
114
+ duration_ms=duration_ms,
115
+ warning=str(e),
116
+ selection_reason=selection_reason,
117
+ )
118
+
119
+ return AcquireResult(
120
+ status="failure",
121
+ canonical_document=None,
122
+ diagnostics=[diagnostics],
123
+ error=str(e),
124
+ )
125
+
126
+ def execute_with_registry(
127
+ self,
128
+ request: AcquireRequest,
129
+ execution_context: Optional[ExecutionContext] = None,
130
+ ) -> AcquireResult:
131
+ """Execute with Registry-based plugin selection.
132
+
133
+ Selection logic per ADR-0004 §5.2 + ADR-0013 + ADR-0014:
134
+ 0. If required_capability set → validate against canonical vocabulary; fail if unknown
135
+ 1. If engine_hint provided → Registry.get_by_id(engine_hint) → strict, no fallback
136
+ - If required_capability set → fail if plugin doesn't provide capability
137
+ 2. Else → derive extension → Registry.get_by_extension(ext)
138
+ - If required_capability set → filter candidates by capability
139
+ 3. Health-ordered fallback (M13) on remaining candidates
140
+ 4. No match → AcquireResult(status="failure")
141
+
142
+ Args:
143
+ request: The acquisition request
144
+ execution_context: Optional internal context for selection_reason
145
+
146
+ Returns:
147
+ AcquireResult
148
+ """
149
+ if not isinstance(request, AcquireRequest):
150
+ raise TypeError(f"request must be an AcquireRequest, got {type(request).__name__}")
151
+ if execution_context is not None and not isinstance(execution_context, ExecutionContext):
152
+ raise TypeError(f"execution_context must be an ExecutionContext or None, got {type(execution_context).__name__}")
153
+
154
+ if self.registry is None:
155
+ raise ValueError("Registry not configured")
156
+
157
+ # M14 §6.7: Validate capability against canonical vocabulary (first step)
158
+ if request.required_capability is not None:
159
+ try:
160
+ _validate_capability(request.required_capability)
161
+ except ValueError as e:
162
+ return AcquireResult(
163
+ status="failure",
164
+ canonical_document=None,
165
+ diagnostics=[],
166
+ error=str(e),
167
+ )
168
+
169
+ # engine_hint path — STRICT per ADR-0013 §8.3
170
+ if request.engine_hint:
171
+ plugin = self.registry.get_by_id(request.engine_hint)
172
+ if plugin is None:
173
+ return AcquireResult(
174
+ status="failure",
175
+ canonical_document=None,
176
+ diagnostics=[],
177
+ error=f"Plugin not found: {request.engine_hint}",
178
+ )
179
+
180
+ # M14: capability validation on engine_hint path (hard constraint)
181
+ if request.required_capability is not None:
182
+ if request.required_capability not in plugin.capabilities():
183
+ return AcquireResult(
184
+ status="failure",
185
+ canonical_document=None,
186
+ diagnostics=[],
187
+ error=f"Plugin '{request.engine_hint}' does not provide capability: {request.required_capability}",
188
+ )
189
+
190
+ if execution_context is not None:
191
+ execution_context.selection_method = "engine_hint"
192
+ execution_context.selected_plugin_id = request.engine_hint
193
+ execution_context.available_plugins = [request.engine_hint]
194
+ execution_context.selection_reason = (
195
+ f"engine_hint: matched plugin id '{request.engine_hint}' from [{request.engine_hint}]"
196
+ )
197
+
198
+ return self.execute(plugin, request, execution_context)
199
+
200
+ # No engine_hint — extension-based selection with M13 fallback
201
+ ext = request.file_path.rsplit(".", 1)[-1] if "." in request.file_path else ""
202
+ candidates = self.registry.get_by_extension(ext)
203
+ if not candidates:
204
+ return AcquireResult(
205
+ status="failure",
206
+ canonical_document=None,
207
+ diagnostics=[],
208
+ error=f"No plugin supports extension: {ext}",
209
+ )
210
+
211
+ # M14 §9.1: Filter candidates by capability (hard constraint, after extension matching)
212
+ if request.required_capability is not None:
213
+ candidates = [
214
+ c for c in candidates
215
+ if request.required_capability in c.capabilities()
216
+ ]
217
+ if not candidates:
218
+ return AcquireResult(
219
+ status="failure",
220
+ canonical_document=None,
221
+ diagnostics=[],
222
+ error=f"No eligible plugin provides capability: {request.required_capability}",
223
+ )
224
+
225
+ # M13 §8.4: Evaluate health once per candidate; retain (candidate, health) pairs
226
+ evaluated = [(candidate, candidate.health_check()) for candidate in candidates]
227
+
228
+ # M13 §8.4: Partition — healthy first, unavailable after; registration order within groups
229
+ healthy_pairs = [(c, h) for c, h in evaluated if h.status == "healthy"]
230
+ unavailable_pairs = [(c, h) for c, h in evaluated if h.status != "healthy"]
231
+ ordered = healthy_pairs + unavailable_pairs
232
+
233
+ candidate_ids = [c.metadata()["id"] for c in candidates]
234
+
235
+ diagnostics_entries: list[Diagnostics] = []
236
+
237
+ # M13: Record skip entries for ALL unavailable candidates upfront,
238
+ # so they appear in diagnostics even if a healthy candidate succeeds early.
239
+ for candidate, health in ordered:
240
+ if health.status != "healthy":
241
+ diag = Diagnostics(
242
+ plugin_id=candidate.metadata()["id"],
243
+ plugin_version=candidate.metadata()["version"],
244
+ engine_used="unknown",
245
+ duration_ms=0,
246
+ warning=f"Skipped: health={health.status} ({health.message})",
247
+ selection_reason=None,
248
+ )
249
+ diagnostics_entries.append(diag)
250
+
251
+ last_result: Optional[AcquireResult] = None
252
+
253
+ for candidate, health in ordered:
254
+ plugin_id = candidate.metadata()["id"]
255
+
256
+ # Skip unavailable candidates (already recorded in diagnostics_entries above)
257
+ if health.status != "healthy":
258
+ continue
259
+
260
+ # M13 §10.1: Capture selection_reason before execute() overwrites execution_context
261
+ if execution_context is not None:
262
+ execution_context.selection_method = "extension"
263
+ execution_context.selected_plugin_id = plugin_id
264
+ execution_context.available_plugins = candidate_ids
265
+ execution_context.selection_reason = (
266
+ f"extension: .{ext} matched plugin supports ['{ext}'] from {candidate_ids}"
267
+ )
268
+
269
+ # M13: Attempt acquisition via existing execute() (single-plugin, exception boundary)
270
+ result = self.execute(candidate, request, execution_context)
271
+
272
+ # extract singleton Diagnostics entry from execute()'s result
273
+ diagnostics_entries.append(result.diagnostics[0])
274
+
275
+ if result.status == "success":
276
+ # M13: Replace singleton diagnostics with accumulated multi-attempt list
277
+ result.diagnostics = diagnostics_entries
278
+ return result
279
+
280
+ # Acquisition failed — record and try next candidate
281
+ last_result = result
282
+ last_result.diagnostics = diagnostics_entries
283
+
284
+ # All candidates exhausted
285
+ if last_result is not None:
286
+ return last_result
287
+
288
+ # All candidates were unavailable — return failure with accumulated skip diagnostics
289
+ return AcquireResult(
290
+ status="failure",
291
+ canonical_document=None,
292
+ diagnostics=diagnostics_entries,
293
+ error=f"All {len(candidates)} candidate(s) unavailable",
294
+ )
295
+
296
+ def __repr__(self) -> str:
297
+ return f"Pipeline(registry={self.registry!r})"
298
+
299
+
300
+ class SelectionContext:
301
+ """Internal execution context for selection reasoning.
302
+
303
+ Internal architectural component — NOT part of public contracts.
304
+ Per ADR-0006 §7.1: Pipeline carries this during selection.
305
+ """
306
+
307
+ def __init__(self):
308
+ self.selection_reason: str | None = None
309
+ self.selected_plugin_id: str | None = None
310
+ self.available_plugins: list[str] = []
311
+ self.selection_method: str | None = None
312
+
313
+ def __repr__(self) -> str:
314
+ return (
315
+ f"SelectionContext(reason={self.selection_reason!r}, "
316
+ f"plugin={self.selected_plugin_id!r}, method={self.selection_method!r})"
317
+ )
acquivela/plugin.py ADDED
@@ -0,0 +1,68 @@
1
+ """Plugin SPI — Frozen per ADR-0003."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from dataclasses import dataclass
7
+ from typing import Any, Literal
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class PluginHealth:
12
+ """Plugin-level health status per ADR-0012.
13
+
14
+ Two states only for M12:
15
+ - healthy: plugin minimum health probe succeeds (import-level)
16
+ - unavailable: plugin minimum health probe fails
17
+
18
+ ``healthy`` does NOT guarantee ``acquire()`` will succeed.
19
+ M12 health is plugin-level availability, not execution assurance.
20
+ """
21
+
22
+ status: Literal["healthy", "unavailable"]
23
+ message: str | None = None
24
+ last_checked: float | None = None
25
+
26
+
27
+ class Plugin(ABC):
28
+ """Plugin SPI — contract between plugin and pipeline.
29
+
30
+ Plugins implement this interface. The pipeline interacts with plugins
31
+ only through this SPI. Per ADR-0004, plugins MUST NOT import:
32
+ - acquivela.pipeline
33
+ - acquivela.registry
34
+ - acquivela.public_result
35
+ - acquivela.planner
36
+ """
37
+
38
+ @abstractmethod
39
+ def metadata(self) -> dict[str, Any]:
40
+ """Return plugin metadata.
41
+
42
+ Required keys (per ADR-0003 frozen SPI):
43
+ - id: str — unique plugin identifier
44
+ - name: str — human-readable name
45
+ - version: str — plugin version
46
+ - supports: list[str] — file extensions/MIME types supported
47
+ - provides: list[str] — capability vocabulary (deferred)
48
+ - priority: int — placeholder only (deferred)
49
+ """
50
+ ...
51
+
52
+ @abstractmethod
53
+ def acquire(self, request: "AcquireRequest") -> "PluginResult":
54
+ """Process request and return PluginResult (internal SPI contract)."""
55
+ ...
56
+
57
+ def health_check(self, force: bool = False) -> PluginHealth:
58
+ """Return current plugin-level availability.
59
+
60
+ Default: returns healthy without probing.
61
+ Concrete implementations performing runtime probes SHOULD cache results.
62
+ ``force`` has effect only for implementations with cached runtime probing.
63
+ """
64
+ return PluginHealth(status="healthy")
65
+
66
+ def capabilities(self) -> list[str]:
67
+ """Return declared capabilities as a copy of metadata['provides']."""
68
+ return list(self.metadata().get("provides", []))
@@ -0,0 +1,14 @@
1
+ """Plugin package — plugins must only import allowed modules.
2
+
3
+ Allowed imports (per ADR-0004 §3.3):
4
+ - acquivela.plugin (Plugin ABC)
5
+ - acquivela.models (AcquireRequest)
6
+ - acquivela.cdm (CanonicalDocument, Page, TextBlock)
7
+ - acquivela.result (PluginResult)
8
+
9
+ Forbidden imports (enforced by import-linter):
10
+ - acquivela.pipeline
11
+ - acquivela.registry
12
+ - acquivela.public_result
13
+ - acquivela.planner
14
+ """