aid-runtime-python 0.1.39__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.
- aid_runtime/__init__.py +500 -0
- aid_runtime/agents/__init__.py +94 -0
- aid_runtime/agents/chat_model.py +351 -0
- aid_runtime/agents/deep_agents.py +600 -0
- aid_runtime/agents/engine.py +102 -0
- aid_runtime/agents/guardrails.py +519 -0
- aid_runtime/agents/ir.py +622 -0
- aid_runtime/agents/loop.py +1308 -0
- aid_runtime/agents/memory.py +241 -0
- aid_runtime/agents/policy.py +163 -0
- aid_runtime/agents/state.py +182 -0
- aid_runtime/cache.py +185 -0
- aid_runtime/concurrency.py +70 -0
- aid_runtime/control.py +329 -0
- aid_runtime/cost.py +168 -0
- aid_runtime/errors.py +337 -0
- aid_runtime/evals/__init__.py +72 -0
- aid_runtime/evals/dataset.py +77 -0
- aid_runtime/evals/gate.py +430 -0
- aid_runtime/evals/metrics.py +335 -0
- aid_runtime/evals/predicate.py +211 -0
- aid_runtime/evals/runner.py +224 -0
- aid_runtime/gateway.py +473 -0
- aid_runtime/pipeline.py +168 -0
- aid_runtime/prompts.py +185 -0
- aid_runtime/providers/__init__.py +20 -0
- aid_runtime/providers/base.py +57 -0
- aid_runtime/providers/fake.py +256 -0
- aid_runtime/providers/http.py +344 -0
- aid_runtime/py.typed +0 -0
- aid_runtime/retrieval/__init__.py +178 -0
- aid_runtime/retrieval/chunking.py +222 -0
- aid_runtime/retrieval/citations.py +131 -0
- aid_runtime/retrieval/connectors.py +131 -0
- aid_runtime/retrieval/embedding.py +293 -0
- aid_runtime/retrieval/gate.py +120 -0
- aid_runtime/retrieval/hybrid.py +164 -0
- aid_runtime/retrieval/metrics.py +213 -0
- aid_runtime/retrieval/pgvector.py +258 -0
- aid_runtime/retrieval/rerank.py +133 -0
- aid_runtime/retrieval/retriever.py +335 -0
- aid_runtime/retrieval/stores.py +161 -0
- aid_runtime/retrieval/types.py +433 -0
- aid_runtime/steps.py +284 -0
- aid_runtime/structured.py +150 -0
- aid_runtime/tools.py +271 -0
- aid_runtime/tracing.py +241 -0
- aid_runtime/types.py +332 -0
- aid_runtime_python-0.1.39.dist-info/METADATA +268 -0
- aid_runtime_python-0.1.39.dist-info/RECORD +52 -0
- aid_runtime_python-0.1.39.dist-info/WHEEL +5 -0
- aid_runtime_python-0.1.39.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Turning documents into deterministic, size-bounded chunks.
|
|
2
|
+
|
|
3
|
+
The IR declares a strategy, a size and an overlap; it does not declare the split
|
|
4
|
+
algorithm. This module pins one, and the pin is the spec validator's own reading
|
|
5
|
+
of the intent: *"spec v0.1 chunks by size (fixed, recursive, code)"*. ``semantic``
|
|
6
|
+
is refused because it needs an embedding pass over the whole source at ingest --
|
|
7
|
+
a different shape of job, not a different boundary rule.
|
|
8
|
+
|
|
9
|
+
So all three implemented strategies are size-bounded splitters and the only thing
|
|
10
|
+
that distinguishes them is **where a boundary is allowed to fall**:
|
|
11
|
+
|
|
12
|
+
``fixed``
|
|
13
|
+
Anywhere. The window always fills to ``size``, cutting mid-word when that is
|
|
14
|
+
where the budget ends. This is the honest baseline other strategies improve on.
|
|
15
|
+
``recursive``
|
|
16
|
+
Prefer the last paragraph break, then the last line break, then the last
|
|
17
|
+
sentence end, then the last word break inside the window.
|
|
18
|
+
``code``
|
|
19
|
+
Prefer the last blank line, then the last line break, then the last statement
|
|
20
|
+
end. Code is line-structured, so a blank line is the closest thing it has to a
|
|
21
|
+
paragraph, and cutting mid-statement is the failure worth avoiding.
|
|
22
|
+
|
|
23
|
+
Chunks are pure functions of the text, so the same input always produces the same
|
|
24
|
+
chunks with the same ids in the same order -- which is what makes re-ingesting a
|
|
25
|
+
corpus a no-op instead of a duplicate.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import abc
|
|
31
|
+
from collections.abc import Sequence
|
|
32
|
+
from typing import Protocol, runtime_checkable
|
|
33
|
+
|
|
34
|
+
from aid_runtime.errors import ConfigurationError, FeatureDeferredError
|
|
35
|
+
from aid_runtime.retrieval.connectors import SourceDocument
|
|
36
|
+
from aid_runtime.retrieval.types import Chunk, ChunkingSpec, ChunkingStrategy, chunk_id_for
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"Chunker",
|
|
40
|
+
"CodeChunker",
|
|
41
|
+
"FixedChunker",
|
|
42
|
+
"RecursiveChunker",
|
|
43
|
+
"chunk_document",
|
|
44
|
+
"chunker_for",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
#: A boundary found in the first half of a window is ignored and the window is
|
|
48
|
+
#: filled to ``size`` instead. Without this, one early paragraph break near the end
|
|
49
|
+
#: of a split would produce a stream of very short chunks, and a chunker that emits
|
|
50
|
+
#: 5-character chunks is worse than one that cuts a sentence in half.
|
|
51
|
+
_MIN_FILL_NUMERATOR = 1
|
|
52
|
+
_MIN_FILL_DENOMINATOR = 2
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@runtime_checkable
|
|
56
|
+
class Chunker(Protocol):
|
|
57
|
+
"""Splits a document into chunks, deterministically."""
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def strategy(self) -> ChunkingStrategy: ...
|
|
61
|
+
|
|
62
|
+
def chunk(self, document: SourceDocument) -> tuple[Chunk, ...]: ...
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class _SizeChunker(abc.ABC):
|
|
66
|
+
"""Shared windowing. Subclasses only choose where a boundary may fall."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, *, size: int, overlap: int, keep_metadata: Sequence[str] = ()) -> None:
|
|
69
|
+
if size <= 0:
|
|
70
|
+
raise ConfigurationError("chunking size must be positive")
|
|
71
|
+
if overlap < 0:
|
|
72
|
+
raise ConfigurationError("chunking overlap must not be negative")
|
|
73
|
+
if overlap >= size:
|
|
74
|
+
raise ConfigurationError(
|
|
75
|
+
f"chunking overlap {overlap} must be smaller than size {size}; otherwise a "
|
|
76
|
+
"window would not advance and chunking would never terminate"
|
|
77
|
+
)
|
|
78
|
+
self._size = size
|
|
79
|
+
self._overlap = overlap
|
|
80
|
+
self._keep = tuple(keep_metadata)
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
@abc.abstractmethod
|
|
84
|
+
def strategy(self) -> ChunkingStrategy: ...
|
|
85
|
+
|
|
86
|
+
@abc.abstractmethod
|
|
87
|
+
def _cut(self, text: str, start: int, limit: int) -> int:
|
|
88
|
+
"""Where to end the window that starts at ``start`` and may reach ``limit``."""
|
|
89
|
+
|
|
90
|
+
def chunk(self, document: SourceDocument) -> tuple[Chunk, ...]:
|
|
91
|
+
text = document.text
|
|
92
|
+
chunks: list[Chunk] = []
|
|
93
|
+
start = 0
|
|
94
|
+
while start < len(text):
|
|
95
|
+
limit = min(start + self._size, len(text))
|
|
96
|
+
end = limit if limit >= len(text) else self._cut(text, start, limit)
|
|
97
|
+
# A boundary at or before `start` would stall the loop, so progress is
|
|
98
|
+
# guaranteed here rather than trusted from the strategy hook.
|
|
99
|
+
end = max(end, start + 1)
|
|
100
|
+
window = text[start:end]
|
|
101
|
+
if window.strip():
|
|
102
|
+
chunks.append(self._build(document, len(chunks), start, end, window))
|
|
103
|
+
if end >= len(text):
|
|
104
|
+
break
|
|
105
|
+
start = max(end - self._overlap, start + 1)
|
|
106
|
+
return tuple(chunks)
|
|
107
|
+
|
|
108
|
+
def _build(
|
|
109
|
+
self, document: SourceDocument, index: int, start: int, end: int, text: str
|
|
110
|
+
) -> Chunk:
|
|
111
|
+
return Chunk(
|
|
112
|
+
chunk_id=chunk_id_for(document.doc_id, index, text),
|
|
113
|
+
doc_id=document.doc_id,
|
|
114
|
+
uri=document.uri,
|
|
115
|
+
index=index,
|
|
116
|
+
start=start,
|
|
117
|
+
end=end,
|
|
118
|
+
text=text,
|
|
119
|
+
metadata={
|
|
120
|
+
key: document.metadata[key] for key in self._keep if key in document.metadata
|
|
121
|
+
},
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def _boundary(self, text: str, start: int, limit: int, markers: Sequence[str]) -> int | None:
|
|
125
|
+
"""The end offset of the last ``marker`` in ``text[start:limit]``, if usable.
|
|
126
|
+
|
|
127
|
+
``None`` means "no boundary worth taking", and the caller falls back to
|
|
128
|
+
``limit``. The minimum-fill rule is applied here rather than by each
|
|
129
|
+
strategy so no strategy can forget it.
|
|
130
|
+
"""
|
|
131
|
+
floor = start + (self._size * _MIN_FILL_NUMERATOR) // _MIN_FILL_DENOMINATOR
|
|
132
|
+
window = text[start:limit]
|
|
133
|
+
found: int | None = None
|
|
134
|
+
for marker in markers:
|
|
135
|
+
position = window.rfind(marker)
|
|
136
|
+
if position != -1:
|
|
137
|
+
end = start + position + len(marker)
|
|
138
|
+
if end >= max(floor, start + 1):
|
|
139
|
+
found = end
|
|
140
|
+
break
|
|
141
|
+
return found
|
|
142
|
+
|
|
143
|
+
def _fill(
|
|
144
|
+
self, text: str, start: int, limit: int, marker_groups: Sequence[Sequence[str]]
|
|
145
|
+
) -> int:
|
|
146
|
+
"""The first usable boundary from ``marker_groups``, in preference order."""
|
|
147
|
+
for markers in marker_groups:
|
|
148
|
+
found = self._boundary(text, start, limit, markers)
|
|
149
|
+
if found is not None:
|
|
150
|
+
return found
|
|
151
|
+
return limit
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class FixedChunker(_SizeChunker):
|
|
155
|
+
"""Cuts on size alone."""
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def strategy(self) -> ChunkingStrategy:
|
|
159
|
+
return ChunkingStrategy.FIXED
|
|
160
|
+
|
|
161
|
+
def _cut(self, text: str, start: int, limit: int) -> int:
|
|
162
|
+
return limit
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class RecursiveChunker(_SizeChunker):
|
|
166
|
+
"""Prefers prose boundaries: paragraph, then line, then sentence, then word."""
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def strategy(self) -> ChunkingStrategy:
|
|
170
|
+
return ChunkingStrategy.RECURSIVE
|
|
171
|
+
|
|
172
|
+
def _cut(self, text: str, start: int, limit: int) -> int:
|
|
173
|
+
return self._fill(
|
|
174
|
+
text,
|
|
175
|
+
start,
|
|
176
|
+
limit,
|
|
177
|
+
(("\n\n",), ("\n",), (". ", "! ", "? "), (" ",)),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class CodeChunker(_SizeChunker):
|
|
182
|
+
"""Prefers code boundaries: blank line, then line, then statement, then token."""
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def strategy(self) -> ChunkingStrategy:
|
|
186
|
+
return ChunkingStrategy.CODE
|
|
187
|
+
|
|
188
|
+
def _cut(self, text: str, start: int, limit: int) -> int:
|
|
189
|
+
return self._fill(
|
|
190
|
+
text,
|
|
191
|
+
start,
|
|
192
|
+
limit,
|
|
193
|
+
(("\n\n",), ("\n",), (";\n", ";\r\n", "; "), (" ",)),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
_STRATEGIES: dict[ChunkingStrategy, type[_SizeChunker]] = {
|
|
198
|
+
ChunkingStrategy.FIXED: FixedChunker,
|
|
199
|
+
ChunkingStrategy.RECURSIVE: RecursiveChunker,
|
|
200
|
+
ChunkingStrategy.CODE: CodeChunker,
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def chunker_for(spec: ChunkingSpec) -> Chunker:
|
|
205
|
+
"""Resolve a chunking block to an implementation.
|
|
206
|
+
|
|
207
|
+
``semantic`` is refused here as well as in the spec validator, because the
|
|
208
|
+
runtime is reachable from generated code the validator never saw.
|
|
209
|
+
"""
|
|
210
|
+
implementation = _STRATEGIES.get(spec.strategy)
|
|
211
|
+
if implementation is None:
|
|
212
|
+
raise FeatureDeferredError(
|
|
213
|
+
f"chunking strategy {spec.strategy.value!r} is declared but not implemented: spec "
|
|
214
|
+
"v0.1 chunks by size (fixed, recursive, code), and semantic chunking needs an "
|
|
215
|
+
"embedding pass over the whole source at ingest"
|
|
216
|
+
)
|
|
217
|
+
return implementation(size=spec.size, overlap=spec.overlap, keep_metadata=spec.keep_metadata)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def chunk_document(document: SourceDocument, spec: ChunkingSpec) -> tuple[Chunk, ...]:
|
|
221
|
+
"""Chunk one document with the strategy the spec names."""
|
|
222
|
+
return chunker_for(spec).chunk(document)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Retrieved passages, their provenance, and the context they assemble into.
|
|
2
|
+
|
|
3
|
+
A retrieved set is only useful to a model if the passages arrive with provenance
|
|
4
|
+
the model can repeat back, so ranking and citation are one type here rather than
|
|
5
|
+
a search result plus a separate lookup table. :class:`RetrievalResult` is the
|
|
6
|
+
runtime's answer type: it is what a ``retrieve`` step produces and what every
|
|
7
|
+
retrieval metric scores.
|
|
8
|
+
|
|
9
|
+
The assembled context is numbered by **rank**, not by position in the tuple. The
|
|
10
|
+
two are the same in practice because ranks are assigned contiguously from 1, but
|
|
11
|
+
naming the rank is what makes a model's ``[2]`` resolvable by the caller without
|
|
12
|
+
re-running the search.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Sequence
|
|
18
|
+
|
|
19
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
20
|
+
|
|
21
|
+
from aid_runtime.agents.guardrails import GuardrailRecord
|
|
22
|
+
from aid_runtime.retrieval.types import Chunk
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"Citation",
|
|
26
|
+
"RetrievalResult",
|
|
27
|
+
"RetrievedItem",
|
|
28
|
+
"assemble_context",
|
|
29
|
+
"cite",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
_RECORD = ConfigDict(frozen=True, extra="forbid")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Citation(BaseModel):
|
|
36
|
+
"""Where one retrieved passage came from.
|
|
37
|
+
|
|
38
|
+
``doc_id`` is the document identity the IR's retrieval metric already uses
|
|
39
|
+
(``expectDocs: [getting-started.md]``), so a citation and a gate expectation
|
|
40
|
+
are the same vocabulary rather than two spellings of the same thing.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
model_config = _RECORD
|
|
44
|
+
|
|
45
|
+
doc_id: str = Field(min_length=1)
|
|
46
|
+
chunk_id: str = Field(min_length=1)
|
|
47
|
+
uri: str = ""
|
|
48
|
+
index: int = Field(ge=0)
|
|
49
|
+
|
|
50
|
+
def __str__(self) -> str:
|
|
51
|
+
return f"{self.doc_id}#{self.index}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class RetrievedItem(BaseModel):
|
|
55
|
+
"""One ranked passage: the chunk, its score, and its provenance."""
|
|
56
|
+
|
|
57
|
+
model_config = _RECORD
|
|
58
|
+
|
|
59
|
+
chunk: Chunk
|
|
60
|
+
score: float
|
|
61
|
+
rank: int = Field(ge=1)
|
|
62
|
+
citation: Citation
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class RetrievalResult(BaseModel):
|
|
66
|
+
"""What a ``retrieve`` step returns.
|
|
67
|
+
|
|
68
|
+
``items`` is rank-ordered and may be empty -- "the corpus has nothing for this
|
|
69
|
+
query" is a result, not an error, and a caller that needs a non-empty answer
|
|
70
|
+
says so with a metric rather than by crashing.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
model_config = _RECORD
|
|
74
|
+
|
|
75
|
+
retriever: str = Field(min_length=1)
|
|
76
|
+
query: str
|
|
77
|
+
items: tuple[RetrievedItem, ...] = ()
|
|
78
|
+
#: What the ``retrieval``-stage guardrails recorded. A ``flag`` reaches a caller
|
|
79
|
+
#: here; a ``block`` never returns a result at all.
|
|
80
|
+
guardrails: tuple[GuardrailRecord, ...] = ()
|
|
81
|
+
|
|
82
|
+
def __len__(self) -> int:
|
|
83
|
+
return len(self.items)
|
|
84
|
+
|
|
85
|
+
def top(self, k: int) -> tuple[RetrievedItem, ...]:
|
|
86
|
+
"""The first ``k`` items, or all of them when the result is shorter."""
|
|
87
|
+
return self.items[:k]
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def context(self) -> str:
|
|
91
|
+
"""The numbered excerpts a prompt binds."""
|
|
92
|
+
return assemble_context(self.items)
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def citations(self) -> tuple[Citation, ...]:
|
|
96
|
+
"""One citation per item, in rank order."""
|
|
97
|
+
return tuple(item.citation for item in self.items)
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def doc_ids(self) -> tuple[str, ...]:
|
|
101
|
+
"""Rank-ordered, de-duplicated document ids.
|
|
102
|
+
|
|
103
|
+
De-duplicated because recall is about *documents found*, and a document
|
|
104
|
+
that contributed three passages was found once. First occurrence wins, so
|
|
105
|
+
the order still reflects relevance.
|
|
106
|
+
"""
|
|
107
|
+
return tuple(dict.fromkeys(item.citation.doc_id for item in self.items))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def cite(chunk: Chunk) -> Citation:
|
|
111
|
+
"""The citation for a chunk.
|
|
112
|
+
|
|
113
|
+
Derived from the chunk rather than passed alongside it, so a citation cannot
|
|
114
|
+
drift from the passage it names.
|
|
115
|
+
"""
|
|
116
|
+
return Citation(
|
|
117
|
+
doc_id=chunk.doc_id,
|
|
118
|
+
chunk_id=chunk.chunk_id,
|
|
119
|
+
uri=chunk.uri,
|
|
120
|
+
index=chunk.index,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def assemble_context(items: Sequence[RetrievedItem], *, separator: str = "\n\n") -> str:
|
|
125
|
+
"""Render ranked items as numbered excerpts, each tagged with its citation.
|
|
126
|
+
|
|
127
|
+
An empty retrieval renders as the empty string rather than as a header with
|
|
128
|
+
nothing under it: a header alone reads to a model as "sources were found", and
|
|
129
|
+
a prompt that invites a model to invent a source is worse than an empty prompt.
|
|
130
|
+
"""
|
|
131
|
+
return separator.join(f"[{item.rank}] {item.citation} :: {item.chunk.text}" for item in items)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Reading a ``file`` source.
|
|
2
|
+
|
|
3
|
+
The IR names five source kinds and Phase 1 implements one. That is the spec
|
|
4
|
+
validator's own position ("spec v0.1 indexes a local ``file`` source; the remote
|
|
5
|
+
loaders belong to the phase that ships them"), so the other four raise
|
|
6
|
+
:class:`~aid_runtime.errors.FeatureDeferredError` here rather than indexing an
|
|
7
|
+
empty corpus and looking healthy.
|
|
8
|
+
|
|
9
|
+
**Document identity is the POSIX path relative to the source root.** That is the
|
|
10
|
+
vocabulary the IR's retrieval metric already uses -- its ``expectDocs`` hold
|
|
11
|
+
``getting-started.md``, not ``./docs/getting-started.md`` -- and it is
|
|
12
|
+
machine-independent, so the same corpus indexed on Windows and on Linux yields the
|
|
13
|
+
same ids and the same eval outcome. Absolute paths would make a baseline
|
|
14
|
+
non-portable.
|
|
15
|
+
|
|
16
|
+
The protocol is ``async`` even though the only implementation reads local files
|
|
17
|
+
synchronously, because the kinds that follow (``s3``, ``http``, ``db``, ``mcp``) are
|
|
18
|
+
genuinely concurrent. Making it sync now would be a breaking change to the seam
|
|
19
|
+
exactly when the first real loader arrives.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Protocol, runtime_checkable
|
|
26
|
+
|
|
27
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
28
|
+
|
|
29
|
+
from aid_runtime.errors import ConfigurationError, FeatureDeferredError
|
|
30
|
+
from aid_runtime.retrieval.types import SourceKind, SourceSpec
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"Connector",
|
|
34
|
+
"FileConnector",
|
|
35
|
+
"SourceDocument",
|
|
36
|
+
"open_connector",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
_RECORD = ConfigDict(frozen=True, extra="forbid")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class SourceDocument(BaseModel):
|
|
43
|
+
"""One document read from a source, before chunking."""
|
|
44
|
+
|
|
45
|
+
model_config = _RECORD
|
|
46
|
+
|
|
47
|
+
#: POSIX path relative to the source root; the document's identity everywhere.
|
|
48
|
+
doc_id: str = Field(min_length=1)
|
|
49
|
+
#: The absolute resolved location, for a human reading a citation. Never matched on.
|
|
50
|
+
uri: str = Field(min_length=1)
|
|
51
|
+
text: str
|
|
52
|
+
metadata: dict[str, str] = Field(default_factory=dict)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@runtime_checkable
|
|
56
|
+
class Connector(Protocol):
|
|
57
|
+
"""A source that yields documents in a deterministic order."""
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def kind(self) -> SourceKind: ...
|
|
61
|
+
|
|
62
|
+
async def documents(self) -> tuple[SourceDocument, ...]: ...
|
|
63
|
+
|
|
64
|
+
async def aclose(self) -> None: ...
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class FileConnector:
|
|
68
|
+
"""Reads ``*.md``-style files under a local directory.
|
|
69
|
+
|
|
70
|
+
Files are read and returned in sorted ``doc_id`` order, never in directory
|
|
71
|
+
order: directory order is a filesystem accident, and a corpus whose chunk ids
|
|
72
|
+
depend on it would reindex itself differently on a different machine.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(self, root: str | Path, *, glob: str | None = None) -> None:
|
|
76
|
+
self._root = Path(root)
|
|
77
|
+
if not self._root.exists():
|
|
78
|
+
raise ConfigurationError(f"source path {str(self._root)!r} does not exist")
|
|
79
|
+
if not self._root.is_dir():
|
|
80
|
+
raise ConfigurationError(f"source path {str(self._root)!r} is not a directory")
|
|
81
|
+
self._glob = glob
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def kind(self) -> SourceKind:
|
|
85
|
+
return SourceKind.FILE
|
|
86
|
+
|
|
87
|
+
async def documents(self) -> tuple[SourceDocument, ...]:
|
|
88
|
+
return tuple(self._read(path) for path in self._paths())
|
|
89
|
+
|
|
90
|
+
async def aclose(self) -> None:
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
def _paths(self) -> tuple[Path, ...]:
|
|
94
|
+
pattern = self._glob or "**/*"
|
|
95
|
+
found = [
|
|
96
|
+
path
|
|
97
|
+
for path in self._root.glob(pattern)
|
|
98
|
+
if path.is_file() and not path.name.startswith(".")
|
|
99
|
+
]
|
|
100
|
+
return tuple(sorted(found, key=self._doc_id))
|
|
101
|
+
|
|
102
|
+
def _doc_id(self, path: Path) -> str:
|
|
103
|
+
return path.relative_to(self._root).as_posix()
|
|
104
|
+
|
|
105
|
+
def _read(self, path: Path) -> SourceDocument:
|
|
106
|
+
doc_id = self._doc_id(path)
|
|
107
|
+
return SourceDocument(
|
|
108
|
+
doc_id=doc_id,
|
|
109
|
+
uri=str(path.resolve()),
|
|
110
|
+
text=path.read_text(encoding="utf-8"),
|
|
111
|
+
metadata={"path": doc_id, "suffix": path.suffix},
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def open_connector(spec: SourceSpec, *, base_dir: str | Path | None = None) -> Connector:
|
|
116
|
+
"""Open the connector for a source entry.
|
|
117
|
+
|
|
118
|
+
``base_dir`` resolves a relative source ``uri``; it is explicit because the
|
|
119
|
+
runtime has no opinion about a working directory, and a corpus must not depend
|
|
120
|
+
on where a process happened to be started.
|
|
121
|
+
"""
|
|
122
|
+
if spec.kind is not SourceKind.FILE:
|
|
123
|
+
raise FeatureDeferredError(
|
|
124
|
+
f"source kind {spec.kind.value!r} is declared but not implemented: spec v0.1 "
|
|
125
|
+
"indexes a local 'file' source, and the remote loaders (s3, http, db, mcp) "
|
|
126
|
+
"belong to the phase that ships them"
|
|
127
|
+
)
|
|
128
|
+
root = Path(spec.uri)
|
|
129
|
+
if not root.is_absolute() and base_dir is not None:
|
|
130
|
+
root = Path(base_dir) / root
|
|
131
|
+
return FileConnector(root, glob=spec.glob)
|