lorebase-core 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_core/__init__.py +1 -0
- lorebase_core/canonical/__init__.py +35 -0
- lorebase_core/canonical/hashing.py +128 -0
- lorebase_core/canonical/jcs.py +162 -0
- lorebase_core/expr/__init__.py +118 -0
- lorebase_core/expr/ast.py +443 -0
- lorebase_core/expr/checker.py +428 -0
- lorebase_core/expr/compiler.py +87 -0
- lorebase_core/expr/context.py +82 -0
- lorebase_core/expr/derivations.py +306 -0
- lorebase_core/expr/environment.py +176 -0
- lorebase_core/expr/errors.py +98 -0
- lorebase_core/expr/evaluator.py +414 -0
- lorebase_core/expr/functions.py +898 -0
- lorebase_core/expr/limits.py +52 -0
- lorebase_core/expr/operations.py +479 -0
- lorebase_core/expr/ops.py +243 -0
- lorebase_core/expr/resolver.py +96 -0
- lorebase_core/expr/rows.py +148 -0
- lorebase_core/expr/sensitivity.py +337 -0
- lorebase_core/expr/types.py +227 -0
- lorebase_core/expr/validation.py +51 -0
- lorebase_core/expr/values.py +169 -0
- lorebase_core/expr/visibility.py +363 -0
- lorebase_core/infer/__init__.py +36 -0
- lorebase_core/infer/columns.py +621 -0
- lorebase_core/infer/engine.py +546 -0
- lorebase_core/infer/keys.py +164 -0
- lorebase_core/infer/names.py +145 -0
- lorebase_core/infer/options.py +290 -0
- lorebase_core/infer/profile.py +54 -0
- lorebase_core/infer/proposal.py +122 -0
- lorebase_core/infer/references.py +314 -0
- lorebase_core/infer/suggestions.py +731 -0
- lorebase_core/infer/tables.py +81 -0
- lorebase_core/merge/__init__.py +89 -0
- lorebase_core/merge/conflicts.py +262 -0
- lorebase_core/merge/consistency.py +440 -0
- lorebase_core/merge/data_merge.py +522 -0
- lorebase_core/merge/engine.py +476 -0
- lorebase_core/merge/index.py +100 -0
- lorebase_core/merge/objects.py +204 -0
- lorebase_core/merge/policies.py +322 -0
- lorebase_core/merge/resolve.py +321 -0
- lorebase_core/merge/schema_merge.py +429 -0
- lorebase_core/merge/state.py +443 -0
- lorebase_core/model/__init__.py +245 -0
- lorebase_core/model/attribute.py +72 -0
- lorebase_core/model/base.py +61 -0
- lorebase_core/model/derivation.py +95 -0
- lorebase_core/model/domain.py +71 -0
- lorebase_core/model/embedding.py +193 -0
- lorebase_core/model/ids.py +78 -0
- lorebase_core/model/kind.py +85 -0
- lorebase_core/model/lineage.py +114 -0
- lorebase_core/model/operation.py +161 -0
- lorebase_core/model/paths.py +141 -0
- lorebase_core/model/record.py +127 -0
- lorebase_core/model/relation.py +83 -0
- lorebase_core/model/schema.py +219 -0
- lorebase_core/model/source.py +204 -0
- lorebase_core/model/trait.py +37 -0
- lorebase_core/model/vault.py +43 -0
- lorebase_core/model/yaml_schema.py +1058 -0
- lorebase_core/query/__init__.py +97 -0
- lorebase_core/query/dsl.py +227 -0
- lorebase_core/query/resolve.py +794 -0
- lorebase_core/revision/__init__.py +59 -0
- lorebase_core/revision/ancestry.py +272 -0
- lorebase_core/revision/changeset.py +62 -0
- lorebase_core/revision/revision.py +121 -0
- lorebase_core/tree/__init__.py +76 -0
- lorebase_core/tree/diff.py +119 -0
- lorebase_core/tree/hamt.py +333 -0
- lorebase_core/tree/node.py +280 -0
- lorebase_core/tree/params.py +33 -0
- lorebase_core/tree/root.py +70 -0
- lorebase_core/tree/store.py +98 -0
- lorebase_core/types/__init__.py +71 -0
- lorebase_core/types/casts.py +271 -0
- lorebase_core/types/constraints.py +299 -0
- lorebase_core/types/encoding.py +538 -0
- lorebase_core/types/errors.py +41 -0
- lorebase_core/types/matching.py +40 -0
- lorebase_core/types/types.py +187 -0
- lorebase_core/validate/__init__.py +49 -0
- lorebase_core/validate/changes.py +70 -0
- lorebase_core/validate/changeset.py +551 -0
- lorebase_core/validate/data_rules.py +291 -0
- lorebase_core/validate/issues.py +123 -0
- lorebase_core/validate/schema_rules.py +1304 -0
- lorebase_core-0.2.3.dist-info/METADATA +9 -0
- lorebase_core-0.2.3.dist-info/RECORD +94 -0
- lorebase_core-0.2.3.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,731 @@
|
|
|
1
|
+
"""Schema suggestions from document extraction (design 9.2 step 10).
|
|
2
|
+
|
|
3
|
+
Extraction sees types, fields and relations that the schema does not have: a field label no
|
|
4
|
+
Attribute matches, a heading naming a thing of no Kind, a span or relation label of a local
|
|
5
|
+
model outside the schema, a fact the chat model reports outside its JSON schema. Each sighting
|
|
6
|
+
is an ``Observation``; observations with the same ``fingerprint`` aggregate into one
|
|
7
|
+
suggestion (a new Kind, a new Attribute of a Kind, a new Relation) with a support count,
|
|
8
|
+
examples and sample values.
|
|
9
|
+
|
|
10
|
+
This module holds the pure parts:
|
|
11
|
+
|
|
12
|
+
- names: ``kind_name``, ``field_name`` and the ``fingerprint`` of an observation;
|
|
13
|
+
- deduplication against a schema: ``existing_match`` compares names after normalization and
|
|
14
|
+
singularization, through synonym groups and by similarity (``SuggestionOptions``);
|
|
15
|
+
- the type of a new Attribute, inferred from sample values by the column analysis of schema
|
|
16
|
+
inference (``infer_attribute``);
|
|
17
|
+
- ``suggestion_patch``: the schema change set patch (``kinds`` and ``relations`` sections of the
|
|
18
|
+
schema document) that adds accepted suggestions to a schema. It is never applied here. New
|
|
19
|
+
Kinds and Relations are drafts (as schema inference proposes them): a modeler describes and
|
|
20
|
+
activates them; Attributes added to an existing Kind are active.
|
|
21
|
+
|
|
22
|
+
A Kind proposed by another suggestion is referred to as ``+<KindName>`` until it exists.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import re
|
|
28
|
+
from collections.abc import Collection, Iterable, Mapping, Sequence
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from difflib import SequenceMatcher
|
|
31
|
+
from enum import StrEnum
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
35
|
+
|
|
36
|
+
from lorebase_core.expr.sensitivity import effective_tags
|
|
37
|
+
from lorebase_core.infer.columns import analyze_column, shape_pattern
|
|
38
|
+
from lorebase_core.infer.names import humanize, normalize, pascal, plural, singular, unique_name
|
|
39
|
+
from lorebase_core.infer.options import InferenceOptions
|
|
40
|
+
from lorebase_core.infer.profile import ColumnProfile
|
|
41
|
+
from lorebase_core.model.attribute import Attribute, Sensitivity
|
|
42
|
+
from lorebase_core.model.base import ElementStatus, snake_case
|
|
43
|
+
from lorebase_core.model.ids import IdPrefix, new_id
|
|
44
|
+
from lorebase_core.model.kind import Kind
|
|
45
|
+
from lorebase_core.model.relation import Cardinality, Relation
|
|
46
|
+
from lorebase_core.model.schema import Schema
|
|
47
|
+
from lorebase_core.model.yaml_schema import schema_to_document
|
|
48
|
+
from lorebase_core.types import AttributeType, Constraints, TypeSpec
|
|
49
|
+
|
|
50
|
+
PROPOSED = "+"
|
|
51
|
+
"""Prefix of a reference to a Kind that a suggestion proposes (``+QualityNotice``)."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class SuggestionType(StrEnum):
|
|
55
|
+
KIND = "kind"
|
|
56
|
+
ATTRIBUTE = "attribute"
|
|
57
|
+
RELATION = "relation"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _type_inference() -> InferenceOptions:
|
|
61
|
+
return InferenceOptions(min_support_values=5, propose_max_length=False, enum_min_values=5)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class SuggestionOptions(BaseModel):
|
|
65
|
+
"""Thresholds of schema suggestions, shared by the extraction worker and Keeper."""
|
|
66
|
+
|
|
67
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
68
|
+
|
|
69
|
+
min_support: int = Field(
|
|
70
|
+
default=2,
|
|
71
|
+
ge=1,
|
|
72
|
+
description="Observations a suggestion needs before it is listed for review.",
|
|
73
|
+
)
|
|
74
|
+
max_suggestions: int = Field(
|
|
75
|
+
default=50,
|
|
76
|
+
ge=1,
|
|
77
|
+
description="Pending suggestions listed at most, the best supported first.",
|
|
78
|
+
)
|
|
79
|
+
max_examples: int = Field(default=3, ge=0, description="Evidence examples kept per suggestion.")
|
|
80
|
+
max_samples: int = Field(
|
|
81
|
+
default=20, ge=0, description="Distinct sample values kept per suggestion."
|
|
82
|
+
)
|
|
83
|
+
max_labels: int = Field(
|
|
84
|
+
default=5, ge=1, description="Distinct labels (as written) kept per suggestion."
|
|
85
|
+
)
|
|
86
|
+
max_tracked: int = Field(
|
|
87
|
+
default=2000,
|
|
88
|
+
ge=1,
|
|
89
|
+
description="Suggestions kept per Domain; observations of new ones beyond are dropped.",
|
|
90
|
+
)
|
|
91
|
+
name_similarity: float = Field(
|
|
92
|
+
default=0.85,
|
|
93
|
+
gt=0,
|
|
94
|
+
le=1,
|
|
95
|
+
description="Least similarity (0 to 1, after normalization and singular) for a name "
|
|
96
|
+
"to count as an existing element's.",
|
|
97
|
+
)
|
|
98
|
+
synonyms: tuple[tuple[str, ...], ...] = Field(
|
|
99
|
+
default=(
|
|
100
|
+
("supplier", "vendor"),
|
|
101
|
+
("contract", "agreement"),
|
|
102
|
+
("part", "component", "item"),
|
|
103
|
+
("plant", "site", "facility"),
|
|
104
|
+
("purchase_order", "order"),
|
|
105
|
+
("customer", "client"),
|
|
106
|
+
("quantity", "qty"),
|
|
107
|
+
),
|
|
108
|
+
description="Groups of names that mean the same element (snake_case, singular).",
|
|
109
|
+
)
|
|
110
|
+
merge_similarity: float = Field(
|
|
111
|
+
default=0.6,
|
|
112
|
+
gt=0,
|
|
113
|
+
le=1,
|
|
114
|
+
description="Least overlap (Jaccard index of the snake_case words, 0 to 1) for two "
|
|
115
|
+
"Attribute suggestions of one Kind with compatible sample values to count as one "
|
|
116
|
+
"(renewal_notice_days and renewal_proposal_notice_days). 1 merges only the same words.",
|
|
117
|
+
)
|
|
118
|
+
kind_stoplist: tuple[str, ...] = Field(
|
|
119
|
+
default=(
|
|
120
|
+
"section",
|
|
121
|
+
"clause",
|
|
122
|
+
"article",
|
|
123
|
+
"paragraph",
|
|
124
|
+
"chapter",
|
|
125
|
+
"annex",
|
|
126
|
+
"appendix",
|
|
127
|
+
"schedule",
|
|
128
|
+
"exhibit",
|
|
129
|
+
"attachment",
|
|
130
|
+
"page",
|
|
131
|
+
"table",
|
|
132
|
+
"figure",
|
|
133
|
+
"document",
|
|
134
|
+
"proposal",
|
|
135
|
+
),
|
|
136
|
+
description="Generic document-structure and workflow words (snake_case, singular) "
|
|
137
|
+
"that are never suggested as a Kind.",
|
|
138
|
+
)
|
|
139
|
+
kind_min_attributes: int = Field(
|
|
140
|
+
default=2,
|
|
141
|
+
ge=0,
|
|
142
|
+
description="Attribute suggestions (or observations) a proposed Kind needs before it "
|
|
143
|
+
"is listed for review; a Kind seen once without fields is noise more often than not. "
|
|
144
|
+
"0 lists every Kind.",
|
|
145
|
+
)
|
|
146
|
+
kind_independent_instances: int = Field(
|
|
147
|
+
default=2,
|
|
148
|
+
ge=0,
|
|
149
|
+
description="Distinct codes or names of things (samples) a Kind suggestion needs to "
|
|
150
|
+
"count as an independent entity, which is then kept although an Attribute has its name "
|
|
151
|
+
"(a Kind `Buyer` with the codes B-1 and B-2). 0 turns this criterion off.",
|
|
152
|
+
)
|
|
153
|
+
kind_independent_attributes: int = Field(
|
|
154
|
+
default=2,
|
|
155
|
+
ge=0,
|
|
156
|
+
description="Distinct Attribute suggestions owned by a proposed Kind that make it an "
|
|
157
|
+
"independent entity (see `kind_independent_instances`). 0 turns this criterion off.",
|
|
158
|
+
)
|
|
159
|
+
type_inference: InferenceOptions = Field(
|
|
160
|
+
default_factory=_type_inference,
|
|
161
|
+
description="Options of the column analysis that types new Attributes from samples.",
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# Names ------------------------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def kind_name(label: str) -> str:
|
|
169
|
+
"""``quality notices`` -> ``QualityNotice``."""
|
|
170
|
+
return pascal(singular(normalize(label)))
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def field_name(label: str) -> str:
|
|
174
|
+
"""``Received at`` -> ``received_at``."""
|
|
175
|
+
return normalize(label)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def proposed_kind(label: str) -> str:
|
|
179
|
+
"""Reference to the Kind a ``kind`` suggestion of ``label`` proposes."""
|
|
180
|
+
return f"{PROPOSED}{kind_name(label)}"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _canonical(name: str) -> str:
|
|
184
|
+
return singular(normalize(name))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass(frozen=True, slots=True)
|
|
188
|
+
class Observation:
|
|
189
|
+
"""One sighting of something the schema does not have.
|
|
190
|
+
|
|
191
|
+
``kind`` is the owner Kind of an Attribute or the from Kind of a Relation, ``target`` the
|
|
192
|
+
to Kind of a Relation: a Kind id, or ``+<KindName>`` for a proposed Kind. ``value`` is a
|
|
193
|
+
sample value (an Attribute value, the key or name of a thing of a new Kind).
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
type: SuggestionType
|
|
197
|
+
label: str
|
|
198
|
+
document_id: str
|
|
199
|
+
confidence: float
|
|
200
|
+
source: str
|
|
201
|
+
kind: str | None = None
|
|
202
|
+
target: str | None = None
|
|
203
|
+
value: str | None = None
|
|
204
|
+
evidence: Mapping[str, Any] | None = None
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def name(self) -> str:
|
|
208
|
+
return kind_name(self.label) if self.type is SuggestionType.KIND else field_name(self.label)
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def fingerprint(self) -> str:
|
|
212
|
+
return fingerprint(self.type, self.name, self.kind, self.target)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def fingerprint(
|
|
216
|
+
type_: SuggestionType, name: str, kind: str | None = None, target: str | None = None
|
|
217
|
+
) -> str:
|
|
218
|
+
"""Identity of a suggestion: observations with the same fingerprint aggregate."""
|
|
219
|
+
if type_ is SuggestionType.KIND:
|
|
220
|
+
return f"kind:{_canonical(name)}"
|
|
221
|
+
if type_ is SuggestionType.ATTRIBUTE:
|
|
222
|
+
return f"attribute:{kind}:{_canonical(name)}"
|
|
223
|
+
return f"relation:{kind}:{_canonical(name)}:{target}"
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# Deduplication ----------------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def name_similarity(first: str, second: str, options: SuggestionOptions) -> float:
|
|
230
|
+
"""1 for equal names (after normalization and singular) or synonyms, else the similarity
|
|
231
|
+
ratio of the normalized names."""
|
|
232
|
+
a, b = _canonical(first), _canonical(second)
|
|
233
|
+
if not a or not b:
|
|
234
|
+
return 0.0
|
|
235
|
+
if a == b:
|
|
236
|
+
return 1.0
|
|
237
|
+
for group in options.synonyms:
|
|
238
|
+
names = {_canonical(n) for n in group}
|
|
239
|
+
if a in names and b in names:
|
|
240
|
+
return 1.0
|
|
241
|
+
return SequenceMatcher(None, a, b).ratio()
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
_WORD = re.compile(r"[a-z0-9]+")
|
|
245
|
+
_INTEGER = re.compile(r"[+-]?[0-9]+")
|
|
246
|
+
_NUMBER = re.compile(r"[+-]?[0-9]+(?:[.,][0-9]+)?")
|
|
247
|
+
_DATE = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}(?:[T ].*)?")
|
|
248
|
+
_BOOLEAN = frozenset({"true", "false", "yes", "no"})
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def name_words(name: str) -> frozenset[str]:
|
|
252
|
+
"""The singular words of a name: ``renewal_notice_days`` -> ``renewal``, ``notice``, ``day``."""
|
|
253
|
+
return frozenset(singular(w) for w in _WORD.findall(normalize(name)))
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def word_overlap(first: str, second: str) -> float:
|
|
257
|
+
"""Jaccard index of the words of two names (0 when either has none)."""
|
|
258
|
+
a, b = name_words(first), name_words(second)
|
|
259
|
+
if not a or not b:
|
|
260
|
+
return 0.0
|
|
261
|
+
return len(a & b) / len(a | b)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def value_classes(samples: Iterable[str]) -> frozenset[str]:
|
|
265
|
+
"""Coarse value types of sample texts: ``number``, ``date``, ``boolean`` or ``text``."""
|
|
266
|
+
found: set[str] = set()
|
|
267
|
+
for sample in samples:
|
|
268
|
+
text = sample.strip()
|
|
269
|
+
if _NUMBER.fullmatch(text) or _INTEGER.fullmatch(text):
|
|
270
|
+
found.add("number")
|
|
271
|
+
elif _DATE.fullmatch(text):
|
|
272
|
+
found.add("date")
|
|
273
|
+
elif text.casefold() in _BOOLEAN:
|
|
274
|
+
found.add("boolean")
|
|
275
|
+
elif text:
|
|
276
|
+
found.add("text")
|
|
277
|
+
return frozenset(found)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@dataclass(frozen=True, slots=True)
|
|
281
|
+
class AttributeSighting:
|
|
282
|
+
"""An Attribute suggestion (stored or in a job) that may be a variant of another."""
|
|
283
|
+
|
|
284
|
+
key: str
|
|
285
|
+
"""Identity of the suggestion (its fingerprint)."""
|
|
286
|
+
name: str
|
|
287
|
+
kind: str | None
|
|
288
|
+
samples: tuple[str, ...]
|
|
289
|
+
support: int
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def merge_attributes(
|
|
293
|
+
sightings: Sequence[AttributeSighting], options: SuggestionOptions
|
|
294
|
+
) -> dict[str, str]:
|
|
295
|
+
"""Attribute suggestions that are variants of one another, as ``key -> kept key``.
|
|
296
|
+
|
|
297
|
+
Two suggestions are variants when they belong to the same Kind, their names share at least
|
|
298
|
+
``merge_similarity`` of their words and their samples have a value type in common (both
|
|
299
|
+
need samples). The best supported suggestion is kept, then the shorter name, then the
|
|
300
|
+
alphabetically first, so the result does not depend on the order of ``sightings``. Every
|
|
301
|
+
key is in the result; a kept key maps to itself.
|
|
302
|
+
"""
|
|
303
|
+
ordered = sorted(sightings, key=lambda s: (-s.support, len(s.name), s.name, s.key))
|
|
304
|
+
kept: list[tuple[AttributeSighting, frozenset[str]]] = []
|
|
305
|
+
merged: dict[str, str] = {}
|
|
306
|
+
for sighting in ordered:
|
|
307
|
+
classes = value_classes(sighting.samples)
|
|
308
|
+
best: tuple[float, AttributeSighting] | None = None
|
|
309
|
+
for other, other_classes in kept:
|
|
310
|
+
if other.kind != sighting.kind or not (classes & other_classes):
|
|
311
|
+
continue
|
|
312
|
+
score = word_overlap(sighting.name, other.name)
|
|
313
|
+
if score >= options.merge_similarity and (best is None or score > best[0]):
|
|
314
|
+
best = (score, other)
|
|
315
|
+
if best is None:
|
|
316
|
+
kept.append((sighting, classes))
|
|
317
|
+
merged[sighting.key] = sighting.key
|
|
318
|
+
else:
|
|
319
|
+
merged[sighting.key] = best[1].key
|
|
320
|
+
return merged
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def kind_is_independent(instances: int, fields: int, options: SuggestionOptions) -> bool:
|
|
324
|
+
"""Whether the evidence of a Kind suggestion shows an entity of its own: ``instances``
|
|
325
|
+
distinct codes or names seen for it, or ``fields`` Attribute suggestions it owns, reach
|
|
326
|
+
``kind_independent_instances`` or ``kind_independent_attributes`` (0 turns one off)."""
|
|
327
|
+
by_instances = options.kind_independent_instances > 0 and (
|
|
328
|
+
instances >= options.kind_independent_instances
|
|
329
|
+
)
|
|
330
|
+
by_fields = options.kind_independent_attributes > 0 and (
|
|
331
|
+
fields >= options.kind_independent_attributes
|
|
332
|
+
)
|
|
333
|
+
return by_instances or by_fields
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def kind_is_noise(
|
|
337
|
+
name: str,
|
|
338
|
+
attribute_names: Collection[str],
|
|
339
|
+
options: SuggestionOptions,
|
|
340
|
+
*,
|
|
341
|
+
independent: bool = False,
|
|
342
|
+
) -> bool:
|
|
343
|
+
"""Whether a Kind suggestion is a generic word (``kind_stoplist``) or only names a field
|
|
344
|
+
(an Attribute called ``attribute_names``: a Kind ``Buyer`` beside the Attribute ``buyer``).
|
|
345
|
+
An ``independent`` Kind (``kind_is_independent``) is not dropped for naming a field."""
|
|
346
|
+
canonical = _canonical(name)
|
|
347
|
+
if canonical in {_canonical(w) for w in options.kind_stoplist}:
|
|
348
|
+
return True
|
|
349
|
+
if independent:
|
|
350
|
+
return False
|
|
351
|
+
return canonical in {_canonical(n) for n in attribute_names}
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def kind_is_filtered(
|
|
355
|
+
name: str,
|
|
356
|
+
attribute_names: Collection[str],
|
|
357
|
+
options: SuggestionOptions,
|
|
358
|
+
*,
|
|
359
|
+
instances: int,
|
|
360
|
+
fields: int,
|
|
361
|
+
) -> bool:
|
|
362
|
+
"""The one rule that drops a Kind suggestion as noise, for the extraction job and the
|
|
363
|
+
store alike: ``kind_is_noise`` with ``independent`` taken from the ``instances`` and
|
|
364
|
+
``fields`` the evidence shows (``kind_is_independent``)."""
|
|
365
|
+
return kind_is_noise(
|
|
366
|
+
name,
|
|
367
|
+
attribute_names,
|
|
368
|
+
options,
|
|
369
|
+
independent=kind_is_independent(instances, fields, options),
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def dangling_reference_names(schema: Schema) -> list[str]:
|
|
374
|
+
"""Api names of the Attributes that hold a reference as free text and point to nothing:
|
|
375
|
+
``string`` or ``text`` Attributes that no Relation of their Kind is named like. A Kind
|
|
376
|
+
suggestion named like one of these only restates the field; a ``ref`` Attribute, a typed
|
|
377
|
+
Attribute (date, number...) or a field a Relation models is not one."""
|
|
378
|
+
names: list[str] = []
|
|
379
|
+
for kind in schema.kinds.values():
|
|
380
|
+
linked = {
|
|
381
|
+
_canonical(n)
|
|
382
|
+
for relation in schema.relations_of(kind.id)
|
|
383
|
+
for n in (relation.api_name, relation.inverse_api_name)
|
|
384
|
+
}
|
|
385
|
+
names.extend(
|
|
386
|
+
a.api_name
|
|
387
|
+
for a in kind.attributes.values()
|
|
388
|
+
if a.type.name in (AttributeType.STRING, AttributeType.TEXT)
|
|
389
|
+
and _canonical(a.api_name) not in linked
|
|
390
|
+
)
|
|
391
|
+
return names
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
@dataclass(frozen=True, slots=True)
|
|
395
|
+
class ExistingMatch:
|
|
396
|
+
"""The schema element a suggestion duplicates."""
|
|
397
|
+
|
|
398
|
+
section: str
|
|
399
|
+
"""``kinds``, ``attributes`` or ``relations``."""
|
|
400
|
+
id: str
|
|
401
|
+
api_name: str
|
|
402
|
+
score: float
|
|
403
|
+
tags: frozenset[Sensitivity] = frozenset()
|
|
404
|
+
|
|
405
|
+
@property
|
|
406
|
+
def sensitive(self) -> bool:
|
|
407
|
+
return bool(self.tags)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def resolve_kind(schema: Schema, ref: str | None, options: SuggestionOptions) -> Kind | None:
|
|
411
|
+
"""The Kind a reference names: a Kind id, or a proposed Kind that exists by now."""
|
|
412
|
+
if not ref:
|
|
413
|
+
return None
|
|
414
|
+
if not ref.startswith(PROPOSED):
|
|
415
|
+
return schema.kinds.get(ref)
|
|
416
|
+
match = _best_kind(schema, ref[len(PROPOSED) :], options)
|
|
417
|
+
return None if match is None else schema.kinds.get(match.id)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _names_of(element: Kind | Relation | Attribute) -> list[str]:
|
|
421
|
+
names = [element.api_name]
|
|
422
|
+
if element.display_name:
|
|
423
|
+
names.append(element.display_name)
|
|
424
|
+
if isinstance(element, Relation):
|
|
425
|
+
names.append(element.inverse_api_name)
|
|
426
|
+
return names
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _best(
|
|
430
|
+
name: str,
|
|
431
|
+
candidates: Iterable[tuple[str, Kind | Relation | Attribute]],
|
|
432
|
+
options: SuggestionOptions,
|
|
433
|
+
tags: Mapping[str, frozenset[Sensitivity]],
|
|
434
|
+
) -> ExistingMatch | None:
|
|
435
|
+
best: ExistingMatch | None = None
|
|
436
|
+
for section, element in candidates:
|
|
437
|
+
score = max(name_similarity(name, n, options) for n in _names_of(element))
|
|
438
|
+
if score >= options.name_similarity and (best is None or score > best.score):
|
|
439
|
+
best = ExistingMatch(
|
|
440
|
+
section, element.id, element.api_name, score, tags.get(element.id, frozenset())
|
|
441
|
+
)
|
|
442
|
+
return best
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _best_kind(schema: Schema, name: str, options: SuggestionOptions) -> ExistingMatch | None:
|
|
446
|
+
return _best(name, (("kinds", k) for k in schema.kinds.values()), options, {})
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def existing_match(
|
|
450
|
+
schema: Schema,
|
|
451
|
+
type_: SuggestionType,
|
|
452
|
+
name: str,
|
|
453
|
+
*,
|
|
454
|
+
kind: str | None = None,
|
|
455
|
+
target: str | None = None,
|
|
456
|
+
options: SuggestionOptions,
|
|
457
|
+
tags: Mapping[str, frozenset[Sensitivity]] | None = None,
|
|
458
|
+
) -> ExistingMatch | None:
|
|
459
|
+
"""The element of ``schema`` a suggestion duplicates, or None.
|
|
460
|
+
|
|
461
|
+
A Kind matches a Kind by name. An Attribute matches an Attribute of its Kind (also
|
|
462
|
+
``<kind>_<name>``, as ``code`` for ``supplier_code``), a Relation of its Kind, or a Kind
|
|
463
|
+
(a field naming another Kind refers to it). A Relation matches a Relation between its
|
|
464
|
+
Kinds, in either direction, by name or by the name of its far Kind. ``tags`` are the
|
|
465
|
+
sensitivity tags by Attribute id (``effective_tags`` of the schema by default).
|
|
466
|
+
"""
|
|
467
|
+
tags = effective_tags(schema) if tags is None else tags
|
|
468
|
+
if type_ is SuggestionType.KIND:
|
|
469
|
+
return _best_kind(schema, name, options)
|
|
470
|
+
owner = resolve_kind(schema, kind, options)
|
|
471
|
+
if type_ is SuggestionType.ATTRIBUTE:
|
|
472
|
+
if owner is None:
|
|
473
|
+
return None
|
|
474
|
+
prefixed = f"{snake_case(owner.api_name)}_{_canonical(name)}"
|
|
475
|
+
for attribute in owner.attributes.values():
|
|
476
|
+
if _canonical(attribute.api_name) == prefixed:
|
|
477
|
+
return ExistingMatch(
|
|
478
|
+
"attributes",
|
|
479
|
+
attribute.id,
|
|
480
|
+
attribute.api_name,
|
|
481
|
+
1.0,
|
|
482
|
+
tags.get(attribute.id, frozenset()),
|
|
483
|
+
)
|
|
484
|
+
candidates: list[tuple[str, Kind | Relation | Attribute]] = [
|
|
485
|
+
("attributes", a) for a in owner.attributes.values()
|
|
486
|
+
]
|
|
487
|
+
candidates += [("relations", r) for r in schema.relations_of(owner.id)]
|
|
488
|
+
candidates += [("kinds", k) for k in schema.kinds.values()]
|
|
489
|
+
return _best(name, candidates, options, tags)
|
|
490
|
+
far = resolve_kind(schema, target, options)
|
|
491
|
+
if owner is None or far is None:
|
|
492
|
+
return None
|
|
493
|
+
between = [
|
|
494
|
+
r for r in schema.relations.values() if {r.from_kind, r.to_kind} == {owner.id, far.id}
|
|
495
|
+
]
|
|
496
|
+
found = _best(name, (("relations", r) for r in between), options, tags)
|
|
497
|
+
if found is not None or not between:
|
|
498
|
+
return found
|
|
499
|
+
if name_similarity(name, far.api_name, options) >= options.name_similarity:
|
|
500
|
+
return ExistingMatch("relations", between[0].id, between[0].api_name, 1.0)
|
|
501
|
+
return None
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def sensitive_attributes(schema: Schema) -> dict[str, frozenset[Sensitivity]]:
|
|
505
|
+
"""Attribute id -> sensitivity tags, carried through Derivations, of tagged Attributes."""
|
|
506
|
+
return {k: v for k, v in effective_tags(schema).items() if v}
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
# Types ------------------------------------------------------------------------------------------
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
@dataclass(frozen=True, slots=True)
|
|
513
|
+
class AttributeGuess:
|
|
514
|
+
"""The type a new Attribute gets from its sample values."""
|
|
515
|
+
|
|
516
|
+
type: TypeSpec
|
|
517
|
+
constraints: Constraints
|
|
518
|
+
confidence: float
|
|
519
|
+
reasons: tuple[str, ...]
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def infer_attribute(
|
|
523
|
+
name: str, samples: Sequence[str], options: SuggestionOptions
|
|
524
|
+
) -> AttributeGuess:
|
|
525
|
+
"""Type and constraints of an Attribute holding ``samples`` (schema inference rules)."""
|
|
526
|
+
analysis = analyze_column(ColumnProfile(name, tuple(samples)), 0, options.type_inference)
|
|
527
|
+
return AttributeGuess(
|
|
528
|
+
analysis.type, analysis.constraints, analysis.confidence, tuple(analysis.reasons)
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
# Change set -------------------------------------------------------------------------------------
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
@dataclass(frozen=True, slots=True)
|
|
536
|
+
class Suggestion:
|
|
537
|
+
"""What a schema change needs from an aggregated suggestion."""
|
|
538
|
+
|
|
539
|
+
id: str
|
|
540
|
+
type: SuggestionType
|
|
541
|
+
name: str
|
|
542
|
+
kind: str | None = None
|
|
543
|
+
target: str | None = None
|
|
544
|
+
samples: tuple[str, ...] = ()
|
|
545
|
+
labels: tuple[str, ...] = ()
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
class SuggestionError(ValueError):
|
|
549
|
+
"""A suggestion cannot be added to the schema (unknown Kind, name taken)."""
|
|
550
|
+
|
|
551
|
+
def __init__(self, suggestion: str, message: str) -> None:
|
|
552
|
+
super().__init__(message)
|
|
553
|
+
self.suggestion = suggestion
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
@dataclass
|
|
557
|
+
class _Draft:
|
|
558
|
+
schema: Schema
|
|
559
|
+
options: SuggestionOptions
|
|
560
|
+
kinds: dict[str, Kind] = field(default_factory=dict)
|
|
561
|
+
relations: dict[str, Relation] = field(default_factory=dict)
|
|
562
|
+
touched_kinds: set[str] = field(default_factory=set)
|
|
563
|
+
|
|
564
|
+
def kind(self, ref: str | None, suggestion: Suggestion) -> Kind:
|
|
565
|
+
if ref and ref.startswith(PROPOSED):
|
|
566
|
+
proposed = self.kinds.get(ref[len(PROPOSED) :])
|
|
567
|
+
if proposed is not None:
|
|
568
|
+
return proposed
|
|
569
|
+
found = resolve_kind(self.schema, ref, self.options)
|
|
570
|
+
if found is None:
|
|
571
|
+
raise SuggestionError(
|
|
572
|
+
suggestion.id,
|
|
573
|
+
f"Kind {(ref or '').removeprefix(PROPOSED)!r} does not exist; accept its Kind "
|
|
574
|
+
"suggestion with this one",
|
|
575
|
+
)
|
|
576
|
+
return self.kinds.get(found.api_name, found)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _description(suggestion: Suggestion) -> str:
|
|
580
|
+
labels = ", ".join(f'"{label}"' for label in suggestion.labels[:3])
|
|
581
|
+
found = f" ({labels})" if labels else ""
|
|
582
|
+
return f"Suggested by document extraction{found}."
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _new_kind(suggestion: Suggestion, options: SuggestionOptions) -> Kind:
|
|
586
|
+
snake = snake_case(suggestion.name)
|
|
587
|
+
kind_id = new_id(IdPrefix.KIND)
|
|
588
|
+
samples = [s for s in suggestion.samples if s]
|
|
589
|
+
pattern = shape_pattern(samples) if samples else None
|
|
590
|
+
if pattern is not None:
|
|
591
|
+
key = Attribute(
|
|
592
|
+
id=new_id(IdPrefix.ATTRIBUTE),
|
|
593
|
+
api_name=f"{snake}_code",
|
|
594
|
+
display_name=humanize(f"{snake}_code"),
|
|
595
|
+
description=f"Code of the {humanize(snake).lower()}, for example {samples[0]}.",
|
|
596
|
+
type=TypeSpec.of(AttributeType.STRING),
|
|
597
|
+
required=True,
|
|
598
|
+
constraints=Constraints(pattern=pattern),
|
|
599
|
+
)
|
|
600
|
+
return Kind(
|
|
601
|
+
id=kind_id,
|
|
602
|
+
api_name=suggestion.name,
|
|
603
|
+
display_name=humanize(snake),
|
|
604
|
+
description=_description(suggestion),
|
|
605
|
+
primary_key=(key.id,),
|
|
606
|
+
attributes={key.id: key},
|
|
607
|
+
status=ElementStatus.DRAFT,
|
|
608
|
+
)
|
|
609
|
+
title = Attribute(
|
|
610
|
+
id=new_id(IdPrefix.ATTRIBUTE),
|
|
611
|
+
api_name="name",
|
|
612
|
+
display_name="Name",
|
|
613
|
+
description=f"Name of the {humanize(snake).lower()}.",
|
|
614
|
+
type=TypeSpec.of(AttributeType.STRING),
|
|
615
|
+
required=True,
|
|
616
|
+
)
|
|
617
|
+
return Kind(
|
|
618
|
+
id=kind_id,
|
|
619
|
+
api_name=suggestion.name,
|
|
620
|
+
display_name=humanize(snake),
|
|
621
|
+
description=_description(suggestion),
|
|
622
|
+
title_attribute=title.id,
|
|
623
|
+
attributes={title.id: title},
|
|
624
|
+
status=ElementStatus.DRAFT,
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _relation_names(schema: Schema, extra: Iterable[Relation]) -> set[str]:
|
|
629
|
+
names: set[str] = set()
|
|
630
|
+
for relation in [*schema.relations.values(), *extra]:
|
|
631
|
+
names.update((relation.api_name, relation.inverse_api_name))
|
|
632
|
+
return names
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def suggestion_patch(
|
|
636
|
+
schema: Schema, suggestions: Sequence[Suggestion], options: SuggestionOptions
|
|
637
|
+
) -> dict[str, Any]:
|
|
638
|
+
"""A schema change set patch adding ``suggestions`` to ``schema``: the ``kinds`` and
|
|
639
|
+
``relations`` sections holding the new and the changed elements (whole, as the patch
|
|
640
|
+
replaces elements by api name). Raises ``SuggestionError``."""
|
|
641
|
+
draft = _Draft(schema, options)
|
|
642
|
+
ordered = sorted(
|
|
643
|
+
suggestions,
|
|
644
|
+
key=lambda s: [
|
|
645
|
+
SuggestionType.KIND,
|
|
646
|
+
SuggestionType.ATTRIBUTE,
|
|
647
|
+
SuggestionType.RELATION,
|
|
648
|
+
].index(s.type),
|
|
649
|
+
)
|
|
650
|
+
for suggestion in ordered:
|
|
651
|
+
if suggestion.type is SuggestionType.KIND:
|
|
652
|
+
if schema.kind_by_api_name(suggestion.name) or suggestion.name in draft.kinds:
|
|
653
|
+
raise SuggestionError(suggestion.id, f"Kind {suggestion.name!r} exists")
|
|
654
|
+
kind = _new_kind(suggestion, options)
|
|
655
|
+
draft.kinds[kind.api_name] = kind
|
|
656
|
+
draft.touched_kinds.add(kind.api_name)
|
|
657
|
+
elif suggestion.type is SuggestionType.ATTRIBUTE:
|
|
658
|
+
owner = draft.kind(suggestion.kind, suggestion)
|
|
659
|
+
taken = {a.api_name for a in owner.attributes.values()}
|
|
660
|
+
name = unique_name(suggestion.name, taken)
|
|
661
|
+
guess = infer_attribute(name, suggestion.samples, options)
|
|
662
|
+
attribute = Attribute(
|
|
663
|
+
id=new_id(IdPrefix.ATTRIBUTE),
|
|
664
|
+
api_name=name,
|
|
665
|
+
display_name=humanize(name),
|
|
666
|
+
description=_description(suggestion),
|
|
667
|
+
type=guess.type,
|
|
668
|
+
constraints=guess.constraints,
|
|
669
|
+
)
|
|
670
|
+
draft.kinds[owner.api_name] = owner.model_copy(
|
|
671
|
+
update={"attributes": {**owner.attributes, attribute.id: attribute}}
|
|
672
|
+
)
|
|
673
|
+
draft.touched_kinds.add(owner.api_name)
|
|
674
|
+
else:
|
|
675
|
+
source = draft.kind(suggestion.kind, suggestion)
|
|
676
|
+
target = draft.kind(suggestion.target, suggestion)
|
|
677
|
+
taken = _relation_names(schema, draft.relations.values())
|
|
678
|
+
name = unique_name(suggestion.name, taken)
|
|
679
|
+
inverse = unique_name(plural(snake_case(source.api_name)), taken, (f"{name}_of",))
|
|
680
|
+
relation = Relation(
|
|
681
|
+
id=new_id(IdPrefix.RELATION),
|
|
682
|
+
api_name=name,
|
|
683
|
+
inverse_api_name=inverse,
|
|
684
|
+
display_name=humanize(name),
|
|
685
|
+
description=_description(suggestion),
|
|
686
|
+
from_kind=source.id,
|
|
687
|
+
to_kind=target.id,
|
|
688
|
+
cardinality=Cardinality.MANY_TO_MANY,
|
|
689
|
+
status=ElementStatus.DRAFT,
|
|
690
|
+
)
|
|
691
|
+
draft.relations[name] = relation
|
|
692
|
+
kinds = {**schema.kinds, **{k.id: k for k in draft.kinds.values()}}
|
|
693
|
+
relations = {**schema.relations, **{r.id: r for r in draft.relations.values()}}
|
|
694
|
+
target_schema = schema.model_copy(update={"kinds": kinds, "relations": relations})
|
|
695
|
+
document = schema_to_document(target_schema)
|
|
696
|
+
patch: dict[str, Any] = {}
|
|
697
|
+
if draft.touched_kinds:
|
|
698
|
+
patch["kinds"] = {n: document["kinds"][n] for n in sorted(draft.touched_kinds)}
|
|
699
|
+
if draft.relations:
|
|
700
|
+
patch["relations"] = {n: document["relations"][n] for n in sorted(draft.relations)}
|
|
701
|
+
return patch
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
__all__ = [
|
|
705
|
+
"PROPOSED",
|
|
706
|
+
"AttributeGuess",
|
|
707
|
+
"AttributeSighting",
|
|
708
|
+
"ExistingMatch",
|
|
709
|
+
"Observation",
|
|
710
|
+
"Suggestion",
|
|
711
|
+
"SuggestionError",
|
|
712
|
+
"SuggestionOptions",
|
|
713
|
+
"SuggestionType",
|
|
714
|
+
"dangling_reference_names",
|
|
715
|
+
"existing_match",
|
|
716
|
+
"field_name",
|
|
717
|
+
"fingerprint",
|
|
718
|
+
"infer_attribute",
|
|
719
|
+
"kind_is_filtered",
|
|
720
|
+
"kind_is_independent",
|
|
721
|
+
"kind_is_noise",
|
|
722
|
+
"kind_name",
|
|
723
|
+
"merge_attributes",
|
|
724
|
+
"name_similarity",
|
|
725
|
+
"proposed_kind",
|
|
726
|
+
"resolve_kind",
|
|
727
|
+
"sensitive_attributes",
|
|
728
|
+
"suggestion_patch",
|
|
729
|
+
"value_classes",
|
|
730
|
+
"word_overlap",
|
|
731
|
+
]
|