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,282 @@
1
+ """Rule-based Attribute values (tier 0), sentence by sentence.
2
+
3
+ In each sentence, dates (ISO and written forms such as `8 February 2024`), numbers (with
4
+ thousands separators) and enum values are found. A value goes to an Attribute of a Record when
5
+ a cue word of that Attribute (from its api name, display name and longer description words)
6
+ precedes it by at most `value_cue_words` words in the sentence; enum values written as
7
+ upper-case codes (`DDP`, `EUR`) need no cue. The Records a sentence can describe are the ones
8
+ it names; a sentence that names none describes the Record of its section heading. The closer
9
+ the cue (and the Record), the higher the confidence, up to `confidence.value_max`. Each
10
+ Attribute of a Record takes at most one value per sentence and each occurrence goes to at most
11
+ one Attribute. Booleans and free text are left to tier 2, except the title of a Record whose
12
+ key appears in a heading, which is taken from the heading.
13
+
14
+ These rules propose; reviewers decide. Their confidence stays below the default
15
+ `auto_accept_threshold`, so nothing they find is accepted without review by default.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ from collections.abc import Sequence
22
+ from dataclasses import dataclass
23
+ from datetime import date
24
+ from typing import Any
25
+
26
+ from lorebase_core.model import Attribute
27
+ from lorebase_core.types import AttributeType, EncodingError, encode_value
28
+ from lorebase_scribe.extraction.candidates import Evidence, Resolved, ValueCandidate
29
+ from lorebase_scribe.extraction.ontology import Ontology, humanize
30
+ from lorebase_scribe.extraction.settings import ExtractionSettings
31
+
32
+ _MONTHS = {
33
+ name: index
34
+ for index, names in enumerate(
35
+ (
36
+ ("jan", "january"),
37
+ ("feb", "february"),
38
+ ("mar", "march"),
39
+ ("apr", "april"),
40
+ ("may",),
41
+ ("jun", "june"),
42
+ ("jul", "july"),
43
+ ("aug", "august"),
44
+ ("sep", "sept", "september"),
45
+ ("oct", "october"),
46
+ ("nov", "november"),
47
+ ("dec", "december"),
48
+ ),
49
+ start=1,
50
+ )
51
+ for name in names
52
+ }
53
+ _MONTH = (
54
+ r"(Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|June?|July?|Aug(?:ust)?"
55
+ r"|Sept?(?:ember)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)"
56
+ )
57
+ _ISO_DATE = re.compile(r"(?<![\w-])(\d{4})-(\d{2})-(\d{2})(?![\w-])")
58
+ _DMY = re.compile(rf"(?<!\w)(\d{{1,2}})\s+{_MONTH}\.?,?\s+(\d{{4}})(?!\w)")
59
+ _MDY = re.compile(rf"(?<!\w){_MONTH}\.?\s+(\d{{1,2}}),?\s+(\d{{4}})(?!\w)")
60
+ _MONTH_YEAR = re.compile(rf"(?<!\w){_MONTH}\.?,?\s+(\d{{4}})(?!\w)")
61
+ _NUMBER = re.compile(r"(?<![\w.,-])(\d{1,3}(?:,\d{3})+|\d+)(?:\.(\d+))?(?![\w-]|[.,]\d)")
62
+ _TOKEN = re.compile(r"\w+")
63
+ _GENERIC = frozenset(
64
+ {
65
+ *("date", "day", "days", "time", "first", "last", "name", "type", "kind", "number"),
66
+ *("code", "value", "iso", "when", "units", "unit"),
67
+ }
68
+ )
69
+ _DATE_TYPES = (AttributeType.DATE,)
70
+ _NUMBER_TYPES = (AttributeType.INTEGER, AttributeType.DECIMAL, AttributeType.FLOAT)
71
+
72
+
73
+ @dataclass(frozen=True, slots=True)
74
+ class Occurrence:
75
+ start: int
76
+ end: int
77
+ kind: str # date | number | enum
78
+ value: Any
79
+
80
+
81
+ def find_dates(text: str, start: int, end: int) -> list[Occurrence]:
82
+ """Full dates in `text[start:end]`, as ISO strings."""
83
+ found: list[Occurrence] = []
84
+ for match in _ISO_DATE.finditer(text, start, end):
85
+ _add_date(found, match, int(match[1]), int(match[2]), int(match[3]))
86
+ for match in _DMY.finditer(text, start, end):
87
+ _add_date(found, match, int(match[3]), _MONTHS[match[2].lower()], int(match[1]))
88
+ for match in _MDY.finditer(text, start, end):
89
+ _add_date(found, match, int(match[3]), _MONTHS[match[1].lower()], int(match[2]))
90
+ return found
91
+
92
+
93
+ def _add_date(found: list[Occurrence], match: re.Match[str], y: int, m: int, d: int) -> None:
94
+ try:
95
+ found.append(Occurrence(match.start(), match.end(), "date", date(y, m, d).isoformat()))
96
+ except ValueError:
97
+ return
98
+
99
+
100
+ def find_numbers(
101
+ text: str, start: int, end: int, taken: Sequence[tuple[int, int]]
102
+ ) -> list[Occurrence]:
103
+ """Numbers in `text[start:end]` outside `taken` spans and month-year phrases, as strings
104
+ without thousands separators."""
105
+ blocked = [*taken, *((m.start(), m.end()) for m in _MONTH_YEAR.finditer(text, start, end))]
106
+ found: list[Occurrence] = []
107
+ for match in _NUMBER.finditer(text, start, end):
108
+ if any(match.start() < e and match.end() > s for s, e in blocked):
109
+ continue
110
+ whole = match[1].replace(",", "")
111
+ value = f"{whole}.{match[2]}" if match[2] else whole
112
+ found.append(Occurrence(match.start(), match.end(), "number", value))
113
+ return found
114
+
115
+
116
+ def _enums(text: str, start: int, end: int, attribute: Attribute) -> list[Occurrence]:
117
+ found: list[Occurrence] = []
118
+ for option in attribute.type.enum_values or ():
119
+ if len(option) < 2:
120
+ continue
121
+ pattern = re.compile(rf"(?<![\w-]){re.escape(option)}(?![\w-])")
122
+ found.extend(
123
+ Occurrence(m.start(), m.end(), "enum", option)
124
+ for m in pattern.finditer(text, start, end)
125
+ )
126
+ return found
127
+
128
+
129
+ def _cue_matches(token: str, cue: str) -> bool:
130
+ return token == cue or (len(cue) >= 3 and token.startswith(cue))
131
+
132
+
133
+ def _encode(attribute: Attribute, occurrence: Occurrence) -> Any:
134
+ name = attribute.type.name
135
+ raw: Any = occurrence.value
136
+ if occurrence.kind == "number":
137
+ if name is AttributeType.INTEGER:
138
+ if "." in raw:
139
+ return None
140
+ raw = int(raw)
141
+ elif name is AttributeType.FLOAT:
142
+ raw = float(raw)
143
+ try:
144
+ return encode_value(attribute.type, raw)
145
+ except EncodingError:
146
+ return None
147
+
148
+
149
+ def _compatible(attribute: Attribute, occurrence: Occurrence) -> bool:
150
+ name = attribute.type.name
151
+ if occurrence.kind == "date":
152
+ return name in _DATE_TYPES
153
+ if occurrence.kind == "number":
154
+ return name in _NUMBER_TYPES
155
+ return name is AttributeType.ENUM and occurrence.value in (attribute.type.enum_values or ())
156
+
157
+
158
+ @dataclass(frozen=True, slots=True)
159
+ class _Option:
160
+ score: float
161
+ owner_distance: int
162
+ owner: Resolved
163
+ attribute: Attribute
164
+ occurrence: Occurrence
165
+
166
+
167
+ def sentence_values(
168
+ ontology: Ontology,
169
+ owners: Sequence[Resolved],
170
+ text: str,
171
+ sentence: tuple[int, int],
172
+ *,
173
+ excluded: Sequence[tuple[int, int]],
174
+ settings: ExtractionSettings,
175
+ ) -> list[ValueCandidate]:
176
+ """Values the sentence `text[sentence[0]:sentence[1]]` gives the Records `owners` name."""
177
+ start, end = sentence
178
+ dates = find_dates(text, start, end)
179
+ numbers = find_numbers(text, start, end, [*excluded, *((d.start, d.end) for d in dates)])
180
+ tokens = [(m.start(), m.group().lower()) for m in _TOKEN.finditer(text, start, end)]
181
+ limit = settings.value_cue_words
182
+ names = {view.id: set(humanize(view.api_name).split()) for view in ontology.kinds.values()}
183
+ options: list[_Option] = []
184
+ for owner in owners:
185
+ view = ontology.kinds[owner.kind_id]
186
+ own = names[view.id]
187
+ others = set().union(*(n for k, n in names.items() if k != view.id)) - own
188
+ skip = {a.id for a in (view.key_attribute, view.title_attribute) if a is not None}
189
+ for attribute in view.attributes:
190
+ if attribute.id in skip:
191
+ continue
192
+ cues = [c for c in view.cues.get(attribute.id, ()) if c not in own | _GENERIC]
193
+ pool = (
194
+ _enums(text, start, end, attribute)
195
+ if attribute.type.name is AttributeType.ENUM
196
+ else [*dates, *numbers]
197
+ )
198
+ for occurrence in pool:
199
+ if not _compatible(attribute, occurrence):
200
+ continue
201
+ before = [
202
+ i for i, (position, _t) in enumerate(tokens) if position < occurrence.start
203
+ ]
204
+ cue_gaps = [
205
+ len(before) - 1 - index
206
+ for index in before
207
+ if any(_cue_matches(tokens[index][1], cue) for cue in cues)
208
+ # "supplier status" announces a value of a Supplier, not of this Kind
209
+ and not (index > 0 and tokens[index - 1][1] in others)
210
+ ]
211
+ gap = min((g for g in cue_gaps if g <= limit), default=None)
212
+ if gap is None:
213
+ if occurrence.kind == "enum" and str(occurrence.value).isupper():
214
+ gap = limit
215
+ else:
216
+ continue
217
+ score = settings.confidence.value_max * (1 - 0.5 * gap / max(limit, 1))
218
+ distance = abs(owner.mention.start - occurrence.start)
219
+ options.append(_Option(score, distance, owner, attribute, occurrence))
220
+ options.sort(
221
+ key=lambda o: (-o.score, o.owner_distance, o.occurrence.start, o.attribute.api_name)
222
+ )
223
+ used: set[tuple[str, str | None, str]] = set()
224
+ places: set[int] = set()
225
+ found: list[ValueCandidate] = []
226
+ for option in options:
227
+ slot = (option.owner.kind_id, option.owner.key, option.attribute.id)
228
+ if slot in used or option.occurrence.start in places:
229
+ continue
230
+ value = _encode(option.attribute, option.occurrence)
231
+ if value is None:
232
+ continue
233
+ used.add(slot)
234
+ places.add(option.occurrence.start)
235
+ mention = option.owner.mention
236
+ found.append(
237
+ ValueCandidate(
238
+ subject=option.owner,
239
+ attribute_id=option.attribute.id,
240
+ value=value,
241
+ evidence=Evidence(mention.document_id, mention.chunk, start, end, text[start:end]),
242
+ confidence=option.score,
243
+ tier=0,
244
+ model="rules",
245
+ )
246
+ )
247
+ return found
248
+
249
+
250
+ def heading_title(
251
+ ontology: Ontology, subject: Resolved, text: str, *, settings: ExtractionSettings
252
+ ) -> ValueCandidate | None:
253
+ """The title of a Record named by key in a heading: the heading text."""
254
+ view = ontology.kinds[subject.kind_id]
255
+ attribute = view.title_attribute
256
+ if attribute is None or attribute.type.name not in (AttributeType.STRING, AttributeType.TEXT):
257
+ return None
258
+ if view.key_attribute is not None and attribute.id == view.key_attribute.id:
259
+ return None
260
+ mention = subject.mention
261
+ line_start = text.rfind("\n", 0, mention.start) + 1
262
+ line_end = text.find("\n", mention.end)
263
+ line_end = len(text) if line_end == -1 else line_end
264
+ heading = text[line_start:line_end].lstrip("#").strip()
265
+ if not heading:
266
+ return None
267
+ try:
268
+ value = encode_value(attribute.type, heading)
269
+ except EncodingError:
270
+ return None
271
+ offset = line_start + text[line_start:line_end].index(heading)
272
+ return ValueCandidate(
273
+ subject=subject,
274
+ attribute_id=attribute.id,
275
+ value=value,
276
+ evidence=Evidence(
277
+ mention.document_id, mention.chunk, offset, offset + len(heading), heading
278
+ ),
279
+ confidence=settings.confidence.value_max,
280
+ tier=0,
281
+ model="rules",
282
+ )
@@ -0,0 +1,418 @@
1
+ """Job types Scribe runs and their handlers.
2
+
3
+ | Type | Payload | Handler |
4
+ |------|---------|---------|
5
+ | `source_sync` | `{"source": id or api name, "dry_run": bool}`, Domain set | `SourceSync.run` |
6
+ | `webhook_delivery` | none | `lorebase_store.webhooks.deliver_pending`, one pass |
7
+ | `store.index` | `{"action", "name", "branch", "kind"}`, Domain set | `indexes.run_index_job` |
8
+ | `gc` | `{"dry_run": bool, "grace_seconds": int}`, Domain set | `lorebase_store.gc.run_gc_job` |
9
+ | `store.materialize` | `{"branch"}`, Domain set | `materialize.run_materialize_job` |
10
+ | `store.branch_delete` | `{"branch"}`, Domain set | `materialize.run_branch_delete_job` |
11
+ | `store.sensitivity_refresh` | `{"branch", "resume"}`, Domain set | `run_sensitivity_refresh_job` |
12
+ | `extraction` | `{"documents", "tiers", "requested_by"}`, Domain set | `ExtractionRun.run` |
13
+ | `embeddings` | `{"branches", "reindex", "resume"}` optional, Domain set | `EmbeddingPass.run` |
14
+ | `audit_verify` | none | `lorebase_store.auth.AuditRepository.verify` over every chain |
15
+ | `audit_checkpoint` | none | `checkpoints.publish_checkpoints` |
16
+ | `vault.rotate` | `{"after": [domain id, token]}` optional | `lorebase_store.vault.rotate` |
17
+
18
+ An `extraction` job reads `documents` (ids) with `tiers` (a list, or null for
19
+ `scribe.extraction.tiers`); `requested_by` is recorded on the extraction job.
20
+
21
+ An `embeddings` job that reads `scribe.embeddings.max_records_per_job` rows before its pass is
22
+ complete queues its continuation (same payload plus `resume`) and succeeds.
23
+
24
+ `store.materialize` and `store.branch_delete` copy or delete a large branch's rows in batches
25
+ (`store.materialize.job_batch_rows` per transaction); at most `store.materialize.job_concurrency`
26
+ of them run at once across workers, and one that has to wait queues itself again.
27
+
28
+ `store.sensitivity_refresh` rewrites, in batches, the titles and search vectors a branch
29
+ materialized before the default branch tagged an Attribute (`materialize.stale`); one whose
30
+ branch stays locked queues its continuation (same branch plus `resume`).
31
+
32
+ An `audit_verify` job succeeds whether the chains verify or not: its result and the
33
+ `lorebase.audit.verifications` counter (outcome `intact` or `broken`, by stream) carry the
34
+ answer, and a broken chain is logged at error level. It verifies the chains of actions and,
35
+ under `reads`, those of the read stream.
36
+
37
+ An `audit_checkpoint` job signs the head of every chain of `scribe.audit_checkpoints.streams`
38
+ and stores one checkpoint per stream in the configured sink; `lorebase.audit.checkpoints`
39
+ counts them by outcome. A failure fails the attempt (it is retried like any job).
40
+
41
+ A `vault.rotate` job makes the vault rows of every Domain sealed or fingerprinted under
42
+ earlier keys again under the current ones (`vault.rekey_batch` rows per transaction); after
43
+ `vault.rekey_rows_per_job` rows it queues its continuation from where it stopped and succeeds.
44
+ Without vault keys it fails at once.
45
+
46
+ Index DDL runs on `JobContext.ddl_engine` (the maintenance role) when one is configured.
47
+ """
48
+
49
+ from __future__ import annotations
50
+
51
+ import asyncio
52
+ import dataclasses
53
+ import logging
54
+ from collections.abc import Callable, Iterable, Mapping, Sequence
55
+ from dataclasses import dataclass, field
56
+ from datetime import datetime
57
+ from typing import Any
58
+
59
+ import httpx
60
+ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
61
+
62
+ from lorebase_inference import InferenceSettings
63
+ from lorebase_scribe.checkpoints import CheckpointSink, build_sink, publish_checkpoints
64
+ from lorebase_scribe.embeddings import PERMANENT_ERRORS as EMBEDDING_ERRORS
65
+ from lorebase_scribe.embeddings import EmbeddingModels, EmbeddingPass, Templates
66
+ from lorebase_scribe.extraction.pipeline import PERMANENT_ERRORS as EXTRACTION_ERRORS
67
+ from lorebase_scribe.extraction.pipeline import ExtractionModels, ExtractionRun
68
+ from lorebase_scribe.settings import ScribeSettings
69
+ from lorebase_scribe.sources import PERMANENT_ERRORS, SourceSync
70
+ from lorebase_scribe.sources.connectors import ConnectorFactory, default_connector
71
+ from lorebase_scribe.sources.sync import utc_now
72
+ from lorebase_scribe.telemetry import AUDIT_CHECKPOINTS, AUDIT_VERIFICATIONS
73
+ from lorebase_scribe.webhooks import HttpxSender
74
+ from lorebase_scribe.worker import JobHandler, PermanentJobError
75
+ from lorebase_store.auth import AuditRepository, AuditStream, Chains
76
+ from lorebase_store.embeddings import SchemaCache
77
+ from lorebase_store.gc import GcBusyError, GcError, run_gc_job
78
+ from lorebase_store.indexes import run_index_job
79
+ from lorebase_store.jobs import (
80
+ BRANCH_DELETE_JOB,
81
+ EMBEDDINGS_JOB,
82
+ EXTRACTION_JOB,
83
+ GC_JOB,
84
+ INDEX_JOB,
85
+ LINEAGE_BACKFILL_JOB,
86
+ MATERIALIZE_JOB,
87
+ SENSITIVITY_REFRESH_JOB,
88
+ SOURCE_SYNC_JOB,
89
+ VAULT_ROTATE_JOB,
90
+ JobRow,
91
+ embeddings_payload,
92
+ enqueue_job,
93
+ )
94
+ from lorebase_store.materialize import (
95
+ run_branch_delete_job,
96
+ run_lineage_backfill_job,
97
+ run_materialize_job,
98
+ run_sensitivity_refresh_job,
99
+ )
100
+ from lorebase_store.runtime import StoreRuntime
101
+ from lorebase_store.storage import constraints_only
102
+ from lorebase_store.vault import rotate
103
+ from lorebase_store.webhooks import WebhookSettings, deliver_pending
104
+
105
+ WEBHOOK_DELIVERY_JOB = "webhook_delivery"
106
+ AUDIT_VERIFY_JOB = "audit_verify"
107
+ AUDIT_CHECKPOINT_JOB = "audit_checkpoint"
108
+
109
+ log = logging.getLogger("lorebase.scribe.jobs")
110
+
111
+ JOB_TYPES = (
112
+ SOURCE_SYNC_JOB,
113
+ WEBHOOK_DELIVERY_JOB,
114
+ INDEX_JOB,
115
+ MATERIALIZE_JOB,
116
+ BRANCH_DELETE_JOB,
117
+ SENSITIVITY_REFRESH_JOB,
118
+ LINEAGE_BACKFILL_JOB,
119
+ GC_JOB,
120
+ EXTRACTION_JOB,
121
+ EMBEDDINGS_JOB,
122
+ AUDIT_VERIFY_JOB,
123
+ AUDIT_CHECKPOINT_JOB,
124
+ VAULT_ROTATE_JOB,
125
+ )
126
+ """Every job type Scribe knows."""
127
+
128
+
129
+ @dataclass(frozen=True)
130
+ class JobContext:
131
+ """What the handlers share: database, store runtime, settings and the HTTP client.
132
+
133
+ `ddl_engine` runs per-Attribute index DDL: the maintenance engine
134
+ (`database.maintenance_dsn`) when configured, else `engine`. `extraction_models` replaces
135
+ the models built from `inference` (tests). `embedding_models` is the process's embedding
136
+ model, created on first use from `inference` unless given; `embedding_templates` the
137
+ process's cache of resolved embedding templates (one per pass when not given).
138
+ `checkpoint_sink` replaces the sink of `scribe.audit_checkpoints` (tests)."""
139
+
140
+ engine: AsyncEngine
141
+ sessions: async_sessionmaker[AsyncSession]
142
+ runtime: StoreRuntime
143
+ settings: ScribeSettings
144
+ webhooks: WebhookSettings
145
+ http: httpx.AsyncClient
146
+ worker_id: str
147
+ connectors: ConnectorFactory = default_connector
148
+ clock: Callable[[], datetime] = utc_now
149
+ ddl_engine: AsyncEngine | None = None
150
+ inference: InferenceSettings = field(default_factory=InferenceSettings)
151
+ extraction_models: Callable[[Sequence[int]], ExtractionModels] | None = None
152
+ embedding_models: EmbeddingModels | None = None
153
+ embedding_templates: SchemaCache[Templates] | None = None
154
+ checkpoint_sink: CheckpointSink | None = None
155
+
156
+ def embedding_pass(self) -> EmbeddingPass:
157
+ """An embedding pass sharing the process's model."""
158
+ models = self.embedding_models
159
+ if models is None:
160
+ raise RuntimeError("no embedding model: build the context with `embedding_models`")
161
+ return EmbeddingPass(
162
+ self.engine,
163
+ self.sessions,
164
+ models,
165
+ self.settings.embeddings,
166
+ dims=self.runtime.settings.embeddings.dims,
167
+ clock=self.clock,
168
+ templates=self.embedding_templates,
169
+ )
170
+
171
+
172
+ def _domain(job: JobRow) -> str:
173
+ if job.domain_id is None:
174
+ raise PermanentJobError(f"a {job.type} job needs a Domain")
175
+ return job.domain_id
176
+
177
+
178
+ def build_handlers(context: JobContext, types: Iterable[str] = ()) -> dict[str, JobHandler]:
179
+ """Handlers of `types` (default: every type); unknown types raise `ValueError`."""
180
+
181
+ async def source_sync(job: JobRow) -> Mapping[str, Any]:
182
+ reference = job.payload.get("source")
183
+ if not isinstance(reference, str) or not reference:
184
+ raise PermanentJobError("a source_sync job needs a `source`")
185
+ sync = SourceSync(
186
+ context.sessions,
187
+ context.runtime,
188
+ context.settings.sources,
189
+ principal=context.settings.worker.principal,
190
+ connectors=context.connectors,
191
+ clock=context.clock,
192
+ )
193
+ report = await sync.run(
194
+ _domain(job), reference, dry_run=bool(job.payload.get("dry_run", False))
195
+ )
196
+ return report.as_dict()
197
+
198
+ async def webhook_delivery(job: JobRow) -> Mapping[str, Any]:
199
+ # The pass bounds its attempts itself; past the lease of its deliveries another pass
200
+ # may send them again, so a pass still running then is abandoned (a failed attempt of
201
+ # the job) instead of holding a worker slot that its heartbeat keeps from going stale.
202
+ sender = HttpxSender(context.http, connect_timeout_s=context.webhooks.connect_timeout_s)
203
+ lease_s = context.webhooks.lease_s
204
+ budget = asyncio.timeout(lease_s)
205
+ try:
206
+ async with budget:
207
+ report = await deliver_pending(
208
+ context.sessions,
209
+ context.webhooks,
210
+ sender,
211
+ writes=context.runtime.settings.writes,
212
+ outbox=context.runtime.settings.outbox,
213
+ vault=context.runtime.vault,
214
+ )
215
+ except TimeoutError:
216
+ if not budget.expired():
217
+ raise
218
+ raise TimeoutError(f"the pass outlasted webhooks.lease_s ({lease_s:g}s)") from None
219
+ return dataclasses.asdict(report)
220
+
221
+ async def index(job: JobRow) -> Mapping[str, Any]:
222
+ _domain(job)
223
+ return await run_index_job(
224
+ context.ddl_engine or context.engine,
225
+ job,
226
+ constraints_only=constraints_only(context.runtime.settings),
227
+ )
228
+
229
+ async def materialize(job: JobRow) -> Mapping[str, Any]:
230
+ _domain(job)
231
+ return await run_materialize_job(
232
+ context.engine, job, runtime=context.runtime, clock=context.clock
233
+ )
234
+
235
+ async def branch_delete(job: JobRow) -> Mapping[str, Any]:
236
+ _domain(job)
237
+ return await run_branch_delete_job(
238
+ context.engine, job, runtime=context.runtime, clock=context.clock
239
+ )
240
+
241
+ async def sensitivity_refresh(job: JobRow) -> Mapping[str, Any]:
242
+ _domain(job)
243
+ return await run_sensitivity_refresh_job(
244
+ context.engine, job, runtime=context.runtime, clock=context.clock
245
+ )
246
+
247
+ async def lineage_backfill(job: JobRow) -> Mapping[str, Any]:
248
+ return await run_lineage_backfill_job(context.engine, job, runtime=context.runtime)
249
+
250
+ async def gc(job: JobRow) -> Mapping[str, Any]:
251
+ _domain(job)
252
+ try:
253
+ return await run_gc_job(context.engine, job, runtime=context.runtime)
254
+ except GcBusyError:
255
+ raise
256
+ except GcError as error:
257
+ raise PermanentJobError(str(error)) from None
258
+
259
+ async def extraction(job: JobRow) -> Mapping[str, Any]:
260
+ documents = job.payload.get("documents")
261
+ if not isinstance(documents, list) or not documents:
262
+ raise PermanentJobError("an extraction job needs `documents`")
263
+ tiers = job.payload.get("tiers")
264
+ if tiers is not None and not isinstance(tiers, list):
265
+ raise PermanentJobError("`tiers` must be a list")
266
+ run = ExtractionRun(
267
+ context.sessions,
268
+ context.runtime,
269
+ context.settings.extraction,
270
+ context.inference,
271
+ principal=context.settings.worker.principal,
272
+ models=context.extraction_models,
273
+ clock=context.clock,
274
+ )
275
+ requested_by = job.payload.get("requested_by")
276
+ report = await run.run(
277
+ _domain(job),
278
+ [str(d) for d in documents],
279
+ tiers=None if tiers is None else [int(t) for t in tiers],
280
+ requested_by=str(requested_by) if requested_by else None,
281
+ )
282
+ return report.as_dict()
283
+
284
+ async def embeddings(job: JobRow) -> Mapping[str, Any]:
285
+ domain = _domain(job)
286
+ branches = job.payload.get("branches")
287
+ if branches is not None and (
288
+ not isinstance(branches, list) or not all(isinstance(b, str) for b in branches)
289
+ ):
290
+ raise PermanentJobError("`branches` must be a list of branch names")
291
+ resume = job.payload.get("resume")
292
+ if resume is not None and (
293
+ not isinstance(resume, list)
294
+ or len(resume) != 3
295
+ or not all(isinstance(part, str) for part in resume)
296
+ ):
297
+ raise PermanentJobError("`resume` must be [branch, kind id, key]")
298
+ reindex = bool(job.payload.get("reindex", False))
299
+ report = await context.embedding_pass().run(
300
+ domain,
301
+ branches=branches,
302
+ reindex=reindex,
303
+ resume=None if resume is None else (resume[0], resume[1], resume[2]),
304
+ )
305
+ result = report.as_dict()
306
+ if report.resume is not None:
307
+ async with context.sessions() as session, session.begin():
308
+ result["continuation"] = await enqueue_job(
309
+ session,
310
+ EMBEDDINGS_JOB,
311
+ domain_id=domain,
312
+ payload=embeddings_payload(
313
+ branches=branches, reindex=reindex, resume=report.resume
314
+ ),
315
+ unique=True,
316
+ )
317
+ return result
318
+
319
+ async def verify_stream(stream: AuditStream) -> dict[str, Any]:
320
+ async with context.sessions() as session, session.begin():
321
+ result = await AuditRepository(session, stream).verify(domain_id=Chains.ALL)
322
+ outcome = "intact" if result.ok else "broken"
323
+ AUDIT_VERIFICATIONS.add(1, {"outcome": outcome, "stream": stream.value})
324
+ report: dict[str, Any] = {
325
+ "intact": result.ok,
326
+ "checked": result.checked,
327
+ "chains": result.chains,
328
+ "last_seq": result.last_seq,
329
+ }
330
+ if not result.ok:
331
+ report["first_broken"] = {
332
+ "seq": result.bad_seq,
333
+ "domain_id": result.bad_domain_id,
334
+ "reason": result.reason,
335
+ }
336
+ log.error(
337
+ "audit chain broken", extra={**report["first_broken"], "stream": stream.value}
338
+ )
339
+ return report
340
+
341
+ async def audit_verify(job: JobRow) -> Mapping[str, Any]:
342
+ report = await verify_stream(AuditStream.ACTIONS)
343
+ report["reads"] = await verify_stream(AuditStream.READS)
344
+ return report
345
+
346
+ async def audit_checkpoint(job: JobRow) -> Mapping[str, Any]:
347
+ settings = context.settings.audit_checkpoints
348
+ if not settings.enabled:
349
+ raise PermanentJobError("scribe.audit_checkpoints is not enabled")
350
+ sink = context.checkpoint_sink or build_sink(settings, context.http)
351
+ try:
352
+ published = await publish_checkpoints(
353
+ context.sessions, settings, sink, clock=context.clock
354
+ )
355
+ except Exception:
356
+ AUDIT_CHECKPOINTS.add(1, {"outcome": "failed"})
357
+ raise
358
+ AUDIT_CHECKPOINTS.add(len(published), {"outcome": "published"})
359
+ return {"published": [item.as_dict() for item in published]}
360
+
361
+ async def vault_rotate(job: JobRow) -> Mapping[str, Any]:
362
+ keys = context.runtime.vault
363
+ if keys is None:
364
+ raise PermanentJobError("vault.rotate needs vault.token_key and vault.encryption_key")
365
+ after = job.payload.get("after")
366
+ if after is not None and (
367
+ not isinstance(after, list)
368
+ or len(after) != 2
369
+ or not all(isinstance(part, str) for part in after)
370
+ ):
371
+ raise PermanentJobError("`after` must be [domain id, token]")
372
+ settings = context.runtime.vault_settings
373
+ report = await rotate(
374
+ context.sessions,
375
+ keys,
376
+ batch=settings.rekey_batch,
377
+ max_rows=settings.rekey_rows_per_job,
378
+ after=None if after is None else (after[0], after[1]),
379
+ )
380
+ result = report.as_dict()
381
+ if report.after is not None:
382
+ async with context.sessions() as session, session.begin():
383
+ result["continuation"] = await enqueue_job(
384
+ session,
385
+ VAULT_ROTATE_JOB,
386
+ payload={"after": list(report.after)},
387
+ unique=True,
388
+ )
389
+ return result
390
+
391
+ available: dict[str, JobHandler] = {
392
+ SOURCE_SYNC_JOB: source_sync,
393
+ WEBHOOK_DELIVERY_JOB: webhook_delivery,
394
+ INDEX_JOB: index,
395
+ MATERIALIZE_JOB: materialize,
396
+ BRANCH_DELETE_JOB: branch_delete,
397
+ SENSITIVITY_REFRESH_JOB: sensitivity_refresh,
398
+ LINEAGE_BACKFILL_JOB: lineage_backfill,
399
+ GC_JOB: gc,
400
+ EXTRACTION_JOB: extraction,
401
+ EMBEDDINGS_JOB: embeddings,
402
+ AUDIT_VERIFY_JOB: audit_verify,
403
+ AUDIT_CHECKPOINT_JOB: audit_checkpoint,
404
+ VAULT_ROTATE_JOB: vault_rotate,
405
+ }
406
+ chosen = list(types) or list(available)
407
+ unknown = sorted(set(chosen) - available.keys())
408
+ if unknown:
409
+ raise ValueError(f"unknown job type(s): {', '.join(unknown)}")
410
+ return {name: available[name] for name in chosen}
411
+
412
+
413
+ PERMANENT_JOB_ERRORS: tuple[type[Exception], ...] = (
414
+ *PERMANENT_ERRORS,
415
+ *EXTRACTION_ERRORS,
416
+ *EMBEDDING_ERRORS,
417
+ )
418
+ """Handler failures the worker fails at once instead of retrying."""