pandion 0.1.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.
Files changed (74) hide show
  1. pandion/__init__.py +45 -0
  2. pandion/answer.py +16 -0
  3. pandion/artifacts.py +44 -0
  4. pandion/chunk.py +23 -0
  5. pandion/clean.py +21 -0
  6. pandion/cli.py +87 -0
  7. pandion/config.py +381 -0
  8. pandion/embedding_cache.py +145 -0
  9. pandion/embeddings.py +354 -0
  10. pandion/endpoints.py +171 -0
  11. pandion/errors.py +35 -0
  12. pandion/events.py +21 -0
  13. pandion/executor.py +342 -0
  14. pandion/graph.py +284 -0
  15. pandion/ids.py +86 -0
  16. pandion/llm.py +411 -0
  17. pandion/manifest.py +63 -0
  18. pandion/models.py +96 -0
  19. pandion/parse.py +20 -0
  20. pandion/payloads.py +238 -0
  21. pandion/pipeline.py +700 -0
  22. pandion/plugins/__init__.py +60 -0
  23. pandion/plugins/answer/__init__.py +5 -0
  24. pandion/plugins/answer/_grounding.py +161 -0
  25. pandion/plugins/answer/chat.py +589 -0
  26. pandion/plugins/answer/search.py +125 -0
  27. pandion/plugins/chunk/__init__.py +235 -0
  28. pandion/plugins/chunk/layout_blocks.py +449 -0
  29. pandion/plugins/chunk/markdown_header.py +202 -0
  30. pandion/plugins/chunk/recursive_character.py +298 -0
  31. pandion/plugins/chunk/sentence_window.py +203 -0
  32. pandion/plugins/chunk/token_based.py +156 -0
  33. pandion/plugins/clean/__init__.py +223 -0
  34. pandion/plugins/clean/dedupe_blocks.py +221 -0
  35. pandion/plugins/clean/drop_matching.py +165 -0
  36. pandion/plugins/clean/header_footer_strip.py +303 -0
  37. pandion/plugins/index/__init__.py +1 -0
  38. pandion/plugins/index/lancedb_store.py +372 -0
  39. pandion/plugins/parse/__init__.py +0 -0
  40. pandion/plugins/parse/docling.py +448 -0
  41. pandion/plugins/parse/pdfium.py +270 -0
  42. pandion/plugins/query/__init__.py +0 -0
  43. pandion/plugins/query/llm_rewrite.py +241 -0
  44. pandion/plugins/query/text.py +98 -0
  45. pandion/plugins/rerank/__init__.py +1 -0
  46. pandion/plugins/rerank/cross_encoder.py +204 -0
  47. pandion/plugins/rerank/llm_rerank.py +271 -0
  48. pandion/plugins/rerank/mmr.py +240 -0
  49. pandion/plugins/retrieve/__init__.py +10 -0
  50. pandion/plugins/retrieve/_base.py +270 -0
  51. pandion/plugins/retrieve/_prf.py +65 -0
  52. pandion/plugins/retrieve/bm25.py +94 -0
  53. pandion/plugins/retrieve/dense.py +98 -0
  54. pandion/plugins/retrieve/hybrid_rrf.py +278 -0
  55. pandion/plugins/source/__init__.py +0 -0
  56. pandion/plugins/source/upload.py +94 -0
  57. pandion/ports.py +106 -0
  58. pandion/registry.py +95 -0
  59. pandion/rerank.py +15 -0
  60. pandion/results.py +26 -0
  61. pandion/retrieve.py +15 -0
  62. pandion/rewrite.py +22 -0
  63. pandion/sources.py +51 -0
  64. pandion/stages.py +191 -0
  65. pandion/storage.py +147 -0
  66. pandion/text.py +125 -0
  67. pandion/textmatch.py +87 -0
  68. pandion/tokenize.py +35 -0
  69. pandion/transform.py +223 -0
  70. pandion-0.1.0.dist-info/METADATA +118 -0
  71. pandion-0.1.0.dist-info/RECORD +74 -0
  72. pandion-0.1.0.dist-info/WHEEL +4 -0
  73. pandion-0.1.0.dist-info/entry_points.txt +2 -0
  74. pandion-0.1.0.dist-info/licenses/LICENSE +21 -0
pandion/__init__.py ADDED
@@ -0,0 +1,45 @@
1
+ """Pandion: indexing and retrieval pipelines you can inspect stage by stage.
2
+
3
+ The engine lives in plain modules (`pandion.graph`, `pandion.executor`,
4
+ `pandion.storage` and the rest). A name that starts with an underscore is
5
+ private; the RAG Playground app may import every other name.
6
+
7
+ `pandion.parse`, `pandion.clean`, `pandion.chunk` and `pandion.rewrite` run one
8
+ stage when called, and hold that stage's step classes. `pandion.retrieve`,
9
+ `pandion.rerank` and `pandion.answer` hold the step classes of the rest.
10
+ """
11
+
12
+ from pandion.models import ModelSettings, preload_embedder, preload_reranker
13
+ from pandion.pipeline import (
14
+ Answer,
15
+ IndexPipeline,
16
+ Pipeline,
17
+ QueryPipeline,
18
+ Retrieval,
19
+ RetrievalPipeline,
20
+ )
21
+ from pandion.results import StageResult
22
+ from pandion import answer, chunk, clean, parse, rerank, retrieve, rewrite
23
+
24
+ __version__ = "0.1.0"
25
+
26
+ __all__ = [
27
+ "Answer",
28
+ "IndexPipeline",
29
+ "ModelSettings",
30
+ "Pipeline",
31
+ "QueryPipeline",
32
+ "Retrieval",
33
+ "RetrievalPipeline",
34
+ "StageResult",
35
+ "answer",
36
+ "chunk",
37
+ "clean",
38
+ "parse",
39
+ "preload_embedder",
40
+ "preload_reranker",
41
+ "rerank",
42
+ "retrieve",
43
+ "rewrite",
44
+ "__version__",
45
+ ]
pandion/answer.py ADDED
@@ -0,0 +1,16 @@
1
+ """The answer steps, as classes for a pipeline written in code.
2
+
3
+ `Search(top_k=5)` shows the pieces retrieval found, as they are. `Chat(model=
4
+ "claude-haiku-4-5")` asks a chat model for an answer whose claims cite those
5
+ pieces. Each is the same as its block, for example `{"use": "search",
6
+ "top_k": 5}` (`pandion.config.step_class`). Answering runs through a
7
+ pipeline: `p.ask(question)`.
8
+ """
9
+
10
+ from pandion.config import step_class
11
+ from pandion.ports import Stage
12
+
13
+ Search = step_class(Stage.USE_CASE, "search", __name__)
14
+ Chat = step_class(Stage.USE_CASE, "chat", __name__)
15
+
16
+ __all__ = ["Chat", "Search"]
pandion/artifacts.py ADDED
@@ -0,0 +1,44 @@
1
+ """Artifact types and the immutable artifact value.
2
+
3
+ An artifact is what a transform produces: an immutable value identified by a
4
+ recipe hash (see `pandion.ids`). The payload itself lives on disk and is loaded
5
+ lazily; `meta` is small and always in memory.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from enum import StrEnum
12
+ from typing import Any
13
+
14
+
15
+ class ArtifactType(StrEnum):
16
+ """The value types that flow between pipeline stages.
17
+
18
+ A StrEnum rather than a Literal so that `inputs = {"file": ArtifactType.RAW_FILE}`
19
+ typechecks in plugins without an explicit annotation, and so it serializes to
20
+ JSON as a plain string.
21
+ """
22
+
23
+ RAW_FILE = "raw_file"
24
+ QUERY = "query"
25
+ PARSED_DOC = "parsed_doc"
26
+ CHUNK_SET = "chunk_set"
27
+ INDEX = "index"
28
+ RETRIEVAL_RESULT = "retrieval_result"
29
+ OUTPUT = "output"
30
+ QA_SET = "qa_set"
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Artifact:
35
+ """A committed pipeline value.
36
+
37
+ `id` is a *recipe* hash, not a content hash — it is computed from the
38
+ transform and its inputs before the payload exists. That is sound only for
39
+ deterministic transforms; see `Transform.deterministic`.
40
+ """
41
+
42
+ id: str
43
+ type: ArtifactType
44
+ meta: dict[str, Any] = field(default_factory=dict)
pandion/chunk.py ADDED
@@ -0,0 +1,23 @@
1
+ """The chunkers: call this module to chunk one document, or use a class in a pipeline.
2
+
3
+ `pandion.chunk(doc, use="recursive_character", chunk_size=400)` cuts one parsed
4
+ document into pieces (`pandion.stages.chunk`). `RecursiveCharacter(chunk_size=400)`
5
+ is the same settings for a pipeline written in code (`pandion.config.step_class`).
6
+ """
7
+
8
+ import sys
9
+
10
+ from pandion.config import step_class
11
+ from pandion.ports import Stage
12
+ from pandion.stages import StageModule
13
+ from pandion.stages import chunk as run
14
+
15
+ RecursiveCharacter = step_class(Stage.CHUNK, "recursive_character", __name__)
16
+ LayoutBlocks = step_class(Stage.CHUNK, "layout_blocks", __name__)
17
+ MarkdownHeader = step_class(Stage.CHUNK, "markdown_header", __name__)
18
+ TokenBased = step_class(Stage.CHUNK, "token_based", __name__)
19
+ SentenceWindow = step_class(Stage.CHUNK, "sentence_window", __name__)
20
+
21
+ __all__ = ["LayoutBlocks", "MarkdownHeader", "RecursiveCharacter", "SentenceWindow", "TokenBased"]
22
+
23
+ sys.modules[__name__].__class__ = StageModule
pandion/clean.py ADDED
@@ -0,0 +1,21 @@
1
+ """The cleaners: call this module to clean one document, or use a class in a pipeline.
2
+
3
+ `pandion.clean(doc, steps=[{"use": "dedupe_blocks"}])` cleans one parsed
4
+ document (`pandion.stages.clean`). `DedupeBlocks()` is the same step for a
5
+ pipeline written in code (`pandion.config.step_class`).
6
+ """
7
+
8
+ import sys
9
+
10
+ from pandion.config import step_class
11
+ from pandion.ports import Stage
12
+ from pandion.stages import StageModule
13
+ from pandion.stages import clean as run
14
+
15
+ HeaderFooterStrip = step_class(Stage.CLEAN, "header_footer_strip", __name__)
16
+ DedupeBlocks = step_class(Stage.CLEAN, "dedupe_blocks", __name__)
17
+ DropMatching = step_class(Stage.CLEAN, "drop_matching", __name__)
18
+
19
+ __all__ = ["DedupeBlocks", "DropMatching", "HeaderFooterStrip"]
20
+
21
+ sys.modules[__name__].__class__ = StageModule
pandion/cli.py ADDED
@@ -0,0 +1,87 @@
1
+ """The `pandion` command.
2
+
3
+ `pandion install pipeline.yaml` reads the file, works out which extras its
4
+ steps need, and installs them with pip into the Python that runs it.
5
+ `--print` only prints the command. Nothing is ever installed while a pipeline
6
+ runs.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import subprocess
13
+ import sys
14
+
15
+ from pandion.config import ConfigError, PipelineConfig, load
16
+
17
+ #: The extra each strategy or embedder needs. A name that is not here needs
18
+ #: only the base install.
19
+ EXTRAS = {
20
+ "pdfium": "pdfium",
21
+ "docling": "docling",
22
+ "lancedb": "lancedb",
23
+ "qwen3-embedding-0.6b": "local",
24
+ "bge-small-en-v1.5": "local",
25
+ "mmr": "local",
26
+ "cross_encoder": "local",
27
+ }
28
+
29
+ #: Steps that call a chat model, by stage; their extra depends on the model's provider.
30
+ CHAT_STEPS = {"llm_rewrite": "query", "llm_rerank": "rerank", "chat": "use_case"}
31
+
32
+
33
+ def extras_for(config: PipelineConfig) -> list[str]:
34
+ """The extras the pipeline's steps need, sorted."""
35
+ index = config.index
36
+ names = [index.parse[0], *(use for use, _ in index.clean), index.chunk[0]]
37
+ names += ["lancedb", index.store["embedder"]]
38
+ extras = {EXTRAS[name] for name in names if name in EXTRAS}
39
+ retrieve = config.retrieve
40
+ if retrieve is not None:
41
+ steps = [*([retrieve.rewrite] if retrieve.rewrite else []), retrieve.search, *retrieve.rerank]
42
+ if config.answer is not None:
43
+ steps.append(config.answer)
44
+ for use, settings in steps:
45
+ if use in CHAT_STEPS:
46
+ extras.add(_chat_extra(use, settings))
47
+ elif use in EXTRAS:
48
+ extras.add(EXTRAS[use])
49
+ return sorted(extras)
50
+
51
+
52
+ def _chat_extra(use: str, settings: dict) -> str:
53
+ """Claude models need the Anthropic SDK; every other model the OpenAI SDK."""
54
+ from pandion.llm import CHAT_MODELS
55
+ from pandion.plugins import discover
56
+ from pandion.ports import Stage
57
+ from pandion.registry import registry
58
+
59
+ discover()
60
+ model = registry.get(Stage(CHAT_STEPS[use]), use).config_model(**settings).model
61
+ return "anthropic" if CHAT_MODELS[model].provider == "anthropic" else "openai"
62
+
63
+
64
+ def main(argv: list[str] | None = None) -> int:
65
+ parser = argparse.ArgumentParser(prog="pandion")
66
+ commands = parser.add_subparsers(dest="command", required=True)
67
+ install = commands.add_parser("install", help="install the extras a pipeline file needs")
68
+ install.add_argument("config", help="the pipeline file, for example pipeline.yaml")
69
+ install.add_argument("--print", action="store_true", help="only print the pip command")
70
+ args = parser.parse_args(argv)
71
+
72
+ try:
73
+ config = load(args.config)
74
+ except (ConfigError, OSError) as exc:
75
+ print(f"pandion: {exc}", file=sys.stderr)
76
+ return 2
77
+ extras = extras_for(config)
78
+ package = f"pandion[{','.join(extras)}]" if extras else "pandion"
79
+ command = [sys.executable, "-m", "pip", "install", package]
80
+ if args.print:
81
+ print(f'"{sys.executable}" -m pip install "{package}"')
82
+ return 0
83
+ return subprocess.call(command)
84
+
85
+
86
+ if __name__ == "__main__":
87
+ raise SystemExit(main())
pandion/config.py ADDED
@@ -0,0 +1,381 @@
1
+ """Read a pipeline.yaml and check it before anything runs.
2
+
3
+ ```yaml
4
+ path: ./my-index # where sources, artifacts and the embedding cache go
5
+ models: # optional: pandion.models.ModelSettings
6
+ download: auto
7
+ index:
8
+ parse: {use: docling, do_ocr: false}
9
+ clean: # optional: a list of cleaners, run in order
10
+ - use: dedupe_blocks
11
+ chunk: {use: recursive_character, chunk_size: 400}
12
+ embed: {use: qwen3-embedding-0.6b, truncate_dim: null}
13
+ store: {use: lancedb, metric: cosine, build_fts: true}
14
+ retrieve: # optional: what p.retrieve runs
15
+ rewrite: none # optional: none, or {use: llm_rewrite, style: keywords}
16
+ search: {use: hybrid_rrf, top_k: 20}
17
+ rerank: # optional: none, one step, or a list run in order
18
+ - use: cross_encoder
19
+ top_k: 5
20
+ answer: # optional: what p.ask runs after retrieval
21
+ use: chat # search (the pieces as they are) or chat
22
+ model: claude-haiku-4-5
23
+ ```
24
+
25
+ Every key under a step other than `use` is a setting of that step, with the
26
+ same name as in its config model, so a file and the same pipeline built in the
27
+ RAG Playground hash to the same artifact ids. `embed` and `store` are one step
28
+ when it runs: the lancedb index embeds while it writes, so `embed.use` becomes
29
+ its `embedder` setting. A relative `path` or `models.cache_dir` is read from
30
+ the folder the file is in.
31
+
32
+ The question is not part of the file: it is passed to `p.retrieve` or `p.ask`,
33
+ which put it in the query step's `text`. A model's key is never in the file
34
+ either: it is passed to `p.retrieve` or `p.ask` as `credentials`.
35
+
36
+ The same pipeline can be written in code (`build`): each stage as a dict that
37
+ is the file's block for that stage, as a step class such as
38
+ `pandion.parse.Docling(do_ocr=False)` (`step_class`), or as a plain name, which
39
+ is short for `{"use": name}`. Every way goes through `from_dict`, so it is
40
+ checked the same way and gives the same `PipelineConfig`. `to_dict` turns a
41
+ `PipelineConfig` back into the file's shape.
42
+
43
+ Anything the file gets wrong is a `ConfigError` that names the step and what
44
+ it accepts.
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ from dataclasses import dataclass, field
50
+ from pathlib import Path
51
+ from typing import Any, ClassVar
52
+
53
+ import yaml
54
+ from pydantic import BaseModel, ValidationError
55
+
56
+ from pandion.embeddings import EMBEDDER_NAMES
57
+ from pandion.models import ModelSettings
58
+ from pandion.plugins import discover
59
+ from pandion.ports import Stage
60
+ from pandion.registry import UnknownTransformError, registry
61
+ from pandion.stages import _strategy
62
+
63
+ TOP_KEYS = ("path", "models", "index", "retrieve", "answer")
64
+ RETRIEVE_KEYS = ("rewrite", "search", "rerank")
65
+ INDEX_KEYS = ("parse", "clean", "chunk", "embed", "store")
66
+ EMBED_KEYS = ("truncate_dim",)
67
+ STORE_KEYS = ("metric", "build_fts")
68
+ #: The answer steps. `eval` is a use case too, but it scores, it does not answer.
69
+ ANSWER_STEPS = ("search", "chat")
70
+
71
+ #: Where each stage's step sits in the file, for messages.
72
+ WHERE = {
73
+ Stage.PARSE: "index.parse",
74
+ Stage.CLEAN: "index.clean",
75
+ Stage.CHUNK: "index.chunk",
76
+ Stage.QUERY: "retrieve.rewrite",
77
+ Stage.RETRIEVE: "retrieve.search",
78
+ Stage.RERANK: "retrieve.rerank",
79
+ Stage.USE_CASE: "answer",
80
+ }
81
+
82
+
83
+ class ConfigError(ValueError):
84
+ """A pipeline file that cannot run, with what is wrong and where."""
85
+
86
+
87
+ @dataclass
88
+ class IndexConfig:
89
+ """The index steps, each as (strategy name, its settings)."""
90
+
91
+ parse: tuple[str, dict[str, Any]]
92
+ chunk: tuple[str, dict[str, Any]]
93
+ #: The lancedb index's settings, with `embedder` taken from `embed.use`.
94
+ store: dict[str, Any]
95
+ clean: list[tuple[str, dict[str, Any]]] = field(default_factory=list)
96
+
97
+
98
+ @dataclass
99
+ class RetrieveConfig:
100
+ """The retrieval steps. No rewrite means the question is searched as asked."""
101
+
102
+ search: tuple[str, dict[str, Any]]
103
+ rewrite: tuple[str, dict[str, Any]] | None = None
104
+ rerank: list[tuple[str, dict[str, Any]]] = field(default_factory=list)
105
+
106
+
107
+ @dataclass
108
+ class PipelineConfig:
109
+ path: Path
110
+ models: ModelSettings
111
+ index: IndexConfig
112
+ retrieve: RetrieveConfig | None = None
113
+ #: The answer step, as (strategy name, its settings).
114
+ answer: tuple[str, dict[str, Any]] | None = None
115
+
116
+
117
+ def load(file: str | Path) -> PipelineConfig:
118
+ """Read and check the pipeline file at `file`."""
119
+ file = Path(file)
120
+ data = yaml.safe_load(file.read_text(encoding="utf-8"))
121
+ return from_dict(data, base=file.parent)
122
+
123
+
124
+ def from_dict(data: Any, *, base: Path) -> PipelineConfig:
125
+ """Check a loaded pipeline file. Relative paths are read from `base`."""
126
+ _keys(data, TOP_KEYS, "The pipeline file")
127
+ if not data.get("path"):
128
+ raise ConfigError(
129
+ "The pipeline file has no path: the folder where Pandion keeps the "
130
+ "sources, the artifacts and the embedding cache."
131
+ )
132
+ models = dict(data.get("models") or {})
133
+ if models.get("cache_dir"):
134
+ models["cache_dir"] = _resolve(models["cache_dir"], base)
135
+ try:
136
+ settings = ModelSettings(**models)
137
+ except ValidationError as exc:
138
+ problems = "; ".join(
139
+ f"models.{'.'.join(map(str, e['loc']))}: {e['msg']}" for e in exc.errors()
140
+ )
141
+ raise ConfigError(problems) from None
142
+ if "index" not in data:
143
+ raise ConfigError("The pipeline file has no index section.")
144
+ if "answer" in data and "retrieve" not in data:
145
+ raise ConfigError(
146
+ "The answer needs a retrieve section: the answer step reads what "
147
+ "retrieval found."
148
+ )
149
+ return PipelineConfig(
150
+ path=_resolve(data["path"], base),
151
+ models=settings,
152
+ index=_index(data["index"]),
153
+ retrieve=_retrieve(data["retrieve"]) if "retrieve" in data else None,
154
+ answer=_answer(data["answer"]) if "answer" in data else None,
155
+ )
156
+
157
+
158
+ def build(
159
+ *,
160
+ path: str | Path,
161
+ models: ModelSettings | dict[str, Any] | None = None,
162
+ index: dict[str, Any],
163
+ retrieve: dict[str, Any] | None = None,
164
+ answer: Any = None,
165
+ ) -> PipelineConfig:
166
+ """Check a pipeline written in code; relative paths are read from the
167
+ current folder.
168
+
169
+ Each stage is a dict (the file's block for that stage), a step class
170
+ (`step_class`), a plain name for `{"use": name}`, or a list of these
171
+ where the file takes a list. A stage that is None is left out.
172
+ """
173
+ if isinstance(models, BaseModel):
174
+ models = models.model_dump(exclude_unset=True)
175
+ data: dict[str, Any] = {"path": path, "models": models or {}}
176
+ data["index"] = {k: _block(v) for k, v in index.items() if v is not None}
177
+ if isinstance(data["index"].get("clean"), dict):
178
+ data["index"]["clean"] = [data["index"]["clean"]]
179
+ if retrieve is not None and any(v is not None for v in retrieve.values()):
180
+ data["retrieve"] = {k: _block(v) for k, v in retrieve.items() if v is not None}
181
+ if answer is not None:
182
+ data["answer"] = _block(answer)
183
+ return from_dict(data, base=Path("."))
184
+
185
+
186
+ def _block(value: Any) -> Any:
187
+ """One stage written in code, as the file's block for it."""
188
+ if isinstance(value, str):
189
+ return value if value == "none" else {"use": value}
190
+ if isinstance(value, BaseModel) and isinstance(getattr(type(value), "use", None), str):
191
+ return {"use": type(value).use, **value.model_dump(mode="json", exclude_unset=True)}
192
+ if isinstance(value, (list, tuple)):
193
+ return [_block(v) for v in value]
194
+ return value
195
+
196
+
197
+ def to_dict(config: PipelineConfig) -> dict[str, Any]:
198
+ """`config` in the file's shape: `from_dict` of it gives `config` again."""
199
+ index = config.index
200
+ store = dict(index.store)
201
+ embed = {"use": store.pop("embedder")}
202
+ embed.update({key: store.pop(key) for key in EMBED_KEYS if key in store})
203
+ data: dict[str, Any] = {"path": config.path.as_posix()}
204
+ models = config.models.model_dump(mode="json", exclude_defaults=True)
205
+ if models:
206
+ data["models"] = models
207
+ data["index"] = {"parse": _out(index.parse)}
208
+ if index.clean:
209
+ data["index"]["clean"] = [_out(step) for step in index.clean]
210
+ data["index"].update(chunk=_out(index.chunk), embed=embed, store={"use": "lancedb", **store})
211
+ if config.retrieve is not None:
212
+ retrieve = config.retrieve
213
+ data["retrieve"] = {}
214
+ if retrieve.rewrite is not None:
215
+ data["retrieve"]["rewrite"] = _out(retrieve.rewrite)
216
+ data["retrieve"]["search"] = _out(retrieve.search)
217
+ if retrieve.rerank:
218
+ data["retrieve"]["rerank"] = [_out(step) for step in retrieve.rerank]
219
+ if config.answer is not None:
220
+ data["answer"] = _out(config.answer)
221
+ return data
222
+
223
+
224
+ def _out(step: tuple[str, dict[str, Any]]) -> dict[str, Any]:
225
+ use, settings = step
226
+ return {"use": use, **settings}
227
+
228
+
229
+ def step_class(stage: Stage, use: str, module: str) -> type[BaseModel]:
230
+ """A typed class for the `use` strategy of `stage`: its config model, which
231
+ also knows its strategy's name, so a pipeline can take it in place of a dict.
232
+
233
+ `Docling(do_ocr=False)` has the same fields and validation as the docling
234
+ config model, keeps only the settings it was given (so it makes the same
235
+ node as `{"use": "docling", "do_ocr": False}`), and refuses an unknown
236
+ setting at once, naming the stage and listing the settings it has.
237
+ """
238
+ discover()
239
+ strategy = registry.get(stage, use)
240
+ model = strategy.config_model
241
+ where = WHERE[stage]
242
+
243
+ def __init__(self, **settings: Any) -> None:
244
+ try:
245
+ _strategy(stage, use, settings, where=where)
246
+ except ValueError as exc:
247
+ raise ConfigError(str(exc)) from None
248
+ model.__init__(self, **settings)
249
+
250
+ name = "".join(part.capitalize() for part in use.split("_"))
251
+ return type(
252
+ name,
253
+ (model,),
254
+ {
255
+ "__init__": __init__,
256
+ "__module__": module,
257
+ "__qualname__": name,
258
+ "__doc__": f"The {use} {stage} step. {strategy.summary}",
259
+ "__annotations__": {"use": ClassVar[str]},
260
+ "use": use,
261
+ },
262
+ )
263
+
264
+
265
+ def _answer(answer: Any) -> tuple[str, dict[str, Any]]:
266
+ use, settings = _use(answer, "answer")
267
+ if use not in ANSWER_STEPS:
268
+ raise ConfigError(
269
+ f"answer uses '{use}', which is not an answer step. "
270
+ f"The answer steps are: {', '.join(ANSWER_STEPS)}."
271
+ )
272
+ discover()
273
+ return _step(Stage.USE_CASE, answer, "answer")
274
+
275
+
276
+ def _index(index: Any) -> IndexConfig:
277
+ _keys(index, INDEX_KEYS, "index")
278
+ for stage in ("parse", "chunk", "embed", "store"):
279
+ if stage not in index:
280
+ raise ConfigError(
281
+ f"The index has no {stage} step. An index needs parse, chunk, "
282
+ "embed and store; clean may be left out."
283
+ )
284
+ discover()
285
+ steps = index.get("clean") or []
286
+ if not isinstance(steps, list):
287
+ raise ConfigError("index.clean must be a list of cleaners, each with use.")
288
+ return IndexConfig(
289
+ parse=_step(Stage.PARSE, index["parse"], "index.parse"),
290
+ clean=[
291
+ _step(Stage.CLEAN, step, f"index.clean step {n}")
292
+ for n, step in enumerate(steps, start=1)
293
+ ],
294
+ chunk=_step(Stage.CHUNK, index["chunk"], "index.chunk"),
295
+ store=_store(index["embed"], index["store"]),
296
+ )
297
+
298
+
299
+ def _retrieve(retrieve: Any) -> RetrieveConfig:
300
+ _keys(retrieve, RETRIEVE_KEYS, "retrieve")
301
+ if not retrieve.get("search"):
302
+ raise ConfigError(
303
+ "retrieve has no search step. Retrieval needs search; rewrite and "
304
+ "rerank may be left out."
305
+ )
306
+ discover()
307
+ rewrite = retrieve.get("rewrite")
308
+ if _none(rewrite):
309
+ rewrite = None
310
+ else:
311
+ if isinstance(rewrite, dict) and "text" in rewrite:
312
+ raise ConfigError(
313
+ "retrieve.rewrite sets text, but the question is not part of the "
314
+ "file: it is passed to p.retrieve."
315
+ )
316
+ rewrite = _step(Stage.QUERY, rewrite, "retrieve.rewrite")
317
+ rerank = retrieve.get("rerank")
318
+ steps = [] if _none(rerank) else rerank if isinstance(rerank, list) else [rerank]
319
+ return RetrieveConfig(
320
+ rewrite=rewrite,
321
+ search=_step(Stage.RETRIEVE, retrieve["search"], "retrieve.search"),
322
+ rerank=[
323
+ _step(Stage.RERANK, step, "retrieve.rerank" if len(steps) == 1 else f"retrieve.rerank step {n}")
324
+ for n, step in enumerate(steps, start=1)
325
+ ],
326
+ )
327
+
328
+
329
+ def _none(step: Any) -> bool:
330
+ """Left out, or written as `none`."""
331
+ return step is None or step == "none"
332
+
333
+
334
+ def _step(stage: Stage, step: Any, where: str) -> tuple[str, dict[str, Any]]:
335
+ use, settings = _use(step, where)
336
+ try:
337
+ _strategy(stage, use, settings, where=where)
338
+ except (UnknownTransformError, ValueError) as exc:
339
+ raise ConfigError(str(exc)) from None
340
+ return use, settings
341
+
342
+
343
+ def _store(embed: Any, store: Any) -> dict[str, Any]:
344
+ embedder, embed_settings = _use(embed, "index.embed")
345
+ kind, store_settings = _use(store, "index.store")
346
+ if embedder not in EMBEDDER_NAMES:
347
+ raise ConfigError(
348
+ f"index.embed uses '{embedder}', which is not an embedder. "
349
+ f"The embedders are: {', '.join(EMBEDDER_NAMES)}."
350
+ )
351
+ if kind != "lancedb":
352
+ raise ConfigError(f"index.store uses '{kind}'. The only store is lancedb.")
353
+ _keys(embed_settings, EMBED_KEYS, "index.embed")
354
+ _keys(store_settings, STORE_KEYS, "index.store")
355
+ settings = {"embedder": embedder, **embed_settings, **store_settings}
356
+ try:
357
+ _strategy(Stage.INDEX, "lancedb", settings, where="index.store")
358
+ except ValueError as exc:
359
+ raise ConfigError(str(exc)) from None
360
+ return settings
361
+
362
+
363
+ def _use(step: Any, where: str) -> tuple[str, dict[str, Any]]:
364
+ if not isinstance(step, dict) or not step.get("use"):
365
+ raise ConfigError(f"{where} must say which strategy to use, as use: <name>.")
366
+ settings = dict(step)
367
+ return settings.pop("use"), settings
368
+
369
+
370
+ def _keys(data: Any, valid: tuple[str, ...], where: str) -> None:
371
+ if not isinstance(data, dict):
372
+ raise ConfigError(f"{where} must be a mapping of settings.")
373
+ unknown = [key for key in data if key not in valid]
374
+ if unknown:
375
+ names = ", ".join(f"'{key}'" for key in unknown)
376
+ raise ConfigError(f"{where} has no setting {names}. Its settings are: {', '.join(valid)}.")
377
+
378
+
379
+ def _resolve(value: Any, base: Path) -> Path:
380
+ path = Path(str(value)).expanduser()
381
+ return path if path.is_absolute() else base / path