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.
Files changed (41) hide show
  1. lorebase_scribe/__init__.py +3 -0
  2. lorebase_scribe/__main__.py +156 -0
  3. lorebase_scribe/checkpoints.py +324 -0
  4. lorebase_scribe/cron.py +117 -0
  5. lorebase_scribe/embeddings.py +570 -0
  6. lorebase_scribe/extraction/__init__.py +17 -0
  7. lorebase_scribe/extraction/assemble.py +339 -0
  8. lorebase_scribe/extraction/candidates.py +136 -0
  9. lorebase_scribe/extraction/discovery.py +567 -0
  10. lorebase_scribe/extraction/llm_discovery.py +271 -0
  11. lorebase_scribe/extraction/matching.py +285 -0
  12. lorebase_scribe/extraction/ontology.py +432 -0
  13. lorebase_scribe/extraction/parsing.py +114 -0
  14. lorebase_scribe/extraction/pipeline.py +1091 -0
  15. lorebase_scribe/extraction/relations.py +194 -0
  16. lorebase_scribe/extraction/sandbox.py +269 -0
  17. lorebase_scribe/extraction/settings.py +293 -0
  18. lorebase_scribe/extraction/text.py +179 -0
  19. lorebase_scribe/extraction/tier2.py +573 -0
  20. lorebase_scribe/extraction/values.py +282 -0
  21. lorebase_scribe/jobs.py +418 -0
  22. lorebase_scribe/scheduler.py +318 -0
  23. lorebase_scribe/service.py +146 -0
  24. lorebase_scribe/settings.py +521 -0
  25. lorebase_scribe/sources/__init__.py +60 -0
  26. lorebase_scribe/sources/access.py +241 -0
  27. lorebase_scribe/sources/connectors.py +320 -0
  28. lorebase_scribe/sources/delta.py +175 -0
  29. lorebase_scribe/sources/http_json.py +283 -0
  30. lorebase_scribe/sources/mapping.py +429 -0
  31. lorebase_scribe/sources/profile.py +36 -0
  32. lorebase_scribe/sources/records.py +298 -0
  33. lorebase_scribe/sources/sql.py +332 -0
  34. lorebase_scribe/sources/sync.py +730 -0
  35. lorebase_scribe/telemetry.py +113 -0
  36. lorebase_scribe/webhooks.py +99 -0
  37. lorebase_scribe/worker.py +254 -0
  38. lorebase_scribe-0.2.3.dist-info/METADATA +23 -0
  39. lorebase_scribe-0.2.3.dist-info/RECORD +41 -0
  40. lorebase_scribe-0.2.3.dist-info/WHEEL +4 -0
  41. 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
+ """