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,259 @@
|
|
|
1
|
+
"""Change feed of a branch: Revision events as Server-Sent Events, with a polling fallback.
|
|
2
|
+
|
|
3
|
+
Events are the `revision.committed` outbox events of the branch, described by
|
|
4
|
+
`lorebase_store.changefeed` (Revision metadata, changes per Kind and Relation, changed keys up
|
|
5
|
+
to `agents.feed.changed_keys_max`). The SSE `id` of an event is its outbox sequence, so a
|
|
6
|
+
client reconnecting with `Last-Event-ID` resumes after the last event it saw; without it the
|
|
7
|
+
stream starts with the next commit. The stream sends a comment line after `heartbeat_s` of
|
|
8
|
+
silence and closes after `max_connection_s`.
|
|
9
|
+
|
|
10
|
+
Every `reauthorize_interval_s` the stream runs the authentication and authorization of its
|
|
11
|
+
request again and closes when they fail, so a revoked API key, an expired token or a withdrawn
|
|
12
|
+
grant ends it without waiting for `max_connection_s`. The check goes through the usual caches:
|
|
13
|
+
a grant changed through another Keeper process is seen after at most
|
|
14
|
+
`keeper.auth.authz.grants_cache_ttl_s` more, OIDC group changes after
|
|
15
|
+
`keeper.auth.oidc.principal_cache_ttl_s`.
|
|
16
|
+
|
|
17
|
+
`GET .../changes?since=<revision>` reads the same events from the first-parent history of the
|
|
18
|
+
branch instead of the outbox: the Revisions after `since`, oldest first, a page at a time,
|
|
19
|
+
with `next_since` to continue. Use it to catch up after a gap or where streams are not
|
|
20
|
+
available.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
import json
|
|
27
|
+
import logging
|
|
28
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from pydantic import BaseModel, Field, JsonValue
|
|
33
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
34
|
+
|
|
35
|
+
from lorebase_core.model import Schema
|
|
36
|
+
from lorebase_core.tree import kind_partition
|
|
37
|
+
from lorebase_keeper.agents.settings import FeedSettings
|
|
38
|
+
from lorebase_keeper.errors import CONFLICT, ProblemError
|
|
39
|
+
from lorebase_keeper.reading.points import branch_schema
|
|
40
|
+
from lorebase_keeper.reading.problems import invalid
|
|
41
|
+
from lorebase_keeper.reading.runtime import ReadRuntime, begin_read
|
|
42
|
+
from lorebase_store.changefeed import EventRef, describe, first_parents, matches
|
|
43
|
+
from lorebase_store.commit.pipeline import COMMITTED_EVENT
|
|
44
|
+
from lorebase_store.repositories import OutboxRepository, RevisionRow, scope_to_domain
|
|
45
|
+
|
|
46
|
+
logger = logging.getLogger("lorebase.keeper.feed")
|
|
47
|
+
|
|
48
|
+
EVENT_STREAM = "text/event-stream"
|
|
49
|
+
SSE_EVENT = "revision"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class RevisionEventOut(BaseModel):
|
|
53
|
+
"""A Revision committed on a branch."""
|
|
54
|
+
|
|
55
|
+
sequence: int | None = Field(
|
|
56
|
+
default=None, description="Outbox sequence; the SSE id. Absent in `changes` pages."
|
|
57
|
+
)
|
|
58
|
+
type: str
|
|
59
|
+
domain: str
|
|
60
|
+
branch: str
|
|
61
|
+
revision: str
|
|
62
|
+
parents: list[str]
|
|
63
|
+
generation: int
|
|
64
|
+
author: str
|
|
65
|
+
on_behalf_of: str | None = None
|
|
66
|
+
message: str
|
|
67
|
+
origin: dict[str, JsonValue]
|
|
68
|
+
stats: dict[str, JsonValue]
|
|
69
|
+
created_at: str
|
|
70
|
+
kinds: dict[str, int] = Field(description="Changed Records per Kind api name.")
|
|
71
|
+
relations: dict[str, int] = Field(description="Changed Links per Relation api name.")
|
|
72
|
+
schema_changed: bool
|
|
73
|
+
keys: dict[str, list[str]] = Field(description="Changed Record keys per Kind, capped.")
|
|
74
|
+
keys_truncated: bool
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ChangesFeedPage(BaseModel):
|
|
78
|
+
branch: str
|
|
79
|
+
head: str
|
|
80
|
+
since: str | None = None
|
|
81
|
+
items: list[RevisionEventOut]
|
|
82
|
+
next_since: str = Field(description="Pass as `since` to continue.")
|
|
83
|
+
more: bool = Field(description="True when more Revisions follow `next_since`.")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True, slots=True)
|
|
87
|
+
class KindFilter:
|
|
88
|
+
"""Kinds an event must touch (None: every event); keys are listed for those Kinds only."""
|
|
89
|
+
|
|
90
|
+
names: frozenset[str] | None = None
|
|
91
|
+
partitions: frozenset[str] | None = None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def kind_filter(schema: Schema | None, values: Sequence[str] | None) -> KindFilter:
|
|
95
|
+
"""The filter of a `kinds` query parameter (repeated or comma-separated api names)."""
|
|
96
|
+
if values is None:
|
|
97
|
+
return KindFilter()
|
|
98
|
+
names = [name.strip() for value in values for name in value.split(",") if name.strip()]
|
|
99
|
+
partitions = set()
|
|
100
|
+
for index, name in enumerate(names):
|
|
101
|
+
kind = None if schema is None else schema.kind_by_api_name(name)
|
|
102
|
+
if kind is None:
|
|
103
|
+
raise invalid(f"/query/kinds/{index}", f"unknown Kind {name!r}", "unknown_kind")
|
|
104
|
+
partitions.add(kind_partition(kind.id))
|
|
105
|
+
return KindFilter(frozenset(names), frozenset(partitions))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def parse_sequence(value: str | None) -> int | None:
|
|
109
|
+
"""An event id given by the client (`Last-Event-ID`); 422 when it is not one."""
|
|
110
|
+
if value is None or value == "":
|
|
111
|
+
return None
|
|
112
|
+
try:
|
|
113
|
+
sequence = int(value)
|
|
114
|
+
except ValueError:
|
|
115
|
+
sequence = -1
|
|
116
|
+
if sequence < 0:
|
|
117
|
+
raise invalid("/headers/Last-Event-ID", "is not an event id", "invalid")
|
|
118
|
+
return sequence
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def events_after(
|
|
122
|
+
session: AsyncSession,
|
|
123
|
+
runtime: ReadRuntime,
|
|
124
|
+
domain_id: str,
|
|
125
|
+
branch: str,
|
|
126
|
+
after: int,
|
|
127
|
+
wanted: KindFilter,
|
|
128
|
+
settings: FeedSettings,
|
|
129
|
+
) -> tuple[int, list[dict[str, Any]]]:
|
|
130
|
+
"""Events of the branch after sequence `after` (one batch) and the last sequence read."""
|
|
131
|
+
rows = await OutboxRepository(session).after(
|
|
132
|
+
domain_id,
|
|
133
|
+
after,
|
|
134
|
+
event_types=(COMMITTED_EVENT,),
|
|
135
|
+
branch=branch,
|
|
136
|
+
limit=settings.batch_events,
|
|
137
|
+
)
|
|
138
|
+
if not rows:
|
|
139
|
+
return after, []
|
|
140
|
+
schema = await branch_schema(session, runtime, domain_id, branch)
|
|
141
|
+
documents = await describe(
|
|
142
|
+
session,
|
|
143
|
+
domain_id,
|
|
144
|
+
[EventRef(bytes.fromhex(str(r.payload["revision"])), branch, r.id) for r in rows],
|
|
145
|
+
schemas={branch: schema},
|
|
146
|
+
keys_max=settings.changed_keys_max,
|
|
147
|
+
kind_partitions=wanted.partitions,
|
|
148
|
+
)
|
|
149
|
+
return rows[-1].id, [d for d in documents if matches(d, wanted.names)]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def sse(document: dict[str, Any]) -> bytes:
|
|
153
|
+
data = json.dumps(document, ensure_ascii=False, separators=(",", ":"))
|
|
154
|
+
return f"id: {document['sequence']}\nevent: {SSE_EVENT}\ndata: {data}\n\n".encode()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
async def stream(
|
|
158
|
+
runtime: ReadRuntime,
|
|
159
|
+
domain_id: str,
|
|
160
|
+
branch: str,
|
|
161
|
+
start: int,
|
|
162
|
+
wanted: KindFilter,
|
|
163
|
+
settings: FeedSettings,
|
|
164
|
+
disconnected: Any,
|
|
165
|
+
still_allowed: Callable[[], Awaitable[bool]],
|
|
166
|
+
) -> AsyncIterator[bytes]:
|
|
167
|
+
"""The SSE body: events as they are committed, heartbeats, closed after the max time or
|
|
168
|
+
once `still_allowed` (called every `reauthorize_interval_s`) answers false."""
|
|
169
|
+
loop = asyncio.get_running_loop()
|
|
170
|
+
deadline = loop.time() + settings.max_connection_s
|
|
171
|
+
next_check = loop.time() + settings.reauthorize_interval_s
|
|
172
|
+
quiet_since = loop.time()
|
|
173
|
+
cursor = start
|
|
174
|
+
yield f"retry: {settings.retry_ms}\n\n".encode()
|
|
175
|
+
while loop.time() < deadline:
|
|
176
|
+
if await disconnected():
|
|
177
|
+
return
|
|
178
|
+
if loop.time() >= next_check:
|
|
179
|
+
try:
|
|
180
|
+
allowed = await still_allowed()
|
|
181
|
+
except Exception:
|
|
182
|
+
logger.exception("change feed authorization failed", extra={"domain": domain_id})
|
|
183
|
+
return
|
|
184
|
+
if not allowed:
|
|
185
|
+
logger.info("change feed closed: access withdrawn", extra={"domain": domain_id})
|
|
186
|
+
return
|
|
187
|
+
next_check = loop.time() + settings.reauthorize_interval_s
|
|
188
|
+
try:
|
|
189
|
+
async with runtime.sessions() as session, session.begin():
|
|
190
|
+
await begin_read(session, runtime)
|
|
191
|
+
await scope_to_domain(session, domain_id)
|
|
192
|
+
cursor, documents = await events_after(
|
|
193
|
+
session, runtime, domain_id, branch, cursor, wanted, settings
|
|
194
|
+
)
|
|
195
|
+
except Exception:
|
|
196
|
+
logger.exception("change feed poll failed", extra={"domain": domain_id})
|
|
197
|
+
return
|
|
198
|
+
for document in documents:
|
|
199
|
+
yield sse(document)
|
|
200
|
+
quiet_since = loop.time()
|
|
201
|
+
if loop.time() - quiet_since >= settings.heartbeat_s:
|
|
202
|
+
yield b": keep-alive\n\n"
|
|
203
|
+
quiet_since = loop.time()
|
|
204
|
+
await asyncio.sleep(max(min(settings.poll_interval_s, deadline - loop.time()), 0))
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
async def changes_since(
|
|
208
|
+
session: AsyncSession,
|
|
209
|
+
runtime: ReadRuntime,
|
|
210
|
+
domain_id: str,
|
|
211
|
+
branch: str,
|
|
212
|
+
head: bytes,
|
|
213
|
+
since: RevisionRow | None,
|
|
214
|
+
*,
|
|
215
|
+
limit: int,
|
|
216
|
+
wanted: KindFilter,
|
|
217
|
+
settings: FeedSettings,
|
|
218
|
+
) -> ChangesFeedPage:
|
|
219
|
+
"""A page of the first-parent Revisions after `since` (see module doc)."""
|
|
220
|
+
if since is None:
|
|
221
|
+
segment = await first_parents(session, domain_id, head, floor=-1, max_steps=limit - 1)
|
|
222
|
+
newer = [revision for revision, _ in reversed(segment)]
|
|
223
|
+
else:
|
|
224
|
+
segment = await first_parents(
|
|
225
|
+
session, domain_id, head, floor=since.generation, max_steps=settings.max_scan
|
|
226
|
+
)
|
|
227
|
+
boundary, generation = segment[-1]
|
|
228
|
+
if boundary != since.id:
|
|
229
|
+
reason = (
|
|
230
|
+
"is not on the first-parent history of the branch"
|
|
231
|
+
if generation <= since.generation
|
|
232
|
+
else f"is more than {settings.max_scan} Revisions behind the head"
|
|
233
|
+
)
|
|
234
|
+
raise ProblemError(
|
|
235
|
+
CONFLICT,
|
|
236
|
+
f"Revision {since.id.hex()} {reason}; read the current state and follow the "
|
|
237
|
+
"branch from its head.",
|
|
238
|
+
extensions={"code": "since_unreachable"},
|
|
239
|
+
)
|
|
240
|
+
newer = [revision for revision, _ in reversed(segment[:-1])]
|
|
241
|
+
page = newer[:limit]
|
|
242
|
+
schema = await branch_schema(session, runtime, domain_id, branch)
|
|
243
|
+
documents = await describe(
|
|
244
|
+
session,
|
|
245
|
+
domain_id,
|
|
246
|
+
[EventRef(revision, branch) for revision in page],
|
|
247
|
+
schemas={branch: schema},
|
|
248
|
+
keys_max=settings.changed_keys_max,
|
|
249
|
+
kind_partitions=wanted.partitions,
|
|
250
|
+
)
|
|
251
|
+
fallback = head if since is None else since.id
|
|
252
|
+
return ChangesFeedPage(
|
|
253
|
+
branch=branch,
|
|
254
|
+
head=head.hex(),
|
|
255
|
+
since=None if since is None else since.id.hex(),
|
|
256
|
+
items=[RevisionEventOut.model_validate(d) for d in documents if matches(d, wanted.names)],
|
|
257
|
+
next_since=(page[-1] if page else fallback).hex(),
|
|
258
|
+
more=len(newer) > limit,
|
|
259
|
+
)
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Markdown rendering of context packs and Record explanations, meant to be pasted into a prompt.
|
|
2
|
+
|
|
3
|
+
The output is stable (same input, same bytes) and compact: plain ASCII markup, lists for
|
|
4
|
+
Records and schema, tables for neighbors and changes, empty sections left out. Revisions
|
|
5
|
+
inside sections are shortened to `SHORT_REVISION` hex digits; the header carries the full id.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from lorebase_core.model import Schema
|
|
15
|
+
from lorebase_keeper.agents.budget import Draft
|
|
16
|
+
from lorebase_keeper.agents.pack import links_json, schema_fragment
|
|
17
|
+
|
|
18
|
+
SHORT_REVISION = 12
|
|
19
|
+
SEPARATOR = "\n\n"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def short(revision: str | None) -> str:
|
|
23
|
+
return "" if revision is None else revision[:SHORT_REVISION]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def text(value: Any) -> str:
|
|
27
|
+
"""A value on one line: strings as they are, lists comma-separated, objects as JSON."""
|
|
28
|
+
if value is None:
|
|
29
|
+
return ""
|
|
30
|
+
if isinstance(value, bool):
|
|
31
|
+
return "true" if value else "false"
|
|
32
|
+
if isinstance(value, list):
|
|
33
|
+
return ", ".join(text(v) for v in value)
|
|
34
|
+
if isinstance(value, dict):
|
|
35
|
+
return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
|
|
36
|
+
return " ".join(str(value).split())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def cell(value: Any) -> str:
|
|
40
|
+
"""A value inside a table cell."""
|
|
41
|
+
return text(value).replace("|", "\\|")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def pairs(values: Mapping[str, Any] | None) -> str:
|
|
45
|
+
return "; ".join(f"{name}={text(value)}" for name, value in (values or {}).items())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def table(header: Sequence[str], rows: Iterable[Sequence[Any]]) -> list[str]:
|
|
49
|
+
lines = ["| " + " | ".join(header) + " |", "|" + " --- |" * len(header)]
|
|
50
|
+
lines.extend("| " + " | ".join(cell(v) for v in row) + " |" for row in rows)
|
|
51
|
+
return lines
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def heading(ref: str, title: str | None) -> str:
|
|
55
|
+
return f"### {ref}" + (f": {text(title)}" if title else "")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Context pack sections ----------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _seeds(draft: Draft) -> str:
|
|
62
|
+
lines = ["## Records"]
|
|
63
|
+
for seed in draft.seeds:
|
|
64
|
+
lines.append(heading(seed.ref, seed.title))
|
|
65
|
+
lines.extend(f"- {name}: {text(value)}" for name, value in seed.values.items())
|
|
66
|
+
if seed.updated_revision is not None:
|
|
67
|
+
lines.append(f"- (last changed in {short(seed.updated_revision)})")
|
|
68
|
+
return "\n".join(lines)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _neighbors(draft: Draft) -> str:
|
|
72
|
+
if not draft.neighbors:
|
|
73
|
+
return ""
|
|
74
|
+
rows = [
|
|
75
|
+
(n.ref, n.title, n.depth, "(omitted)" if n.values is None else pairs(n.values))
|
|
76
|
+
for n in draft.neighbors
|
|
77
|
+
]
|
|
78
|
+
return "\n".join(["## Neighbors", *table(("ref", "title", "depth", "values"), rows)])
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _links(draft: Draft, schema: Schema) -> str:
|
|
82
|
+
if not draft.links:
|
|
83
|
+
return ""
|
|
84
|
+
lines = ["## Links"]
|
|
85
|
+
for name, links in links_json(draft.links).items():
|
|
86
|
+
found = schema.relation_by_api_name(name)
|
|
87
|
+
ends = ""
|
|
88
|
+
if found is not None:
|
|
89
|
+
relation = found[0]
|
|
90
|
+
source, target = (
|
|
91
|
+
schema.kinds.get(relation.from_kind),
|
|
92
|
+
schema.kinds.get(relation.to_kind),
|
|
93
|
+
)
|
|
94
|
+
if source is not None and target is not None:
|
|
95
|
+
ends = f" ({source.api_name} -> {target.api_name})"
|
|
96
|
+
lines.append(f"### {name}{ends}")
|
|
97
|
+
for link in links:
|
|
98
|
+
extra = f" ({pairs(link['values'])})" if link.get("values") else ""
|
|
99
|
+
lines.append(f"- {link['from']} -> {link['to']}{extra}")
|
|
100
|
+
return "\n".join(lines)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _attribute_line(attribute: Mapping[str, Any], indent: str = "") -> str:
|
|
104
|
+
flags = [attribute["type"]]
|
|
105
|
+
if attribute.get("required"):
|
|
106
|
+
flags.append("required")
|
|
107
|
+
if attribute.get("derived"):
|
|
108
|
+
flags.append("derived")
|
|
109
|
+
description = attribute.get("description")
|
|
110
|
+
return f"{indent}- {attribute['name']} ({', '.join(flags)})" + (
|
|
111
|
+
f": {text(description)}" if description else ""
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def schema_markdown(fragment: Mapping[str, Any]) -> str:
|
|
116
|
+
lines = ["## Schema"]
|
|
117
|
+
for kind in fragment["kinds"]:
|
|
118
|
+
lines.append(f"### Kind {kind['name']}")
|
|
119
|
+
if kind.get("description"):
|
|
120
|
+
lines.append(text(kind["description"]))
|
|
121
|
+
keys = f"key: {', '.join(kind['key'])}"
|
|
122
|
+
lines.append(keys + (f"; title: {kind['title']}" if kind.get("title") else ""))
|
|
123
|
+
lines.extend(_attribute_line(a) for a in kind["attributes"])
|
|
124
|
+
if fragment["relations"]:
|
|
125
|
+
lines.append("### Relations")
|
|
126
|
+
for relation in fragment["relations"]:
|
|
127
|
+
line = (
|
|
128
|
+
f"- {relation['name']}: {relation['from']} -> {relation['to']}, "
|
|
129
|
+
f"{relation['cardinality']}, inverse {relation['inverse']}"
|
|
130
|
+
)
|
|
131
|
+
if relation.get("description"):
|
|
132
|
+
line += f". {text(relation['description'])}"
|
|
133
|
+
lines.append(line)
|
|
134
|
+
lines.extend(_attribute_line(a, " ") for a in relation.get("attributes", ()))
|
|
135
|
+
return "\n".join(lines)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _changes(draft: Draft) -> str:
|
|
139
|
+
if not draft.changes:
|
|
140
|
+
return ""
|
|
141
|
+
rows = [
|
|
142
|
+
(
|
|
143
|
+
short(c.revision),
|
|
144
|
+
c.at,
|
|
145
|
+
c.ref,
|
|
146
|
+
c.change,
|
|
147
|
+
", ".join(c.attributes),
|
|
148
|
+
c.actor,
|
|
149
|
+
c.origin,
|
|
150
|
+
c.message,
|
|
151
|
+
)
|
|
152
|
+
for c in draft.changes
|
|
153
|
+
]
|
|
154
|
+
header = ("revision", "at", "record", "change", "attributes", "by", "origin", "message")
|
|
155
|
+
return "\n".join(["## Recent changes", *table(header, rows)])
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _lineage(draft: Draft) -> str:
|
|
159
|
+
if not draft.lineage:
|
|
160
|
+
return ""
|
|
161
|
+
lines = ["## Lineage"]
|
|
162
|
+
for item in draft.lineage:
|
|
163
|
+
source = " ".join(
|
|
164
|
+
part
|
|
165
|
+
for part in (
|
|
166
|
+
item.origin or "unknown origin",
|
|
167
|
+
f"by {item.actor}" if item.actor else "",
|
|
168
|
+
f"at {item.at}" if item.at else "",
|
|
169
|
+
)
|
|
170
|
+
if part
|
|
171
|
+
)
|
|
172
|
+
lines.append(f"- {item.ref}: {', '.join(item.attributes)} <- {source}")
|
|
173
|
+
return "\n".join(lines)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def markdown_sections(draft: Draft, schema: Schema) -> dict[str, str]:
|
|
177
|
+
"""Text of each section, in output order; empty for sections without content."""
|
|
178
|
+
return {
|
|
179
|
+
"seeds": _seeds(draft),
|
|
180
|
+
"neighbors": _neighbors(draft),
|
|
181
|
+
"links": _links(draft, schema),
|
|
182
|
+
"schema": schema_markdown(schema_fragment(draft, schema)) if draft.schema else "",
|
|
183
|
+
"recent_changes": _changes(draft),
|
|
184
|
+
"lineage": _lineage(draft),
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def markdown_measure(schema: Schema) -> Any:
|
|
189
|
+
"""Characters of each section including its separator, a `budget.Measure`."""
|
|
190
|
+
|
|
191
|
+
def measure(draft: Draft) -> Mapping[str, int]:
|
|
192
|
+
return {
|
|
193
|
+
name: len(body) + len(SEPARATOR) if body else 0
|
|
194
|
+
for name, body in markdown_sections(draft, schema).items()
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return measure
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def pack_markdown(
|
|
201
|
+
sections: Mapping[str, str],
|
|
202
|
+
*,
|
|
203
|
+
domain: str,
|
|
204
|
+
branch: str,
|
|
205
|
+
revision: str,
|
|
206
|
+
budget: Mapping[str, Any],
|
|
207
|
+
since: str | None,
|
|
208
|
+
) -> str:
|
|
209
|
+
"""The whole pack: a header line, then the non-empty sections."""
|
|
210
|
+
trimmed = ", ".join(f"{t['step']}({t['removed']})" for t in budget["trimmed"]) or "none"
|
|
211
|
+
header = [
|
|
212
|
+
f"# Context: {domain}/{branch}",
|
|
213
|
+
f"revision {revision}; estimated {budget['used_tokens']} of "
|
|
214
|
+
f"{budget['limit_tokens']} tokens; trimmed: {trimmed}"
|
|
215
|
+
+ ("; over budget" if budget["over_budget"] else "")
|
|
216
|
+
+ (f"; changes since {short(since)}" if since else ""),
|
|
217
|
+
]
|
|
218
|
+
body = [part for part in sections.values() if part]
|
|
219
|
+
return SEPARATOR.join(["\n".join(header), *body]) + "\n"
|