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,529 @@
1
+ """`POST /context`: one call that returns what an agent needs about some Records.
2
+
3
+ Seeds come from `refs`, a DSL `query` or a lexical `search`. Around them the materialized
4
+ head gives the neighborhood up to `depth` (one statement, `lorebase_store.traverse`, walking
5
+ only Relations between Kinds the caller may read),
6
+ the schema fragment of what is present, the recent changes of those Records and Links (the
7
+ branch history, `mat_history`, which mirrors the first-parent changesets) and the lineage of
8
+ the seed values. The pack is then trimmed to the token budget (`agents.budget`) and rendered
9
+ as JSON or markdown. No model is called and every ordering is fixed, so a request at a given
10
+ Revision always yields the same bytes; packs are cached by Revision like GET reads.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections import defaultdict, deque
16
+ from collections.abc import Iterable, Mapping, Sequence
17
+ from dataclasses import dataclass
18
+ from datetime import UTC, datetime
19
+ from typing import Any
20
+
21
+ from sqlalchemy import select, tuple_
22
+ from sqlalchemy.ext.asyncio import AsyncSession
23
+
24
+ from lorebase_core.model import Kind, LinkDirection, Relation, Schema
25
+ from lorebase_core.tree import kind_partition, link_key, relation_partition
26
+ from lorebase_keeper.agents.budget import (
27
+ ChangeItem,
28
+ Draft,
29
+ LineageItem,
30
+ LinkItem,
31
+ NeighborItem,
32
+ SeedItem,
33
+ fit,
34
+ )
35
+ from lorebase_keeper.agents.markdown import markdown_measure, markdown_sections, pack_markdown
36
+ from lorebase_keeper.agents.pack import (
37
+ ContextRequest,
38
+ budget_json,
39
+ dumps,
40
+ json_measure,
41
+ json_sections,
42
+ ordered_attributes,
43
+ ordered_values,
44
+ )
45
+ from lorebase_keeper.agents.settings import ContextSettings
46
+ from lorebase_keeper.reading.compiler import record_columns
47
+ from lorebase_keeper.reading.history import REVISIONS
48
+ from lorebase_keeper.reading.points import ReadPoint, not_found
49
+ from lorebase_keeper.reading.problems import invalid
50
+ from lorebase_keeper.reading.records import plan_query, require_kind, run_query
51
+ from lorebase_keeper.reading.requests import SearchRequest
52
+ from lorebase_keeper.reading.runtime import ReadRuntime
53
+ from lorebase_keeper.reading.search import search
54
+ from lorebase_keeper.reading.sensitivity import deferred_markdown
55
+ from lorebase_keeper.reading.snapshots import with_rows
56
+ from lorebase_keeper.reading.views import RecordOut, link_values, record_out
57
+ from lorebase_store.materialize.rows import ELEMENT_PROVENANCE_KEY, PartitionType, parse_partition
58
+ from lorebase_store.reads import Direction
59
+ from lorebase_store.repositories import ProvenanceRepository
60
+ from lorebase_store.traverse import TraversalLimitError, neighborhood
61
+
62
+ MARKDOWN = "text/markdown; charset=utf-8"
63
+ JSON = "application/json"
64
+
65
+
66
+ def ref_of(kind: str, key: str) -> str:
67
+ return f"{kind}/{key}"
68
+
69
+
70
+ def timestamp(value: datetime | None) -> str | None:
71
+ """RFC 3339 UTC to the second: compact and stable."""
72
+ return None if value is None else value.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
73
+
74
+
75
+ @dataclass(frozen=True, slots=True)
76
+ class PackSettings:
77
+ budget_tokens: int
78
+ depth: int
79
+
80
+
81
+ def effective(
82
+ request: ContextRequest, settings: ContextSettings, runtime: ReadRuntime
83
+ ) -> PackSettings:
84
+ """Budget and depth of a request, within the configured bounds (422 otherwise)."""
85
+ budget = request.budget_tokens or settings.default_budget_tokens
86
+ if budget > settings.max_budget_tokens:
87
+ raise invalid(
88
+ "/body/budget_tokens", f"must not exceed {settings.max_budget_tokens}", "limit_exceeded"
89
+ )
90
+ depth = settings.default_depth if request.depth is None else request.depth
91
+ deepest = min(
92
+ settings.max_depth,
93
+ runtime.read.traverse.max_depth,
94
+ runtime.store_settings.traverse.max_depth,
95
+ )
96
+ if depth > deepest:
97
+ raise invalid("/body/depth", f"must not exceed {deepest}", "limit_exceeded")
98
+ return PackSettings(budget, depth)
99
+
100
+
101
+ # Seeds ----------------------------------------------------------------------------------------
102
+
103
+
104
+ async def seed_addresses(
105
+ session: AsyncSession,
106
+ runtime: ReadRuntime,
107
+ point: ReadPoint,
108
+ request: ContextRequest,
109
+ settings: ContextSettings,
110
+ ) -> list[tuple[Kind, str]]:
111
+ """`(Kind, key)` of the seeds, in request, query or ranking order, without repeats."""
112
+ cap = settings.max_seeds
113
+ found: list[tuple[str, str]] = []
114
+ if request.refs is not None:
115
+ if len(request.refs) > cap:
116
+ raise invalid("/body/refs", f"at most {cap} references", "limit_exceeded")
117
+ for index, ref in enumerate(request.refs):
118
+ kind, sep, key = ref.partition("/")
119
+ if not sep or not kind or not key:
120
+ raise invalid(f"/body/refs/{index}", "must be `Kind/key`", "invalid_ref")
121
+ found.append((kind, key))
122
+ elif request.query is not None:
123
+ limit = min(request.query.limit or cap, cap)
124
+ query = request.query.model_copy(
125
+ update={"limit": limit, "cursor": None, "include": {}, "aggregate": None}
126
+ )
127
+ plan = plan_query(query, point, runtime, "/body/query")
128
+ page = await run_query(
129
+ session, runtime, point, plan, shape="context", cursor_pointer="/body/query/cursor"
130
+ )
131
+ found = [(item.kind, item.key) for item in page.items]
132
+ else:
133
+ assert request.search is not None
134
+ limit = min(cap, runtime.search.results.max_limit)
135
+ hits = await search(
136
+ session, runtime, point, SearchRequest(q=request.search, mode="lexical", limit=limit)
137
+ )
138
+ found = [(hit.kind, hit.key) for hit in hits.items]
139
+ unique = list(dict.fromkeys(found))
140
+ return [(require_kind(point, kind), key) for kind, key in unique]
141
+
142
+
143
+ async def fetch_rows(
144
+ session: AsyncSession, point: ReadPoint, addresses: Sequence[tuple[Kind, str]]
145
+ ) -> list[Mapping[Any, Any]]:
146
+ """Materialized rows of `addresses`, in their order; 404 for a missing Record."""
147
+ by_kind: dict[str, list[str]] = defaultdict(list)
148
+ for kind, key in addresses:
149
+ by_kind[kind.id].append(key)
150
+ r = point.layout.records()
151
+ rows: dict[tuple[str, str], Mapping[Any, Any]] = {}
152
+ for kind_id, keys in by_kind.items():
153
+ result = await session.execute(
154
+ select(*record_columns(r)).where(
155
+ r.c.domain_id == point.domain.id,
156
+ r.c.branch == point.layout.branch,
157
+ r.c.kind_id == kind_id,
158
+ r.c.key.in_(keys),
159
+ )
160
+ )
161
+ for row in result.mappings():
162
+ rows[(kind_id, row["key"])] = row
163
+ out: list[Mapping[Any, Any]] = []
164
+ for kind, key in addresses:
165
+ found = rows.get((kind.id, key))
166
+ if found is None:
167
+ raise not_found(f"Record {kind.api_name}/{key}")
168
+ out.append(found)
169
+ return out
170
+
171
+
172
+ # Neighborhood ---------------------------------------------------------------------------------
173
+
174
+
175
+ @dataclass(frozen=True, slots=True)
176
+ class Surroundings:
177
+ neighbors: tuple[NeighborItem, ...]
178
+ links: tuple[LinkItem, ...]
179
+ capped: bool
180
+
181
+
182
+ def _depths(seeds: Iterable[str], links: Iterable[LinkItem]) -> dict[str, int]:
183
+ """Hops from the nearest seed over Links in either direction."""
184
+ adjacent: dict[str, set[str]] = defaultdict(set)
185
+ for link in links:
186
+ adjacent[link.source].add(link.target)
187
+ adjacent[link.target].add(link.source)
188
+ depth = dict.fromkeys(seeds, 0)
189
+ queue = deque(depth)
190
+ while queue:
191
+ node = queue.popleft()
192
+ for other in sorted(adjacent[node]):
193
+ if other not in depth:
194
+ depth[other] = depth[node] + 1
195
+ queue.append(other)
196
+ return depth
197
+
198
+
199
+ async def surroundings(
200
+ session: AsyncSession,
201
+ runtime: ReadRuntime,
202
+ point: ReadPoint,
203
+ seeds: Sequence[RecordOut],
204
+ request: ContextRequest,
205
+ settings: ContextSettings,
206
+ depth: int,
207
+ ) -> Surroundings:
208
+ if depth == 0 or not seeds:
209
+ return Surroundings((), (), False)
210
+ caps, walk = runtime.read.traverse, runtime.store_settings.traverse
211
+ try:
212
+ found = await neighborhood(
213
+ session,
214
+ point.domain.id,
215
+ point.layout,
216
+ point.schema,
217
+ [(require_kind(point, s.kind).id, s.key) for s in seeds],
218
+ depth=depth,
219
+ follow=_follow(point, request.relations),
220
+ max_nodes=min(len(seeds) + settings.max_neighbors, caps.max_nodes, walk.max_nodes),
221
+ max_links=max(min(settings.max_links, caps.max_edges, walk.max_links), 1),
222
+ runtime=runtime.store,
223
+ )
224
+ except TraversalLimitError as error:
225
+ raise invalid("/body", str(error), "limit_exceeded") from None
226
+ schema = point.schema
227
+ links: dict[tuple[str, str, str], LinkItem] = {}
228
+ for link in found.links:
229
+ relation = schema.relations[link.relation_id]
230
+ source = ref_of(schema.kinds[relation.from_kind].api_name, link.from_key)
231
+ target = ref_of(schema.kinds[relation.to_kind].api_name, link.to_key)
232
+ links.setdefault(
233
+ (relation.api_name, source, target),
234
+ LinkItem(
235
+ relation.api_name,
236
+ source,
237
+ target,
238
+ ordered_values(
239
+ relation, link_values(point.schema, relation, link.values, point.masking)
240
+ ),
241
+ ),
242
+ )
243
+ seed_refs = [ref_of(s.kind, s.key) for s in seeds]
244
+ ordered_links = tuple(links[k] for k in sorted(links))
245
+ depths = _depths(seed_refs, ordered_links)
246
+ seeded = set(seed_refs)
247
+ neighbors = []
248
+ for reached in found.records:
249
+ record = reached.record
250
+ kind = schema.kinds[record.kind_id]
251
+ ref = ref_of(kind.api_name, record.key)
252
+ if ref in seeded:
253
+ continue
254
+ neighbors.append(
255
+ NeighborItem(
256
+ ref=ref,
257
+ kind=kind.api_name,
258
+ title=point.masking.title(schema, record.kind_id, record.title),
259
+ depth=depths.get(ref, depth),
260
+ values=ordered_values(kind, point.masking.kind_values(kind, record.values)),
261
+ )
262
+ )
263
+ neighbors.sort(key=lambda n: (n.depth, n.kind, n.ref))
264
+ return Surroundings(tuple(neighbors), ordered_links, found.truncated)
265
+
266
+
267
+ def _follow(point: ReadPoint, names: list[str] | None) -> dict[str, Direction]:
268
+ """Relations to walk (id to direction): those named (forward or inverse api name; default:
269
+ all), in the directions named, between Kinds the caller may read."""
270
+ schema = point.schema
271
+ wanted: dict[str, set[LinkDirection]] = defaultdict(set)
272
+ if names is None:
273
+ for relation in schema.relations.values():
274
+ wanted[relation.id] = set(LinkDirection)
275
+ else:
276
+ for index, name in enumerate(names):
277
+ found = schema.relation_by_api_name(name)
278
+ if found is None:
279
+ raise invalid(f"/body/relations/{index}", f"unknown Relation {name!r}", "unknown")
280
+ wanted[found[0].id].add(found[1])
281
+ follow: dict[str, Direction] = {}
282
+ for relation_id, directions in sorted(wanted.items()):
283
+ relation = schema.relations[relation_id]
284
+ ends = (schema.kinds.get(relation.from_kind), schema.kinds.get(relation.to_kind))
285
+ if any(kind is None or not point.scope.allows(kind.api_name) for kind in ends):
286
+ continue # Kinds the caller may not read are neither returned nor walked through
287
+ if len(directions) == 2:
288
+ follow[relation_id] = Direction.BOTH
289
+ else:
290
+ follow[relation_id] = (
291
+ Direction.OUT if LinkDirection.FORWARD in directions else Direction.IN
292
+ )
293
+ return follow
294
+
295
+
296
+ # History and lineage --------------------------------------------------------------------------
297
+
298
+
299
+ def _change_kind(old: bytes | None, new: bytes | None) -> str:
300
+ if old is None:
301
+ return "added"
302
+ return "removed" if new is None else "modified"
303
+
304
+
305
+ def _attribute_names(
306
+ schema: Schema, partition: str, provenance: Mapping[str, str] | None
307
+ ) -> tuple[str, ...]:
308
+ parsed = parse_partition(partition)
309
+ owner: Kind | Relation | None = None
310
+ if parsed.type is PartitionType.KIND:
311
+ owner = schema.kinds.get(parsed.element_id)
312
+ elif parsed.type is PartitionType.RELATION:
313
+ owner = schema.relations.get(parsed.element_id)
314
+ if owner is None:
315
+ return ()
316
+ changed = set(provenance or {}) - {ELEMENT_PROVENANCE_KEY}
317
+ return tuple(a.api_name for a in ordered_attributes(owner) if a.id in changed)
318
+
319
+
320
+ async def recent_changes(
321
+ session: AsyncSession,
322
+ point: ReadPoint,
323
+ records: Sequence[tuple[Kind, str]],
324
+ links: Sequence[LinkItem],
325
+ *,
326
+ floor: int | None,
327
+ limit: int,
328
+ ) -> tuple[ChangeItem, ...]:
329
+ """Changes of the given Records and Links on the branch, newest `limit`, oldest first."""
330
+ elements: dict[tuple[str, str], str] = {}
331
+ for kind, key in records:
332
+ elements[(kind_partition(kind.id), key)] = ref_of(kind.api_name, key)
333
+ for link in links:
334
+ named = point.schema.relation_by_api_name(link.relation)
335
+ if named is None:
336
+ continue
337
+ source_key, target_key = link.source.partition("/")[2], link.target.partition("/")[2]
338
+ elements[(relation_partition(named[0].id), link_key(source_key, target_key))] = (
339
+ f"{link.relation}: {link.source} -> {link.target}"
340
+ )
341
+ if not elements:
342
+ return ()
343
+ h, r = point.layout.history(), REVISIONS
344
+ query = (
345
+ select(
346
+ h.c.partition,
347
+ h.c.element_key,
348
+ h.c.revision_id,
349
+ h.c.at,
350
+ h.c.old_hash,
351
+ h.c.new_hash,
352
+ h.c.provenance,
353
+ r.c.author,
354
+ r.c.message,
355
+ r.c.origin,
356
+ )
357
+ .join(r, (r.c.domain_id == h.c.domain_id) & (r.c.id == h.c.revision_id))
358
+ .where(
359
+ h.c.domain_id == point.domain.id,
360
+ h.c.branch == point.layout.branch,
361
+ tuple_(h.c.partition, h.c.element_key).in_(list(elements)),
362
+ )
363
+ .order_by(r.c.generation.desc(), h.c.partition, h.c.element_key)
364
+ .limit(limit)
365
+ )
366
+ if floor is not None:
367
+ query = query.where(r.c.generation > floor)
368
+ rows = list((await session.execute(query)).mappings())
369
+ items = [
370
+ ChangeItem(
371
+ revision=bytes(row["revision_id"]).hex(),
372
+ at=timestamp(row["at"]) or "",
373
+ ref=elements[(row["partition"], row["element_key"])],
374
+ change=_change_kind(row["old_hash"], row["new_hash"]),
375
+ attributes=_attribute_names(point.schema, row["partition"], row["provenance"]),
376
+ actor=row["author"],
377
+ origin=str((row["origin"] or {}).get("type", "")),
378
+ # Messages naming something invisible to the caller are left out (design 13.3).
379
+ message="" if point.tells(row["message"]) else row["message"],
380
+ )
381
+ for row in reversed(rows)
382
+ ]
383
+ if point.hidden:
384
+ # A change of nothing but Attributes left out of the caller's schema tells that
385
+ # something invisible changed.
386
+ items = [i for i in items if i.change != "modified" or i.attributes]
387
+ return tuple(items)
388
+
389
+
390
+ async def lineage_items(
391
+ session: AsyncSession,
392
+ runtime: ReadRuntime,
393
+ point: ReadPoint,
394
+ rows: Sequence[Mapping[Any, Any]],
395
+ ) -> tuple[LineageItem, ...]:
396
+ """Per seed, its Attributes grouped by the provenance entry of their last change."""
397
+ ids = [p for row in rows for p in (row.get("lineage") or {}).values()]
398
+ entries = await ProvenanceRepository(
399
+ session, point.domain.id, writes=runtime.store_settings.writes
400
+ ).get_many(ids)
401
+ items: list[LineageItem] = []
402
+ for row in rows:
403
+ kind = point.schema.kinds.get(row["kind_id"])
404
+ if kind is None:
405
+ continue
406
+ lineage: Mapping[str, str] = row.get("lineage") or {}
407
+ groups: dict[str, list[str]] = {}
408
+ for attribute in ordered_attributes(kind):
409
+ provenance = lineage.get(attribute.id)
410
+ if provenance is not None:
411
+ groups.setdefault(provenance, []).append(attribute.api_name)
412
+ for provenance, names in groups.items():
413
+ entry = entries.get(provenance)
414
+ items.append(
415
+ LineageItem(
416
+ ref=ref_of(kind.api_name, row["key"]),
417
+ origin=None if entry is None else entry.origin_type,
418
+ actor=None if entry is None else entry.actor,
419
+ at=None if entry is None else timestamp(entry.at),
420
+ attributes=tuple(names),
421
+ )
422
+ )
423
+ return tuple(items)
424
+
425
+
426
+ # The pack -------------------------------------------------------------------------------------
427
+
428
+
429
+ @dataclass(frozen=True, slots=True)
430
+ class PackContext:
431
+ """What the route resolved before building: where, for whom, and the cache tag."""
432
+
433
+ point: ReadPoint
434
+ etag: str
435
+ since: str | None
436
+ floor: int | None
437
+
438
+
439
+ async def build_pack(
440
+ session: AsyncSession,
441
+ runtime: ReadRuntime,
442
+ context: PackContext,
443
+ request: ContextRequest,
444
+ settings: ContextSettings,
445
+ ) -> tuple[bytes, str]:
446
+ """The rendered pack and its media type."""
447
+ bounds = effective(request, settings, runtime)
448
+ point = await with_rows(session, runtime, context.point, "Context packs")
449
+ addresses = await seed_addresses(session, runtime, point, request, settings)
450
+ rows = await fetch_rows(session, point, addresses)
451
+ seeds = [record_out(point.schema, row, masking=point.masking) for row in rows]
452
+ around = await surroundings(session, runtime, point, seeds, request, settings, bounds.depth)
453
+ changes = None
454
+ if request.include_recent_changes:
455
+ records = list(addresses)
456
+ for neighbor in around.neighbors:
457
+ kind = point.schema.kind_by_api_name(neighbor.kind)
458
+ if kind is not None:
459
+ records.append((kind, neighbor.ref.partition("/")[2]))
460
+ changes = await recent_changes(
461
+ session,
462
+ point,
463
+ records,
464
+ around.links,
465
+ floor=context.floor,
466
+ limit=settings.recent_changes_max,
467
+ )
468
+ lineage = (
469
+ await lineage_items(session, runtime, point, rows) if request.include_lineage else None
470
+ )
471
+ draft = Draft(
472
+ seeds=tuple(
473
+ SeedItem(
474
+ ref=ref_of(s.kind, s.key),
475
+ kind=s.kind,
476
+ title=s.title,
477
+ values=ordered_values(point.schema.kind_by_api_name(s.kind), s.values),
478
+ updated_revision=s.updated_revision,
479
+ )
480
+ for s in seeds
481
+ ),
482
+ neighbors=around.neighbors,
483
+ links=around.links,
484
+ schema=request.include_schema,
485
+ changes=changes,
486
+ lineage=lineage,
487
+ )
488
+ markdown = request.format == "markdown"
489
+ measure = markdown_measure(point.schema) if markdown else json_measure(point.schema)
490
+ result = fit(
491
+ draft,
492
+ budget_tokens=bounds.budget_tokens,
493
+ chars_per_token=settings.chars_per_token,
494
+ measure=measure,
495
+ order=settings.trim_order,
496
+ )
497
+ budget = budget_json(
498
+ result,
499
+ draft,
500
+ budget_tokens=bounds.budget_tokens,
501
+ chars_per_token=settings.chars_per_token,
502
+ capped=around.capped,
503
+ )
504
+ if markdown:
505
+ # References are resolved when the pack is sent, from the envelope (never by
506
+ # matching the text, where a stored string may look like one).
507
+ envelope = deferred_markdown(
508
+ result.draft,
509
+ lambda draft: pack_markdown(
510
+ markdown_sections(draft, point.schema),
511
+ domain=point.domain.id,
512
+ branch=point.branch,
513
+ revision=point.revision_hex,
514
+ budget=budget,
515
+ since=context.since,
516
+ ),
517
+ )
518
+ return envelope, MARKDOWN
519
+ document: dict[str, Any] = {
520
+ "domain": point.domain.id,
521
+ "branch": point.branch,
522
+ "revision": point.revision_hex,
523
+ "etag": context.etag,
524
+ **json_sections(result.draft, point.schema),
525
+ }
526
+ if context.since is not None:
527
+ document["since"] = context.since
528
+ document["budget"] = budget
529
+ return dumps(document).encode(), JSON