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.
Files changed (126) hide show
  1. lorebase_keeper/__init__.py +1 -0
  2. lorebase_keeper/__main__.py +456 -0
  3. lorebase_keeper/agents/__init__.py +1 -0
  4. lorebase_keeper/agents/budget.py +247 -0
  5. lorebase_keeper/agents/context.py +529 -0
  6. lorebase_keeper/agents/explain.py +582 -0
  7. lorebase_keeper/agents/feed.py +259 -0
  8. lorebase_keeper/agents/markdown.py +219 -0
  9. lorebase_keeper/agents/pack.py +403 -0
  10. lorebase_keeper/agents/settings.py +111 -0
  11. lorebase_keeper/app.py +373 -0
  12. lorebase_keeper/auth/__init__.py +4 -0
  13. lorebase_keeper/auth/api_keys.py +127 -0
  14. lorebase_keeper/auth/audit.py +356 -0
  15. lorebase_keeper/auth/cache.py +53 -0
  16. lorebase_keeper/auth/config.py +618 -0
  17. lorebase_keeper/auth/deps.py +183 -0
  18. lorebase_keeper/auth/keyring.py +14 -0
  19. lorebase_keeper/auth/oidc.py +242 -0
  20. lorebase_keeper/auth/permissions.py +142 -0
  21. lorebase_keeper/auth/ports.py +212 -0
  22. lorebase_keeper/auth/principals.py +377 -0
  23. lorebase_keeper/auth/provider.py +250 -0
  24. lorebase_keeper/auth/rbac.py +247 -0
  25. lorebase_keeper/auth/read_audit.py +357 -0
  26. lorebase_keeper/auth/runtime.py +113 -0
  27. lorebase_keeper/auth/sessions.py +452 -0
  28. lorebase_keeper/bootstrap.py +151 -0
  29. lorebase_keeper/client_address.py +129 -0
  30. lorebase_keeper/context.py +226 -0
  31. lorebase_keeper/db.py +93 -0
  32. lorebase_keeper/deps.py +47 -0
  33. lorebase_keeper/errors.py +257 -0
  34. lorebase_keeper/health.py +61 -0
  35. lorebase_keeper/kindhealth/__init__.py +6 -0
  36. lorebase_keeper/kindhealth/config.py +51 -0
  37. lorebase_keeper/kindhealth/markdown.py +163 -0
  38. lorebase_keeper/kindhealth/models.py +179 -0
  39. lorebase_keeper/kindhealth/report.py +154 -0
  40. lorebase_keeper/kindhealth/scan.py +512 -0
  41. lorebase_keeper/middleware.py +285 -0
  42. lorebase_keeper/pagination.py +87 -0
  43. lorebase_keeper/ratelimit/__init__.py +10 -0
  44. lorebase_keeper/ratelimit/bucket.py +118 -0
  45. lorebase_keeper/ratelimit/config.py +283 -0
  46. lorebase_keeper/ratelimit/enforce.py +393 -0
  47. lorebase_keeper/ratelimit/limiter.py +149 -0
  48. lorebase_keeper/ratelimit/quotas.py +204 -0
  49. lorebase_keeper/ratelimit/shared.py +242 -0
  50. lorebase_keeper/readiness.py +257 -0
  51. lorebase_keeper/reading/__init__.py +1 -0
  52. lorebase_keeper/reading/caching.py +251 -0
  53. lorebase_keeper/reading/compiler.py +678 -0
  54. lorebase_keeper/reading/cursors.py +56 -0
  55. lorebase_keeper/reading/diff.py +408 -0
  56. lorebase_keeper/reading/graph.py +268 -0
  57. lorebase_keeper/reading/history.py +152 -0
  58. lorebase_keeper/reading/points.py +410 -0
  59. lorebase_keeper/reading/problems.py +74 -0
  60. lorebase_keeper/reading/record_history.py +722 -0
  61. lorebase_keeper/reading/records.py +780 -0
  62. lorebase_keeper/reading/requests.py +107 -0
  63. lorebase_keeper/reading/runtime.py +145 -0
  64. lorebase_keeper/reading/search.py +708 -0
  65. lorebase_keeper/reading/sensitivity.py +882 -0
  66. lorebase_keeper/reading/settings.py +339 -0
  67. lorebase_keeper/reading/snapshots.py +148 -0
  68. lorebase_keeper/reading/tree.py +98 -0
  69. lorebase_keeper/reading/views.py +264 -0
  70. lorebase_keeper/reading/visibility.py +93 -0
  71. lorebase_keeper/routes/__init__.py +1 -0
  72. lorebase_keeper/routes/admin.py +597 -0
  73. lorebase_keeper/routes/agents.py +160 -0
  74. lorebase_keeper/routes/branches.py +438 -0
  75. lorebase_keeper/routes/derivations.py +215 -0
  76. lorebase_keeper/routes/documents.py +497 -0
  77. lorebase_keeper/routes/domain_api.py +59 -0
  78. lorebase_keeper/routes/domains.py +220 -0
  79. lorebase_keeper/routes/events.py +187 -0
  80. lorebase_keeper/routes/kind_health.py +166 -0
  81. lorebase_keeper/routes/operations.py +392 -0
  82. lorebase_keeper/routes/proposal_items.py +563 -0
  83. lorebase_keeper/routes/proposals.py +1107 -0
  84. lorebase_keeper/routes/read.py +493 -0
  85. lorebase_keeper/routes/revisions.py +523 -0
  86. lorebase_keeper/routes/schema.py +682 -0
  87. lorebase_keeper/routes/schema_inference.py +437 -0
  88. lorebase_keeper/routes/schema_suggestions.py +328 -0
  89. lorebase_keeper/routes/sessions.py +286 -0
  90. lorebase_keeper/routes/source_preview.py +232 -0
  91. lorebase_keeper/routes/sources.py +200 -0
  92. lorebase_keeper/routes/trace.py +506 -0
  93. lorebase_keeper/routes/vault.py +332 -0
  94. lorebase_keeper/routes/webhooks.py +298 -0
  95. lorebase_keeper/routes/whoami.py +88 -0
  96. lorebase_keeper/services/__init__.py +1 -0
  97. lorebase_keeper/services/agent_writes.py +1035 -0
  98. lorebase_keeper/services/branches.py +337 -0
  99. lorebase_keeper/services/config.py +431 -0
  100. lorebase_keeper/services/derivation_preview.py +279 -0
  101. lorebase_keeper/services/document_urls.py +114 -0
  102. lorebase_keeper/services/domains.py +118 -0
  103. lorebase_keeper/services/errors.py +196 -0
  104. lorebase_keeper/services/operation_catalog.py +806 -0
  105. lorebase_keeper/services/operation_events.py +199 -0
  106. lorebase_keeper/services/operations.py +1229 -0
  107. lorebase_keeper/services/pii_import.py +222 -0
  108. lorebase_keeper/services/proposal_reads.py +158 -0
  109. lorebase_keeper/services/proposals.py +850 -0
  110. lorebase_keeper/services/reads.py +286 -0
  111. lorebase_keeper/services/schema.py +601 -0
  112. lorebase_keeper/services/schema_inference.py +390 -0
  113. lorebase_keeper/services/schema_suggestions.py +418 -0
  114. lorebase_keeper/services/source_preview.py +127 -0
  115. lorebase_keeper/settings.py +313 -0
  116. lorebase_keeper/tracing/__init__.py +1 -0
  117. lorebase_keeper/tracing/capture.py +350 -0
  118. lorebase_keeper/tracing/config.py +157 -0
  119. lorebase_keeper/tracing/explain.py +97 -0
  120. lorebase_keeper/tracing/middleware.py +359 -0
  121. lorebase_keeper/tracing/notes.py +80 -0
  122. lorebase_keeper/tracing/recorder.py +180 -0
  123. lorebase_keeper-0.2.3.dist-info/METADATA +21 -0
  124. lorebase_keeper-0.2.3.dist-info/RECORD +126 -0
  125. lorebase_keeper-0.2.3.dist-info/WHEEL +4 -0
  126. 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"