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,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
|
+
)
|
lorebase_scribe/jobs.py
ADDED
|
@@ -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."""
|