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,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
|