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.
Files changed (22) hide show
  1. mistralai_workflows_plugins_search-0.1.0/.gitignore +32 -0
  2. mistralai_workflows_plugins_search-0.1.0/PKG-INFO +22 -0
  3. mistralai_workflows_plugins_search-0.1.0/README.md +186 -0
  4. mistralai_workflows_plugins_search-0.1.0/examples/worker.py +48 -0
  5. mistralai_workflows_plugins_search-0.1.0/mistralai/workflows/__init__.py +6 -0
  6. mistralai_workflows_plugins_search-0.1.0/mistralai/workflows/plugins/search/__init__.py +56 -0
  7. mistralai_workflows_plugins_search-0.1.0/mistralai/workflows/plugins/search/_stages.py +87 -0
  8. mistralai_workflows_plugins_search-0.1.0/mistralai/workflows/plugins/search/activities.py +288 -0
  9. mistralai_workflows_plugins_search-0.1.0/mistralai/workflows/plugins/search/config.py +60 -0
  10. mistralai_workflows_plugins_search-0.1.0/mistralai/workflows/plugins/search/models.py +182 -0
  11. mistralai_workflows_plugins_search-0.1.0/mistralai/workflows/plugins/search/registry.py +122 -0
  12. mistralai_workflows_plugins_search-0.1.0/mistralai/workflows/plugins/search/workflows.py +166 -0
  13. mistralai_workflows_plugins_search-0.1.0/pyproject.toml +118 -0
  14. mistralai_workflows_plugins_search-0.1.0/tasks.py +91 -0
  15. mistralai_workflows_plugins_search-0.1.0/tests/conftest.py +270 -0
  16. mistralai_workflows_plugins_search-0.1.0/tests/test_activities.py +736 -0
  17. mistralai_workflows_plugins_search-0.1.0/tests/test_import.py +41 -0
  18. mistralai_workflows_plugins_search-0.1.0/tests/test_models.py +93 -0
  19. mistralai_workflows_plugins_search-0.1.0/tests/test_payload_sizes.py +131 -0
  20. mistralai_workflows_plugins_search-0.1.0/tests/test_registry.py +80 -0
  21. mistralai_workflows_plugins_search-0.1.0/tests/test_stages.py +216 -0
  22. 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