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,582 @@
|
|
|
1
|
+
"""`GET .../records/{key}/explain`: why a Record exists and why its values are what they are.
|
|
2
|
+
|
|
3
|
+
Read at the materialized head of a branch, from the branch history (`mat_history`, the
|
|
4
|
+
first-parent changesets), the provenance entries (design 5.11) and the Revisions:
|
|
5
|
+
|
|
6
|
+
- `created`: the Revision that last created the Record (its first change, or the change after
|
|
7
|
+
a deletion), its author, message and origin, and the provenance of that change;
|
|
8
|
+
- `attributes`: per Attribute, the provenance of its last change (`mat_records.lineage`) and
|
|
9
|
+
the Revision that made it;
|
|
10
|
+
- `links`: the Links of the Record and when each was made (Links backed by a foreign key are
|
|
11
|
+
attributed to the change of that key Attribute);
|
|
12
|
+
- `history`: how many changes the Record has on the branch;
|
|
13
|
+
- `proposals`: merged Proposals whose merge Revision changed the Record, or whose conflict
|
|
14
|
+
resolutions set one of its values.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from pydantic import BaseModel, ConfigDict, Field, JsonValue
|
|
24
|
+
from sqlalchemy import ColumnElement, Table, and_, func, literal, or_, select, tuple_
|
|
25
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
26
|
+
|
|
27
|
+
from lorebase_core.model import Kind, LinkDirection
|
|
28
|
+
from lorebase_core.model.relation import RelationBacking
|
|
29
|
+
from lorebase_core.query import Hop, Target
|
|
30
|
+
from lorebase_core.tree import kind_partition, link_key, relation_partition
|
|
31
|
+
from lorebase_keeper.agents.context import ref_of, timestamp
|
|
32
|
+
from lorebase_keeper.agents.markdown import pairs, short, table, text
|
|
33
|
+
from lorebase_keeper.agents.pack import ordered_attributes, ordered_values
|
|
34
|
+
from lorebase_keeper.agents.settings import ExplainSettings
|
|
35
|
+
from lorebase_keeper.reading.history import REVISIONS
|
|
36
|
+
from lorebase_keeper.reading.points import ReadPoint
|
|
37
|
+
from lorebase_keeper.reading.records import compiler_for, fetch_record
|
|
38
|
+
from lorebase_keeper.reading.runtime import ReadRuntime
|
|
39
|
+
from lorebase_keeper.reading.snapshots import with_rows
|
|
40
|
+
from lorebase_keeper.reading.views import record_out, relation_name
|
|
41
|
+
from lorebase_store.layout import lineage_column
|
|
42
|
+
from lorebase_store.materialize.rows import ELEMENT_PROVENANCE_KEY
|
|
43
|
+
from lorebase_store.models import metadata
|
|
44
|
+
from lorebase_store.repositories import MaterializedRepository, ProvenanceRepository
|
|
45
|
+
from lorebase_store.repositories.provenance import ProvenanceRow
|
|
46
|
+
|
|
47
|
+
PROPOSALS: Table = metadata.tables["proposals"]
|
|
48
|
+
MERGE_RESOLUTION = "merge_resolution"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# Response shape -------------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class _Out(BaseModel):
|
|
55
|
+
model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class OriginOut(_Out):
|
|
59
|
+
"""Where a change came from: the Revision and the provenance entry."""
|
|
60
|
+
|
|
61
|
+
revision: str | None = None
|
|
62
|
+
at: str | None = None
|
|
63
|
+
actor: str | None = None
|
|
64
|
+
origin_type: str | None = Field(default=None, description="Provenance origin type.")
|
|
65
|
+
origin: dict[str, JsonValue] | None = Field(default=None, description="Provenance origin.")
|
|
66
|
+
message: str | None = Field(default=None, description="Message of the Revision.")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class CreatedOut(OriginOut):
|
|
70
|
+
revision_origin: dict[str, JsonValue] | None = Field(
|
|
71
|
+
default=None, description="Origin of the Revision (`type`, `ref`)."
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class AttributeLineageOut(OriginOut):
|
|
76
|
+
name: str
|
|
77
|
+
value: JsonValue = None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class LinkLineageOut(OriginOut):
|
|
81
|
+
relation: str = Field(description="Relation api name seen from the Record.")
|
|
82
|
+
direction: LinkDirection
|
|
83
|
+
ref: str = Field(description="The Record at the other end.")
|
|
84
|
+
title: str | None = None
|
|
85
|
+
values: dict[str, JsonValue] | None = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ExplainedRecordOut(_Out):
|
|
89
|
+
ref: str
|
|
90
|
+
kind: str
|
|
91
|
+
key: str
|
|
92
|
+
title: str | None = None
|
|
93
|
+
values: dict[str, JsonValue]
|
|
94
|
+
updated_revision: str | None = None
|
|
95
|
+
updated_at: str | None = None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class HistoryOut(_Out):
|
|
99
|
+
changes: int
|
|
100
|
+
first_revision: str | None = None
|
|
101
|
+
last_revision: str | None = None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class ProposalOut(_Out):
|
|
105
|
+
id: str
|
|
106
|
+
title: str
|
|
107
|
+
status: str
|
|
108
|
+
merged_revision: str | None = None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class ExplainOut(_Out):
|
|
112
|
+
"""An explained Record (`format: json`)."""
|
|
113
|
+
|
|
114
|
+
domain: str
|
|
115
|
+
branch: str
|
|
116
|
+
revision: str
|
|
117
|
+
record: ExplainedRecordOut
|
|
118
|
+
created: CreatedOut | None = None
|
|
119
|
+
attributes: list[AttributeLineageOut]
|
|
120
|
+
links: list[LinkLineageOut]
|
|
121
|
+
links_truncated: bool
|
|
122
|
+
history: HistoryOut
|
|
123
|
+
proposals: list[ProposalOut]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# Building -------------------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True, slots=True)
|
|
130
|
+
class _Revision:
|
|
131
|
+
id: str
|
|
132
|
+
at: str | None
|
|
133
|
+
author: str
|
|
134
|
+
message: str
|
|
135
|
+
origin: dict[str, Any]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _compact(values: Mapping[str, Any]) -> dict[str, Any]:
|
|
139
|
+
return {name: value for name, value in values.items() if value is not None}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
_TOLD = ("origin", "message")
|
|
143
|
+
"""Members of an origin that may name an Operation or Source left out of the caller's schema."""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _origin(
|
|
147
|
+
entry: ProvenanceRow | None, revision: _Revision | None, at: str | None = None
|
|
148
|
+
) -> dict[str, Any]:
|
|
149
|
+
return _compact(
|
|
150
|
+
{
|
|
151
|
+
"revision": None if revision is None else revision.id,
|
|
152
|
+
"at": timestamp(entry.at) if entry is not None else at,
|
|
153
|
+
"actor": entry.actor if entry is not None else (revision and revision.author),
|
|
154
|
+
"origin_type": None if entry is None else entry.origin_type,
|
|
155
|
+
"origin": None if entry is None else dict(entry.origin_ref),
|
|
156
|
+
"message": None if revision is None else revision.message,
|
|
157
|
+
}
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
async def _revisions(
|
|
162
|
+
session: AsyncSession, domain_id: str, ids: Iterable[bytes]
|
|
163
|
+
) -> dict[bytes, _Revision]:
|
|
164
|
+
wanted = list(dict.fromkeys(ids))
|
|
165
|
+
if not wanted:
|
|
166
|
+
return {}
|
|
167
|
+
r = REVISIONS
|
|
168
|
+
result = await session.execute(
|
|
169
|
+
select(r.c.id, r.c.created_at, r.c.author, r.c.message, r.c.origin).where(
|
|
170
|
+
r.c.domain_id == domain_id, r.c.id.in_(wanted)
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
return {
|
|
174
|
+
bytes(row.id): _Revision(
|
|
175
|
+
bytes(row.id).hex(), timestamp(row.created_at), row.author, row.message, row.origin
|
|
176
|
+
)
|
|
177
|
+
for row in result
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _hops(point: ReadPoint, kind: Kind) -> list[Hop]:
|
|
182
|
+
"""Relations touching the Kind, both directions, whose far Kind the caller may read."""
|
|
183
|
+
hops: list[Hop] = []
|
|
184
|
+
for relation in point.schema.relations_of(kind.id):
|
|
185
|
+
for direction in LinkDirection:
|
|
186
|
+
if relation.endpoint(direction) != kind.id:
|
|
187
|
+
continue
|
|
188
|
+
far = point.schema.kinds.get(relation.far_endpoint(direction))
|
|
189
|
+
if far is None or not point.scope.allows(far.api_name):
|
|
190
|
+
continue
|
|
191
|
+
name = relation_name(relation, direction)
|
|
192
|
+
hops.append(Hop(name, relation, direction, kind.id, Target(far.api_name, (far,))))
|
|
193
|
+
return sorted(hops, key=lambda hop: hop.name)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@dataclass(frozen=True, slots=True)
|
|
197
|
+
class _Link:
|
|
198
|
+
hop: Hop
|
|
199
|
+
near_key: str
|
|
200
|
+
far_key: str
|
|
201
|
+
title: str | None
|
|
202
|
+
values: Mapping[str, Any]
|
|
203
|
+
key_provenance: str | None = None
|
|
204
|
+
"""For a Link backed by a foreign key: provenance of the last change of that key."""
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def element(self) -> tuple[str, str]:
|
|
208
|
+
"""Partition and element key of an explicit Link."""
|
|
209
|
+
forward = self.hop.direction is LinkDirection.FORWARD
|
|
210
|
+
source, target = (self.near_key, self.far_key) if forward else (self.far_key, self.near_key)
|
|
211
|
+
return relation_partition(self.hop.relation.id), link_key(source, target)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
async def _links(
|
|
215
|
+
session: AsyncSession,
|
|
216
|
+
runtime: ReadRuntime,
|
|
217
|
+
point: ReadPoint,
|
|
218
|
+
kind: Kind,
|
|
219
|
+
key: str,
|
|
220
|
+
row: Mapping[Any, Any],
|
|
221
|
+
limit: int,
|
|
222
|
+
) -> tuple[list[_Link], bool]:
|
|
223
|
+
"""The Record's Links by Relation name and far key, at most `limit` (and whether more)."""
|
|
224
|
+
compiler = compiler_for(point, runtime)
|
|
225
|
+
found: list[_Link] = []
|
|
226
|
+
for hop in _hops(point, kind):
|
|
227
|
+
remaining = limit - len(found)
|
|
228
|
+
if remaining < 0:
|
|
229
|
+
break
|
|
230
|
+
source = compiler.link_source(hop)
|
|
231
|
+
far = point.layout.records("far")
|
|
232
|
+
inverse_key = hop.foreign_key and hop.direction is LinkDirection.INVERSE
|
|
233
|
+
holder = lineage_column(source.rows) if inverse_key else None
|
|
234
|
+
columns: list[Any] = [
|
|
235
|
+
source.far.label("far_key"),
|
|
236
|
+
source.values.label("link_values"),
|
|
237
|
+
far.c.title,
|
|
238
|
+
]
|
|
239
|
+
if holder is not None:
|
|
240
|
+
columns.append(holder.label("holder_lineage"))
|
|
241
|
+
query = (
|
|
242
|
+
select(*columns)
|
|
243
|
+
.select_from(source.rows)
|
|
244
|
+
.outerjoin(
|
|
245
|
+
far,
|
|
246
|
+
and_(
|
|
247
|
+
far.c.domain_id == literal(point.domain.id),
|
|
248
|
+
far.c.branch == literal(point.layout.branch),
|
|
249
|
+
far.c.kind_id == literal(hop.far.kind_ids[0]),
|
|
250
|
+
far.c.key == source.far,
|
|
251
|
+
),
|
|
252
|
+
)
|
|
253
|
+
.where(source.scope, source.near == literal(key))
|
|
254
|
+
.order_by(source.far)
|
|
255
|
+
.limit(remaining + 1)
|
|
256
|
+
)
|
|
257
|
+
for link_row in (await session.execute(query)).mappings():
|
|
258
|
+
key_provenance = None
|
|
259
|
+
if hop.foreign_key:
|
|
260
|
+
held = (
|
|
261
|
+
link_row["holder_lineage"]
|
|
262
|
+
if hop.direction is LinkDirection.INVERSE
|
|
263
|
+
else row.get("lineage")
|
|
264
|
+
) or {}
|
|
265
|
+
key_provenance = held.get(hop.relation.foreign_key_attribute or "")
|
|
266
|
+
found.append(
|
|
267
|
+
_Link(
|
|
268
|
+
hop,
|
|
269
|
+
key,
|
|
270
|
+
link_row["far_key"],
|
|
271
|
+
link_row["title"],
|
|
272
|
+
link_row["link_values"] or {},
|
|
273
|
+
key_provenance,
|
|
274
|
+
)
|
|
275
|
+
)
|
|
276
|
+
return found[:limit], len(found) > limit
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
async def _link_creations(
|
|
280
|
+
session: AsyncSession, point: ReadPoint, elements: Sequence[tuple[str, str]]
|
|
281
|
+
) -> dict[tuple[str, str], tuple[bytes, Mapping[str, str] | None]]:
|
|
282
|
+
"""Per explicit Link element, the Revision of its latest creation and its provenance."""
|
|
283
|
+
if not elements:
|
|
284
|
+
return {}
|
|
285
|
+
h, r = point.layout.history(), REVISIONS
|
|
286
|
+
ranked = (
|
|
287
|
+
select(
|
|
288
|
+
h.c.partition,
|
|
289
|
+
h.c.element_key,
|
|
290
|
+
h.c.revision_id,
|
|
291
|
+
h.c.provenance,
|
|
292
|
+
func.row_number()
|
|
293
|
+
.over(
|
|
294
|
+
partition_by=(h.c.partition, h.c.element_key),
|
|
295
|
+
order_by=r.c.generation.desc(),
|
|
296
|
+
)
|
|
297
|
+
.label("rank"),
|
|
298
|
+
)
|
|
299
|
+
.join(r, (r.c.domain_id == h.c.domain_id) & (r.c.id == h.c.revision_id))
|
|
300
|
+
.where(
|
|
301
|
+
h.c.domain_id == point.domain.id,
|
|
302
|
+
h.c.branch == point.layout.branch,
|
|
303
|
+
h.c.old_hash.is_(None),
|
|
304
|
+
tuple_(h.c.partition, h.c.element_key).in_(list(elements)),
|
|
305
|
+
)
|
|
306
|
+
.subquery()
|
|
307
|
+
)
|
|
308
|
+
result = await session.execute(select(ranked).where(ranked.c.rank == 1))
|
|
309
|
+
return {
|
|
310
|
+
(row.partition, row.element_key): (bytes(row.revision_id), row.provenance) for row in result
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _first_provenance(provenance: Mapping[str, str] | None) -> str | None:
|
|
315
|
+
if not provenance:
|
|
316
|
+
return None
|
|
317
|
+
return provenance.get(ELEMENT_PROVENANCE_KEY) or provenance[sorted(provenance)[0]]
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
async def _proposals(
|
|
321
|
+
session: AsyncSession,
|
|
322
|
+
point: ReadPoint,
|
|
323
|
+
revisions: Iterable[bytes],
|
|
324
|
+
resolved: Iterable[str],
|
|
325
|
+
limit: int,
|
|
326
|
+
) -> list[dict[str, Any]]:
|
|
327
|
+
if limit == 0:
|
|
328
|
+
return []
|
|
329
|
+
p = PROPOSALS
|
|
330
|
+
ids = list(resolved)
|
|
331
|
+
condition: ColumnElement[bool] = p.c.merged_revision.in_(list(revisions))
|
|
332
|
+
if ids:
|
|
333
|
+
condition = or_(condition, p.c.id.in_(ids))
|
|
334
|
+
result = await session.execute(
|
|
335
|
+
select(p.c.id, p.c.title, p.c.status, p.c.merged_revision)
|
|
336
|
+
.where(p.c.domain_id == point.domain.id, condition)
|
|
337
|
+
.order_by(p.c.created_at, p.c.id)
|
|
338
|
+
.limit(limit)
|
|
339
|
+
)
|
|
340
|
+
return [
|
|
341
|
+
_compact(
|
|
342
|
+
{
|
|
343
|
+
"id": row.id,
|
|
344
|
+
"title": "" if point.tells(row.title) else row.title,
|
|
345
|
+
"status": str(row.status),
|
|
346
|
+
"merged_revision": None
|
|
347
|
+
if row.merged_revision is None
|
|
348
|
+
else bytes(row.merged_revision).hex(),
|
|
349
|
+
}
|
|
350
|
+
)
|
|
351
|
+
for row in result
|
|
352
|
+
]
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
async def explain_record(
|
|
356
|
+
session: AsyncSession,
|
|
357
|
+
runtime: ReadRuntime,
|
|
358
|
+
point: ReadPoint,
|
|
359
|
+
kind: Kind,
|
|
360
|
+
key: str,
|
|
361
|
+
settings: ExplainSettings,
|
|
362
|
+
) -> dict[str, Any]:
|
|
363
|
+
"""The explanation document of `kind/key` (see module doc)."""
|
|
364
|
+
point = await with_rows(session, runtime, point, "Explanations")
|
|
365
|
+
fetched = await fetch_record(session, runtime, point, kind, key)
|
|
366
|
+
row = fetched.row
|
|
367
|
+
record = record_out(point.schema, row, masking=point.masking)
|
|
368
|
+
writes = runtime.store_settings.writes
|
|
369
|
+
history = await MaterializedRepository(
|
|
370
|
+
session, point.domain.id, point.layout.branch, writes=writes, layout=point.layout
|
|
371
|
+
).history(kind_partition(kind.id), key)
|
|
372
|
+
links, truncated = await _links(session, runtime, point, kind, key, row, settings.max_links)
|
|
373
|
+
explicit = [
|
|
374
|
+
link.element for link in links if link.hop.relation.backing is RelationBacking.EXPLICIT
|
|
375
|
+
]
|
|
376
|
+
creations = await _link_creations(session, point, explicit)
|
|
377
|
+
|
|
378
|
+
lineage: Mapping[str, str] = row.get("lineage") or {}
|
|
379
|
+
created_row = next((h for h in reversed(history) if h.old_hash is None), None)
|
|
380
|
+
provenance_ids = [*lineage.values()]
|
|
381
|
+
provenance_ids += [link.key_provenance for link in links if link.key_provenance]
|
|
382
|
+
provenance_ids += [
|
|
383
|
+
p for _, provenance in creations.values() for p in (provenance or {}).values()
|
|
384
|
+
]
|
|
385
|
+
if created_row is not None:
|
|
386
|
+
provenance_ids += list((created_row.provenance or {}).values())
|
|
387
|
+
entries = await ProvenanceRepository(session, point.domain.id, writes=writes).get_many(
|
|
388
|
+
provenance_ids
|
|
389
|
+
)
|
|
390
|
+
revisions = await _revisions(
|
|
391
|
+
session,
|
|
392
|
+
point.domain.id,
|
|
393
|
+
[h.revision_id for h in history] + [rev for rev, _ in creations.values()],
|
|
394
|
+
)
|
|
395
|
+
# The Revision that set each provenance entry on this Record, newest change winning.
|
|
396
|
+
revision_of: dict[str, bytes] = {}
|
|
397
|
+
for change in history:
|
|
398
|
+
for provenance in (change.provenance or {}).values():
|
|
399
|
+
revision_of[provenance] = change.revision_id
|
|
400
|
+
|
|
401
|
+
def origin_of(provenance: str | None, revision: bytes | None = None) -> dict[str, Any]:
|
|
402
|
+
entry = None if provenance is None else entries.get(provenance)
|
|
403
|
+
if revision is None and provenance is not None:
|
|
404
|
+
revision = revision_of.get(provenance)
|
|
405
|
+
found = _origin(entry, None if revision is None else revisions.get(revision))
|
|
406
|
+
# Origins and messages naming something invisible to the caller are left out.
|
|
407
|
+
return {k: v for k, v in found.items() if k not in _TOLD or not point.tells(v)}
|
|
408
|
+
|
|
409
|
+
created = None
|
|
410
|
+
if created_row is not None:
|
|
411
|
+
revision = revisions.get(created_row.revision_id)
|
|
412
|
+
created = origin_of(_first_provenance(created_row.provenance), created_row.revision_id)
|
|
413
|
+
if revision is not None and not point.tells(revision.origin):
|
|
414
|
+
created["revision_origin"] = revision.origin
|
|
415
|
+
|
|
416
|
+
values = ordered_values(kind, record.values)
|
|
417
|
+
attributes = []
|
|
418
|
+
for attribute in ordered_attributes(kind):
|
|
419
|
+
if attribute.api_name not in values and attribute.id not in lineage:
|
|
420
|
+
continue
|
|
421
|
+
attributes.append(
|
|
422
|
+
{
|
|
423
|
+
"name": attribute.api_name,
|
|
424
|
+
"value": values.get(attribute.api_name),
|
|
425
|
+
**origin_of(lineage.get(attribute.id)),
|
|
426
|
+
}
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
link_out = []
|
|
430
|
+
for link in links:
|
|
431
|
+
hop = link.hop
|
|
432
|
+
far_kind = hop.far.kinds[0].api_name
|
|
433
|
+
if hop.foreign_key:
|
|
434
|
+
since = origin_of(link.key_provenance)
|
|
435
|
+
else:
|
|
436
|
+
created_link = creations.get(link.element)
|
|
437
|
+
since = (
|
|
438
|
+
{}
|
|
439
|
+
if created_link is None
|
|
440
|
+
else origin_of(_first_provenance(created_link[1]), created_link[0])
|
|
441
|
+
)
|
|
442
|
+
entry = {
|
|
443
|
+
"relation": hop.name,
|
|
444
|
+
"direction": str(hop.direction),
|
|
445
|
+
"ref": ref_of(far_kind, link.far_key),
|
|
446
|
+
"title": point.masking.title(point.schema, hop.far.kind_ids[0], link.title),
|
|
447
|
+
"values": ordered_values(
|
|
448
|
+
hop.relation, point.masking.link_values(point.schema, hop.relation, link.values)
|
|
449
|
+
)
|
|
450
|
+
or None,
|
|
451
|
+
**since,
|
|
452
|
+
}
|
|
453
|
+
link_out.append(_compact(entry))
|
|
454
|
+
|
|
455
|
+
resolved = [
|
|
456
|
+
str(e.origin_ref.get("proposal_id"))
|
|
457
|
+
for e in entries.values()
|
|
458
|
+
if e.origin_type == MERGE_RESOLUTION and e.origin_ref.get("proposal_id")
|
|
459
|
+
]
|
|
460
|
+
proposals = await _proposals(
|
|
461
|
+
session, point, [h.revision_id for h in history], resolved, settings.max_proposals
|
|
462
|
+
)
|
|
463
|
+
return {
|
|
464
|
+
"domain": point.domain.id,
|
|
465
|
+
"branch": point.branch,
|
|
466
|
+
"revision": point.revision_hex,
|
|
467
|
+
"record": _compact(
|
|
468
|
+
{
|
|
469
|
+
"ref": ref_of(kind.api_name, key),
|
|
470
|
+
"kind": kind.api_name,
|
|
471
|
+
"key": key,
|
|
472
|
+
"title": record.title,
|
|
473
|
+
"values": values,
|
|
474
|
+
"updated_revision": record.updated_revision,
|
|
475
|
+
"updated_at": timestamp(record.updated_at),
|
|
476
|
+
}
|
|
477
|
+
),
|
|
478
|
+
"created": created,
|
|
479
|
+
"attributes": attributes,
|
|
480
|
+
"links": link_out,
|
|
481
|
+
"links_truncated": truncated,
|
|
482
|
+
"history": _compact(
|
|
483
|
+
{
|
|
484
|
+
"changes": len(history),
|
|
485
|
+
"first_revision": history[0].revision_id.hex() if history else None,
|
|
486
|
+
"last_revision": history[-1].revision_id.hex() if history else None,
|
|
487
|
+
}
|
|
488
|
+
),
|
|
489
|
+
"proposals": proposals,
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
# Markdown -------------------------------------------------------------------------------------
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _source(item: Mapping[str, Any]) -> str:
|
|
497
|
+
origin = item.get("origin") or {}
|
|
498
|
+
kind = item.get("origin_type") or ""
|
|
499
|
+
ref = origin.get("ref") if isinstance(origin, dict) else None
|
|
500
|
+
return f"{kind} {ref}" if kind and ref else kind
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def explain_markdown(document: Mapping[str, Any]) -> str:
|
|
504
|
+
"""The explanation as compact markdown (see `agents.markdown`)."""
|
|
505
|
+
record = document["record"]
|
|
506
|
+
history = document["history"]
|
|
507
|
+
lines = [
|
|
508
|
+
f"# Explain: {record['ref']}"
|
|
509
|
+
+ (f": {text(record['title'])}" if record.get("title") else ""),
|
|
510
|
+
f"{document['domain']}/{document['branch']} at revision {document['revision']}; "
|
|
511
|
+
f"{history['changes']} change{'' if history['changes'] == 1 else 's'} on this branch",
|
|
512
|
+
]
|
|
513
|
+
sections = ["\n".join(lines)]
|
|
514
|
+
created = document.get("created")
|
|
515
|
+
if created:
|
|
516
|
+
revision_origin = created.get("revision_origin") or {}
|
|
517
|
+
what = " ".join(
|
|
518
|
+
str(part) for part in (revision_origin.get("type"), revision_origin.get("ref")) if part
|
|
519
|
+
)
|
|
520
|
+
sections.append(
|
|
521
|
+
"\n".join(
|
|
522
|
+
[
|
|
523
|
+
"## Created",
|
|
524
|
+
f"In {short(created.get('revision'))} at {created.get('at', '')} by "
|
|
525
|
+
f"{created.get('actor', 'unknown')}"
|
|
526
|
+
+ (f" ({what})" if what else "")
|
|
527
|
+
+ (f": {text(created['message'])}" if created.get("message") else ""),
|
|
528
|
+
]
|
|
529
|
+
)
|
|
530
|
+
)
|
|
531
|
+
rows = [
|
|
532
|
+
(
|
|
533
|
+
a["name"],
|
|
534
|
+
a.get("value"),
|
|
535
|
+
short(a.get("revision")),
|
|
536
|
+
a.get("at", ""),
|
|
537
|
+
a.get("actor", ""),
|
|
538
|
+
_source(a),
|
|
539
|
+
a.get("message", ""),
|
|
540
|
+
)
|
|
541
|
+
for a in document["attributes"]
|
|
542
|
+
]
|
|
543
|
+
header = ("attribute", "value", "last change", "at", "by", "origin", "message")
|
|
544
|
+
sections.append("\n".join(["## Attributes", *table(header, rows)]))
|
|
545
|
+
if document["links"]:
|
|
546
|
+
link_rows = [
|
|
547
|
+
(
|
|
548
|
+
link["relation"],
|
|
549
|
+
link["ref"],
|
|
550
|
+
link.get("title", ""),
|
|
551
|
+
pairs(link.get("values")),
|
|
552
|
+
short(link.get("revision")),
|
|
553
|
+
link.get("at", ""),
|
|
554
|
+
link.get("actor", ""),
|
|
555
|
+
_source(link),
|
|
556
|
+
)
|
|
557
|
+
for link in document["links"]
|
|
558
|
+
]
|
|
559
|
+
link_header = ("relation", "record", "title", "values", "made in", "at", "by", "origin")
|
|
560
|
+
block = ["## Links", *table(link_header, link_rows)]
|
|
561
|
+
if document["links_truncated"]:
|
|
562
|
+
block.append("(more Links not listed)")
|
|
563
|
+
sections.append("\n".join(block))
|
|
564
|
+
if document["proposals"]:
|
|
565
|
+
sections.append(
|
|
566
|
+
"\n".join(
|
|
567
|
+
[
|
|
568
|
+
"## Proposals",
|
|
569
|
+
*(
|
|
570
|
+
f"- {p['id']}: {text(p['title'])} ({p['status']}"
|
|
571
|
+
+ (
|
|
572
|
+
f", merged in {short(p.get('merged_revision'))}"
|
|
573
|
+
if p.get("merged_revision")
|
|
574
|
+
else ""
|
|
575
|
+
)
|
|
576
|
+
+ ")"
|
|
577
|
+
for p in document["proposals"]
|
|
578
|
+
),
|
|
579
|
+
]
|
|
580
|
+
)
|
|
581
|
+
)
|
|
582
|
+
return "\n\n".join(sections) + "\n"
|