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,432 @@
|
|
|
1
|
+
"""What the schema tells the extractors: labels, key patterns, value cues and the tier 2 JSON
|
|
2
|
+
schema of an ontology fragment.
|
|
3
|
+
|
|
4
|
+
Only active Kinds and Relations take part. Attributes that are derived, sensitive (`pii`,
|
|
5
|
+
`confidential`, `restricted`) or of a type extraction does not handle (`json`, `geo_point`,
|
|
6
|
+
`array`, `duration`) are never proposed. Sensitive means by the effective tags, as reads see
|
|
7
|
+
them: the schema's own, those the default branch gives the same Attribute, and those carried
|
|
8
|
+
through Derivations (`lorebase_core.expr.sensitivity.effective_tags`), so a schema forked
|
|
9
|
+
before the default branch tagged an Attribute does not make it extractable.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from lorebase_core.expr.sensitivity import Tags, effective_tags
|
|
20
|
+
from lorebase_core.model import Attribute, ElementStatus, Kind, Relation, Schema
|
|
21
|
+
from lorebase_core.types import AttributeType
|
|
22
|
+
|
|
23
|
+
EXTRACTABLE_TYPES = frozenset(
|
|
24
|
+
{
|
|
25
|
+
AttributeType.STRING,
|
|
26
|
+
AttributeType.TEXT,
|
|
27
|
+
AttributeType.URI,
|
|
28
|
+
AttributeType.REF,
|
|
29
|
+
AttributeType.ENUM,
|
|
30
|
+
AttributeType.INTEGER,
|
|
31
|
+
AttributeType.DECIMAL,
|
|
32
|
+
AttributeType.FLOAT,
|
|
33
|
+
AttributeType.BOOLEAN,
|
|
34
|
+
AttributeType.DATE,
|
|
35
|
+
AttributeType.TIMESTAMP,
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
_STOPWORDS = frozenset(
|
|
39
|
+
[
|
|
40
|
+
"a",
|
|
41
|
+
"an",
|
|
42
|
+
"and",
|
|
43
|
+
"are",
|
|
44
|
+
"as",
|
|
45
|
+
"at",
|
|
46
|
+
"be",
|
|
47
|
+
"by",
|
|
48
|
+
"for",
|
|
49
|
+
"from",
|
|
50
|
+
"in",
|
|
51
|
+
"into",
|
|
52
|
+
"is",
|
|
53
|
+
"it",
|
|
54
|
+
"its",
|
|
55
|
+
"of",
|
|
56
|
+
"on",
|
|
57
|
+
"or",
|
|
58
|
+
"that",
|
|
59
|
+
"the",
|
|
60
|
+
"this",
|
|
61
|
+
"to",
|
|
62
|
+
"when",
|
|
63
|
+
"with",
|
|
64
|
+
"which",
|
|
65
|
+
"who",
|
|
66
|
+
"whose",
|
|
67
|
+
"was",
|
|
68
|
+
"were",
|
|
69
|
+
"will",
|
|
70
|
+
"can",
|
|
71
|
+
"may",
|
|
72
|
+
"per",
|
|
73
|
+
"than",
|
|
74
|
+
"then",
|
|
75
|
+
"there",
|
|
76
|
+
"their",
|
|
77
|
+
"them",
|
|
78
|
+
"they",
|
|
79
|
+
"has",
|
|
80
|
+
"have",
|
|
81
|
+
"had",
|
|
82
|
+
"not",
|
|
83
|
+
"no",
|
|
84
|
+
"yes",
|
|
85
|
+
"true",
|
|
86
|
+
"false",
|
|
87
|
+
"example",
|
|
88
|
+
"code",
|
|
89
|
+
"number",
|
|
90
|
+
"value",
|
|
91
|
+
"values",
|
|
92
|
+
"one",
|
|
93
|
+
"two",
|
|
94
|
+
"three",
|
|
95
|
+
]
|
|
96
|
+
)
|
|
97
|
+
_CAMEL = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
|
|
98
|
+
_WORD = re.compile(r"[A-Za-z][A-Za-z0-9]+")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def humanize(api_name: str) -> str:
|
|
102
|
+
"""`PurchaseOrder` and `purchase_order` both give `purchase order`."""
|
|
103
|
+
return _CAMEL.sub(" ", api_name).replace("_", " ").strip().lower()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def cue_tokens(attribute: Attribute) -> tuple[str, ...]:
|
|
107
|
+
"""Words that announce a value of the Attribute in text (from its names and description)."""
|
|
108
|
+
words = [*humanize(attribute.api_name).split(), *attribute.display_name.lower().split()]
|
|
109
|
+
words += [w.lower() for w in _WORD.findall(attribute.description)[:8]]
|
|
110
|
+
return tuple(dict.fromkeys(w for w in words if len(w) >= 3 and w not in _STOPWORDS))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def key_regex(pattern: str) -> re.Pattern[str] | None:
|
|
114
|
+
"""A search pattern for keys from an anchored `pattern` constraint; None when unusable."""
|
|
115
|
+
core = pattern.removeprefix("^").removesuffix("$")
|
|
116
|
+
if not core or core in (".*", ".+"):
|
|
117
|
+
return None
|
|
118
|
+
try:
|
|
119
|
+
return re.compile(rf"(?<![\w-])(?:{core})(?![\w-])")
|
|
120
|
+
except re.error:
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def extractable(attribute: Attribute, tags: Mapping[str, Tags] | None = None) -> bool:
|
|
125
|
+
"""Whether extraction may propose values of `attribute`; `tags` are the effective tags
|
|
126
|
+
by Attribute id (the Attribute's own when not given)."""
|
|
127
|
+
sensitive = attribute.sensitivity if tags is None else tags.get(attribute.id)
|
|
128
|
+
return not attribute.is_derived and not sensitive and attribute.type.name in EXTRACTABLE_TYPES
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass(frozen=True)
|
|
132
|
+
class KindView:
|
|
133
|
+
"""One Kind as the extractors see it."""
|
|
134
|
+
|
|
135
|
+
kind: Kind
|
|
136
|
+
label: str
|
|
137
|
+
key_attribute: Attribute | None
|
|
138
|
+
key_pattern: re.Pattern[str] | None
|
|
139
|
+
title_attribute: Attribute | None
|
|
140
|
+
attributes: tuple[Attribute, ...]
|
|
141
|
+
cues: Mapping[str, tuple[str, ...]] = field(default_factory=dict)
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def id(self) -> str:
|
|
145
|
+
return self.kind.id
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def api_name(self) -> str:
|
|
149
|
+
return self.kind.api_name
|
|
150
|
+
|
|
151
|
+
def describe(self) -> str:
|
|
152
|
+
"""Label description for span extractors: the Kind and what names its Records."""
|
|
153
|
+
named = (
|
|
154
|
+
f" Named by its {humanize(self.title_attribute.api_name)}."
|
|
155
|
+
if self.title_attribute
|
|
156
|
+
else ""
|
|
157
|
+
)
|
|
158
|
+
return f"{self.kind.description}{named}".strip()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass(frozen=True)
|
|
162
|
+
class Ontology:
|
|
163
|
+
"""The extraction view of a schema."""
|
|
164
|
+
|
|
165
|
+
schema: Schema
|
|
166
|
+
kinds: Mapping[str, KindView]
|
|
167
|
+
relations: Mapping[str, Relation]
|
|
168
|
+
new_record_kinds: frozenset[str]
|
|
169
|
+
tags: Mapping[str, Tags]
|
|
170
|
+
"""Effective sensitivity tags by Attribute id (module doc)."""
|
|
171
|
+
|
|
172
|
+
def kind_by_label(self, label: str) -> KindView | None:
|
|
173
|
+
folded = label.casefold()
|
|
174
|
+
for view in self.kinds.values():
|
|
175
|
+
if folded in (view.label.casefold(), view.api_name.casefold()):
|
|
176
|
+
return view
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
def allows_new(self, kind_id: str) -> bool:
|
|
180
|
+
return kind_id in self.new_record_kinds
|
|
181
|
+
|
|
182
|
+
def relations_between(self, kind_ids: Iterable[str]) -> list[Relation]:
|
|
183
|
+
present = set(kind_ids)
|
|
184
|
+
return [
|
|
185
|
+
r for r in self.relations.values() if r.from_kind in present and r.to_kind in present
|
|
186
|
+
]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def build_ontology(
|
|
190
|
+
schema: Schema,
|
|
191
|
+
new_record_kinds: Sequence[str] | None,
|
|
192
|
+
reference: Mapping[str, Tags] | None = None,
|
|
193
|
+
) -> Ontology:
|
|
194
|
+
"""The extraction view of `schema`; `new_record_kinds` as in `scribe.extraction`;
|
|
195
|
+
`reference` the tags of the default branch's schema by Attribute id, added to the
|
|
196
|
+
schema's own (module doc)."""
|
|
197
|
+
tags = effective_tags(schema, reference)
|
|
198
|
+
kinds: dict[str, KindView] = {}
|
|
199
|
+
for kind in schema.kinds.values():
|
|
200
|
+
if kind.status is not ElementStatus.ACTIVE:
|
|
201
|
+
continue
|
|
202
|
+
key_attribute = None
|
|
203
|
+
pattern = None
|
|
204
|
+
if len(kind.primary_key) == 1:
|
|
205
|
+
key_attribute = kind.attributes.get(kind.primary_key[0])
|
|
206
|
+
if key_attribute is not None and key_attribute.constraints.pattern:
|
|
207
|
+
pattern = key_regex(key_attribute.constraints.pattern)
|
|
208
|
+
title = kind.attributes.get(kind.title_attribute or "")
|
|
209
|
+
attributes = tuple(a for a in kind.attributes.values() if extractable(a, tags))
|
|
210
|
+
kinds[kind.id] = KindView(
|
|
211
|
+
kind=kind,
|
|
212
|
+
label=kind.display_name or humanize(kind.api_name),
|
|
213
|
+
key_attribute=key_attribute,
|
|
214
|
+
key_pattern=pattern,
|
|
215
|
+
title_attribute=title,
|
|
216
|
+
attributes=attributes,
|
|
217
|
+
cues={a.id: cue_tokens(a) for a in attributes},
|
|
218
|
+
)
|
|
219
|
+
relations = {
|
|
220
|
+
r.id: r
|
|
221
|
+
for r in schema.relations.values()
|
|
222
|
+
if r.status is ElementStatus.ACTIVE and r.from_kind in kinds and r.to_kind in kinds
|
|
223
|
+
}
|
|
224
|
+
if new_record_kinds is None:
|
|
225
|
+
allowed = {k for k, view in kinds.items() if view.kind.has_natural_key}
|
|
226
|
+
else:
|
|
227
|
+
names = set(new_record_kinds)
|
|
228
|
+
allowed = {k for k, view in kinds.items() if view.api_name in names}
|
|
229
|
+
return Ontology(schema, kinds, relations, frozenset(allowed), tags)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# Tier 2 JSON schema -------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def value_schema(attribute: Attribute) -> dict[str, Any]:
|
|
236
|
+
"""JSON schema of one Attribute value as the model writes it."""
|
|
237
|
+
spec = attribute.type
|
|
238
|
+
name = spec.name
|
|
239
|
+
schema: dict[str, Any]
|
|
240
|
+
if name is AttributeType.ENUM:
|
|
241
|
+
schema = {"enum": list(spec.enum_values or ())}
|
|
242
|
+
elif name is AttributeType.INTEGER:
|
|
243
|
+
schema = {"type": "integer"}
|
|
244
|
+
elif name is AttributeType.FLOAT:
|
|
245
|
+
schema = {"type": "number"}
|
|
246
|
+
elif name is AttributeType.BOOLEAN:
|
|
247
|
+
schema = {"type": "boolean"}
|
|
248
|
+
elif name is AttributeType.DATE:
|
|
249
|
+
schema = {"type": "string", "format": "date"}
|
|
250
|
+
elif name is AttributeType.TIMESTAMP:
|
|
251
|
+
schema = {"type": "string", "format": "date-time"}
|
|
252
|
+
elif name is AttributeType.DECIMAL:
|
|
253
|
+
schema = {"type": "string", "description": "decimal number, digits and one dot"}
|
|
254
|
+
else:
|
|
255
|
+
schema = {"type": "string"}
|
|
256
|
+
if attribute.description:
|
|
257
|
+
schema["description"] = attribute.description
|
|
258
|
+
return schema
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _record_schema(view: KindView) -> dict[str, Any]:
|
|
262
|
+
values = {
|
|
263
|
+
a.api_name: {
|
|
264
|
+
"type": "object",
|
|
265
|
+
"additionalProperties": False,
|
|
266
|
+
"properties": {
|
|
267
|
+
"value": value_schema(a),
|
|
268
|
+
"evidence": {"type": "string", "description": "Verbatim quote holding the value"},
|
|
269
|
+
},
|
|
270
|
+
"required": ["value", "evidence"],
|
|
271
|
+
}
|
|
272
|
+
for a in view.attributes
|
|
273
|
+
}
|
|
274
|
+
return {
|
|
275
|
+
"type": "object",
|
|
276
|
+
"additionalProperties": False,
|
|
277
|
+
"properties": {
|
|
278
|
+
"kind": {"const": view.api_name},
|
|
279
|
+
"ref": {
|
|
280
|
+
"type": "string",
|
|
281
|
+
"description": "Key of the candidate Record this is, or an empty string for "
|
|
282
|
+
"a Record that is not among the candidates",
|
|
283
|
+
},
|
|
284
|
+
"evidence": {"type": "string", "description": "Verbatim quote naming the Record"},
|
|
285
|
+
"values": {"type": "object", "additionalProperties": False, "properties": values},
|
|
286
|
+
},
|
|
287
|
+
"required": ["kind", "ref", "evidence", "values"],
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
UNLISTED_SCHEMA: dict[str, Any] = {
|
|
292
|
+
"type": "array",
|
|
293
|
+
"description": "Facts the text states that the schema above has no place for",
|
|
294
|
+
"items": {
|
|
295
|
+
"type": "object",
|
|
296
|
+
"additionalProperties": False,
|
|
297
|
+
"properties": {
|
|
298
|
+
"type": {"enum": ["kind", "attribute", "relation"]},
|
|
299
|
+
"name": {
|
|
300
|
+
"type": "string",
|
|
301
|
+
"description": "Name of the missing kind of thing, field or relationship",
|
|
302
|
+
},
|
|
303
|
+
"subject": {
|
|
304
|
+
"type": "string",
|
|
305
|
+
"description": "Key or name of the record the field or relationship belongs "
|
|
306
|
+
"to; empty for a kind",
|
|
307
|
+
},
|
|
308
|
+
"value": {
|
|
309
|
+
"type": "string",
|
|
310
|
+
"description": "The field value, the key or name of the related record, or "
|
|
311
|
+
"the name of the thing of the missing kind",
|
|
312
|
+
},
|
|
313
|
+
"evidence": {"type": "string", "description": "Verbatim quote of the fact"},
|
|
314
|
+
},
|
|
315
|
+
"required": ["type", "name", "subject", "value", "evidence"],
|
|
316
|
+
},
|
|
317
|
+
}
|
|
318
|
+
"""Tier 2 `unlisted` answers: what the model saw outside the schema (schema discovery)."""
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def link_attributes(relation: Relation, tags: Mapping[str, Tags] | None = None) -> list[Attribute]:
|
|
322
|
+
"""Link Attributes of `relation` that extraction can propose (`tags` as in
|
|
323
|
+
`extractable`)."""
|
|
324
|
+
return [a for a in relation.attributes.values() if extractable(a, tags)]
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _link_schema(
|
|
328
|
+
relations: Sequence[Relation],
|
|
329
|
+
tags: Mapping[str, Tags],
|
|
330
|
+
with_values: Relation | None = None,
|
|
331
|
+
) -> dict[str, Any]:
|
|
332
|
+
properties: dict[str, Any] = {
|
|
333
|
+
"relation": {"const": with_values.api_name}
|
|
334
|
+
if with_values
|
|
335
|
+
else {"enum": [r.api_name for r in relations]},
|
|
336
|
+
"from": {"type": "string", "description": "Key or name of the from Record"},
|
|
337
|
+
"to": {"type": "string", "description": "Key or name of the to Record"},
|
|
338
|
+
"evidence": {"type": "string", "description": "Verbatim quote of the fact"},
|
|
339
|
+
}
|
|
340
|
+
required = ["relation", "from", "to", "evidence"]
|
|
341
|
+
if with_values is not None:
|
|
342
|
+
attributes = link_attributes(with_values, tags)
|
|
343
|
+
properties["values"] = {
|
|
344
|
+
"type": "object",
|
|
345
|
+
"additionalProperties": False,
|
|
346
|
+
"description": "Link Attributes stated by the text; give every required one",
|
|
347
|
+
"properties": {
|
|
348
|
+
a.api_name: {
|
|
349
|
+
"type": "object",
|
|
350
|
+
"additionalProperties": False,
|
|
351
|
+
"properties": {
|
|
352
|
+
"value": value_schema(a),
|
|
353
|
+
"evidence": {
|
|
354
|
+
"type": "string",
|
|
355
|
+
"description": "Verbatim quote holding the value",
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
"required": ["value", "evidence"],
|
|
359
|
+
}
|
|
360
|
+
for a in attributes
|
|
361
|
+
},
|
|
362
|
+
"required": [a.api_name for a in attributes if a.required],
|
|
363
|
+
}
|
|
364
|
+
required.append("values")
|
|
365
|
+
return {
|
|
366
|
+
"type": "object",
|
|
367
|
+
"additionalProperties": False,
|
|
368
|
+
"properties": properties,
|
|
369
|
+
"required": required,
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _links_schema(relations: Sequence[Relation], tags: Mapping[str, Tags]) -> dict[str, Any]:
|
|
374
|
+
"""Schema of one link answer: Relations with link Attributes get a branch of their own
|
|
375
|
+
whose `values` lists those Attributes."""
|
|
376
|
+
plain = [r for r in relations if not link_attributes(r, tags)]
|
|
377
|
+
typed = [r for r in relations if link_attributes(r, tags)]
|
|
378
|
+
if not typed:
|
|
379
|
+
return _link_schema(plain, tags)
|
|
380
|
+
branches = [_link_schema(plain, tags)] if plain else []
|
|
381
|
+
branches.extend(_link_schema(relations, tags, r) for r in typed)
|
|
382
|
+
return {"anyOf": branches}
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def fragment_schema(
|
|
386
|
+
ontology: Ontology, kind_ids: Sequence[str], *, unlisted: bool = False
|
|
387
|
+
) -> dict[str, Any]:
|
|
388
|
+
"""JSON schema of a tier 2 answer restricted to `kind_ids` and the Relations between them;
|
|
389
|
+
with `unlisted`, the answer also lists facts outside the schema."""
|
|
390
|
+
views = [ontology.kinds[k] for k in kind_ids if k in ontology.kinds]
|
|
391
|
+
properties: dict[str, Any] = {
|
|
392
|
+
"records": {"type": "array", "items": {"anyOf": [_record_schema(v) for v in views]}}
|
|
393
|
+
}
|
|
394
|
+
required = ["records"]
|
|
395
|
+
relations = ontology.relations_between(kind_ids)
|
|
396
|
+
if relations:
|
|
397
|
+
properties["links"] = {"type": "array", "items": _links_schema(relations, ontology.tags)}
|
|
398
|
+
required.append("links")
|
|
399
|
+
if unlisted:
|
|
400
|
+
properties["unlisted"] = UNLISTED_SCHEMA
|
|
401
|
+
required.append("unlisted")
|
|
402
|
+
return {
|
|
403
|
+
"type": "object",
|
|
404
|
+
"additionalProperties": False,
|
|
405
|
+
"properties": properties,
|
|
406
|
+
"required": required,
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def fragment_description(ontology: Ontology, kind_ids: Sequence[str]) -> str:
|
|
411
|
+
"""The ontology fragment as prompt text: Kinds with Attributes, then Relations."""
|
|
412
|
+
lines: list[str] = []
|
|
413
|
+
for kind_id in kind_ids:
|
|
414
|
+
view = ontology.kinds.get(kind_id)
|
|
415
|
+
if view is None:
|
|
416
|
+
continue
|
|
417
|
+
key = f" (key: {view.key_attribute.api_name})" if view.key_attribute else ""
|
|
418
|
+
lines.append(f"Kind {view.api_name}{key}: {view.kind.description}".rstrip())
|
|
419
|
+
for attribute in view.attributes:
|
|
420
|
+
lines.append(f" - {attribute.api_name}: {attribute.description}".rstrip())
|
|
421
|
+
for relation in ontology.relations_between(kind_ids):
|
|
422
|
+
source = ontology.kinds[relation.from_kind].api_name
|
|
423
|
+
target = ontology.kinds[relation.to_kind].api_name
|
|
424
|
+
lines.append(
|
|
425
|
+
f"Relation {relation.api_name}: {source} -> {target}. {relation.description}".rstrip()
|
|
426
|
+
)
|
|
427
|
+
for attribute in link_attributes(relation, ontology.tags):
|
|
428
|
+
need = " (required: give it with evidence)" if attribute.required else ""
|
|
429
|
+
lines.append(
|
|
430
|
+
f" - link attribute {attribute.api_name}{need}: {attribute.description}".rstrip()
|
|
431
|
+
)
|
|
432
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Document bytes to text with stable offsets (design 9.2 step 2).
|
|
2
|
+
|
|
3
|
+
Markdown and plain text are decoded natively (UTF-8, with a BOM tolerated). PDF goes through
|
|
4
|
+
`pypdf` and DOCX through `python-docx`, both from the `documents` extra of this package; when
|
|
5
|
+
the extra is missing the job fails with a message naming it. OCR is out of scope: a PDF whose
|
|
6
|
+
pages hold no text is reported as scanned and yields no text.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import io
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
MARKDOWN = "text/markdown"
|
|
16
|
+
PLAIN_TEXT = "text/plain"
|
|
17
|
+
PDF = "application/pdf"
|
|
18
|
+
DOCX = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
|
19
|
+
MEDIA_TYPES = (MARKDOWN, PLAIN_TEXT, PDF, DOCX)
|
|
20
|
+
EXTRA_HINT = "install lorebase-scribe[documents]"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ParseError(ValueError):
|
|
24
|
+
"""The document cannot be turned into text; retrying will not help."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class ParsedDocument:
|
|
29
|
+
"""Parsed text; `pages` holds the start offset of each page (PDF only)."""
|
|
30
|
+
|
|
31
|
+
text: str
|
|
32
|
+
media_type: str
|
|
33
|
+
pages: tuple[int, ...] = ()
|
|
34
|
+
warnings: tuple[str, ...] = field(default_factory=tuple)
|
|
35
|
+
|
|
36
|
+
def metadata(self) -> dict[str, Any]:
|
|
37
|
+
found: dict[str, Any] = {"chars": len(self.text)}
|
|
38
|
+
if self.pages:
|
|
39
|
+
found["pages"] = len(self.pages)
|
|
40
|
+
if self.warnings:
|
|
41
|
+
found["warnings"] = list(self.warnings)
|
|
42
|
+
return found
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _normalize(text: str) -> str:
|
|
46
|
+
return text.replace("\r\n", "\n").replace("\r", "\n").replace("\x00", "")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _decode(data: bytes) -> str:
|
|
50
|
+
try:
|
|
51
|
+
return data.decode("utf-8-sig")
|
|
52
|
+
except UnicodeDecodeError:
|
|
53
|
+
raise ParseError("text documents must be UTF-8") from None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _pdf(data: bytes) -> ParsedDocument:
|
|
57
|
+
try:
|
|
58
|
+
from pypdf import PdfReader
|
|
59
|
+
from pypdf.errors import PdfReadError
|
|
60
|
+
except ImportError:
|
|
61
|
+
raise ParseError(f"PDF documents need pypdf: {EXTRA_HINT}") from None
|
|
62
|
+
try:
|
|
63
|
+
reader = PdfReader(io.BytesIO(data))
|
|
64
|
+
pages: list[int] = []
|
|
65
|
+
parts: list[str] = []
|
|
66
|
+
offset = 0
|
|
67
|
+
for page in reader.pages:
|
|
68
|
+
pages.append(offset)
|
|
69
|
+
content = _normalize(page.extract_text() or "").strip()
|
|
70
|
+
parts.append(content)
|
|
71
|
+
offset += len(content) + 2
|
|
72
|
+
except (PdfReadError, ValueError, KeyError) as error:
|
|
73
|
+
raise ParseError(f"unreadable PDF: {error}") from None
|
|
74
|
+
text = "\n\n".join(parts)
|
|
75
|
+
warnings = () if text.strip() else ("no text layer: the PDF looks scanned (OCR is not run)",)
|
|
76
|
+
return ParsedDocument(text, PDF, tuple(pages), warnings)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _docx(data: bytes) -> ParsedDocument:
|
|
80
|
+
try:
|
|
81
|
+
import docx
|
|
82
|
+
except ImportError:
|
|
83
|
+
raise ParseError(f"DOCX documents need python-docx: {EXTRA_HINT}") from None
|
|
84
|
+
try:
|
|
85
|
+
document = docx.Document(io.BytesIO(data))
|
|
86
|
+
except Exception as error: # python-docx raises zipfile, lxml and its own errors
|
|
87
|
+
raise ParseError(f"unreadable DOCX: {error}") from None
|
|
88
|
+
blocks: list[str] = []
|
|
89
|
+
for paragraph in document.paragraphs:
|
|
90
|
+
content = paragraph.text.strip()
|
|
91
|
+
if not content:
|
|
92
|
+
continue
|
|
93
|
+
style = (paragraph.style.name if paragraph.style is not None else "") or ""
|
|
94
|
+
if style.startswith("Heading"):
|
|
95
|
+
level = style.removeprefix("Heading").strip()
|
|
96
|
+
depth = int(level) if level.isdigit() else 1
|
|
97
|
+
content = f"{'#' * min(depth, 6)} {content}"
|
|
98
|
+
blocks.append(content)
|
|
99
|
+
for table in document.tables:
|
|
100
|
+
for row in table.rows:
|
|
101
|
+
cells = [cell.text.strip().replace("\n", " ") for cell in row.cells]
|
|
102
|
+
blocks.append("| " + " | ".join(cells) + " |")
|
|
103
|
+
return ParsedDocument(_normalize("\n\n".join(blocks)), DOCX)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def parse_document(data: bytes, media_type: str) -> ParsedDocument:
|
|
107
|
+
"""Text of a document of a supported media type; raises `ParseError`."""
|
|
108
|
+
if media_type in (MARKDOWN, PLAIN_TEXT):
|
|
109
|
+
return ParsedDocument(_normalize(_decode(data)), media_type)
|
|
110
|
+
if media_type == PDF:
|
|
111
|
+
return _pdf(data)
|
|
112
|
+
if media_type == DOCX:
|
|
113
|
+
return _docx(data)
|
|
114
|
+
raise ParseError(f"unsupported media type {media_type!r}")
|