lorebase-scribe 0.2.3__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.
- lorebase_scribe/__init__.py +3 -0
- lorebase_scribe/__main__.py +156 -0
- lorebase_scribe/checkpoints.py +324 -0
- lorebase_scribe/cron.py +117 -0
- lorebase_scribe/embeddings.py +570 -0
- lorebase_scribe/extraction/__init__.py +17 -0
- lorebase_scribe/extraction/assemble.py +339 -0
- lorebase_scribe/extraction/candidates.py +136 -0
- lorebase_scribe/extraction/discovery.py +567 -0
- lorebase_scribe/extraction/llm_discovery.py +271 -0
- lorebase_scribe/extraction/matching.py +285 -0
- lorebase_scribe/extraction/ontology.py +432 -0
- lorebase_scribe/extraction/parsing.py +114 -0
- lorebase_scribe/extraction/pipeline.py +1091 -0
- lorebase_scribe/extraction/relations.py +194 -0
- lorebase_scribe/extraction/sandbox.py +269 -0
- lorebase_scribe/extraction/settings.py +293 -0
- lorebase_scribe/extraction/text.py +179 -0
- lorebase_scribe/extraction/tier2.py +573 -0
- lorebase_scribe/extraction/values.py +282 -0
- lorebase_scribe/jobs.py +418 -0
- lorebase_scribe/scheduler.py +318 -0
- lorebase_scribe/service.py +146 -0
- lorebase_scribe/settings.py +521 -0
- lorebase_scribe/sources/__init__.py +60 -0
- lorebase_scribe/sources/access.py +241 -0
- lorebase_scribe/sources/connectors.py +320 -0
- lorebase_scribe/sources/delta.py +175 -0
- lorebase_scribe/sources/http_json.py +283 -0
- lorebase_scribe/sources/mapping.py +429 -0
- lorebase_scribe/sources/profile.py +36 -0
- lorebase_scribe/sources/records.py +298 -0
- lorebase_scribe/sources/sql.py +332 -0
- lorebase_scribe/sources/sync.py +730 -0
- lorebase_scribe/telemetry.py +113 -0
- lorebase_scribe/webhooks.py +99 -0
- lorebase_scribe/worker.py +254 -0
- lorebase_scribe-0.2.3.dist-info/METADATA +23 -0
- lorebase_scribe-0.2.3.dist-info/RECORD +41 -0
- lorebase_scribe-0.2.3.dist-info/WHEEL +4 -0
- lorebase_scribe-0.2.3.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
"""Embedding passes (design 9.3): vectors for the Records of Kinds with an embedding template.
|
|
2
|
+
|
|
3
|
+
A pass covers the materialized branches of one Domain. For each branch:
|
|
4
|
+
|
|
5
|
+
1. A branch whose schema has no embedding template is skipped without creating the model.
|
|
6
|
+
A branch whose head equals the head of its last complete pass for the model is skipped
|
|
7
|
+
too (`embedding_cursors`); a schema change moves the head, so templates are unchanged.
|
|
8
|
+
The templates of a schema are resolved once per schema hash and kept for the last
|
|
9
|
+
`template_cache_size` schemas, so branches sharing a schema and passes over an unchanged
|
|
10
|
+
one neither read nor parse it again.
|
|
11
|
+
2. Otherwise its own live rows are read in `(kind, key)` order, page by page: rows whose
|
|
12
|
+
object hash has no vector of the model under the fingerprint of their Kind's template,
|
|
13
|
+
and every row of the Kinds whose template fingerprint differs from the last pass (or of
|
|
14
|
+
every Kind with `reindex`).
|
|
15
|
+
3. Each row's text is rendered from the Kind's template (`lorebase_core.model.embedding`)
|
|
16
|
+
and hashed. A row whose stored vector has the same text hash is unchanged; a row whose
|
|
17
|
+
text equals one already embedded copies that vector; the remaining texts go to the model
|
|
18
|
+
in batches of `batch_texts`, at most `max_calls_per_second`, each call retried
|
|
19
|
+
`retry.attempts` times. Empty texts get no vector.
|
|
20
|
+
4. A complete pass records the head and the fingerprints.
|
|
21
|
+
|
|
22
|
+
Vectors are keyed by object hash and template fingerprint, so the rows a branch shares with
|
|
23
|
+
another one under the same template (an overlay's inherited rows, the unchanged rows of a copy)
|
|
24
|
+
are embedded once, and a branch whose template differs gets vectors of its own (copied without a
|
|
25
|
+
model call when the rendered text is the same). A job reads at most
|
|
26
|
+
`max_records_per_job` rows; the rest of the pass is left to a continuation that resumes after
|
|
27
|
+
the last row read. One pass per Domain runs at a time (a session advisory lock); a second one
|
|
28
|
+
returns at once with `busy`.
|
|
29
|
+
|
|
30
|
+
Model checks: the model's dimension must equal `store.embeddings.dims` (the dimension of the
|
|
31
|
+
vector index Keeper searches), and the first use of the model embeds a probe text whose
|
|
32
|
+
vector size must match. Either mismatch fails the job without retries.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import asyncio
|
|
38
|
+
import contextlib
|
|
39
|
+
import hashlib
|
|
40
|
+
import logging
|
|
41
|
+
import time
|
|
42
|
+
from collections.abc import Awaitable, Callable, Collection, Sequence
|
|
43
|
+
from dataclasses import dataclass, field
|
|
44
|
+
from datetime import UTC, datetime
|
|
45
|
+
from typing import Any
|
|
46
|
+
|
|
47
|
+
from sqlalchemy import text
|
|
48
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
|
|
49
|
+
|
|
50
|
+
from lorebase_core.model import Schema
|
|
51
|
+
from lorebase_core.model.embedding import RENDERING_VERSION, KindTemplate, TemplateError
|
|
52
|
+
from lorebase_core.model.embedding import kind_template as resolve_template
|
|
53
|
+
from lorebase_inference import (
|
|
54
|
+
DimensionMismatchError,
|
|
55
|
+
EmbeddingModel,
|
|
56
|
+
InferenceError,
|
|
57
|
+
InferenceSettings,
|
|
58
|
+
ProviderUnavailableError,
|
|
59
|
+
UnknownProviderError,
|
|
60
|
+
create_embedding_model,
|
|
61
|
+
)
|
|
62
|
+
from lorebase_scribe.settings import EmbeddingJobSettings
|
|
63
|
+
from lorebase_scribe.telemetry import EMBEDDING_CALL_DURATION, EMBEDDING_RECORDS, tracer
|
|
64
|
+
from lorebase_store.embeddings import (
|
|
65
|
+
BranchHead,
|
|
66
|
+
Candidate,
|
|
67
|
+
Cursor,
|
|
68
|
+
NewVector,
|
|
69
|
+
SchemaCache,
|
|
70
|
+
branch_schema,
|
|
71
|
+
branch_schema_hash,
|
|
72
|
+
candidates,
|
|
73
|
+
lock_key,
|
|
74
|
+
materialized_branches,
|
|
75
|
+
prune_models,
|
|
76
|
+
read_cursor,
|
|
77
|
+
reuse_vectors,
|
|
78
|
+
save_cursor,
|
|
79
|
+
store_vectors,
|
|
80
|
+
text_hash,
|
|
81
|
+
)
|
|
82
|
+
from lorebase_store.repositories import scope_to_domain
|
|
83
|
+
|
|
84
|
+
log = logging.getLogger("lorebase.scribe.embeddings")
|
|
85
|
+
|
|
86
|
+
PROBE_TEXT = "embedding dimension probe"
|
|
87
|
+
VERSION_KEY = "_rendering"
|
|
88
|
+
"""Fingerprint entry holding `RENDERING_VERSION`, so a new rendering rule reruns passes."""
|
|
89
|
+
|
|
90
|
+
Sleep = Callable[[float], Awaitable[None]]
|
|
91
|
+
Templates = dict[str, KindTemplate]
|
|
92
|
+
"""Resolved embedding templates of a schema, by Kind id."""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class EmbeddingConfigError(Exception):
|
|
96
|
+
"""The embedding model does not fit the vector storage; retrying cannot help."""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
PERMANENT_ERRORS: tuple[type[Exception], ...] = (
|
|
100
|
+
EmbeddingConfigError,
|
|
101
|
+
DimensionMismatchError,
|
|
102
|
+
ProviderUnavailableError,
|
|
103
|
+
UnknownProviderError,
|
|
104
|
+
)
|
|
105
|
+
"""Failures of an embedding job that are not retried."""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def utc_now() -> datetime:
|
|
109
|
+
return datetime.now(UTC)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _fingerprint(fingerprint: str, prefix: str) -> str:
|
|
113
|
+
"""A template fingerprint that also changes with the document prefix; unchanged without
|
|
114
|
+
one, so installations that set no prefix keep their vectors."""
|
|
115
|
+
if not prefix:
|
|
116
|
+
return fingerprint
|
|
117
|
+
return hashlib.sha256(f"{fingerprint}\x00{prefix}".encode()).hexdigest()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class EmbeddingModels:
|
|
121
|
+
"""The process's embedding model: created on first use, checked once, then shared."""
|
|
122
|
+
|
|
123
|
+
def __init__(
|
|
124
|
+
self,
|
|
125
|
+
inference: InferenceSettings,
|
|
126
|
+
*,
|
|
127
|
+
dims: int,
|
|
128
|
+
factory: Callable[[], EmbeddingModel] | None = None,
|
|
129
|
+
) -> None:
|
|
130
|
+
self._inference = inference
|
|
131
|
+
self._dims = dims
|
|
132
|
+
self._factory = factory or (lambda: create_embedding_model(inference))
|
|
133
|
+
self._model: EmbeddingModel | None = None
|
|
134
|
+
self._checked = False
|
|
135
|
+
self._lock = asyncio.Lock()
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def label(self) -> str:
|
|
139
|
+
"""The model's name once created, else the configured model (for reports)."""
|
|
140
|
+
return self._inference.embeddings.model if self._model is None else self._model.name
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def document_prefix(self) -> str:
|
|
144
|
+
"""Text put before every embedded document (`inference.embeddings.document_prefix`)."""
|
|
145
|
+
return self._inference.embeddings.document_prefix
|
|
146
|
+
|
|
147
|
+
def instance(self) -> EmbeddingModel:
|
|
148
|
+
"""The model, created (not yet called) on first use; its `name` keys the vectors."""
|
|
149
|
+
if self._model is None:
|
|
150
|
+
model = self._factory()
|
|
151
|
+
if model.dims != self._dims:
|
|
152
|
+
raise EmbeddingConfigError(
|
|
153
|
+
f"embedding model {model.name!r} has {model.dims} dimensions, "
|
|
154
|
+
f"store.embeddings.dims is {self._dims}"
|
|
155
|
+
)
|
|
156
|
+
self._model = model
|
|
157
|
+
return self._model
|
|
158
|
+
|
|
159
|
+
async def get(self) -> EmbeddingModel:
|
|
160
|
+
"""The model, checked once with a probe text before its first real call."""
|
|
161
|
+
async with self._lock:
|
|
162
|
+
model = self.instance()
|
|
163
|
+
if not self._checked:
|
|
164
|
+
probe = await model.embed([PROBE_TEXT])
|
|
165
|
+
if len(probe) != 1 or len(probe[0]) != self._dims:
|
|
166
|
+
size = len(probe[0]) if probe else 0
|
|
167
|
+
raise EmbeddingConfigError(
|
|
168
|
+
f"embedding model {model.name!r} returned {size} dimensions, "
|
|
169
|
+
f"store.embeddings.dims is {self._dims}"
|
|
170
|
+
)
|
|
171
|
+
self._checked = True
|
|
172
|
+
return model
|
|
173
|
+
|
|
174
|
+
async def aclose(self) -> None:
|
|
175
|
+
close = getattr(self._model, "aclose", None)
|
|
176
|
+
if close is not None:
|
|
177
|
+
await close()
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@dataclass
|
|
181
|
+
class BranchReport:
|
|
182
|
+
branch: str
|
|
183
|
+
skipped: bool = False
|
|
184
|
+
rows: int = 0
|
|
185
|
+
embedded: int = 0
|
|
186
|
+
reused: int = 0
|
|
187
|
+
unchanged: int = 0
|
|
188
|
+
empty: int = 0
|
|
189
|
+
calls: int = 0
|
|
190
|
+
complete: bool = False
|
|
191
|
+
|
|
192
|
+
def as_dict(self) -> dict[str, Any]:
|
|
193
|
+
return {
|
|
194
|
+
"branch": self.branch,
|
|
195
|
+
"skipped": self.skipped,
|
|
196
|
+
"rows": self.rows,
|
|
197
|
+
"embedded": self.embedded,
|
|
198
|
+
"reused": self.reused,
|
|
199
|
+
"unchanged": self.unchanged,
|
|
200
|
+
"empty": self.empty,
|
|
201
|
+
"calls": self.calls,
|
|
202
|
+
"complete": self.complete,
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@dataclass
|
|
207
|
+
class EmbeddingReport:
|
|
208
|
+
domain: str
|
|
209
|
+
model: str
|
|
210
|
+
busy: bool = False
|
|
211
|
+
pruned: int = 0
|
|
212
|
+
branches: list[BranchReport] = field(default_factory=list)
|
|
213
|
+
resume: tuple[str, str, str] | None = None
|
|
214
|
+
"""Where a continuation resumes: branch, Kind id and key of the last row read."""
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def complete(self) -> bool:
|
|
218
|
+
return not self.busy and self.resume is None
|
|
219
|
+
|
|
220
|
+
def as_dict(self) -> dict[str, Any]:
|
|
221
|
+
return {
|
|
222
|
+
"domain": self.domain,
|
|
223
|
+
"model": self.model,
|
|
224
|
+
"busy": self.busy,
|
|
225
|
+
"complete": self.complete,
|
|
226
|
+
"pruned": self.pruned,
|
|
227
|
+
"resume": None if self.resume is None else list(self.resume),
|
|
228
|
+
"branches": [b.as_dict() for b in self.branches],
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@dataclass
|
|
233
|
+
class _Budget:
|
|
234
|
+
rows: int
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class _Pacer:
|
|
238
|
+
"""At most `rate` calls per second (0: no limit)."""
|
|
239
|
+
|
|
240
|
+
def __init__(self, rate: float, sleep: Sleep, clock: Callable[[], float]) -> None:
|
|
241
|
+
self._interval = 1.0 / rate if rate > 0 else 0.0
|
|
242
|
+
self._sleep = sleep
|
|
243
|
+
self._clock = clock
|
|
244
|
+
self._next = 0.0
|
|
245
|
+
|
|
246
|
+
async def wait(self) -> None:
|
|
247
|
+
if self._interval <= 0:
|
|
248
|
+
return
|
|
249
|
+
now = self._clock()
|
|
250
|
+
if now < self._next:
|
|
251
|
+
await self._sleep(self._next - now)
|
|
252
|
+
now = self._next
|
|
253
|
+
self._next = now + self._interval
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class EmbeddingPass:
|
|
257
|
+
"""One embedding pass over a Domain (see the module documentation)."""
|
|
258
|
+
|
|
259
|
+
def __init__(
|
|
260
|
+
self,
|
|
261
|
+
engine: AsyncEngine,
|
|
262
|
+
sessions: async_sessionmaker[AsyncSession],
|
|
263
|
+
models: EmbeddingModels,
|
|
264
|
+
settings: EmbeddingJobSettings,
|
|
265
|
+
*,
|
|
266
|
+
dims: int,
|
|
267
|
+
clock: Callable[[], datetime] = utc_now,
|
|
268
|
+
sleep: Sleep = asyncio.sleep,
|
|
269
|
+
monotonic: Callable[[], float] = time.monotonic,
|
|
270
|
+
templates: SchemaCache[Templates] | None = None,
|
|
271
|
+
) -> None:
|
|
272
|
+
"""`templates` is shared by the passes of a process; a pass without one keeps its
|
|
273
|
+
own, sized by `template_cache_size`."""
|
|
274
|
+
self._engine = engine
|
|
275
|
+
self._sessions = sessions
|
|
276
|
+
self._models = models
|
|
277
|
+
self._settings = settings
|
|
278
|
+
self._dims = dims
|
|
279
|
+
self._clock = clock
|
|
280
|
+
self._sleep = sleep
|
|
281
|
+
self._pacer = _Pacer(settings.max_calls_per_second, sleep, monotonic)
|
|
282
|
+
self._templates = (
|
|
283
|
+
templates if templates is not None else SchemaCache(settings.template_cache_size)
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
async def run(
|
|
287
|
+
self,
|
|
288
|
+
domain_id: str,
|
|
289
|
+
*,
|
|
290
|
+
branches: Collection[str] | None = None,
|
|
291
|
+
reindex: bool = False,
|
|
292
|
+
resume: tuple[str, str, str] | None = None,
|
|
293
|
+
prune: bool = False,
|
|
294
|
+
) -> EmbeddingReport:
|
|
295
|
+
"""Run the pass; `prune` then deletes the vectors of other models when it completed."""
|
|
296
|
+
report = EmbeddingReport(domain_id, self._models.label)
|
|
297
|
+
key = lock_key(domain_id)
|
|
298
|
+
with tracer.start_as_current_span(
|
|
299
|
+
"scribe.embeddings", attributes={"lorebase.domain": domain_id}
|
|
300
|
+
) as span:
|
|
301
|
+
async with self._engine.connect() as connection:
|
|
302
|
+
auto = await connection.execution_options(isolation_level="AUTOCOMMIT")
|
|
303
|
+
locked = (
|
|
304
|
+
await auto.execute(text("SELECT pg_try_advisory_lock(:k)"), {"k": key})
|
|
305
|
+
).scalar_one()
|
|
306
|
+
if not locked:
|
|
307
|
+
report.busy = True
|
|
308
|
+
return report
|
|
309
|
+
try:
|
|
310
|
+
await self._run(report, domain_id, branches, reindex, resume)
|
|
311
|
+
if prune and report.complete:
|
|
312
|
+
# The name vectors are stored under, even when no branch needed the
|
|
313
|
+
# model in this pass.
|
|
314
|
+
report.model = self._models.instance().name
|
|
315
|
+
async with self._sessions() as session, session.begin():
|
|
316
|
+
await scope_to_domain(session, domain_id)
|
|
317
|
+
report.pruned = await prune_models(
|
|
318
|
+
session, domain_id, keep=report.model
|
|
319
|
+
)
|
|
320
|
+
finally:
|
|
321
|
+
with contextlib.suppress(Exception):
|
|
322
|
+
await auto.execute(text("SELECT pg_advisory_unlock(:k)"), {"k": key})
|
|
323
|
+
span.set_attribute("lorebase.complete", report.complete)
|
|
324
|
+
return report
|
|
325
|
+
|
|
326
|
+
async def _run(
|
|
327
|
+
self,
|
|
328
|
+
report: EmbeddingReport,
|
|
329
|
+
domain_id: str,
|
|
330
|
+
branches: Collection[str] | None,
|
|
331
|
+
reindex: bool,
|
|
332
|
+
resume: tuple[str, str, str] | None,
|
|
333
|
+
) -> None:
|
|
334
|
+
async with self._sessions() as session, session.begin():
|
|
335
|
+
await scope_to_domain(session, domain_id)
|
|
336
|
+
heads = await materialized_branches(session, domain_id, branches)
|
|
337
|
+
budget = _Budget(self._settings.max_records_per_job)
|
|
338
|
+
for head in heads:
|
|
339
|
+
after: tuple[str, str] | None = None
|
|
340
|
+
if resume is not None:
|
|
341
|
+
if head.name < resume[0]:
|
|
342
|
+
continue
|
|
343
|
+
if head.name == resume[0]:
|
|
344
|
+
after = (resume[1], resume[2])
|
|
345
|
+
branch = BranchReport(head.name)
|
|
346
|
+
report.branches.append(branch)
|
|
347
|
+
last = await self._branch(domain_id, head, branch, reindex, after, budget)
|
|
348
|
+
report.model = self._models.label
|
|
349
|
+
if last is not None:
|
|
350
|
+
report.resume = (head.name, *last)
|
|
351
|
+
return
|
|
352
|
+
|
|
353
|
+
async def _branch(
|
|
354
|
+
self,
|
|
355
|
+
domain_id: str,
|
|
356
|
+
head: BranchHead,
|
|
357
|
+
report: BranchReport,
|
|
358
|
+
reindex: bool,
|
|
359
|
+
after: tuple[str, str] | None,
|
|
360
|
+
budget: _Budget,
|
|
361
|
+
) -> tuple[str, str] | None:
|
|
362
|
+
"""Embed one branch; returns the last row read when the budget ran out first."""
|
|
363
|
+
templates = await self._branch_templates(domain_id, head.name)
|
|
364
|
+
if not templates:
|
|
365
|
+
report.skipped = report.complete = True
|
|
366
|
+
return None
|
|
367
|
+
model_name = self._models.instance().name
|
|
368
|
+
async with self._sessions() as session, session.begin():
|
|
369
|
+
await scope_to_domain(session, domain_id)
|
|
370
|
+
cursor = await read_cursor(session, domain_id, head.name, model_name)
|
|
371
|
+
prefix = self._models.document_prefix
|
|
372
|
+
keys = {kind: t.fingerprint for kind, t in templates.items()}
|
|
373
|
+
fingerprints = {kind: _fingerprint(f, prefix) for kind, f in keys.items()}
|
|
374
|
+
if (
|
|
375
|
+
not reindex
|
|
376
|
+
and cursor is not None
|
|
377
|
+
and cursor.head == head.head
|
|
378
|
+
and cursor.fingerprints.get(VERSION_KEY) == str(RENDERING_VERSION)
|
|
379
|
+
and all(cursor.fingerprints.get(kind) == f for kind, f in fingerprints.items())
|
|
380
|
+
):
|
|
381
|
+
report.skipped = report.complete = True
|
|
382
|
+
return None
|
|
383
|
+
if reindex:
|
|
384
|
+
rerender: set[str] = set(templates)
|
|
385
|
+
else:
|
|
386
|
+
known = {} if cursor is None else cursor.fingerprints
|
|
387
|
+
rerender = {
|
|
388
|
+
kind
|
|
389
|
+
for kind, fingerprint in fingerprints.items()
|
|
390
|
+
if cursor is not None and known.get(kind) != fingerprint
|
|
391
|
+
}
|
|
392
|
+
while templates:
|
|
393
|
+
if budget.rows <= 0:
|
|
394
|
+
# Resume at the start of the branch when nothing of it was read yet: every
|
|
395
|
+
# (kind id, key) sorts after ("", "").
|
|
396
|
+
return after or ("", "")
|
|
397
|
+
async with self._sessions() as session, session.begin():
|
|
398
|
+
await scope_to_domain(session, domain_id)
|
|
399
|
+
rows = await candidates(
|
|
400
|
+
session,
|
|
401
|
+
domain_id,
|
|
402
|
+
head.name,
|
|
403
|
+
model=model_name,
|
|
404
|
+
dims=self._dims,
|
|
405
|
+
fingerprints=keys,
|
|
406
|
+
rerender=rerender,
|
|
407
|
+
after=after,
|
|
408
|
+
limit=min(self._settings.page_rows, budget.rows),
|
|
409
|
+
)
|
|
410
|
+
if not rows:
|
|
411
|
+
break
|
|
412
|
+
budget.rows -= len(rows)
|
|
413
|
+
report.rows += len(rows)
|
|
414
|
+
after = (rows[-1].kind_id, rows[-1].key)
|
|
415
|
+
await self._embed_rows(domain_id, rows, templates, report)
|
|
416
|
+
async with self._sessions() as session, session.begin():
|
|
417
|
+
await scope_to_domain(session, domain_id)
|
|
418
|
+
await save_cursor(
|
|
419
|
+
session,
|
|
420
|
+
domain_id,
|
|
421
|
+
head.name,
|
|
422
|
+
model_name,
|
|
423
|
+
Cursor(head.head, {**fingerprints, VERSION_KEY: str(RENDERING_VERSION)}),
|
|
424
|
+
at=self._clock(),
|
|
425
|
+
)
|
|
426
|
+
report.complete = True
|
|
427
|
+
return None
|
|
428
|
+
|
|
429
|
+
async def _branch_templates(self, domain_id: str, branch: str) -> Templates:
|
|
430
|
+
"""The templates of the branch's schema, from the cache when its hash is known."""
|
|
431
|
+
async with self._sessions() as session, session.begin():
|
|
432
|
+
await scope_to_domain(session, domain_id)
|
|
433
|
+
schema_hash = await branch_schema_hash(session, domain_id, branch)
|
|
434
|
+
if schema_hash is None:
|
|
435
|
+
return {}
|
|
436
|
+
cached = self._templates.get(schema_hash)
|
|
437
|
+
if cached is not None:
|
|
438
|
+
return cached
|
|
439
|
+
schema = await branch_schema(session, domain_id, branch)
|
|
440
|
+
templates = _templates(schema, domain_id, branch)
|
|
441
|
+
self._templates.put(schema_hash, templates)
|
|
442
|
+
return templates
|
|
443
|
+
|
|
444
|
+
async def _embed_rows(
|
|
445
|
+
self,
|
|
446
|
+
domain_id: str,
|
|
447
|
+
rows: Sequence[Candidate],
|
|
448
|
+
templates: dict[str, KindTemplate],
|
|
449
|
+
report: BranchReport,
|
|
450
|
+
) -> None:
|
|
451
|
+
counts = {"empty": 0, "unchanged": 0, "reused": 0, "embedded": 0}
|
|
452
|
+
pending: dict[tuple[bytes, str], tuple[bytes, str]] = {}
|
|
453
|
+
for row in rows:
|
|
454
|
+
rendered = templates[row.kind_id].render(row.key, row.values)
|
|
455
|
+
rendered = rendered[: self._settings.max_text_chars]
|
|
456
|
+
if not rendered:
|
|
457
|
+
counts["empty"] += 1
|
|
458
|
+
continue
|
|
459
|
+
rendered = self._models.document_prefix + rendered
|
|
460
|
+
digest = text_hash(rendered)
|
|
461
|
+
if row.embedded and row.text_hash == digest:
|
|
462
|
+
counts["unchanged"] += 1
|
|
463
|
+
continue
|
|
464
|
+
pending[(row.object_hash, row.fingerprint)] = (digest, rendered)
|
|
465
|
+
try:
|
|
466
|
+
if pending:
|
|
467
|
+
calls = await self._embed_pending(domain_id, pending, counts)
|
|
468
|
+
report.calls += calls
|
|
469
|
+
finally:
|
|
470
|
+
for outcome, count in counts.items():
|
|
471
|
+
setattr(report, outcome, getattr(report, outcome) + count)
|
|
472
|
+
if count:
|
|
473
|
+
EMBEDDING_RECORDS.add(count, {"outcome": outcome})
|
|
474
|
+
|
|
475
|
+
async def _embed_pending(
|
|
476
|
+
self,
|
|
477
|
+
domain_id: str,
|
|
478
|
+
pending: dict[tuple[bytes, str], tuple[bytes, str]],
|
|
479
|
+
counts: dict[str, int],
|
|
480
|
+
) -> int:
|
|
481
|
+
"""Vectors for `pending` (object hash and template fingerprint -> text hash and text);
|
|
482
|
+
returns the model calls."""
|
|
483
|
+
model = await self._models.get()
|
|
484
|
+
async with self._sessions() as session, session.begin():
|
|
485
|
+
await scope_to_domain(session, domain_id)
|
|
486
|
+
reused = await reuse_vectors(
|
|
487
|
+
session,
|
|
488
|
+
domain_id,
|
|
489
|
+
model=model.name,
|
|
490
|
+
dims=self._dims,
|
|
491
|
+
wanted=[(h, f, d) for (h, f), (d, _) in pending.items()],
|
|
492
|
+
)
|
|
493
|
+
counts["reused"] += len(reused)
|
|
494
|
+
texts: dict[bytes, str] = {}
|
|
495
|
+
owners: dict[bytes, list[tuple[bytes, str]]] = {}
|
|
496
|
+
for owner, (digest, rendered) in pending.items():
|
|
497
|
+
if owner not in reused:
|
|
498
|
+
texts[digest] = rendered
|
|
499
|
+
owners.setdefault(digest, []).append(owner)
|
|
500
|
+
digests = list(texts)
|
|
501
|
+
calls = 0
|
|
502
|
+
for start in range(0, len(digests), self._settings.batch_texts):
|
|
503
|
+
batch = digests[start : start + self._settings.batch_texts]
|
|
504
|
+
vectors = await self._call(model, [texts[d] for d in batch])
|
|
505
|
+
calls += 1
|
|
506
|
+
new = [
|
|
507
|
+
NewVector(object_hash, fingerprint, digest, vector)
|
|
508
|
+
for digest, vector in zip(batch, vectors, strict=True)
|
|
509
|
+
for object_hash, fingerprint in owners[digest]
|
|
510
|
+
]
|
|
511
|
+
async with self._sessions() as session, session.begin():
|
|
512
|
+
await scope_to_domain(session, domain_id)
|
|
513
|
+
await store_vectors(session, domain_id, model=model.name, vectors=new)
|
|
514
|
+
counts["embedded"] += len(new)
|
|
515
|
+
return calls
|
|
516
|
+
|
|
517
|
+
async def _call(self, model: EmbeddingModel, texts: list[str]) -> list[list[float]]:
|
|
518
|
+
retry = self._settings.retry
|
|
519
|
+
delay = retry.backoff_s
|
|
520
|
+
for attempt in range(1, retry.attempts + 1):
|
|
521
|
+
await self._pacer.wait()
|
|
522
|
+
started = time.monotonic()
|
|
523
|
+
try:
|
|
524
|
+
vectors = await model.embed(texts)
|
|
525
|
+
except PERMANENT_ERRORS:
|
|
526
|
+
raise
|
|
527
|
+
except InferenceError as error:
|
|
528
|
+
if attempt >= retry.attempts:
|
|
529
|
+
raise
|
|
530
|
+
log.warning(
|
|
531
|
+
"embedding call failed; retrying",
|
|
532
|
+
extra={"attempt": attempt, "error": f"{type(error).__name__}: {error}"},
|
|
533
|
+
)
|
|
534
|
+
await self._sleep(min(delay, retry.backoff_max_s))
|
|
535
|
+
delay *= 2
|
|
536
|
+
continue
|
|
537
|
+
finally:
|
|
538
|
+
EMBEDDING_CALL_DURATION.record(time.monotonic() - started)
|
|
539
|
+
if len(vectors) != len(texts) or any(len(v) != self._dims for v in vectors):
|
|
540
|
+
raise DimensionMismatchError(
|
|
541
|
+
f"embedding model {model.name!r} returned vectors that do not match "
|
|
542
|
+
f"{len(texts)} texts of {self._dims} dimensions"
|
|
543
|
+
)
|
|
544
|
+
return vectors
|
|
545
|
+
raise AssertionError("unreachable") # pragma: no cover
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _templates(schema: Schema | None, domain_id: str, branch: str) -> Templates:
|
|
549
|
+
"""Resolved templates of the Kinds that have one; an unusable template is logged and
|
|
550
|
+
its Kind left out (static validation refuses such schemas)."""
|
|
551
|
+
found: dict[str, KindTemplate] = {}
|
|
552
|
+
if schema is None:
|
|
553
|
+
return found
|
|
554
|
+
for kind in schema.kinds.values():
|
|
555
|
+
try:
|
|
556
|
+
template = resolve_template(kind)
|
|
557
|
+
except TemplateError as error:
|
|
558
|
+
log.warning(
|
|
559
|
+
"embedding template skipped",
|
|
560
|
+
extra={
|
|
561
|
+
"domain": domain_id,
|
|
562
|
+
"branch": branch,
|
|
563
|
+
"kind": kind.api_name,
|
|
564
|
+
"error": str(error),
|
|
565
|
+
},
|
|
566
|
+
)
|
|
567
|
+
continue
|
|
568
|
+
if template is not None:
|
|
569
|
+
found[kind.id] = template
|
|
570
|
+
return found
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Document extraction into reviewable Proposals (design 9.2).
|
|
2
|
+
|
|
3
|
+
- `parsing`: bytes to text (Markdown and plain text natively, PDF and DOCX with the
|
|
4
|
+
`documents` extra);
|
|
5
|
+
- `sandbox`: PDF and DOCX parsed in a child process with time, CPU, memory and size limits and
|
|
6
|
+
no network;
|
|
7
|
+
- `text`: structure-aware chunks and sentences with stable offsets;
|
|
8
|
+
- `ontology`: what the schema tells the extractors (labels, key patterns, cues, the tier 2
|
|
9
|
+
JSON schema and prompt);
|
|
10
|
+
- `matching`: entity resolution of mentions against existing Records;
|
|
11
|
+
- `values` and `relations`: rule-based Attribute values and Links (tier 0 and tier 1);
|
|
12
|
+
- `tier2`: one structured call per chunk to any chat model, with evidence verification;
|
|
13
|
+
- `assemble`: candidates to Proposal items;
|
|
14
|
+
- `pipeline`: the `extraction` job.
|
|
15
|
+
|
|
16
|
+
Modules are imported directly; this package imports nothing so that settings can load early.
|
|
17
|
+
"""
|