lorebase-keeper 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_keeper/__init__.py +1 -0
- lorebase_keeper/__main__.py +456 -0
- lorebase_keeper/agents/__init__.py +1 -0
- lorebase_keeper/agents/budget.py +247 -0
- lorebase_keeper/agents/context.py +529 -0
- lorebase_keeper/agents/explain.py +582 -0
- lorebase_keeper/agents/feed.py +259 -0
- lorebase_keeper/agents/markdown.py +219 -0
- lorebase_keeper/agents/pack.py +403 -0
- lorebase_keeper/agents/settings.py +111 -0
- lorebase_keeper/app.py +373 -0
- lorebase_keeper/auth/__init__.py +4 -0
- lorebase_keeper/auth/api_keys.py +127 -0
- lorebase_keeper/auth/audit.py +356 -0
- lorebase_keeper/auth/cache.py +53 -0
- lorebase_keeper/auth/config.py +618 -0
- lorebase_keeper/auth/deps.py +183 -0
- lorebase_keeper/auth/keyring.py +14 -0
- lorebase_keeper/auth/oidc.py +242 -0
- lorebase_keeper/auth/permissions.py +142 -0
- lorebase_keeper/auth/ports.py +212 -0
- lorebase_keeper/auth/principals.py +377 -0
- lorebase_keeper/auth/provider.py +250 -0
- lorebase_keeper/auth/rbac.py +247 -0
- lorebase_keeper/auth/read_audit.py +357 -0
- lorebase_keeper/auth/runtime.py +113 -0
- lorebase_keeper/auth/sessions.py +452 -0
- lorebase_keeper/bootstrap.py +151 -0
- lorebase_keeper/client_address.py +129 -0
- lorebase_keeper/context.py +226 -0
- lorebase_keeper/db.py +93 -0
- lorebase_keeper/deps.py +47 -0
- lorebase_keeper/errors.py +257 -0
- lorebase_keeper/health.py +61 -0
- lorebase_keeper/kindhealth/__init__.py +6 -0
- lorebase_keeper/kindhealth/config.py +51 -0
- lorebase_keeper/kindhealth/markdown.py +163 -0
- lorebase_keeper/kindhealth/models.py +179 -0
- lorebase_keeper/kindhealth/report.py +154 -0
- lorebase_keeper/kindhealth/scan.py +512 -0
- lorebase_keeper/middleware.py +285 -0
- lorebase_keeper/pagination.py +87 -0
- lorebase_keeper/ratelimit/__init__.py +10 -0
- lorebase_keeper/ratelimit/bucket.py +118 -0
- lorebase_keeper/ratelimit/config.py +283 -0
- lorebase_keeper/ratelimit/enforce.py +393 -0
- lorebase_keeper/ratelimit/limiter.py +149 -0
- lorebase_keeper/ratelimit/quotas.py +204 -0
- lorebase_keeper/ratelimit/shared.py +242 -0
- lorebase_keeper/readiness.py +257 -0
- lorebase_keeper/reading/__init__.py +1 -0
- lorebase_keeper/reading/caching.py +251 -0
- lorebase_keeper/reading/compiler.py +678 -0
- lorebase_keeper/reading/cursors.py +56 -0
- lorebase_keeper/reading/diff.py +408 -0
- lorebase_keeper/reading/graph.py +268 -0
- lorebase_keeper/reading/history.py +152 -0
- lorebase_keeper/reading/points.py +410 -0
- lorebase_keeper/reading/problems.py +74 -0
- lorebase_keeper/reading/record_history.py +722 -0
- lorebase_keeper/reading/records.py +780 -0
- lorebase_keeper/reading/requests.py +107 -0
- lorebase_keeper/reading/runtime.py +145 -0
- lorebase_keeper/reading/search.py +708 -0
- lorebase_keeper/reading/sensitivity.py +882 -0
- lorebase_keeper/reading/settings.py +339 -0
- lorebase_keeper/reading/snapshots.py +148 -0
- lorebase_keeper/reading/tree.py +98 -0
- lorebase_keeper/reading/views.py +264 -0
- lorebase_keeper/reading/visibility.py +93 -0
- lorebase_keeper/routes/__init__.py +1 -0
- lorebase_keeper/routes/admin.py +597 -0
- lorebase_keeper/routes/agents.py +160 -0
- lorebase_keeper/routes/branches.py +438 -0
- lorebase_keeper/routes/derivations.py +215 -0
- lorebase_keeper/routes/documents.py +497 -0
- lorebase_keeper/routes/domain_api.py +59 -0
- lorebase_keeper/routes/domains.py +220 -0
- lorebase_keeper/routes/events.py +187 -0
- lorebase_keeper/routes/kind_health.py +166 -0
- lorebase_keeper/routes/operations.py +392 -0
- lorebase_keeper/routes/proposal_items.py +563 -0
- lorebase_keeper/routes/proposals.py +1107 -0
- lorebase_keeper/routes/read.py +493 -0
- lorebase_keeper/routes/revisions.py +523 -0
- lorebase_keeper/routes/schema.py +682 -0
- lorebase_keeper/routes/schema_inference.py +437 -0
- lorebase_keeper/routes/schema_suggestions.py +328 -0
- lorebase_keeper/routes/sessions.py +286 -0
- lorebase_keeper/routes/source_preview.py +232 -0
- lorebase_keeper/routes/sources.py +200 -0
- lorebase_keeper/routes/trace.py +506 -0
- lorebase_keeper/routes/vault.py +332 -0
- lorebase_keeper/routes/webhooks.py +298 -0
- lorebase_keeper/routes/whoami.py +88 -0
- lorebase_keeper/services/__init__.py +1 -0
- lorebase_keeper/services/agent_writes.py +1035 -0
- lorebase_keeper/services/branches.py +337 -0
- lorebase_keeper/services/config.py +431 -0
- lorebase_keeper/services/derivation_preview.py +279 -0
- lorebase_keeper/services/document_urls.py +114 -0
- lorebase_keeper/services/domains.py +118 -0
- lorebase_keeper/services/errors.py +196 -0
- lorebase_keeper/services/operation_catalog.py +806 -0
- lorebase_keeper/services/operation_events.py +199 -0
- lorebase_keeper/services/operations.py +1229 -0
- lorebase_keeper/services/pii_import.py +222 -0
- lorebase_keeper/services/proposal_reads.py +158 -0
- lorebase_keeper/services/proposals.py +850 -0
- lorebase_keeper/services/reads.py +286 -0
- lorebase_keeper/services/schema.py +601 -0
- lorebase_keeper/services/schema_inference.py +390 -0
- lorebase_keeper/services/schema_suggestions.py +418 -0
- lorebase_keeper/services/source_preview.py +127 -0
- lorebase_keeper/settings.py +313 -0
- lorebase_keeper/tracing/__init__.py +1 -0
- lorebase_keeper/tracing/capture.py +350 -0
- lorebase_keeper/tracing/config.py +157 -0
- lorebase_keeper/tracing/explain.py +97 -0
- lorebase_keeper/tracing/middleware.py +359 -0
- lorebase_keeper/tracing/notes.py +80 -0
- lorebase_keeper/tracing/recorder.py +180 -0
- lorebase_keeper-0.2.3.dist-info/METADATA +21 -0
- lorebase_keeper-0.2.3.dist-info/RECORD +126 -0
- lorebase_keeper-0.2.3.dist-info/WHEEL +4 -0
- lorebase_keeper-0.2.3.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
"""Review of schema suggestions (design 9.2 step 10).
|
|
2
|
+
|
|
3
|
+
Extraction jobs aggregate what they saw outside the schema into `schema_suggestions`
|
|
4
|
+
(`lorebase_scribe.extraction.discovery`). Reviewers list them, reject them, or accept a set of
|
|
5
|
+
them: the accepted suggestions become one schema change set (`suggestion_patch`) committed on a
|
|
6
|
+
new branch (`keeper.schema_suggestions.branch_prefix` and the first suggestion's id) and
|
|
7
|
+
proposed into the default branch as a Proposal. Nothing reaches the default branch until that
|
|
8
|
+
Proposal merges; new Kinds and Relations are drafts for a modeler to describe and activate.
|
|
9
|
+
|
|
10
|
+
Listings compare each suggestion with the default branch's schema of now: one that duplicates
|
|
11
|
+
an element meanwhile is flagged (`duplicate_of`) and cannot be accepted, and one that
|
|
12
|
+
duplicates a sensitive Attribute the caller may not read is listed without examples and
|
|
13
|
+
samples.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections.abc import Sequence
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from lorebase_core.infer.suggestions import (
|
|
23
|
+
PROPOSED,
|
|
24
|
+
AttributeGuess,
|
|
25
|
+
ExistingMatch,
|
|
26
|
+
Suggestion,
|
|
27
|
+
SuggestionError,
|
|
28
|
+
SuggestionOptions,
|
|
29
|
+
SuggestionType,
|
|
30
|
+
existing_match,
|
|
31
|
+
infer_attribute,
|
|
32
|
+
sensitive_attributes,
|
|
33
|
+
suggestion_patch,
|
|
34
|
+
)
|
|
35
|
+
from lorebase_core.model import Kind, Schema
|
|
36
|
+
from lorebase_keeper.auth.principals import Principal
|
|
37
|
+
from lorebase_keeper.context import StoreAccess, require_domain
|
|
38
|
+
from lorebase_keeper.errors import CONFLICT, NOT_FOUND, VALIDATION_FAILED, ProblemError
|
|
39
|
+
from lorebase_keeper.reading.sensitivity import Masking
|
|
40
|
+
from lorebase_keeper.services import branches as branch_service
|
|
41
|
+
from lorebase_keeper.services import proposals as proposal_service
|
|
42
|
+
from lorebase_keeper.services import schema as schema_service
|
|
43
|
+
from lorebase_keeper.services.config import (
|
|
44
|
+
BranchSettings,
|
|
45
|
+
ProposalSettings,
|
|
46
|
+
SchemaSuggestionSettings,
|
|
47
|
+
)
|
|
48
|
+
from lorebase_keeper.services.errors import Finding, violations_problem
|
|
49
|
+
from lorebase_store.commit.schema import load_compiled
|
|
50
|
+
from lorebase_store.repositories import (
|
|
51
|
+
MaterializedRepository,
|
|
52
|
+
MergeOutcome,
|
|
53
|
+
SchemaSuggestionRepository,
|
|
54
|
+
SuggestionRow,
|
|
55
|
+
)
|
|
56
|
+
from lorebase_store.repositories.suggestions import ACCEPTED, PENDING, REJECTED
|
|
57
|
+
|
|
58
|
+
DECISIONS = (PENDING, ACCEPTED, REJECTED)
|
|
59
|
+
ISSUES_LISTED = 50
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class SuggestionView:
|
|
64
|
+
"""A stored suggestion as the caller may see it, against the current schema."""
|
|
65
|
+
|
|
66
|
+
row: SuggestionRow
|
|
67
|
+
kind: str | None
|
|
68
|
+
target: str | None
|
|
69
|
+
guess: AttributeGuess | None
|
|
70
|
+
duplicate: ExistingMatch | None
|
|
71
|
+
withheld: bool
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class SuggestionPage:
|
|
76
|
+
views: list[SuggestionView]
|
|
77
|
+
next_position: dict[str, Any] | None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class DecisionInput:
|
|
82
|
+
suggestion: str
|
|
83
|
+
decision: str
|
|
84
|
+
name: str | None = None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class DecisionOutcome:
|
|
89
|
+
views: list[SuggestionView]
|
|
90
|
+
proposal_id: str | None = None
|
|
91
|
+
proposal_title: str | None = None
|
|
92
|
+
branch: str | None = None
|
|
93
|
+
changes: list[schema_service.ElementChange] = field(default_factory=list)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def invalid(pointer: str, message: str, **extra: Any) -> ProblemError:
|
|
97
|
+
return ProblemError(
|
|
98
|
+
VALIDATION_FAILED,
|
|
99
|
+
"One or more request members are invalid.",
|
|
100
|
+
extensions={"errors": [{"pointer": pointer, "message": message, **extra}]},
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def default_schema(store: StoreAccess, domain_id: str) -> tuple[str, Schema]:
|
|
105
|
+
"""The default branch's name and schema."""
|
|
106
|
+
async with store.sessions() as session, session.begin():
|
|
107
|
+
domain = await require_domain(session, domain_id)
|
|
108
|
+
compiled = await MaterializedRepository(
|
|
109
|
+
session, domain_id, domain.default_branch, writes=store.runtime.settings.writes
|
|
110
|
+
).schema()
|
|
111
|
+
if compiled is None:
|
|
112
|
+
raise ProblemError(NOT_FOUND, f"Branch {domain.default_branch!r} is not materialized.")
|
|
113
|
+
return domain.default_branch, load_compiled(compiled.compiled)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _kind_label(schema: Schema, ref: str | None) -> str | None:
|
|
117
|
+
if ref is None:
|
|
118
|
+
return None
|
|
119
|
+
if ref.startswith(PROPOSED):
|
|
120
|
+
return ref
|
|
121
|
+
kind = schema.kinds.get(ref)
|
|
122
|
+
return ref if kind is None else kind.api_name
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def view_of(
|
|
126
|
+
row: SuggestionRow,
|
|
127
|
+
schema: Schema,
|
|
128
|
+
options: SuggestionOptions,
|
|
129
|
+
masking: Masking | None,
|
|
130
|
+
) -> SuggestionView:
|
|
131
|
+
"""What a listing shows of `row` against `schema` for a caller with `masking`."""
|
|
132
|
+
type_ = SuggestionType(row.suggestion_type)
|
|
133
|
+
duplicate = existing_match(
|
|
134
|
+
schema,
|
|
135
|
+
type_,
|
|
136
|
+
row.name,
|
|
137
|
+
kind=row.kind_ref,
|
|
138
|
+
target=row.target_ref,
|
|
139
|
+
options=options,
|
|
140
|
+
tags=sensitive_attributes(schema),
|
|
141
|
+
)
|
|
142
|
+
withheld = False
|
|
143
|
+
if duplicate is not None and duplicate.sensitive:
|
|
144
|
+
owner = schema.attribute_owner(duplicate.id)
|
|
145
|
+
attribute = owner.attributes.get(duplicate.id) if isinstance(owner, Kind) else None
|
|
146
|
+
kinds = (owner.id,) if isinstance(owner, Kind) else ()
|
|
147
|
+
withheld = masking is None or attribute is None or masking.hidden(attribute, kinds)
|
|
148
|
+
guess = None
|
|
149
|
+
if type_ is SuggestionType.ATTRIBUTE and row.samples and not withheld:
|
|
150
|
+
guess = infer_attribute(row.name, row.samples, options)
|
|
151
|
+
return SuggestionView(
|
|
152
|
+
row=row,
|
|
153
|
+
kind=_kind_label(schema, row.kind_ref),
|
|
154
|
+
target=_kind_label(schema, row.target_ref),
|
|
155
|
+
guess=guess,
|
|
156
|
+
duplicate=duplicate,
|
|
157
|
+
withheld=withheld,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
async def page(
|
|
162
|
+
store: StoreAccess,
|
|
163
|
+
domain_id: str,
|
|
164
|
+
masking: Masking | None,
|
|
165
|
+
*,
|
|
166
|
+
statuses: Sequence[str],
|
|
167
|
+
types: Sequence[str],
|
|
168
|
+
all_support: bool,
|
|
169
|
+
limit: int,
|
|
170
|
+
position: dict[str, Any] | None,
|
|
171
|
+
) -> SuggestionPage:
|
|
172
|
+
"""Suggestions by support, the most supported first: pending ones need
|
|
173
|
+
`schema.suggestions.min_support` (pending Kinds also `kind_min_attributes`), unless
|
|
174
|
+
`all_support`, and at most `max_suggestions` are listed in all."""
|
|
175
|
+
options = store.runtime.schema.suggestions
|
|
176
|
+
_, schema = await default_schema(store, domain_id)
|
|
177
|
+
listed = 0
|
|
178
|
+
after = None
|
|
179
|
+
if position is not None:
|
|
180
|
+
try:
|
|
181
|
+
after = (int(position["support"]), str(position["id"]))
|
|
182
|
+
listed = int(position["listed"])
|
|
183
|
+
except (KeyError, TypeError, ValueError):
|
|
184
|
+
raise ProblemError(
|
|
185
|
+
VALIDATION_FAILED, "The cursor is not a suggestion cursor."
|
|
186
|
+
) from None
|
|
187
|
+
room = options.max_suggestions - listed
|
|
188
|
+
if room <= 0:
|
|
189
|
+
return SuggestionPage([], None)
|
|
190
|
+
take = min(limit, room)
|
|
191
|
+
async with store.sessions() as session, session.begin():
|
|
192
|
+
await require_domain(session, domain_id)
|
|
193
|
+
rows = await SchemaSuggestionRepository(session, domain_id).page(
|
|
194
|
+
statuses=statuses,
|
|
195
|
+
types=types,
|
|
196
|
+
min_support=1 if all_support else options.min_support,
|
|
197
|
+
kind_min_attributes=0 if all_support else options.kind_min_attributes,
|
|
198
|
+
limit=take + 1,
|
|
199
|
+
after=after,
|
|
200
|
+
)
|
|
201
|
+
more = len(rows) > take
|
|
202
|
+
rows = rows[:take]
|
|
203
|
+
following = None
|
|
204
|
+
if more and rows and listed + len(rows) < options.max_suggestions:
|
|
205
|
+
last = rows[-1]
|
|
206
|
+
following = {"support": last.support, "id": last.id, "listed": listed + len(rows)}
|
|
207
|
+
return SuggestionPage([view_of(r, schema, options, masking) for r in rows], following)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
async def merge_variants(store: StoreAccess, domain_id: str) -> MergeOutcome:
|
|
211
|
+
"""Fold the pending Attribute suggestions already stored that are variants of one another
|
|
212
|
+
(`schema.suggestions.merge_similarity`) into the best supported one of each set."""
|
|
213
|
+
async with store.sessions() as session, session.begin():
|
|
214
|
+
await require_domain(session, domain_id)
|
|
215
|
+
return await SchemaSuggestionRepository(session, domain_id).merge_variants(
|
|
216
|
+
options=store.runtime.schema.suggestions, now=store.clock()
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
async def decide(
|
|
221
|
+
store: StoreAccess,
|
|
222
|
+
domain_id: str,
|
|
223
|
+
decisions: Sequence[DecisionInput],
|
|
224
|
+
principal: Principal,
|
|
225
|
+
*,
|
|
226
|
+
settings: SchemaSuggestionSettings,
|
|
227
|
+
branches: BranchSettings,
|
|
228
|
+
proposals: ProposalSettings,
|
|
229
|
+
masking: Masking | None,
|
|
230
|
+
) -> DecisionOutcome:
|
|
231
|
+
"""Reject, reset or accept suggestions. Accepted ones are applied together on a new branch
|
|
232
|
+
and proposed into the default branch; nothing is decided when that fails."""
|
|
233
|
+
if len(decisions) > settings.max_decisions:
|
|
234
|
+
raise invalid("/body/decisions", f"at most {settings.max_decisions} per request")
|
|
235
|
+
ids = [d.suggestion for d in decisions]
|
|
236
|
+
if len(set(ids)) != len(ids):
|
|
237
|
+
raise invalid("/body/decisions", "a suggestion is named twice")
|
|
238
|
+
options = store.runtime.schema.suggestions
|
|
239
|
+
default_branch, schema = await default_schema(store, domain_id)
|
|
240
|
+
async with store.sessions() as session, session.begin():
|
|
241
|
+
await require_domain(session, domain_id)
|
|
242
|
+
rows = await SchemaSuggestionRepository(session, domain_id).get_many(ids)
|
|
243
|
+
for index, decision in enumerate(decisions):
|
|
244
|
+
row = rows.get(decision.suggestion)
|
|
245
|
+
if row is None:
|
|
246
|
+
raise ProblemError(NOT_FOUND, f"Suggestion {decision.suggestion!r} not found.")
|
|
247
|
+
if row.status == ACCEPTED:
|
|
248
|
+
raise ProblemError(
|
|
249
|
+
CONFLICT,
|
|
250
|
+
f"Suggestion {row.id!r} was accepted into Proposal {row.proposal_id!r}.",
|
|
251
|
+
extensions={"proposal": row.proposal_id},
|
|
252
|
+
)
|
|
253
|
+
if decision.name is not None and not decision.name.strip():
|
|
254
|
+
raise invalid(f"/body/decisions/{index}/name", "must not be empty")
|
|
255
|
+
accepted = [d for d in decisions if d.decision == ACCEPTED]
|
|
256
|
+
outcome = DecisionOutcome(views=[])
|
|
257
|
+
now = store.clock()
|
|
258
|
+
if accepted:
|
|
259
|
+
await _accept(
|
|
260
|
+
store,
|
|
261
|
+
domain_id,
|
|
262
|
+
[(d, rows[d.suggestion]) for d in accepted],
|
|
263
|
+
schema,
|
|
264
|
+
default_branch,
|
|
265
|
+
principal,
|
|
266
|
+
options,
|
|
267
|
+
settings,
|
|
268
|
+
branches,
|
|
269
|
+
proposals,
|
|
270
|
+
outcome,
|
|
271
|
+
masking,
|
|
272
|
+
)
|
|
273
|
+
async with store.sessions() as session, session.begin():
|
|
274
|
+
await require_domain(session, domain_id)
|
|
275
|
+
repository = SchemaSuggestionRepository(session, domain_id)
|
|
276
|
+
if accepted:
|
|
277
|
+
await repository.decide(
|
|
278
|
+
[d.suggestion for d in accepted],
|
|
279
|
+
status=ACCEPTED,
|
|
280
|
+
decided_by=principal.id,
|
|
281
|
+
decided_at=now,
|
|
282
|
+
proposal_id=outcome.proposal_id,
|
|
283
|
+
branch=outcome.branch,
|
|
284
|
+
)
|
|
285
|
+
for status in (REJECTED, PENDING):
|
|
286
|
+
chosen = [d.suggestion for d in decisions if d.decision == status]
|
|
287
|
+
await repository.decide(
|
|
288
|
+
chosen,
|
|
289
|
+
status=status,
|
|
290
|
+
decided_by=principal.id if status == REJECTED else None,
|
|
291
|
+
decided_at=now if status == REJECTED else None,
|
|
292
|
+
)
|
|
293
|
+
stored = await repository.get_many(ids)
|
|
294
|
+
outcome.views = [view_of(stored[i], schema, options, masking) for i in ids]
|
|
295
|
+
return outcome
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _patch(
|
|
299
|
+
rows: Sequence[tuple[DecisionInput, SuggestionRow]],
|
|
300
|
+
schema: Schema,
|
|
301
|
+
options: SuggestionOptions,
|
|
302
|
+
) -> dict[str, Any]:
|
|
303
|
+
suggestions: list[Suggestion] = []
|
|
304
|
+
for index, (decision, row) in enumerate(rows):
|
|
305
|
+
type_ = SuggestionType(row.suggestion_type)
|
|
306
|
+
duplicate = existing_match(
|
|
307
|
+
schema, type_, row.name, kind=row.kind_ref, target=row.target_ref, options=options
|
|
308
|
+
)
|
|
309
|
+
if duplicate is not None:
|
|
310
|
+
raise invalid(
|
|
311
|
+
f"/body/decisions/{index}",
|
|
312
|
+
f"the schema has {duplicate.api_name!r} ({duplicate.section}) already",
|
|
313
|
+
suggestion=row.id,
|
|
314
|
+
)
|
|
315
|
+
suggestions.append(
|
|
316
|
+
Suggestion(
|
|
317
|
+
id=row.id,
|
|
318
|
+
type=type_,
|
|
319
|
+
name=decision.name.strip() if decision.name else row.name,
|
|
320
|
+
kind=row.kind_ref,
|
|
321
|
+
target=row.target_ref,
|
|
322
|
+
samples=tuple(row.samples),
|
|
323
|
+
labels=tuple(row.labels),
|
|
324
|
+
)
|
|
325
|
+
)
|
|
326
|
+
try:
|
|
327
|
+
return suggestion_patch(schema, suggestions, options)
|
|
328
|
+
except SuggestionError as error:
|
|
329
|
+
position = next(i for i, (_, row) in enumerate(rows) if row.id == error.suggestion)
|
|
330
|
+
raise invalid(
|
|
331
|
+
f"/body/decisions/{position}", str(error), suggestion=error.suggestion
|
|
332
|
+
) from None
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _issues_problem(issues: Sequence[Finding]) -> ProblemError:
|
|
336
|
+
return violations_problem(issues, limit=ISSUES_LISTED)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
async def _accept(
|
|
340
|
+
store: StoreAccess,
|
|
341
|
+
domain_id: str,
|
|
342
|
+
rows: Sequence[tuple[DecisionInput, SuggestionRow]],
|
|
343
|
+
schema: Schema,
|
|
344
|
+
default_branch: str,
|
|
345
|
+
principal: Principal,
|
|
346
|
+
options: SuggestionOptions,
|
|
347
|
+
settings: SchemaSuggestionSettings,
|
|
348
|
+
branches: BranchSettings,
|
|
349
|
+
proposals: ProposalSettings,
|
|
350
|
+
outcome: DecisionOutcome,
|
|
351
|
+
masking: Masking | None,
|
|
352
|
+
) -> None:
|
|
353
|
+
change = schema_service.ChangeSetInput(patch=_patch(rows, schema, options))
|
|
354
|
+
try:
|
|
355
|
+
target = schema_service.target_schema(schema, change)
|
|
356
|
+
except schema_service.ChangeSetInvalidError as error:
|
|
357
|
+
raise _issues_problem(error.issues) from None
|
|
358
|
+
issues = schema_service.static_issues(target, store.runtime.schema.rules())
|
|
359
|
+
if issues:
|
|
360
|
+
raise _issues_problem(issues)
|
|
361
|
+
branch = f"{settings.branch_prefix}{rows[0][1].id}"
|
|
362
|
+
branch_service.check_ref_name(branch, branches, "/body/decisions")
|
|
363
|
+
await branch_service.create(
|
|
364
|
+
store,
|
|
365
|
+
domain_id,
|
|
366
|
+
branch,
|
|
367
|
+
principal,
|
|
368
|
+
branches,
|
|
369
|
+
source=default_branch,
|
|
370
|
+
revision=None,
|
|
371
|
+
idempotency_key=None,
|
|
372
|
+
)
|
|
373
|
+
names = ", ".join(row.name for _, row in rows)
|
|
374
|
+
try:
|
|
375
|
+
applied = await schema_service.apply(
|
|
376
|
+
store,
|
|
377
|
+
domain_id,
|
|
378
|
+
branch,
|
|
379
|
+
change,
|
|
380
|
+
principal,
|
|
381
|
+
message=f"Schema suggestions: {names}",
|
|
382
|
+
expected_head=None,
|
|
383
|
+
dry_run=False,
|
|
384
|
+
cascade=False,
|
|
385
|
+
clearance=None if masking is None else masking.clearance,
|
|
386
|
+
)
|
|
387
|
+
created = await proposal_service.create(
|
|
388
|
+
store,
|
|
389
|
+
domain_id,
|
|
390
|
+
proposal_service.NewProposal(
|
|
391
|
+
source_ref=branch,
|
|
392
|
+
target_ref=default_branch,
|
|
393
|
+
title=f"Schema suggestions: {names}"[:200],
|
|
394
|
+
description="Suggested by document extraction: "
|
|
395
|
+
+ ", ".join(f"{row.suggestion_type} {row.name} ({row.id})" for _, row in rows),
|
|
396
|
+
),
|
|
397
|
+
principal,
|
|
398
|
+
proposals,
|
|
399
|
+
idempotency_key=None,
|
|
400
|
+
)
|
|
401
|
+
except schema_service.ChangeSetInvalidError as error:
|
|
402
|
+
await _drop(store, domain_id, branch, branches)
|
|
403
|
+
raise _issues_problem(error.issues) from None
|
|
404
|
+
except Exception:
|
|
405
|
+
await _drop(store, domain_id, branch, branches)
|
|
406
|
+
raise
|
|
407
|
+
outcome.branch = branch
|
|
408
|
+
outcome.changes = applied.changes
|
|
409
|
+
outcome.proposal_id = created.view.proposal.id
|
|
410
|
+
outcome.proposal_title = created.view.proposal.title
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
async def _drop(store: StoreAccess, domain_id: str, branch: str, settings: BranchSettings) -> None:
|
|
414
|
+
"""Delete the branch of a change set that could not be proposed (best effort)."""
|
|
415
|
+
try:
|
|
416
|
+
await branch_service.delete(store, domain_id, branch, settings)
|
|
417
|
+
except ProblemError:
|
|
418
|
+
return
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Preview of the expression mappings of a candidate Source (design 5.9) on sample rows.
|
|
2
|
+
|
|
3
|
+
The candidate is a schema change set naming the Source: the schema it produces is validated
|
|
4
|
+
like a schema change, then the `expression` of every Attribute mapping of the Source is
|
|
5
|
+
evaluated on each row the caller sends, with the engine Scribe maps rows with
|
|
6
|
+
(`lorebase_core.expr.rows`, within `schema.expression_limits`). The answer holds the value of
|
|
7
|
+
each expression before the mapping's transforms; transforms and the coercion to the Attribute
|
|
8
|
+
type are the mapper's (`lorebase_scribe.sources.mapping`, which Keeper does not import).
|
|
9
|
+
|
|
10
|
+
The rows are the caller's own input and the answer is made of them: no Record is read, nothing is
|
|
11
|
+
stored and no connection is opened, so reading the schema is the only permission involved (a
|
|
12
|
+
grant narrowed to a Kind is enough when it names the Source's target Kind). Expressions are
|
|
13
|
+
compiled once over every column the rows have; a column a row lacks is null, as in Scribe.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
|
|
22
|
+
from pydantic import JsonValue
|
|
23
|
+
|
|
24
|
+
from lorebase_core.expr import ExprError, ExprLimits, RowExpressions, row_value
|
|
25
|
+
from lorebase_core.model import Schema, Source
|
|
26
|
+
from lorebase_core.validate import SchemaRules
|
|
27
|
+
from lorebase_keeper.services.derivation_preview import CandidateInvalidError
|
|
28
|
+
from lorebase_keeper.services.errors import Finding
|
|
29
|
+
from lorebase_keeper.services.schema import (
|
|
30
|
+
ChangeSetInput,
|
|
31
|
+
ChangeSetInvalidError,
|
|
32
|
+
static_issues,
|
|
33
|
+
target_schema,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
SOURCE_NOT_FOUND = "source.not_found"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class ExpressionCell:
|
|
41
|
+
"""What one expression gives for one row: the value, or why there is none."""
|
|
42
|
+
|
|
43
|
+
value: JsonValue
|
|
44
|
+
error_code: str | None = None
|
|
45
|
+
error_message: str | None = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class SourceCandidate:
|
|
50
|
+
"""A validated candidate: the schema the change set asks for and the Source in it."""
|
|
51
|
+
|
|
52
|
+
schema: Schema
|
|
53
|
+
source: Source
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def candidate_of(
|
|
57
|
+
current: Schema,
|
|
58
|
+
change: ChangeSetInput,
|
|
59
|
+
name: str,
|
|
60
|
+
rules: SchemaRules,
|
|
61
|
+
*,
|
|
62
|
+
authorize: Callable[[Schema, Source | None], None] | None = None,
|
|
63
|
+
) -> SourceCandidate:
|
|
64
|
+
"""The Source `name` of the schema `change` asks for, once the schema is valid.
|
|
65
|
+
|
|
66
|
+
`authorize` is called with the schema the change set produces and the Source in it (None
|
|
67
|
+
when there is none) before the schema is validated, so a caller who may not read the
|
|
68
|
+
Source's target Kind is refused before any issue is reported. Raises
|
|
69
|
+
`CandidateInvalidError` when the change set cannot be read, fails static validation or
|
|
70
|
+
`name` is not a Source of it.
|
|
71
|
+
"""
|
|
72
|
+
try:
|
|
73
|
+
schema = target_schema(current, change)
|
|
74
|
+
except ChangeSetInvalidError as invalid:
|
|
75
|
+
raise CandidateInvalidError(invalid.issues) from None
|
|
76
|
+
source = schema.source_by_api_name(name)
|
|
77
|
+
if authorize is not None:
|
|
78
|
+
authorize(schema, source)
|
|
79
|
+
issues = static_issues(schema, rules)
|
|
80
|
+
if issues:
|
|
81
|
+
raise CandidateInvalidError(issues)
|
|
82
|
+
if source is None:
|
|
83
|
+
raise CandidateInvalidError(
|
|
84
|
+
[Finding(SOURCE_NOT_FOUND, f"No Source {name!r} in the schema.", ("sources",))]
|
|
85
|
+
)
|
|
86
|
+
return SourceCandidate(schema, source)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def expression_attributes(candidate: SourceCandidate) -> dict[str, str]:
|
|
90
|
+
"""Api name of each Attribute mapped by an expression, with the expression."""
|
|
91
|
+
schema, source = candidate.schema, candidate.source
|
|
92
|
+
kind = schema.kinds[source.target_kind]
|
|
93
|
+
return {
|
|
94
|
+
kind.attributes[attribute_id].api_name: mapping.expression
|
|
95
|
+
for attribute_id, mapping in source.attribute_mapping.items()
|
|
96
|
+
if mapping.expression is not None and attribute_id in kind.attributes
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def evaluate(
|
|
101
|
+
candidate: SourceCandidate,
|
|
102
|
+
rows: Sequence[Mapping[str, JsonValue]],
|
|
103
|
+
*,
|
|
104
|
+
limits: ExprLimits,
|
|
105
|
+
now: datetime,
|
|
106
|
+
) -> list[dict[str, ExpressionCell]]:
|
|
107
|
+
"""The value of every expression mapping of the Source for each row, in row order.
|
|
108
|
+
|
|
109
|
+
Expressions are compiled once over every column the rows have, as Scribe compiles them
|
|
110
|
+
over the columns of the Source: a column a row lacks is null."""
|
|
111
|
+
expressions = expression_attributes(candidate)
|
|
112
|
+
columns: dict[str, None] = {}
|
|
113
|
+
for row in rows:
|
|
114
|
+
columns.update(dict.fromkeys(row))
|
|
115
|
+
programs = RowExpressions(candidate.schema, columns, limits)
|
|
116
|
+
cells: list[dict[str, ExpressionCell]] = []
|
|
117
|
+
for row in rows:
|
|
118
|
+
found: dict[str, ExpressionCell] = {}
|
|
119
|
+
for attribute, expression in expressions.items():
|
|
120
|
+
try:
|
|
121
|
+
found[attribute] = ExpressionCell(
|
|
122
|
+
row_value(programs.evaluate(expression, row, now=now))
|
|
123
|
+
)
|
|
124
|
+
except ExprError as error:
|
|
125
|
+
found[attribute] = ExpressionCell(None, error.code.value, error.describe())
|
|
126
|
+
cells.append(found)
|
|
127
|
+
return cells
|