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 +28 -0
- acquivela/capabilities.py +19 -0
- acquivela/cdm.py +106 -0
- acquivela/execution_context.py +27 -0
- acquivela/facade.py +47 -0
- acquivela/models.py +49 -0
- acquivela/pipeline.py +317 -0
- acquivela/plugin.py +68 -0
- acquivela/plugins/__init__.py +14 -0
- acquivela/plugins/docling.py +213 -0
- acquivela/plugins/mineru.py +281 -0
- acquivela/public_result.py +114 -0
- acquivela/registry.py +200 -0
- acquivela/result.py +43 -0
- acquivela-0.2.0.dist-info/METADATA +215 -0
- acquivela-0.2.0.dist-info/RECORD +19 -0
- acquivela-0.2.0.dist-info/WHEEL +5 -0
- acquivela-0.2.0.dist-info/licenses/LICENSE +21 -0
- acquivela-0.2.0.dist-info/top_level.txt +1 -0
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
|
+
"""
|