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.
Files changed (94) hide show
  1. lorebase_core/__init__.py +1 -0
  2. lorebase_core/canonical/__init__.py +35 -0
  3. lorebase_core/canonical/hashing.py +128 -0
  4. lorebase_core/canonical/jcs.py +162 -0
  5. lorebase_core/expr/__init__.py +118 -0
  6. lorebase_core/expr/ast.py +443 -0
  7. lorebase_core/expr/checker.py +428 -0
  8. lorebase_core/expr/compiler.py +87 -0
  9. lorebase_core/expr/context.py +82 -0
  10. lorebase_core/expr/derivations.py +306 -0
  11. lorebase_core/expr/environment.py +176 -0
  12. lorebase_core/expr/errors.py +98 -0
  13. lorebase_core/expr/evaluator.py +414 -0
  14. lorebase_core/expr/functions.py +898 -0
  15. lorebase_core/expr/limits.py +52 -0
  16. lorebase_core/expr/operations.py +479 -0
  17. lorebase_core/expr/ops.py +243 -0
  18. lorebase_core/expr/resolver.py +96 -0
  19. lorebase_core/expr/rows.py +148 -0
  20. lorebase_core/expr/sensitivity.py +337 -0
  21. lorebase_core/expr/types.py +227 -0
  22. lorebase_core/expr/validation.py +51 -0
  23. lorebase_core/expr/values.py +169 -0
  24. lorebase_core/expr/visibility.py +363 -0
  25. lorebase_core/infer/__init__.py +36 -0
  26. lorebase_core/infer/columns.py +621 -0
  27. lorebase_core/infer/engine.py +546 -0
  28. lorebase_core/infer/keys.py +164 -0
  29. lorebase_core/infer/names.py +145 -0
  30. lorebase_core/infer/options.py +290 -0
  31. lorebase_core/infer/profile.py +54 -0
  32. lorebase_core/infer/proposal.py +122 -0
  33. lorebase_core/infer/references.py +314 -0
  34. lorebase_core/infer/suggestions.py +731 -0
  35. lorebase_core/infer/tables.py +81 -0
  36. lorebase_core/merge/__init__.py +89 -0
  37. lorebase_core/merge/conflicts.py +262 -0
  38. lorebase_core/merge/consistency.py +440 -0
  39. lorebase_core/merge/data_merge.py +522 -0
  40. lorebase_core/merge/engine.py +476 -0
  41. lorebase_core/merge/index.py +100 -0
  42. lorebase_core/merge/objects.py +204 -0
  43. lorebase_core/merge/policies.py +322 -0
  44. lorebase_core/merge/resolve.py +321 -0
  45. lorebase_core/merge/schema_merge.py +429 -0
  46. lorebase_core/merge/state.py +443 -0
  47. lorebase_core/model/__init__.py +245 -0
  48. lorebase_core/model/attribute.py +72 -0
  49. lorebase_core/model/base.py +61 -0
  50. lorebase_core/model/derivation.py +95 -0
  51. lorebase_core/model/domain.py +71 -0
  52. lorebase_core/model/embedding.py +193 -0
  53. lorebase_core/model/ids.py +78 -0
  54. lorebase_core/model/kind.py +85 -0
  55. lorebase_core/model/lineage.py +114 -0
  56. lorebase_core/model/operation.py +161 -0
  57. lorebase_core/model/paths.py +141 -0
  58. lorebase_core/model/record.py +127 -0
  59. lorebase_core/model/relation.py +83 -0
  60. lorebase_core/model/schema.py +219 -0
  61. lorebase_core/model/source.py +204 -0
  62. lorebase_core/model/trait.py +37 -0
  63. lorebase_core/model/vault.py +43 -0
  64. lorebase_core/model/yaml_schema.py +1058 -0
  65. lorebase_core/query/__init__.py +97 -0
  66. lorebase_core/query/dsl.py +227 -0
  67. lorebase_core/query/resolve.py +794 -0
  68. lorebase_core/revision/__init__.py +59 -0
  69. lorebase_core/revision/ancestry.py +272 -0
  70. lorebase_core/revision/changeset.py +62 -0
  71. lorebase_core/revision/revision.py +121 -0
  72. lorebase_core/tree/__init__.py +76 -0
  73. lorebase_core/tree/diff.py +119 -0
  74. lorebase_core/tree/hamt.py +333 -0
  75. lorebase_core/tree/node.py +280 -0
  76. lorebase_core/tree/params.py +33 -0
  77. lorebase_core/tree/root.py +70 -0
  78. lorebase_core/tree/store.py +98 -0
  79. lorebase_core/types/__init__.py +71 -0
  80. lorebase_core/types/casts.py +271 -0
  81. lorebase_core/types/constraints.py +299 -0
  82. lorebase_core/types/encoding.py +538 -0
  83. lorebase_core/types/errors.py +41 -0
  84. lorebase_core/types/matching.py +40 -0
  85. lorebase_core/types/types.py +187 -0
  86. lorebase_core/validate/__init__.py +49 -0
  87. lorebase_core/validate/changes.py +70 -0
  88. lorebase_core/validate/changeset.py +551 -0
  89. lorebase_core/validate/data_rules.py +291 -0
  90. lorebase_core/validate/issues.py +123 -0
  91. lorebase_core/validate/schema_rules.py +1304 -0
  92. lorebase_core-0.2.3.dist-info/METADATA +9 -0
  93. lorebase_core-0.2.3.dist-info/RECORD +94 -0
  94. 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
+ ]