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,72 @@
|
|
|
1
|
+
"""Attribute (design 5.4): a typed field on a Kind, or a link Attribute on a Relation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
|
|
7
|
+
from pydantic import Field, JsonValue, field_validator
|
|
8
|
+
|
|
9
|
+
from lorebase_core.model.base import Model
|
|
10
|
+
from lorebase_core.model.ids import AttributeId, DerivationId, SourceId
|
|
11
|
+
from lorebase_core.types import Constraints, TypeSpec
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Sensitivity(StrEnum):
|
|
15
|
+
PII = "pii"
|
|
16
|
+
CONFIDENTIAL = "confidential"
|
|
17
|
+
RESTRICTED = "restricted"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SearchWeight(StrEnum):
|
|
21
|
+
"""Full-text weight of a searchable Attribute (``A`` is the strongest)."""
|
|
22
|
+
|
|
23
|
+
A = "A"
|
|
24
|
+
B = "B"
|
|
25
|
+
C = "C"
|
|
26
|
+
D = "D"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Attribute(Model):
|
|
30
|
+
"""A typed field. ``default`` is a value of ``type`` (stored in canonical form).
|
|
31
|
+
|
|
32
|
+
``vault`` stores the values of a ``pii`` Attribute of a Kind in the vault (design 13.5):
|
|
33
|
+
Record objects hold a vault reference instead of the value (``lorebase_core.model.vault``).
|
|
34
|
+
Unset (None) keeps values inline; the field is then left out of canonical content, so
|
|
35
|
+
schemas written before it keep their hashes.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
id: AttributeId
|
|
39
|
+
api_name: str
|
|
40
|
+
display_name: str = ""
|
|
41
|
+
description: str = ""
|
|
42
|
+
type: TypeSpec
|
|
43
|
+
required: bool = False
|
|
44
|
+
unique: bool = False
|
|
45
|
+
default: JsonValue = None
|
|
46
|
+
constraints: Constraints = Field(default_factory=Constraints)
|
|
47
|
+
sensitivity: tuple[Sensitivity, ...] = ()
|
|
48
|
+
indexed: bool = False
|
|
49
|
+
searchable: bool = False
|
|
50
|
+
search_weight: SearchWeight | None = None
|
|
51
|
+
derivation_id: DerivationId | None = None
|
|
52
|
+
authority: SourceId | None = None
|
|
53
|
+
vault: bool | None = None
|
|
54
|
+
|
|
55
|
+
@field_validator("sensitivity")
|
|
56
|
+
@classmethod
|
|
57
|
+
def _sort_sensitivity(cls, tags: tuple[Sensitivity, ...]) -> tuple[Sensitivity, ...]:
|
|
58
|
+
return tuple(sorted(set(tags)))
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def is_derived(self) -> bool:
|
|
62
|
+
return self.derivation_id is not None
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def is_sensitive(self) -> bool:
|
|
66
|
+
"""Whether reads need a clearance for this Attribute's values (design 10.2)."""
|
|
67
|
+
return bool(self.sensitivity)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def in_vault(self) -> bool:
|
|
71
|
+
"""Whether values are stored in the vault, Record objects holding references."""
|
|
72
|
+
return self.vault is True
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Shared base classes and naming rules of schema elements."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from typing import ClassVar
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, JsonValue
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Model(BaseModel):
|
|
13
|
+
"""Frozen model base. ``content()`` is the JSON payload used for canonical hashing."""
|
|
14
|
+
|
|
15
|
+
model_config = ConfigDict(frozen=True, extra="forbid", populate_by_name=True)
|
|
16
|
+
|
|
17
|
+
def content(self) -> dict[str, JsonValue]:
|
|
18
|
+
"""JSON-compatible payload: field aliases, absent (``None``) fields omitted."""
|
|
19
|
+
return self.model_dump(mode="json", by_alias=True, exclude_none=True)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ElementStatus(StrEnum):
|
|
23
|
+
DRAFT = "draft"
|
|
24
|
+
ACTIVE = "active"
|
|
25
|
+
DEPRECATED = "deprecated"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SchemaElement(Model):
|
|
29
|
+
"""A versioned schema element: stable ``id`` plus renamable ``api_name`` (AC-01)."""
|
|
30
|
+
|
|
31
|
+
#: Object type used by the canonical hashing scheme (design 6.1).
|
|
32
|
+
object_type: ClassVar[str]
|
|
33
|
+
|
|
34
|
+
id: str
|
|
35
|
+
api_name: str
|
|
36
|
+
display_name: str = ""
|
|
37
|
+
description: str = ""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class NameStyle(StrEnum):
|
|
41
|
+
"""Api name styles (design 5.1)."""
|
|
42
|
+
|
|
43
|
+
SNAKE = "snake_case"
|
|
44
|
+
PASCAL = "PascalCase"
|
|
45
|
+
DOTTED_SNAKE = "dotted.snake_case"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_NAME_PATTERNS: dict[NameStyle, re.Pattern[str]] = {
|
|
49
|
+
NameStyle.SNAKE: re.compile(r"[a-z][a-z0-9]*(?:_[a-z0-9]+)*"),
|
|
50
|
+
NameStyle.PASCAL: re.compile(r"[A-Z][A-Za-z0-9]*"),
|
|
51
|
+
NameStyle.DOTTED_SNAKE: re.compile(r"[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)*"),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def is_valid_api_name(name: str, style: NameStyle) -> bool:
|
|
56
|
+
return _NAME_PATTERNS[style].fullmatch(name) is not None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def snake_case(name: str) -> str:
|
|
60
|
+
"""``RiskFlag`` -> ``risk_flag``; used to derive built-in Operation names."""
|
|
61
|
+
return re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", name).lower()
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Derivation (design 5.8): a computed Attribute or Relation. Model only; no evaluation here.
|
|
2
|
+
|
|
3
|
+
Derived values are not part of Record content and never merge (AC-07).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
from typing import Annotated, ClassVar, Literal, Self
|
|
10
|
+
|
|
11
|
+
from pydantic import Field, model_validator
|
|
12
|
+
|
|
13
|
+
from lorebase_core.model.base import ElementStatus, Model, SchemaElement
|
|
14
|
+
from lorebase_core.model.ids import AttributeId, DerivationId, KindId, RelationId
|
|
15
|
+
from lorebase_core.model.operation import Expression
|
|
16
|
+
from lorebase_core.model.relation import LinkDirection
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AggregateFunction(StrEnum):
|
|
20
|
+
COUNT = "count"
|
|
21
|
+
SUM = "sum"
|
|
22
|
+
MIN = "min"
|
|
23
|
+
MAX = "max"
|
|
24
|
+
AVG = "avg"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ExpressionDerivation(Model):
|
|
28
|
+
"""A CEL expression over the Record's own Attributes, computing ``kind.attribute``."""
|
|
29
|
+
|
|
30
|
+
variant: Literal["expression"] = "expression"
|
|
31
|
+
kind: KindId
|
|
32
|
+
attribute: AttributeId
|
|
33
|
+
expression: Expression
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AggregateDerivation(Model):
|
|
37
|
+
"""An aggregate over the Records reached from ``kind`` through ``relation`` in ``direction``.
|
|
38
|
+
|
|
39
|
+
``relation`` is a Relation or a path Derivation (its virtual Relation).
|
|
40
|
+
``source_attribute`` is an Attribute of the Kind at the far end; ``count`` needs none.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
variant: Literal["aggregate"] = "aggregate"
|
|
44
|
+
kind: KindId
|
|
45
|
+
attribute: AttributeId
|
|
46
|
+
relation: RelationId | DerivationId
|
|
47
|
+
direction: LinkDirection = LinkDirection.FORWARD
|
|
48
|
+
function: AggregateFunction
|
|
49
|
+
source_attribute: AttributeId | None = None
|
|
50
|
+
filter: Expression | None = None
|
|
51
|
+
|
|
52
|
+
@model_validator(mode="after")
|
|
53
|
+
def _check_source(self) -> Self:
|
|
54
|
+
if self.function is not AggregateFunction.COUNT and self.source_attribute is None:
|
|
55
|
+
raise ValueError(f"aggregate {self.function} requires source_attribute")
|
|
56
|
+
return self
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class PathStep(Model):
|
|
60
|
+
relation: RelationId
|
|
61
|
+
direction: LinkDirection = LinkDirection.FORWARD
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class PathDerivation(Model):
|
|
65
|
+
"""A composed Relation ``A -r1-> B -r2-> C`` exposed as a virtual Relation ``A -> C``.
|
|
66
|
+
|
|
67
|
+
The virtual Relation's name is the Derivation's ``api_name``.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
variant: Literal["path"] = "path"
|
|
71
|
+
steps: tuple[PathStep, ...] = Field(min_length=2)
|
|
72
|
+
inverse_api_name: str | None = None
|
|
73
|
+
materialized: bool = False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
DerivationDefinition = Annotated[
|
|
77
|
+
ExpressionDerivation | AggregateDerivation | PathDerivation,
|
|
78
|
+
Field(discriminator="variant"),
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Derivation(SchemaElement):
|
|
83
|
+
object_type: ClassVar[str] = "schema.derivation"
|
|
84
|
+
|
|
85
|
+
id: DerivationId
|
|
86
|
+
definition: DerivationDefinition
|
|
87
|
+
status: ElementStatus = ElementStatus.ACTIVE
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def target(self) -> tuple[KindId, AttributeId] | None:
|
|
91
|
+
"""The ``(kind, attribute)`` this Derivation computes; ``None`` for path Derivations."""
|
|
92
|
+
definition = self.definition
|
|
93
|
+
if isinstance(definition, ExpressionDerivation | AggregateDerivation):
|
|
94
|
+
return (definition.kind, definition.attribute)
|
|
95
|
+
return None
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Domain (design 5.2): the isolation boundary for data, permissions and storage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated, ClassVar
|
|
6
|
+
|
|
7
|
+
from pydantic import Field, JsonValue, StringConstraints, field_validator
|
|
8
|
+
|
|
9
|
+
from lorebase_core.model.base import Model
|
|
10
|
+
from lorebase_core.types import AttributeType, TypeSpec, encode_value
|
|
11
|
+
|
|
12
|
+
DomainSlug = Annotated[str, StringConstraints(pattern=r"^[a-z][a-z0-9_-]*$")]
|
|
13
|
+
|
|
14
|
+
_TIMESTAMP = TypeSpec.of(AttributeType.TIMESTAMP)
|
|
15
|
+
|
|
16
|
+
#: Upper bounds of the example questions of a Domain; they are shown as buttons.
|
|
17
|
+
MAX_EXAMPLES = 20
|
|
18
|
+
MAX_EXAMPLE_LABEL = 80
|
|
19
|
+
MAX_EXAMPLE_QUESTION = 500
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DomainExample(Model):
|
|
23
|
+
"""A sample question about the Domain, offered by clients as a ready-made prompt."""
|
|
24
|
+
|
|
25
|
+
label: Annotated[
|
|
26
|
+
str, StringConstraints(strip_whitespace=True, min_length=1, max_length=MAX_EXAMPLE_LABEL)
|
|
27
|
+
]
|
|
28
|
+
question: Annotated[
|
|
29
|
+
str,
|
|
30
|
+
StringConstraints(strip_whitespace=True, min_length=1, max_length=MAX_EXAMPLE_QUESTION),
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Domain(Model):
|
|
35
|
+
object_type: ClassVar[str] = "schema.domain"
|
|
36
|
+
|
|
37
|
+
id: DomainSlug
|
|
38
|
+
display_name: str = ""
|
|
39
|
+
description: str = ""
|
|
40
|
+
default_branch: str = "main"
|
|
41
|
+
settings: dict[str, JsonValue] = Field(default_factory=dict)
|
|
42
|
+
#: Optional sample questions; absent (not empty) when there are none, so the canonical
|
|
43
|
+
#: content of Domains written before this field existed is unchanged.
|
|
44
|
+
examples: tuple[DomainExample, ...] | None = Field(default=None, max_length=MAX_EXAMPLES)
|
|
45
|
+
#: Canonical RFC 3339 UTC timestamp.
|
|
46
|
+
created_at: str | None = None
|
|
47
|
+
created_by: str | None = None
|
|
48
|
+
|
|
49
|
+
@field_validator("created_at", mode="before")
|
|
50
|
+
@classmethod
|
|
51
|
+
def _canonical_timestamp(cls, value: object) -> object:
|
|
52
|
+
return encode_value(_TIMESTAMP, value)
|
|
53
|
+
|
|
54
|
+
@field_validator("examples", mode="before")
|
|
55
|
+
@classmethod
|
|
56
|
+
def _empty_examples_are_absent(cls, value: object) -> object:
|
|
57
|
+
if isinstance(value, list | tuple) and len(value) == 0:
|
|
58
|
+
return None
|
|
59
|
+
return value
|
|
60
|
+
|
|
61
|
+
@field_validator("examples")
|
|
62
|
+
@classmethod
|
|
63
|
+
def _unique_labels(
|
|
64
|
+
cls, value: tuple[DomainExample, ...] | None
|
|
65
|
+
) -> tuple[DomainExample, ...] | None:
|
|
66
|
+
if value is not None:
|
|
67
|
+
labels = [example.label for example in value]
|
|
68
|
+
duplicates = sorted({label for label in labels if labels.count(label) > 1})
|
|
69
|
+
if duplicates:
|
|
70
|
+
raise ValueError(f"duplicate example labels: {', '.join(duplicates)}")
|
|
71
|
+
return value
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Embedding templates (design 8.4, 9.3): the text of a Record that Scribe embeds.
|
|
2
|
+
|
|
3
|
+
A Kind opts into semantic search by setting ``search.embedding_template``, a text with
|
|
4
|
+
``{attribute}`` placeholders naming Attributes of the Kind by api name, plus ``{_kind}`` (the
|
|
5
|
+
Kind's display name, else its api name) and ``{_key}`` (the Record key). ``{{`` and ``}}``
|
|
6
|
+
write literal braces. Example::
|
|
7
|
+
|
|
8
|
+
search:
|
|
9
|
+
embedding_template: "{legal_name} ({country}). {description}"
|
|
10
|
+
|
|
11
|
+
Rendering is deterministic: absent values render as nothing, booleans as ``true``/``false``,
|
|
12
|
+
lists as their items joined by ``, ``, objects as compact JSON, and runs of whitespace collapse
|
|
13
|
+
to one space. Derived Attributes cannot be named (their values are not part of the Record's
|
|
14
|
+
content hash, which keys the stored vectors), and sensitive Attributes only when
|
|
15
|
+
``search.embedding_sensitive`` is true.
|
|
16
|
+
|
|
17
|
+
``KindTemplate.fingerprint`` changes whenever the rendered text of some Record could change
|
|
18
|
+
for reasons other than its values (template text, the Attributes the names resolve to, the
|
|
19
|
+
Kind name used by ``{_kind}``, the rendering rules), so a job can tell which Kinds need their
|
|
20
|
+
texts rendered again.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import hashlib
|
|
26
|
+
import json
|
|
27
|
+
import re
|
|
28
|
+
from collections.abc import Mapping
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
|
|
31
|
+
from pydantic import JsonValue
|
|
32
|
+
|
|
33
|
+
from lorebase_core.model.kind import Kind
|
|
34
|
+
|
|
35
|
+
RENDERING_VERSION = 1
|
|
36
|
+
"""Bumped when the rendering rules change, which changes every fingerprint."""
|
|
37
|
+
|
|
38
|
+
KIND_FIELD = "_kind"
|
|
39
|
+
KEY_FIELD = "_key"
|
|
40
|
+
SPECIAL_FIELDS = frozenset({KIND_FIELD, KEY_FIELD})
|
|
41
|
+
|
|
42
|
+
_TOKEN = re.compile(r"\{\{|\}\}|\{([^{}]*)\}|[{}]")
|
|
43
|
+
_WHITESPACE = re.compile(r"\s+")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TemplateError(ValueError):
|
|
47
|
+
"""An embedding template that cannot be parsed or names something it cannot use."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class Placeholder:
|
|
52
|
+
name: str
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True, slots=True)
|
|
56
|
+
class EmbeddingTemplate:
|
|
57
|
+
"""A parsed template: literal text and placeholders, in order."""
|
|
58
|
+
|
|
59
|
+
source: str
|
|
60
|
+
parts: tuple[str | Placeholder, ...]
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def fields(self) -> tuple[str, ...]:
|
|
64
|
+
"""Placeholder names in order of first use."""
|
|
65
|
+
return tuple(dict.fromkeys(p.name for p in self.parts if isinstance(p, Placeholder)))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def parse_template(source: str) -> EmbeddingTemplate:
|
|
69
|
+
"""Parse ``source``; `TemplateError` on an unbalanced brace or an empty placeholder."""
|
|
70
|
+
parts: list[str | Placeholder] = []
|
|
71
|
+
literal: list[str] = []
|
|
72
|
+
position = 0
|
|
73
|
+
for match in _TOKEN.finditer(source):
|
|
74
|
+
literal.append(source[position : match.start()])
|
|
75
|
+
position = match.end()
|
|
76
|
+
token = match.group(0)
|
|
77
|
+
if token in ("{{", "}}"):
|
|
78
|
+
literal.append(token[0])
|
|
79
|
+
continue
|
|
80
|
+
name = match.group(1)
|
|
81
|
+
if name is None:
|
|
82
|
+
raise TemplateError(
|
|
83
|
+
f"unbalanced {token!r} at offset {match.start()}; write {token * 2}"
|
|
84
|
+
)
|
|
85
|
+
name = name.strip()
|
|
86
|
+
if not name:
|
|
87
|
+
raise TemplateError(f"empty placeholder at offset {match.start()}")
|
|
88
|
+
if any(literal):
|
|
89
|
+
parts.append("".join(literal))
|
|
90
|
+
literal = []
|
|
91
|
+
parts.append(Placeholder(name))
|
|
92
|
+
literal.append(source[position:])
|
|
93
|
+
if any(literal):
|
|
94
|
+
parts.append("".join(literal))
|
|
95
|
+
if not any(isinstance(p, Placeholder) for p in parts):
|
|
96
|
+
raise TemplateError("the template names no Attribute")
|
|
97
|
+
return EmbeddingTemplate(source, tuple(parts))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True, slots=True)
|
|
101
|
+
class KindTemplate:
|
|
102
|
+
"""The embedding template of one Kind, resolved against its Attributes."""
|
|
103
|
+
|
|
104
|
+
kind_id: str
|
|
105
|
+
template: EmbeddingTemplate
|
|
106
|
+
attributes: Mapping[str, str]
|
|
107
|
+
"""Placeholder name -> Attribute id (special fields are not listed)."""
|
|
108
|
+
kind_label: str
|
|
109
|
+
fingerprint: str
|
|
110
|
+
|
|
111
|
+
def render(self, key: str, values: Mapping[str, JsonValue]) -> str:
|
|
112
|
+
"""The text of the Record ``key`` with ``values`` (keyed by Attribute id)."""
|
|
113
|
+
out: list[str] = []
|
|
114
|
+
for part in self.template.parts:
|
|
115
|
+
if isinstance(part, str):
|
|
116
|
+
out.append(part)
|
|
117
|
+
elif part.name == KIND_FIELD:
|
|
118
|
+
out.append(self.kind_label)
|
|
119
|
+
elif part.name == KEY_FIELD:
|
|
120
|
+
out.append(key)
|
|
121
|
+
else:
|
|
122
|
+
out.append(_format(values.get(self.attributes[part.name])))
|
|
123
|
+
return _WHITESPACE.sub(" ", "".join(out)).strip()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def template_problems(kind: Kind) -> list[str]:
|
|
127
|
+
"""Why the Kind's template cannot be used; empty when it can (or there is none)."""
|
|
128
|
+
try:
|
|
129
|
+
_resolve(kind)
|
|
130
|
+
except TemplateError as error:
|
|
131
|
+
return [str(error)]
|
|
132
|
+
return []
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def kind_template(kind: Kind) -> KindTemplate | None:
|
|
136
|
+
"""The resolved template of ``kind``; None without one. Raises `TemplateError`."""
|
|
137
|
+
return _resolve(kind)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _resolve(kind: Kind) -> KindTemplate | None:
|
|
141
|
+
source = kind.search.embedding_template
|
|
142
|
+
if source is None or not source.strip():
|
|
143
|
+
return None
|
|
144
|
+
template = parse_template(source)
|
|
145
|
+
allow_sensitive = bool(kind.search.embedding_sensitive)
|
|
146
|
+
attributes: dict[str, str] = {}
|
|
147
|
+
for name in template.fields:
|
|
148
|
+
if name in SPECIAL_FIELDS:
|
|
149
|
+
continue
|
|
150
|
+
attribute = kind.attribute_by_api_name(name)
|
|
151
|
+
if attribute is None:
|
|
152
|
+
raise TemplateError(f"{{{name}}} is not an Attribute of Kind {kind.api_name!r}")
|
|
153
|
+
if attribute.is_derived:
|
|
154
|
+
raise TemplateError(f"{{{name}}} is derived; templates read stored values only")
|
|
155
|
+
if attribute.in_vault:
|
|
156
|
+
raise TemplateError(
|
|
157
|
+
f"{{{name}}} is stored in the vault; templates cannot read vault values"
|
|
158
|
+
)
|
|
159
|
+
if attribute.sensitivity and not allow_sensitive:
|
|
160
|
+
raise TemplateError(
|
|
161
|
+
f"{{{name}}} is sensitive ({', '.join(attribute.sensitivity)}); set "
|
|
162
|
+
"search.embedding_sensitive to embed it"
|
|
163
|
+
)
|
|
164
|
+
attributes[name] = attribute.id
|
|
165
|
+
label = kind.display_name or kind.api_name
|
|
166
|
+
fingerprint = hashlib.sha256(
|
|
167
|
+
json.dumps(
|
|
168
|
+
{
|
|
169
|
+
"v": RENDERING_VERSION,
|
|
170
|
+
"kind": kind.id,
|
|
171
|
+
"template": source,
|
|
172
|
+
"attributes": attributes,
|
|
173
|
+
"label": label if KIND_FIELD in template.fields else None,
|
|
174
|
+
},
|
|
175
|
+
sort_keys=True,
|
|
176
|
+
separators=(",", ":"),
|
|
177
|
+
).encode("utf-8")
|
|
178
|
+
).hexdigest()
|
|
179
|
+
return KindTemplate(kind.id, template, attributes, label, fingerprint)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _format(value: JsonValue) -> str:
|
|
183
|
+
if value is None:
|
|
184
|
+
return ""
|
|
185
|
+
if isinstance(value, bool):
|
|
186
|
+
return "true" if value else "false"
|
|
187
|
+
if isinstance(value, str):
|
|
188
|
+
return value
|
|
189
|
+
if isinstance(value, int | float):
|
|
190
|
+
return str(value)
|
|
191
|
+
if isinstance(value, list):
|
|
192
|
+
return ", ".join(text for text in (_format(item) for item in value) if text)
|
|
193
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Stable ids (design 5.1, AC-01): prefixed ULIDs such as ``knd_01JB8Z...``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import secrets
|
|
6
|
+
import time
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
from pydantic import StringConstraints
|
|
11
|
+
|
|
12
|
+
CROCKFORD_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
|
13
|
+
ULID_LENGTH = 26
|
|
14
|
+
ULID_REGEX = "[0-7][0-9A-HJKMNP-TV-Z]{25}"
|
|
15
|
+
_TIMESTAMP_BITS = 48
|
|
16
|
+
_RANDOM_BYTES = 10
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class IdPrefix(StrEnum):
|
|
20
|
+
KIND = "knd"
|
|
21
|
+
ATTRIBUTE = "att"
|
|
22
|
+
RELATION = "rel"
|
|
23
|
+
TRAIT = "trt"
|
|
24
|
+
OPERATION = "opr"
|
|
25
|
+
DERIVATION = "drv"
|
|
26
|
+
SOURCE = "src"
|
|
27
|
+
PROVENANCE = "prv"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
KindId = Annotated[str, StringConstraints(pattern=rf"^knd_{ULID_REGEX}$")]
|
|
31
|
+
AttributeId = Annotated[str, StringConstraints(pattern=rf"^att_{ULID_REGEX}$")]
|
|
32
|
+
RelationId = Annotated[str, StringConstraints(pattern=rf"^rel_{ULID_REGEX}$")]
|
|
33
|
+
TraitId = Annotated[str, StringConstraints(pattern=rf"^trt_{ULID_REGEX}$")]
|
|
34
|
+
OperationId = Annotated[str, StringConstraints(pattern=rf"^opr_{ULID_REGEX}$")]
|
|
35
|
+
DerivationId = Annotated[str, StringConstraints(pattern=rf"^drv_{ULID_REGEX}$")]
|
|
36
|
+
SourceId = Annotated[str, StringConstraints(pattern=rf"^src_{ULID_REGEX}$")]
|
|
37
|
+
ProvenanceId = Annotated[str, StringConstraints(pattern=rf"^prv_{ULID_REGEX}$")]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def new_ulid(*, timestamp_ms: int | None = None, randomness: bytes | None = None) -> str:
|
|
41
|
+
"""Mint a ULID. Pass ``timestamp_ms`` and ``randomness`` (10 bytes) for deterministic ids."""
|
|
42
|
+
millis = time.time_ns() // 1_000_000 if timestamp_ms is None else timestamp_ms
|
|
43
|
+
if not 0 <= millis < 2**_TIMESTAMP_BITS:
|
|
44
|
+
raise ValueError("ULID timestamp must fit in 48 bits")
|
|
45
|
+
entropy = secrets.token_bytes(_RANDOM_BYTES) if randomness is None else randomness
|
|
46
|
+
if len(entropy) != _RANDOM_BYTES:
|
|
47
|
+
raise ValueError(f"ULID randomness must be {_RANDOM_BYTES} bytes")
|
|
48
|
+
number = (millis << (8 * _RANDOM_BYTES)) | int.from_bytes(entropy, "big")
|
|
49
|
+
chars = []
|
|
50
|
+
for _ in range(ULID_LENGTH):
|
|
51
|
+
number, digit = divmod(number, 32)
|
|
52
|
+
chars.append(CROCKFORD_ALPHABET[digit])
|
|
53
|
+
return "".join(reversed(chars))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def new_id(
|
|
57
|
+
prefix: IdPrefix, *, timestamp_ms: int | None = None, randomness: bytes | None = None
|
|
58
|
+
) -> str:
|
|
59
|
+
"""Mint a prefixed id, for example ``new_id(IdPrefix.KIND)`` -> ``knd_01JB8Z...``."""
|
|
60
|
+
return f"{prefix.value}_{new_ulid(timestamp_ms=timestamp_ms, randomness=randomness)}"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def is_ulid(text: str) -> bool:
|
|
64
|
+
return (
|
|
65
|
+
len(text) == ULID_LENGTH
|
|
66
|
+
and text[0] in CROCKFORD_ALPHABET[:8]
|
|
67
|
+
and all(ch in CROCKFORD_ALPHABET for ch in text)
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def id_prefix(element_id: str) -> IdPrefix | None:
|
|
72
|
+
"""The prefix of a well-formed id, or ``None``."""
|
|
73
|
+
prefix, _, rest = element_id.partition("_")
|
|
74
|
+
try:
|
|
75
|
+
parsed = IdPrefix(prefix)
|
|
76
|
+
except ValueError:
|
|
77
|
+
return None
|
|
78
|
+
return parsed if is_ulid(rest) else None
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Kind (design 5.3): a type of thing in a Domain."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from typing import ClassVar, Self
|
|
7
|
+
|
|
8
|
+
from pydantic import Field, model_validator
|
|
9
|
+
|
|
10
|
+
from lorebase_core.model.attribute import Attribute
|
|
11
|
+
from lorebase_core.model.base import ElementStatus, Model, SchemaElement
|
|
12
|
+
from lorebase_core.model.ids import AttributeId, KindId, TraitId
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BuiltinOperation(StrEnum):
|
|
16
|
+
"""Built-in Operations a Kind can enable (design 5.7)."""
|
|
17
|
+
|
|
18
|
+
CREATE = "create"
|
|
19
|
+
UPDATE = "update"
|
|
20
|
+
DELETE = "delete"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TraitImplementation(Model):
|
|
24
|
+
"""A Kind implementing a Trait.
|
|
25
|
+
|
|
26
|
+
``attribute_map`` maps Trait Attribute ids to Kind Attribute ids.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
trait_id: TraitId
|
|
30
|
+
attribute_map: dict[AttributeId, AttributeId] = Field(default_factory=dict)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class KindSearch(Model):
|
|
34
|
+
"""Search settings of a Kind. Full-text fields come from searchable Attributes.
|
|
35
|
+
|
|
36
|
+
``embedding_template`` opts the Kind into semantic search: Scribe embeds the text it
|
|
37
|
+
renders for each Record (``lorebase_core.model.embedding``). ``embedding_sensitive`` lets
|
|
38
|
+
the template name sensitive Attributes; unset, they are refused.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
embedding_template: str | None = None
|
|
42
|
+
embedding_sensitive: bool | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def check_attribute_keys(attributes: dict[str, Attribute], owner: str) -> None:
|
|
46
|
+
for key, attribute in attributes.items():
|
|
47
|
+
if key != attribute.id:
|
|
48
|
+
raise ValueError(
|
|
49
|
+
f"{owner}: attribute map key {key} does not match attribute id {attribute.id}"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Kind(SchemaElement):
|
|
54
|
+
object_type: ClassVar[str] = "schema.kind"
|
|
55
|
+
|
|
56
|
+
id: KindId
|
|
57
|
+
primary_key: tuple[AttributeId, ...] = ()
|
|
58
|
+
title_attribute: AttributeId | None = None
|
|
59
|
+
attributes: dict[AttributeId, Attribute] = Field(default_factory=dict)
|
|
60
|
+
traits: tuple[TraitImplementation, ...] = ()
|
|
61
|
+
status: ElementStatus = ElementStatus.ACTIVE
|
|
62
|
+
search: KindSearch = Field(default_factory=KindSearch)
|
|
63
|
+
icon: str | None = None
|
|
64
|
+
color: str | None = None
|
|
65
|
+
operations_builtin: tuple[BuiltinOperation, ...] = (
|
|
66
|
+
BuiltinOperation.CREATE,
|
|
67
|
+
BuiltinOperation.UPDATE,
|
|
68
|
+
BuiltinOperation.DELETE,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
@model_validator(mode="after")
|
|
72
|
+
def _check_keys(self) -> Self:
|
|
73
|
+
check_attribute_keys(self.attributes, f"Kind {self.id}")
|
|
74
|
+
return self
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def has_natural_key(self) -> bool:
|
|
78
|
+
"""True when Records are keyed by primary key values, False when keys are minted ULIDs."""
|
|
79
|
+
return bool(self.primary_key)
|
|
80
|
+
|
|
81
|
+
def attribute_by_api_name(self, api_name: str) -> Attribute | None:
|
|
82
|
+
for attribute in self.attributes.values():
|
|
83
|
+
if attribute.api_name == api_name:
|
|
84
|
+
return attribute
|
|
85
|
+
return None
|