mistralai-workflows-plugins-search 0.1.0__tar.gz
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.
- mistralai_workflows_plugins_search-0.1.0/.gitignore +32 -0
- mistralai_workflows_plugins_search-0.1.0/PKG-INFO +22 -0
- mistralai_workflows_plugins_search-0.1.0/README.md +186 -0
- mistralai_workflows_plugins_search-0.1.0/examples/worker.py +48 -0
- mistralai_workflows_plugins_search-0.1.0/mistralai/workflows/__init__.py +6 -0
- mistralai_workflows_plugins_search-0.1.0/mistralai/workflows/plugins/search/__init__.py +56 -0
- mistralai_workflows_plugins_search-0.1.0/mistralai/workflows/plugins/search/_stages.py +87 -0
- mistralai_workflows_plugins_search-0.1.0/mistralai/workflows/plugins/search/activities.py +288 -0
- mistralai_workflows_plugins_search-0.1.0/mistralai/workflows/plugins/search/config.py +60 -0
- mistralai_workflows_plugins_search-0.1.0/mistralai/workflows/plugins/search/models.py +182 -0
- mistralai_workflows_plugins_search-0.1.0/mistralai/workflows/plugins/search/registry.py +122 -0
- mistralai_workflows_plugins_search-0.1.0/mistralai/workflows/plugins/search/workflows.py +166 -0
- mistralai_workflows_plugins_search-0.1.0/pyproject.toml +118 -0
- mistralai_workflows_plugins_search-0.1.0/tasks.py +91 -0
- mistralai_workflows_plugins_search-0.1.0/tests/conftest.py +270 -0
- mistralai_workflows_plugins_search-0.1.0/tests/test_activities.py +736 -0
- mistralai_workflows_plugins_search-0.1.0/tests/test_import.py +41 -0
- mistralai_workflows_plugins_search-0.1.0/tests/test_models.py +93 -0
- mistralai_workflows_plugins_search-0.1.0/tests/test_payload_sizes.py +131 -0
- mistralai_workflows_plugins_search-0.1.0/tests/test_registry.py +80 -0
- mistralai_workflows_plugins_search-0.1.0/tests/test_stages.py +216 -0
- mistralai_workflows_plugins_search-0.1.0/tests/test_workflows.py +343 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# python sdk
|
|
2
|
+
build
|
|
3
|
+
*.egg-info
|
|
4
|
+
*.egg
|
|
5
|
+
|
|
6
|
+
# Env files
|
|
7
|
+
.env
|
|
8
|
+
.env.*
|
|
9
|
+
!.env.example
|
|
10
|
+
|
|
11
|
+
# Python
|
|
12
|
+
__pycache__/
|
|
13
|
+
*.py[cod]
|
|
14
|
+
*$py.class
|
|
15
|
+
.pytest_cache/
|
|
16
|
+
.coverage
|
|
17
|
+
htmlcov/
|
|
18
|
+
|
|
19
|
+
# IDEs
|
|
20
|
+
.idea/
|
|
21
|
+
.vscode/
|
|
22
|
+
*.swp
|
|
23
|
+
*.swo
|
|
24
|
+
|
|
25
|
+
# docs
|
|
26
|
+
docs/node_modules/**
|
|
27
|
+
docs/.docusaurus/**
|
|
28
|
+
docs/pnpm-lock.yaml
|
|
29
|
+
.docs.temporal.io/
|
|
30
|
+
|
|
31
|
+
#test
|
|
32
|
+
/.test_durations
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: mistralai-workflows-plugins-search
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Search Toolkit plugin for Mistral Workflows - durable document ingestion pipelines
|
|
5
|
+
Project-URL: Homepage, https://mistral.ai
|
|
6
|
+
Project-URL: Documentation, https://docs-internal-frameworks.mistral.ai/workflows
|
|
7
|
+
Author-email: Mistral AI <support@mistral.ai>
|
|
8
|
+
License: Proprietary
|
|
9
|
+
Keywords: ai,ingestion,mistral,plugin,rag,search,workflows
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: Other/Proprietary License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Requires-Python: <3.15,>=3.12
|
|
18
|
+
Requires-Dist: mistral-common
|
|
19
|
+
Requires-Dist: mistralai-search-toolkit<1.0.0,>=0.0.12
|
|
20
|
+
Requires-Dist: mistralai-workflows<4.0.0,>=3.0.0a1
|
|
21
|
+
Requires-Dist: pydantic-settings>=2.2
|
|
22
|
+
Requires-Dist: pydantic<3.0.0,>=2.7.4
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# mistralai-workflows-plugins-search
|
|
2
|
+
|
|
3
|
+
Runs [Search Toolkit](../../../search/toolkit) ingestion pipelines as durable Temporal workflows,
|
|
4
|
+
with one visible step per stage.
|
|
5
|
+
|
|
6
|
+
A toolkit `Pipeline.run()` is a single opaque call: if it dies at document 800 of 1000, the whole
|
|
7
|
+
run restarts and re-pays for the extraction already done. This plugin splits the same pipeline into
|
|
8
|
+
four activities with independent retry boundaries, so a failure resumes at the stage that failed and
|
|
9
|
+
a bad document is recorded rather than fatal.
|
|
10
|
+
|
|
11
|
+
## Topology
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
IngestDocumentsWorkflow route → batch → fan out in bounded waves
|
|
15
|
+
└── plan_batches one activity, no document content
|
|
16
|
+
└── IngestBatchWorkflow one child per batch, homogeneous by mimetype
|
|
17
|
+
├── extract references → documents
|
|
18
|
+
├── split documents → chunks
|
|
19
|
+
├── process chunk enrichers, summarisers, ...
|
|
20
|
+
└── embed_and_index embed (unbounded) then index (bounded)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Documents are declared as `OffloadableField`, so that they *can* move between activities as blob
|
|
24
|
+
references rather than as workflow payloads. Each stage output also carries an inline summary —
|
|
25
|
+
counters and failures — because `OffloadableField.get_value()` raises inside a workflow, so the
|
|
26
|
+
summary is the only thing the orchestrating workflow can read.
|
|
27
|
+
|
|
28
|
+
`embed` and `index` share one activity on purpose: an embedded document is the largest payload the
|
|
29
|
+
pipeline produces, and fusing the two stages is what keeps the vectors from crossing a boundary.
|
|
30
|
+
|
|
31
|
+
> **Offloading is opt-in, and this plugin's defaults assume it is on.**
|
|
32
|
+
>
|
|
33
|
+
> Declaring a field offloadable does nothing by itself: the core SDK only uploads it when payload
|
|
34
|
+
> offloading is enabled *and* pointed at a blob store, via
|
|
35
|
+
> `TEMPORAL_PAYLOAD_OFFLOADING__ENABLED` and the matching storage configuration. Left unset,
|
|
36
|
+
> documents travel inline through Temporal like any other payload.
|
|
37
|
+
>
|
|
38
|
+
> That matters here because Temporal caps a payload at 2 MB (`MAX_INPUT_SIZE_BYTES` in the SDK),
|
|
39
|
+
> and the default `batch_size` of 30 weighs roughly 3.8 MB at the widest stage boundary. **Without
|
|
40
|
+
> offloading, the default batch does not fit** — you either enable it, or lower `batch_size` to
|
|
41
|
+
> around 10 and accept the smaller retry granularity. `tests/test_payload_sizes.py` measures both
|
|
42
|
+
> regimes.
|
|
43
|
+
|
|
44
|
+
## Setting up a worker
|
|
45
|
+
|
|
46
|
+
See [`examples/worker.py`](examples/worker.py). Two registries are the entire setup surface.
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from mistralai.workflows.plugins.search import pipeline_registry
|
|
50
|
+
|
|
51
|
+
pipeline_registry.register("pdf", pdf_pipeline)
|
|
52
|
+
pipeline_registry.register_router("docs", RoutedPipeline({"ocr": pdf_pipeline}))
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
A `Pipeline` holds live clients and credentials, so it cannot cross a Temporal payload. Activities
|
|
56
|
+
carry the *name* and resolve the object from worker state.
|
|
57
|
+
|
|
58
|
+
Which of the two you need depends on the workflow. `IngestBatchWorkflow` carries a `pipeline_name`,
|
|
59
|
+
so it only needs `register()`. `IngestDocumentsWorkflow` carries a `router_name` — but registering
|
|
60
|
+
the router is not enough on its own: **every pipeline the router can return must also be registered
|
|
61
|
+
by name**, because routing resolves a `Pipeline` object and the batch it produces can only carry a
|
|
62
|
+
name. Miss one and `plan_batches` raises `UnregisteredPipelineError` naming what is registered,
|
|
63
|
+
rather than routing the batch through a pipeline it guessed at. That is why `pdf_pipeline` appears
|
|
64
|
+
twice above.
|
|
65
|
+
|
|
66
|
+
The router keys on **protocol**, not on mimetype. `DEFAULT_MIME_REGISTRY` maps `application/pdf` to
|
|
67
|
+
`ocr`, `text/plain` to `plain_text`, `message/rfc822` to `email`, plus `audio`, `html`, `image`,
|
|
68
|
+
`numbers` and `xlsx`. A file whose protocol the router has no pipeline for is reported as
|
|
69
|
+
unroutable, not raised — one unsupported file does not sink a run.
|
|
70
|
+
|
|
71
|
+
Activities register themselves at import time, so `run_worker` takes only the workflows.
|
|
72
|
+
|
|
73
|
+
### Registering your own document types
|
|
74
|
+
|
|
75
|
+
If your extractors or stores use a `Document`, `DocumentChunk` or metadata subclass, register it
|
|
76
|
+
under a stable id you choose and never change:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from mistralai.workflows.plugins.search import document_type_registry
|
|
80
|
+
|
|
81
|
+
document_type_registry.register("acme.contract.v1", ContractDocument)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The id is a contract that outlives the code: it is what a payload written today names, so moving or
|
|
85
|
+
renaming the class must not change it. Everything the toolkit ships is already registered.
|
|
86
|
+
|
|
87
|
+
Forget it and ingestion fails loudly rather than quietly dropping your fields:
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
UnregisteredDocumentTypeError: acme.documents:ContractDocument has no stable type id, so a
|
|
91
|
+
payload naming it could not be read back after the class moves. Register a class of your own
|
|
92
|
+
with `DocumentTypeRegistry.register(<id>, <class>)`; a class shipped by the Search Toolkit is
|
|
93
|
+
missing an entry in `_BUILTIN_TYPE_IDS`.
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
That is deliberate. Without the id, the subclass would come back from the blob as a base
|
|
97
|
+
`Document` — custom top-level fields rejected, custom metadata reduced to untyped extras — and the
|
|
98
|
+
run would report success on documents it had silently downgraded.
|
|
99
|
+
|
|
100
|
+
## Tuning throughput
|
|
101
|
+
|
|
102
|
+
`IngestionExecutionPolicy` travels from the parent workflow down to every activity, so throughput is
|
|
103
|
+
a deployment concern rather than a code change. Every value is validated `>= 1`: a zero would leave
|
|
104
|
+
a stage waiting on a semaphore that never opens.
|
|
105
|
+
|
|
106
|
+
| Field | Default | Governs |
|
|
107
|
+
| --- | --- | --- |
|
|
108
|
+
| `batch_size` | 30 | Documents per child workflow |
|
|
109
|
+
| `max_concurrent_batches` | 5 | Child workflows in flight per wave |
|
|
110
|
+
| `extract_concurrency` | 5 | Documents extracted at once — usually the OCR rate limit |
|
|
111
|
+
| `split_concurrency` | 10 | Documents split at once; CPU-bound, cheap |
|
|
112
|
+
| `process_concurrency` | 10 | Documents through the processor chain at once |
|
|
113
|
+
| `index_concurrency` | 5 | Documents indexed at once; stores have no bulk API yet |
|
|
114
|
+
|
|
115
|
+
Concurrency always applies *across* documents. The processor chain for a single document stays
|
|
116
|
+
sequential because its stages feed each other; if a processor is not safe to call concurrently, set
|
|
117
|
+
`process_concurrency = 1` rather than changing the activity.
|
|
118
|
+
|
|
119
|
+
**There is no `embed_concurrency`, deliberately.** Mistral's embedder batches and rate-limits at the
|
|
120
|
+
chunk level across concurrent calls, so the embed phase runs unbounded and its buffer fills across
|
|
121
|
+
the whole batch. A limiter on top would only starve it. That holds for our embedder specifically —
|
|
122
|
+
a caller who plugs in one that does not batch has no way to bound it today, which
|
|
123
|
+
[CTX-3532](https://linear.app/mistral-ai/issue/CTX-3532) tracks.
|
|
124
|
+
|
|
125
|
+
### Timeouts
|
|
126
|
+
|
|
127
|
+
Activity and workflow timeouts cannot travel in the policy — they are decorator arguments,
|
|
128
|
+
evaluated when the plugin is imported, and the policy only exists once a run starts. They are read
|
|
129
|
+
from the environment instead, so a deployment still tunes them without a fork. **Changing one takes
|
|
130
|
+
a worker restart.**
|
|
131
|
+
|
|
132
|
+
| Variable | Default | Applies to |
|
|
133
|
+
| --- | --- | --- |
|
|
134
|
+
| `SEARCH_PLUGIN_EXTRACT_TIMEOUT_MINUTES` | 30 | `extract` |
|
|
135
|
+
| `SEARCH_PLUGIN_SPLIT_TIMEOUT_MINUTES` | 10 | `split` |
|
|
136
|
+
| `SEARCH_PLUGIN_PROCESS_TIMEOUT_MINUTES` | 30 | `process` |
|
|
137
|
+
| `SEARCH_PLUGIN_EMBED_AND_INDEX_TIMEOUT_MINUTES` | 30 | `embed_and_index` |
|
|
138
|
+
| `SEARCH_PLUGIN_PLAN_BATCHES_TIMEOUT_MINUTES` | 5 | `plan_batches` |
|
|
139
|
+
| `SEARCH_PLUGIN_CORPUS_EXECUTION_TIMEOUT_HOURS` | 12 | `IngestDocumentsWorkflow` |
|
|
140
|
+
|
|
141
|
+
The defaults are per-stage worst cases for a `batch_size` of 30. **Raising `batch_size` means
|
|
142
|
+
raising these too** — nothing links them automatically, because one is a runtime value and the
|
|
143
|
+
other is fixed at import.
|
|
144
|
+
|
|
145
|
+
`IngestBatchWorkflow`'s own timeout is derived rather than configured: the sum of the four stage
|
|
146
|
+
timeouts, multiplied by the SDK's `RETRY_POLICY_MAX_ATTEMPTS`. The multiplier is not padding. A
|
|
147
|
+
stage timeout is per *attempt*, so without it a single retryable failure in `extract` would burn
|
|
148
|
+
three quarters of the batch's budget and the workflow would die before `split`, `process` and
|
|
149
|
+
`embed_and_index` ever ran — the parent's clock undoing the stage boundaries it exists to protect.
|
|
150
|
+
|
|
151
|
+
## Error handling
|
|
152
|
+
|
|
153
|
+
The toolkit decides what is retryable, via `is_retryable_exception`, which walks the `__cause__`
|
|
154
|
+
chain for an `is_retryable` attribute and **assumes retryable when nothing has an opinion**.
|
|
155
|
+
|
|
156
|
+
- An error that declares `is_retryable = False` becomes a per-document failure; the batch continues
|
|
157
|
+
and the document is listed in `failures`.
|
|
158
|
+
- Anything else propagates, so Temporal retries the activity. A store outage must not be recorded
|
|
159
|
+
as thirty permanent document failures.
|
|
160
|
+
|
|
161
|
+
There is deliberately **no** "every document failed the same way, so it must be environmental"
|
|
162
|
+
rule. By the time a batch is complete, anything without an opinion has already propagated, so such
|
|
163
|
+
a rule could only ever fire on errors that declared themselves non-retryable — re-raising exactly
|
|
164
|
+
the failures that never succeed on retry, burning the activity's attempts and then failing the
|
|
165
|
+
batch, which takes the corpus run down with it. A batch where every document is unusable reports
|
|
166
|
+
every document instead.
|
|
167
|
+
|
|
168
|
+
Because the default is "retryable", a failure your own code raises must say so itself. The plugin's
|
|
169
|
+
`DocumentInputError` does exactly that for a reference that cannot be resolved: wrapping a
|
|
170
|
+
`KeyError` would not help, since neither it nor the toolkit's `DocumentProcessingError` carries an
|
|
171
|
+
opinion, and a missing object does not become present on retry.
|
|
172
|
+
|
|
173
|
+
## Development
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
uv sync
|
|
177
|
+
uv run invoke tests
|
|
178
|
+
uv run invoke lint
|
|
179
|
+
uv run invoke typecheck
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## References
|
|
183
|
+
|
|
184
|
+
- [Search Toolkit](../../../search/toolkit) — pipelines, extractors, stores, and the document
|
|
185
|
+
serialization API this plugin moves documents with
|
|
186
|
+
- [Workflows SDK contributing guide](../../CONTRIBUTING.md)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Minimal worker wiring a toolkit pipeline into the search ingestion workflows."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
from mistralai.search.toolkit.ingestion.pipelines import Pipeline
|
|
6
|
+
from mistralai.search.toolkit.ingestion.pipelines.routed_pipeline import RoutedPipeline
|
|
7
|
+
|
|
8
|
+
from mistralai.workflows import run_worker
|
|
9
|
+
from mistralai.workflows.plugins.search import (
|
|
10
|
+
IngestBatchWorkflow,
|
|
11
|
+
IngestDocumentsWorkflow,
|
|
12
|
+
pipeline_registry,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_pdf_pipeline() -> Pipeline:
|
|
17
|
+
raise NotImplementedError("Construct your toolkit Pipeline here: loader, extractor, splitter, stores.")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def main() -> None:
|
|
21
|
+
pdf_pipeline = build_pdf_pipeline()
|
|
22
|
+
|
|
23
|
+
# A Pipeline holds live clients and credentials, so it cannot cross a Temporal payload.
|
|
24
|
+
# Activities carry the name and resolve the object from this registry.
|
|
25
|
+
pipeline_registry.register("pdf", pdf_pipeline)
|
|
26
|
+
|
|
27
|
+
# The router keys on protocol, not on mimetype: DEFAULT_MIME_REGISTRY maps application/pdf to
|
|
28
|
+
# `ocr`, text/plain to `plain_text`, message/rfc822 to `email`, and so on.
|
|
29
|
+
#
|
|
30
|
+
# `pdf_pipeline` deliberately appears twice. Routing resolves a Pipeline *object*, and the batch
|
|
31
|
+
# it produces can only carry a *name*, so every pipeline this router can return also needs its
|
|
32
|
+
# own `register()` call above — otherwise plan_batches raises UnregisteredPipelineError.
|
|
33
|
+
pipeline_registry.register_router("docs", RoutedPipeline({"ocr": pdf_pipeline}))
|
|
34
|
+
|
|
35
|
+
# Register any Document, DocumentChunk or metadata subclass your extractors or stores rely on,
|
|
36
|
+
# each under a stable id you choose and never change. The toolkit's own classes are already
|
|
37
|
+
# registered. Skipping this fails the ingestion — see the README.
|
|
38
|
+
#
|
|
39
|
+
# from mistralai.workflows.plugins.search import document_type_registry
|
|
40
|
+
# document_type_registry.register("acme.contract.v1", ContractDocument)
|
|
41
|
+
|
|
42
|
+
# Activities register themselves with the worker at import time, so `run_worker` takes only the
|
|
43
|
+
# workflows; importing this plugin is what makes the five activities available.
|
|
44
|
+
await run_worker([IngestDocumentsWorkflow, IngestBatchWorkflow])
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
if __name__ == "__main__":
|
|
48
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# This file helps PyLance/VS Code and mypy resolve imports for our PEP 420 namespace
|
|
2
|
+
# mistralai/workflows/plugins. Excluded from the wheel — see pyproject.toml.
|
|
3
|
+
__path__ = __import__("pkgutil").extend_path(__path__, __name__)
|
|
4
|
+
|
|
5
|
+
from mistralai.workflows.exports import * # noqa: F403
|
|
6
|
+
from mistralai.workflows.exports import __all__ as __all__
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Search Toolkit ingestion as durable Mistral Workflows."""
|
|
2
|
+
|
|
3
|
+
from mistralai.workflows.plugins.search.activities import (
|
|
4
|
+
embed_and_index,
|
|
5
|
+
extract,
|
|
6
|
+
plan_batches,
|
|
7
|
+
process,
|
|
8
|
+
split,
|
|
9
|
+
)
|
|
10
|
+
from mistralai.workflows.plugins.search.models import (
|
|
11
|
+
DocumentFailure,
|
|
12
|
+
DocumentRef,
|
|
13
|
+
IngestBatchInput,
|
|
14
|
+
IngestBatchOutput,
|
|
15
|
+
IngestDocumentsInput,
|
|
16
|
+
IngestDocumentsOutput,
|
|
17
|
+
IngestionExecutionPolicy,
|
|
18
|
+
)
|
|
19
|
+
from mistralai.workflows.plugins.search.registry import (
|
|
20
|
+
PipelineRegistry,
|
|
21
|
+
UnknownPipelineError,
|
|
22
|
+
UnknownRouterError,
|
|
23
|
+
UnregisteredPipelineError,
|
|
24
|
+
document_type_registry,
|
|
25
|
+
pipeline_registry,
|
|
26
|
+
)
|
|
27
|
+
from mistralai.workflows.plugins.search.workflows import (
|
|
28
|
+
IngestBatchWorkflow,
|
|
29
|
+
IngestDocumentsWorkflow,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# Serialization types are deliberately not re-exported. They are the Search Toolkit's public API,
|
|
33
|
+
# and a caller who needs them should import from `mistralai.search.toolkit.document.serialization`
|
|
34
|
+
# so there is exactly one obvious home for them.
|
|
35
|
+
__all__ = [
|
|
36
|
+
"DocumentFailure",
|
|
37
|
+
"DocumentRef",
|
|
38
|
+
"IngestBatchInput",
|
|
39
|
+
"IngestBatchOutput",
|
|
40
|
+
"IngestBatchWorkflow",
|
|
41
|
+
"IngestDocumentsInput",
|
|
42
|
+
"IngestDocumentsOutput",
|
|
43
|
+
"IngestDocumentsWorkflow",
|
|
44
|
+
"IngestionExecutionPolicy",
|
|
45
|
+
"PipelineRegistry",
|
|
46
|
+
"UnknownPipelineError",
|
|
47
|
+
"UnknownRouterError",
|
|
48
|
+
"UnregisteredPipelineError",
|
|
49
|
+
"document_type_registry",
|
|
50
|
+
"embed_and_index",
|
|
51
|
+
"extract",
|
|
52
|
+
"pipeline_registry",
|
|
53
|
+
"plan_batches",
|
|
54
|
+
"process",
|
|
55
|
+
"split",
|
|
56
|
+
]
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import Awaitable, Callable
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from mistralai.search.toolkit.document import Document
|
|
8
|
+
from mistralai.search.toolkit.errors import is_retryable_exception
|
|
9
|
+
|
|
10
|
+
from mistralai.workflows.plugins.search.models import DocumentFailure
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DocumentInputError(Exception):
|
|
14
|
+
"""The plugin established that a reference cannot yield a document.
|
|
15
|
+
|
|
16
|
+
Declares ``is_retryable = False`` so the toolkit predicate keeps it out of the retry path.
|
|
17
|
+
``is_retryable_exception`` assumes retryable when nothing in the ``__cause__`` chain has an
|
|
18
|
+
opinion, which is the right default for an unrecognised failure and the wrong one here: a
|
|
19
|
+
missing object does not become present on retry. Wrapping the cause is not enough either — a
|
|
20
|
+
``KeyError`` has no opinion, and neither does the toolkit's own ``DocumentProcessingError``.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
is_retryable = False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class _Outcome:
|
|
28
|
+
document: Document | None = None
|
|
29
|
+
failure: DocumentFailure | None = None
|
|
30
|
+
retryable: Exception | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def run_document_stage(
|
|
34
|
+
documents: list[Document],
|
|
35
|
+
*,
|
|
36
|
+
stage: str,
|
|
37
|
+
apply: Callable[[Document], Awaitable[Document]],
|
|
38
|
+
concurrency: int | None = None,
|
|
39
|
+
) -> tuple[list[Document], list[DocumentFailure]]:
|
|
40
|
+
"""Apply one pipeline stage across a batch, concurrently, isolating per-document failures.
|
|
41
|
+
|
|
42
|
+
``concurrency`` bounds how many documents are in flight. ``None`` leaves the stage unbounded,
|
|
43
|
+
which is what embedding wants: the embedder batches at the chunk level across concurrent calls,
|
|
44
|
+
so a limiter on top of it only starves its buffer.
|
|
45
|
+
|
|
46
|
+
Errors the toolkit classifies as retryable propagate, so Temporal retries the activity — a store
|
|
47
|
+
outage must not be recorded as thirty permanent document failures. Only an error that declares
|
|
48
|
+
``is_retryable = False``, directly or through its ``__cause__`` chain, becomes a per-document
|
|
49
|
+
failure that lets the batch continue; the toolkit assumes retryable when nothing has an opinion,
|
|
50
|
+
and over-retrying a doomed document is cheaper than abandoning a recoverable one.
|
|
51
|
+
|
|
52
|
+
There is deliberately no "every document failed the same way, so it must be environmental" net.
|
|
53
|
+
Such a net can only ever observe errors that already declared themselves non-retryable, because
|
|
54
|
+
anything without an opinion propagates before the batch is complete — so it would re-raise
|
|
55
|
+
precisely the failures that will never succeed on retry, burning the activity's attempts and
|
|
56
|
+
then failing the batch, which takes the whole corpus run down with it. A batch where every
|
|
57
|
+
document is genuinely unusable reports every document, which is both cheaper and more legible.
|
|
58
|
+
"""
|
|
59
|
+
if not documents:
|
|
60
|
+
return [], []
|
|
61
|
+
|
|
62
|
+
semaphore = asyncio.Semaphore(concurrency) if concurrency is not None else None
|
|
63
|
+
|
|
64
|
+
async def run_one(document: Document) -> _Outcome:
|
|
65
|
+
try:
|
|
66
|
+
if semaphore is None:
|
|
67
|
+
return _Outcome(document=await apply(document))
|
|
68
|
+
async with semaphore:
|
|
69
|
+
return _Outcome(document=await apply(document))
|
|
70
|
+
except Exception as exc: # noqa: BLE001 - classified below, never swallowed
|
|
71
|
+
if is_retryable_exception(exc):
|
|
72
|
+
return _Outcome(retryable=exc)
|
|
73
|
+
return _Outcome(failure=DocumentFailure(source_id=document.source_id, stage=stage, message=str(exc)))
|
|
74
|
+
|
|
75
|
+
# gather preserves input order, so `processed` stays aligned with `documents`. Every document is
|
|
76
|
+
# attempted before any decision is taken: the semaphore caps what an outage costs, and aborting
|
|
77
|
+
# on the first error would discard the failures already collected.
|
|
78
|
+
outcomes = await asyncio.gather(*(run_one(document) for document in documents))
|
|
79
|
+
|
|
80
|
+
for outcome in outcomes:
|
|
81
|
+
if outcome.retryable is not None:
|
|
82
|
+
raise outcome.retryable
|
|
83
|
+
|
|
84
|
+
processed = [o.document for o in outcomes if o.document is not None]
|
|
85
|
+
failures = [o.failure for o in outcomes if o.failure is not None]
|
|
86
|
+
|
|
87
|
+
return processed, failures
|