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,339 @@
1
+ """Candidates to Proposal items (design 9.2 step 11).
2
+
3
+ - A mention resolved as `new` becomes a `record` item holding its key and the best value per
4
+ Attribute found for it (defaults filled in). Its confidence is the lowest of the mention's and
5
+ its values'; it records the required Attributes and required Relations still missing.
6
+ - Values of existing Records become `value` items, one per Record and Attribute (the most
7
+ confident); values equal to the current one are dropped as unchanged.
8
+ - Links become `link` items, one per Relation and endpoints (the most confident); Links that
9
+ exist already are dropped. A Link of a foreign-key backed Relation becomes a value of its
10
+ Attribute.
11
+ - Items below `min_confidence` are dropped. An item is accepted at once when its confidence
12
+ reaches `auto_accept_threshold`, its match is not ambiguous, and, for a new Record, nothing
13
+ required is missing; a Link is accepted at once only when its endpoints exist or are accepted
14
+ at once too. Items that set a value of a vault Attribute are never accepted at once: the
15
+ extraction commit checks vault values against erased ones, so a reviewer decides (design
16
+ 13.5).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from collections.abc import Iterable, Mapping, Sequence
22
+ from dataclasses import dataclass, field, replace
23
+ from datetime import datetime
24
+ from typing import Any
25
+
26
+ from lorebase_core.model import RelationBacking, new_ulid
27
+ from lorebase_core.types import EncodingError, encode_value
28
+ from lorebase_scribe.extraction.candidates import LinkCandidate, Resolved, ValueCandidate
29
+ from lorebase_scribe.extraction.ontology import Ontology
30
+ from lorebase_scribe.extraction.settings import ExtractionSettings
31
+ from lorebase_store.extraction import link_element, record_element
32
+ from lorebase_store.models.enums import ItemDecision
33
+ from lorebase_store.repositories.proposal_items import (
34
+ LINK_ITEM,
35
+ RECORD_ITEM,
36
+ VALUE_ITEM,
37
+ ItemRow,
38
+ )
39
+
40
+ ITEM_PREFIX = "pit"
41
+
42
+ RecordKey = tuple[str, str]
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class CurrentState:
47
+ """What the target branch holds for the Records and Links the candidates name."""
48
+
49
+ records: Mapping[RecordKey, Mapping[str, Any]]
50
+ links: frozenset[tuple[str, str, str]]
51
+
52
+
53
+ @dataclass
54
+ class Assembled:
55
+ items: list[ItemRow] = field(default_factory=list)
56
+ stats: dict[str, int] = field(default_factory=dict)
57
+
58
+ def count(self, name: str, n: int = 1) -> None:
59
+ self.stats[name] = self.stats.get(name, 0) + n
60
+
61
+
62
+ def _best_values(
63
+ values: Iterable[ValueCandidate],
64
+ ) -> dict[RecordKey, dict[str, ValueCandidate]]:
65
+ best: dict[RecordKey, dict[str, ValueCandidate]] = {}
66
+ for value in values:
67
+ if not value.subject.usable:
68
+ continue
69
+ element = (value.subject.kind_id, str(value.subject.key))
70
+ per = best.setdefault(element, {})
71
+ current = per.get(value.attribute_id)
72
+ if current is None or value.confidence > current.confidence:
73
+ per[value.attribute_id] = value
74
+ return best
75
+
76
+
77
+ def _best_links(links: Iterable[LinkCandidate]) -> dict[tuple[str, str, str], LinkCandidate]:
78
+ best: dict[tuple[str, str, str], LinkCandidate] = {}
79
+ for link in links:
80
+ if not (link.source.usable and link.target.usable):
81
+ continue
82
+ element = (link.relation_id, str(link.source.key), str(link.target.key))
83
+ if element not in best or link.confidence > best[element].confidence:
84
+ best[element] = link
85
+ return best
86
+
87
+
88
+ def assemble_items(
89
+ ontology: Ontology,
90
+ mentions: Sequence[Resolved],
91
+ values: Sequence[ValueCandidate],
92
+ links: Sequence[LinkCandidate],
93
+ current: CurrentState,
94
+ *,
95
+ job_id: str,
96
+ principal: str,
97
+ now: datetime,
98
+ settings: ExtractionSettings,
99
+ ) -> Assembled:
100
+ """Items of an extraction Proposal, in order: Records, values, Links."""
101
+ result = Assembled()
102
+ threshold = settings.auto_accept_threshold
103
+ schema = ontology.schema
104
+ new_records: dict[RecordKey, Resolved] = {}
105
+ for mention in sorted(mentions, key=lambda m: (m.mention.start, m.kind_id)):
106
+ if mention.resolution.status == "new" and mention.key is not None:
107
+ new_records.setdefault((mention.kind_id, mention.key), mention)
108
+
109
+ pending_values = list(values)
110
+ best_links = _best_links(links)
111
+ for (relation_id, from_key, to_key), link in list(best_links.items()):
112
+ relation = schema.relations[relation_id]
113
+ if relation.backing is RelationBacking.FOREIGN_KEY and relation.foreign_key_attribute:
114
+ del best_links[(relation_id, from_key, to_key)]
115
+ pending_values.append(
116
+ ValueCandidate(
117
+ subject=link.source,
118
+ attribute_id=relation.foreign_key_attribute,
119
+ value=to_key,
120
+ evidence=link.evidence,
121
+ confidence=link.confidence,
122
+ tier=link.tier,
123
+ model=link.model,
124
+ )
125
+ )
126
+ best_values = _best_values(pending_values)
127
+
128
+ drafts: list[tuple[int, ItemRow]] = []
129
+ auto_records: set[RecordKey] = set()
130
+
131
+ def decide(confident: bool) -> tuple[ItemDecision, str | None, datetime | None]:
132
+ if confident:
133
+ return ItemDecision.ACCEPTED, principal, now
134
+ return ItemDecision.PENDING, None, None
135
+
136
+ for element, mention in new_records.items():
137
+ kind_id, key = element
138
+ view = ontology.kinds[kind_id]
139
+ record_values: dict[str, Any] = {}
140
+ evidence: dict[str, Any] = {}
141
+ confidences = [mention.confidence]
142
+ if view.key_attribute is not None:
143
+ try:
144
+ record_values[view.key_attribute.id] = encode_value(view.key_attribute.type, key)
145
+ except EncodingError:
146
+ result.count("invalid_key")
147
+ continue
148
+ evidence[view.key_attribute.id] = mention.mention.evidence.as_dict()
149
+ for attribute_id, value in best_values.get(element, {}).items():
150
+ record_values[attribute_id] = value.value
151
+ evidence[attribute_id] = {**value.evidence.as_dict(), "model": value.model}
152
+ confidences.append(value.confidence)
153
+ for attribute in view.kind.attributes.values():
154
+ if attribute.id not in record_values and attribute.default is not None:
155
+ record_values[attribute.id] = attribute.default
156
+ missing = sorted(
157
+ a.api_name
158
+ for a in view.kind.attributes.values()
159
+ if a.required and not a.is_derived and a.id not in record_values
160
+ )
161
+ needed = [
162
+ r
163
+ for r in ontology.relations.values()
164
+ if r.required_from and r.from_kind == kind_id and r.backing is RelationBacking.EXPLICIT
165
+ ]
166
+ provided = {
167
+ relation_id
168
+ for (relation_id, from_key, _to), link in best_links.items()
169
+ if from_key == key
170
+ and schema.relations[relation_id].from_kind == kind_id
171
+ and link.confidence >= threshold
172
+ and link.target.resolution.status == "matched"
173
+ }
174
+ missing_links = sorted(r.api_name for r in needed if r.id not in provided)
175
+ confidence = min(confidences)
176
+ if confidence < settings.min_confidence:
177
+ result.count("below_min_confidence")
178
+ continue
179
+ vaulted = any(
180
+ view.kind.attributes[a].in_vault
181
+ for a, v in record_values.items()
182
+ if v is not None and a in view.kind.attributes
183
+ )
184
+ confident = confidence >= threshold and not missing and not missing_links and not vaulted
185
+ if confident:
186
+ auto_records.add(element)
187
+ decision, by, at = decide(confident)
188
+ partition, element_key = record_element(kind_id, key)
189
+ drafts.append(
190
+ (
191
+ mention.mention.start,
192
+ ItemRow(
193
+ item_id=f"{ITEM_PREFIX}_{new_ulid()}",
194
+ item_type=RECORD_ITEM,
195
+ seq=0,
196
+ subject={
197
+ "kind": kind_id,
198
+ "key": key,
199
+ "values": record_values,
200
+ "evidence": evidence,
201
+ },
202
+ partition=partition,
203
+ element_key=element_key,
204
+ evidence=mention.mention.evidence.as_dict(),
205
+ confidence=round(confidence, 4),
206
+ decision=decision,
207
+ decided_by=by,
208
+ decided_at=at,
209
+ job_id=job_id,
210
+ match={
211
+ **mention.resolution.as_dict(),
212
+ "complete": not missing and not missing_links,
213
+ "missing": missing,
214
+ "missing_links": missing_links,
215
+ },
216
+ tier=mention.mention.tier,
217
+ model=mention.mention.extractor,
218
+ ),
219
+ )
220
+ )
221
+
222
+ for element, per_attribute in best_values.items():
223
+ if element in new_records:
224
+ continue
225
+ stored = current.records.get(element)
226
+ if stored is None:
227
+ result.count("unknown_record", len(per_attribute))
228
+ continue
229
+ for attribute_id, value in per_attribute.items():
230
+ if stored.get(attribute_id) == value.value:
231
+ result.count("unchanged")
232
+ continue
233
+ if value.confidence < settings.min_confidence:
234
+ result.count("below_min_confidence")
235
+ continue
236
+ resolution = value.subject.resolution
237
+ target = schema.kinds[element[0]].attributes.get(attribute_id)
238
+ confident = (
239
+ value.confidence >= threshold
240
+ and resolution.status == "matched"
241
+ and not (target is not None and target.in_vault)
242
+ )
243
+ decision, by, at = decide(confident)
244
+ partition, element_key = record_element(*element)
245
+ drafts.append(
246
+ (
247
+ value.evidence.start,
248
+ ItemRow(
249
+ item_id=f"{ITEM_PREFIX}_{new_ulid()}",
250
+ item_type=VALUE_ITEM,
251
+ seq=0,
252
+ subject={
253
+ "kind": element[0],
254
+ "key": element[1],
255
+ "attribute": attribute_id,
256
+ "value": value.value,
257
+ "current": stored.get(attribute_id),
258
+ },
259
+ partition=partition,
260
+ element_key=element_key,
261
+ attribute_id=attribute_id,
262
+ evidence=value.evidence.as_dict(),
263
+ confidence=round(value.confidence, 4),
264
+ decision=decision,
265
+ decided_by=by,
266
+ decided_at=at,
267
+ job_id=job_id,
268
+ match=resolution.as_dict(),
269
+ tier=value.tier,
270
+ model=value.model,
271
+ ),
272
+ )
273
+ )
274
+
275
+ for (relation_id, from_key, to_key), link in best_links.items():
276
+ if (relation_id, from_key, to_key) in current.links:
277
+ result.count("unchanged")
278
+ continue
279
+ if link.confidence < settings.min_confidence:
280
+ result.count("below_min_confidence")
281
+ continue
282
+
283
+ def ready(side: Resolved) -> bool:
284
+ if side.resolution.status == "matched":
285
+ return True
286
+ return (side.kind_id, str(side.key)) in auto_records
287
+
288
+ missing_attributes = sorted(
289
+ a.api_name
290
+ for a in schema.relations[relation_id].attributes.values()
291
+ if a.required and not a.is_derived and a.id not in link.values
292
+ )
293
+ confident = (
294
+ link.confidence >= threshold
295
+ and ready(link.source)
296
+ and ready(link.target)
297
+ and not missing_attributes
298
+ )
299
+ decision, by, at = decide(confident)
300
+ partition, element_key = link_element(relation_id, from_key, to_key)
301
+ drafts.append(
302
+ (
303
+ link.evidence.start,
304
+ ItemRow(
305
+ item_id=f"{ITEM_PREFIX}_{new_ulid()}",
306
+ item_type=LINK_ITEM,
307
+ seq=0,
308
+ subject={
309
+ "relation": relation_id,
310
+ "from": from_key,
311
+ "to": to_key,
312
+ "values": dict(link.values),
313
+ },
314
+ partition=partition,
315
+ element_key=element_key,
316
+ evidence=link.evidence.as_dict(),
317
+ confidence=round(link.confidence, 4),
318
+ decision=decision,
319
+ decided_by=by,
320
+ decided_at=at,
321
+ job_id=job_id,
322
+ match={
323
+ "from": link.source.resolution.as_dict(),
324
+ "to": link.target.resolution.as_dict(),
325
+ "missing": missing_attributes,
326
+ },
327
+ tier=link.tier,
328
+ model=link.model,
329
+ ),
330
+ )
331
+ )
332
+
333
+ order = {RECORD_ITEM: 0, VALUE_ITEM: 1, LINK_ITEM: 2}
334
+ drafts.sort(key=lambda d: (order[d[1].item_type], d[0], d[1].partition, d[1].element_key))
335
+ if len(drafts) > settings.max_items_per_job:
336
+ result.count("over_item_limit", len(drafts) - settings.max_items_per_job)
337
+ drafts = drafts[: settings.max_items_per_job]
338
+ result.items = [replace(row, seq=index) for index, (_start, row) in enumerate(drafts)]
339
+ return result
@@ -0,0 +1,136 @@
1
+ """Candidates the tiers produce before they become Proposal items.
2
+
3
+ Offsets are always offsets into the parsed document text (end exclusive).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, field
9
+ from typing import Any, Literal
10
+
11
+ MatchStatus = Literal["matched", "new", "ambiguous", "unresolved"]
12
+ MentionSource = Literal["key", "title", "span", "model"]
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class Evidence:
17
+ document_id: str
18
+ chunk: int
19
+ start: int
20
+ end: int
21
+ quote: str
22
+
23
+ def as_dict(self) -> dict[str, Any]:
24
+ return {
25
+ "document_id": self.document_id,
26
+ "chunk": self.chunk,
27
+ "start": self.start,
28
+ "end": self.end,
29
+ "quote": self.quote,
30
+ }
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class Mention:
35
+ """A piece of text naming a Record of `kind_id` (by key or by title)."""
36
+
37
+ kind_id: str
38
+ text: str
39
+ start: int
40
+ end: int
41
+ chunk: int
42
+ score: float
43
+ tier: int
44
+ source: MentionSource
45
+ extractor: str
46
+ document_id: str
47
+
48
+ @property
49
+ def evidence(self) -> Evidence:
50
+ return Evidence(self.document_id, self.chunk, self.start, self.end, self.text)
51
+
52
+
53
+ @dataclass(frozen=True, slots=True)
54
+ class MatchCandidate:
55
+ kind_id: str
56
+ key: str
57
+ title: str | None
58
+ score: float
59
+ method: str
60
+
61
+ def as_dict(self) -> dict[str, Any]:
62
+ return {
63
+ "key": self.key,
64
+ "title": self.title,
65
+ "score": round(self.score, 4),
66
+ "method": self.method,
67
+ }
68
+
69
+
70
+ @dataclass(frozen=True, slots=True)
71
+ class Resolution:
72
+ """Entity resolution of a mention: the Record it names, a new one, or several."""
73
+
74
+ kind_id: str
75
+ status: MatchStatus
76
+ key: str | None
77
+ score: float
78
+ method: str
79
+ candidates: tuple[MatchCandidate, ...] = ()
80
+
81
+ def as_dict(self) -> dict[str, Any]:
82
+ return {
83
+ "status": self.status,
84
+ "method": self.method,
85
+ "score": round(self.score, 4),
86
+ "candidates": [c.as_dict() for c in self.candidates],
87
+ }
88
+
89
+
90
+ @dataclass(frozen=True, slots=True)
91
+ class Resolved:
92
+ """A mention with its resolution; `confidence` combines both."""
93
+
94
+ mention: Mention
95
+ resolution: Resolution
96
+
97
+ @property
98
+ def key(self) -> str | None:
99
+ return self.resolution.key
100
+
101
+ @property
102
+ def kind_id(self) -> str:
103
+ return self.resolution.kind_id
104
+
105
+ @property
106
+ def confidence(self) -> float:
107
+ return self.mention.score * self.resolution.score
108
+
109
+ @property
110
+ def usable(self) -> bool:
111
+ return self.resolution.status != "unresolved" and self.resolution.key is not None
112
+
113
+
114
+ @dataclass(slots=True)
115
+ class ValueCandidate:
116
+ """An Attribute value for the Record `subject` names."""
117
+
118
+ subject: Resolved
119
+ attribute_id: str
120
+ value: Any
121
+ evidence: Evidence
122
+ confidence: float
123
+ tier: int
124
+ model: str
125
+
126
+
127
+ @dataclass(slots=True)
128
+ class LinkCandidate:
129
+ relation_id: str
130
+ source: Resolved
131
+ target: Resolved
132
+ evidence: Evidence
133
+ confidence: float
134
+ tier: int
135
+ model: str
136
+ values: dict[str, Any] = field(default_factory=dict)