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,403 @@
|
|
|
1
|
+
"""Context packs: request, response shape and the JSON rendering of their sections.
|
|
2
|
+
|
|
3
|
+
A pack has six sections, rendered in this order: `seeds` (the Records asked for, with every
|
|
4
|
+
value), `neighbors` (Records around them up to the requested depth), `links` (grouped by the
|
|
5
|
+
forward api name of their Relation), `schema` (only the Kinds and Relations present),
|
|
6
|
+
`recent_changes` and `lineage`. The `budget` block accounts for them (see `agents.budget`).
|
|
7
|
+
Values stay in canonical form (design 6.1).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from collections.abc import Iterable, Mapping
|
|
14
|
+
from typing import Any, Literal, Self
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel, ConfigDict, Field, JsonValue, model_validator
|
|
17
|
+
|
|
18
|
+
from lorebase_core.model import Attribute, Kind, LinkDirection, Relation, Schema
|
|
19
|
+
from lorebase_core.query import Query
|
|
20
|
+
from lorebase_core.types import AttributeType, TypeSpec
|
|
21
|
+
from lorebase_keeper.agents.budget import (
|
|
22
|
+
BudgetResult,
|
|
23
|
+
ChangeItem,
|
|
24
|
+
Draft,
|
|
25
|
+
LineageItem,
|
|
26
|
+
LinkItem,
|
|
27
|
+
NeighborItem,
|
|
28
|
+
SeedItem,
|
|
29
|
+
estimate_tokens,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
SECTIONS = ("seeds", "neighbors", "links", "schema", "recent_changes", "lineage")
|
|
33
|
+
PackFormat = Literal["json", "markdown"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ContextRequest(BaseModel):
|
|
37
|
+
"""What an agent needs to reason about: seed Records and how much around them."""
|
|
38
|
+
|
|
39
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
40
|
+
|
|
41
|
+
branch: str | None = Field(default=None, description="Default: the Domain's default branch.")
|
|
42
|
+
at: str | None = Field(
|
|
43
|
+
default=None,
|
|
44
|
+
description="Revision id or tag to read at (time travel, served from a snapshot).",
|
|
45
|
+
)
|
|
46
|
+
refs: list[str] | None = Field(
|
|
47
|
+
default=None, min_length=1, description="Seed Records as `Kind/key` references."
|
|
48
|
+
)
|
|
49
|
+
query: Query | None = Field(default=None, description="DSL query selecting the seeds.")
|
|
50
|
+
search: str | None = Field(
|
|
51
|
+
default=None, min_length=1, description="Lexical search text selecting the seeds."
|
|
52
|
+
)
|
|
53
|
+
budget_tokens: int | None = Field(
|
|
54
|
+
default=None, ge=1, description="Estimated token budget; default from settings."
|
|
55
|
+
)
|
|
56
|
+
depth: int | None = Field(default=None, ge=0, description="Neighborhood depth (0: none).")
|
|
57
|
+
relations: list[str] | None = Field(
|
|
58
|
+
default=None,
|
|
59
|
+
description="Relation api names to follow (forward or inverse); default: all.",
|
|
60
|
+
)
|
|
61
|
+
include_schema: bool = True
|
|
62
|
+
include_recent_changes: bool = True
|
|
63
|
+
include_lineage: bool = True
|
|
64
|
+
since: str | None = Field(
|
|
65
|
+
default=None,
|
|
66
|
+
description="Revision id or tag: recent changes after it; default: the latest changes.",
|
|
67
|
+
)
|
|
68
|
+
format: PackFormat = Field(
|
|
69
|
+
default="json", description="`markdown` answers text/markdown to paste into a prompt."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
@model_validator(mode="after")
|
|
73
|
+
def _one_selector(self) -> Self:
|
|
74
|
+
given = [name for name in ("refs", "query", "search") if getattr(self, name) is not None]
|
|
75
|
+
if len(given) != 1:
|
|
76
|
+
raise ValueError("give exactly one of refs, query or search")
|
|
77
|
+
return self
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# Response shape (documentation and tests; bodies are built as plain JSON) -------------------
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class _Out(BaseModel):
|
|
84
|
+
model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class PackRecordOut(_Out):
|
|
88
|
+
ref: str
|
|
89
|
+
title: str | None = None
|
|
90
|
+
values: dict[str, JsonValue]
|
|
91
|
+
updated_revision: str | None = None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class PackNeighborOut(_Out):
|
|
95
|
+
ref: str
|
|
96
|
+
title: str | None = None
|
|
97
|
+
depth: int
|
|
98
|
+
values: dict[str, JsonValue] | None = Field(default=None, description="Absent when trimmed.")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class PackLinkOut(_Out):
|
|
102
|
+
source: str = Field(alias="from")
|
|
103
|
+
to: str
|
|
104
|
+
values: dict[str, JsonValue] | None = None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class SchemaAttributeOut(_Out):
|
|
108
|
+
name: str
|
|
109
|
+
type: str
|
|
110
|
+
description: str | None = None
|
|
111
|
+
required: bool | None = None
|
|
112
|
+
derived: bool | None = None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class SchemaKindOut(_Out):
|
|
116
|
+
name: str
|
|
117
|
+
description: str | None = None
|
|
118
|
+
key: list[str]
|
|
119
|
+
title: str | None = None
|
|
120
|
+
attributes: list[SchemaAttributeOut]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class SchemaRelationOut(_Out):
|
|
124
|
+
name: str
|
|
125
|
+
inverse: str
|
|
126
|
+
source: str = Field(alias="from")
|
|
127
|
+
to: str
|
|
128
|
+
cardinality: str
|
|
129
|
+
description: str | None = None
|
|
130
|
+
attributes: list[SchemaAttributeOut] | None = None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class SchemaFragmentOut(_Out):
|
|
134
|
+
kinds: list[SchemaKindOut]
|
|
135
|
+
relations: list[SchemaRelationOut]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class PackChangeOut(_Out):
|
|
139
|
+
revision: str
|
|
140
|
+
at: str
|
|
141
|
+
ref: str
|
|
142
|
+
change: Literal["added", "modified", "removed"]
|
|
143
|
+
attributes: list[str] | None = None
|
|
144
|
+
actor: str
|
|
145
|
+
origin: str
|
|
146
|
+
message: str
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class PackLineageOut(_Out):
|
|
150
|
+
ref: str
|
|
151
|
+
attributes: list[str]
|
|
152
|
+
origin: str | None = None
|
|
153
|
+
actor: str | None = None
|
|
154
|
+
at: str | None = None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class TrimOut(_Out):
|
|
158
|
+
step: str
|
|
159
|
+
removed: int
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class BudgetOut(_Out):
|
|
163
|
+
limit_tokens: int
|
|
164
|
+
used_tokens: int = Field(description="Estimated tokens of the sections.")
|
|
165
|
+
chars_per_token: float
|
|
166
|
+
sections: dict[str, int] = Field(description="Estimated tokens per section.")
|
|
167
|
+
trimmed: list[TrimOut]
|
|
168
|
+
omitted: dict[str, int] = Field(description="Entries removed to fit, per section.")
|
|
169
|
+
over_budget: bool = Field(description="True when even the seed Records exceed the budget.")
|
|
170
|
+
capped: bool = Field(description="True when graph caps cut the neighborhood.")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class ContextPackOut(_Out):
|
|
174
|
+
"""A context pack in JSON (`format: json`)."""
|
|
175
|
+
|
|
176
|
+
domain: str
|
|
177
|
+
branch: str
|
|
178
|
+
revision: str
|
|
179
|
+
etag: str
|
|
180
|
+
seeds: list[PackRecordOut]
|
|
181
|
+
neighbors: list[PackNeighborOut]
|
|
182
|
+
links: dict[str, list[PackLinkOut]]
|
|
183
|
+
schema_: SchemaFragmentOut | None = Field(default=None, alias="schema")
|
|
184
|
+
recent_changes: list[PackChangeOut] | None = None
|
|
185
|
+
lineage: list[PackLineageOut] | None = None
|
|
186
|
+
since: str | None = None
|
|
187
|
+
budget: BudgetOut
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# Rendering ----------------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def dumps(value: Any) -> str:
|
|
194
|
+
"""Compact JSON, as sent and as measured."""
|
|
195
|
+
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def type_label(spec: TypeSpec, schema: Schema) -> str:
|
|
199
|
+
"""Short type name: `decimal(10,2)`, `enum(a|b)`, `array<string>`, `ref(Kind)`."""
|
|
200
|
+
if spec.name is AttributeType.ENUM:
|
|
201
|
+
return "enum(" + "|".join(spec.enum_values or ()) + ")"
|
|
202
|
+
if spec.name is AttributeType.DECIMAL:
|
|
203
|
+
return f"decimal({spec.precision},{spec.scale})"
|
|
204
|
+
if spec.name is AttributeType.REF:
|
|
205
|
+
kind = schema.kinds.get(spec.kind_ref or "")
|
|
206
|
+
return f"ref({kind.api_name if kind is not None else spec.kind_ref})"
|
|
207
|
+
if spec.name is AttributeType.ARRAY and spec.element is not None:
|
|
208
|
+
return f"array<{type_label(spec.element, schema)}>"
|
|
209
|
+
return str(spec.name)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def ordered_attributes(owner: Kind | Relation) -> list[Attribute]:
|
|
213
|
+
"""Attributes in a stable reading order: key Attributes, the title Attribute, then the rest
|
|
214
|
+
by api name (compiled schemas do not keep the authoring order)."""
|
|
215
|
+
attributes = owner.attributes
|
|
216
|
+
first: list[str] = []
|
|
217
|
+
if isinstance(owner, Kind):
|
|
218
|
+
first = [*owner.primary_key, *([owner.title_attribute] if owner.title_attribute else [])]
|
|
219
|
+
leading = [attributes[a] for a in dict.fromkeys(first) if a in attributes]
|
|
220
|
+
rest = sorted((a for a in attributes.values() if a.id not in first), key=lambda a: a.api_name)
|
|
221
|
+
return [*leading, *rest]
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def ordered_values(owner: Kind | Relation | None, values: Mapping[str, Any]) -> dict[str, Any]:
|
|
225
|
+
"""`values` (keyed by api name) in `ordered_attributes` order."""
|
|
226
|
+
if owner is None:
|
|
227
|
+
return dict(values)
|
|
228
|
+
return {
|
|
229
|
+
a.api_name: values[a.api_name] for a in ordered_attributes(owner) if a.api_name in values
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _attribute(attribute: Attribute, schema: Schema, described: bool) -> dict[str, Any]:
|
|
234
|
+
out: dict[str, Any] = {"name": attribute.api_name, "type": type_label(attribute.type, schema)}
|
|
235
|
+
if described and attribute.description:
|
|
236
|
+
out["description"] = attribute.description
|
|
237
|
+
if attribute.required:
|
|
238
|
+
out["required"] = True
|
|
239
|
+
if attribute.is_derived:
|
|
240
|
+
out["derived"] = True
|
|
241
|
+
return out
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def kind_fragment(kind: Kind, schema: Schema, described: bool = True) -> dict[str, Any]:
|
|
245
|
+
names = {a.id: a.api_name for a in kind.attributes.values()}
|
|
246
|
+
out: dict[str, Any] = {"name": kind.api_name}
|
|
247
|
+
if described and kind.description:
|
|
248
|
+
out["description"] = kind.description
|
|
249
|
+
out["key"] = [names.get(a, a) for a in kind.primary_key] or ["key"]
|
|
250
|
+
if kind.title_attribute is not None:
|
|
251
|
+
out["title"] = names.get(kind.title_attribute, kind.title_attribute)
|
|
252
|
+
out["attributes"] = [_attribute(a, schema, described) for a in ordered_attributes(kind)]
|
|
253
|
+
return out
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def relation_fragment(relation: Relation, schema: Schema, described: bool = True) -> dict[str, Any]:
|
|
257
|
+
def name(kind_id: str) -> str:
|
|
258
|
+
kind = schema.kinds.get(kind_id)
|
|
259
|
+
return kind.api_name if kind is not None else kind_id
|
|
260
|
+
|
|
261
|
+
out: dict[str, Any] = {
|
|
262
|
+
"name": relation.api_name,
|
|
263
|
+
"inverse": relation.inverse_api_name,
|
|
264
|
+
"from": name(relation.from_kind),
|
|
265
|
+
"to": name(relation.to_kind),
|
|
266
|
+
"cardinality": str(relation.cardinality),
|
|
267
|
+
}
|
|
268
|
+
if described and relation.description:
|
|
269
|
+
out["description"] = relation.description
|
|
270
|
+
if relation.attributes:
|
|
271
|
+
out["attributes"] = [_attribute(a, schema, described) for a in ordered_attributes(relation)]
|
|
272
|
+
return out
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def present_kinds(draft: Draft) -> list[str]:
|
|
276
|
+
"""Api names of the Kinds of the Records in the draft, sorted."""
|
|
277
|
+
return sorted({s.kind for s in draft.seeds} | {n.kind for n in draft.neighbors})
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def present_relations(draft: Draft) -> list[str]:
|
|
281
|
+
"""Forward api names of the Relations of the Links in the draft, sorted."""
|
|
282
|
+
return sorted({link.relation for link in draft.links})
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def schema_fragment(draft: Draft, schema: Schema) -> dict[str, Any]:
|
|
286
|
+
kinds = [schema.kind_by_api_name(name) for name in present_kinds(draft)]
|
|
287
|
+
relations = [schema.relation_by_api_name(name) for name in present_relations(draft)]
|
|
288
|
+
return {
|
|
289
|
+
"kinds": [kind_fragment(k, schema, draft.schema_descriptions) for k in kinds if k],
|
|
290
|
+
"relations": [
|
|
291
|
+
relation_fragment(found[0], schema, draft.schema_descriptions)
|
|
292
|
+
for found in relations
|
|
293
|
+
if found is not None and found[1] is LinkDirection.FORWARD
|
|
294
|
+
],
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def seed_json(item: SeedItem) -> dict[str, Any]:
|
|
299
|
+
out: dict[str, Any] = {"ref": item.ref}
|
|
300
|
+
if item.title is not None:
|
|
301
|
+
out["title"] = item.title
|
|
302
|
+
out["values"] = dict(item.values)
|
|
303
|
+
if item.updated_revision is not None:
|
|
304
|
+
out["updated_revision"] = item.updated_revision
|
|
305
|
+
return out
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def neighbor_json(item: NeighborItem) -> dict[str, Any]:
|
|
309
|
+
out: dict[str, Any] = {"ref": item.ref}
|
|
310
|
+
if item.title is not None:
|
|
311
|
+
out["title"] = item.title
|
|
312
|
+
out["depth"] = item.depth
|
|
313
|
+
if item.values is not None:
|
|
314
|
+
out["values"] = dict(item.values)
|
|
315
|
+
return out
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def links_json(links: Iterable[LinkItem]) -> dict[str, list[dict[str, Any]]]:
|
|
319
|
+
grouped: dict[str, list[dict[str, Any]]] = {}
|
|
320
|
+
for link in links:
|
|
321
|
+
entry: dict[str, Any] = {"from": link.source, "to": link.target}
|
|
322
|
+
if link.values:
|
|
323
|
+
entry["values"] = dict(link.values)
|
|
324
|
+
grouped.setdefault(link.relation, []).append(entry)
|
|
325
|
+
return grouped
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def change_json(item: ChangeItem) -> dict[str, Any]:
|
|
329
|
+
out: dict[str, Any] = {
|
|
330
|
+
"revision": item.revision,
|
|
331
|
+
"at": item.at,
|
|
332
|
+
"ref": item.ref,
|
|
333
|
+
"change": item.change,
|
|
334
|
+
}
|
|
335
|
+
if item.attributes:
|
|
336
|
+
out["attributes"] = list(item.attributes)
|
|
337
|
+
out.update(actor=item.actor, origin=item.origin, message=item.message)
|
|
338
|
+
return out
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def lineage_json(item: LineageItem) -> dict[str, Any]:
|
|
342
|
+
out: dict[str, Any] = {"ref": item.ref, "attributes": list(item.attributes)}
|
|
343
|
+
for name in ("origin", "actor", "at"):
|
|
344
|
+
value = getattr(item, name)
|
|
345
|
+
if value is not None:
|
|
346
|
+
out[name] = value
|
|
347
|
+
return out
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def json_sections(draft: Draft, schema: Schema) -> dict[str, Any]:
|
|
351
|
+
"""Value of each present section, in output order."""
|
|
352
|
+
out: dict[str, Any] = {
|
|
353
|
+
"seeds": [seed_json(s) for s in draft.seeds],
|
|
354
|
+
"neighbors": [neighbor_json(n) for n in draft.neighbors],
|
|
355
|
+
"links": links_json(draft.links),
|
|
356
|
+
}
|
|
357
|
+
if draft.schema:
|
|
358
|
+
out["schema"] = schema_fragment(draft, schema)
|
|
359
|
+
if draft.changes is not None:
|
|
360
|
+
out["recent_changes"] = [change_json(c) for c in draft.changes]
|
|
361
|
+
if draft.lineage is not None:
|
|
362
|
+
out["lineage"] = [lineage_json(item) for item in draft.lineage]
|
|
363
|
+
return out
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def json_measure(schema: Schema) -> Any:
|
|
367
|
+
"""Characters of each section in compact JSON, a `budget.Measure`."""
|
|
368
|
+
|
|
369
|
+
def measure(draft: Draft) -> Mapping[str, int]:
|
|
370
|
+
return {name: len(dumps(value)) for name, value in json_sections(draft, schema).items()}
|
|
371
|
+
|
|
372
|
+
return measure
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def budget_json(
|
|
376
|
+
result: BudgetResult,
|
|
377
|
+
original: Draft,
|
|
378
|
+
*,
|
|
379
|
+
budget_tokens: int,
|
|
380
|
+
chars_per_token: float,
|
|
381
|
+
capped: bool,
|
|
382
|
+
) -> dict[str, Any]:
|
|
383
|
+
final = result.draft
|
|
384
|
+
omitted = {
|
|
385
|
+
"neighbors": len(original.neighbors) - len(final.neighbors),
|
|
386
|
+
"neighbor_values": sum(1 for n in final.neighbors if n.values is None)
|
|
387
|
+
- sum(1 for n in original.neighbors if n.values is None),
|
|
388
|
+
"links": len(original.links) - len(final.links),
|
|
389
|
+
"recent_changes": len(original.changes or ()) - len(final.changes or ()),
|
|
390
|
+
"lineage": len(original.lineage or ()) - len(final.lineage or ()),
|
|
391
|
+
}
|
|
392
|
+
return {
|
|
393
|
+
"limit_tokens": budget_tokens,
|
|
394
|
+
"used_tokens": estimate_tokens(result.chars, chars_per_token),
|
|
395
|
+
"chars_per_token": chars_per_token,
|
|
396
|
+
"sections": {
|
|
397
|
+
name: estimate_tokens(chars, chars_per_token) for name, chars in result.sections.items()
|
|
398
|
+
},
|
|
399
|
+
"trimmed": [{"step": str(t.step), "removed": t.removed} for t in result.trims],
|
|
400
|
+
"omitted": {name: count for name, count in omitted.items() if count},
|
|
401
|
+
"over_budget": result.over_budget,
|
|
402
|
+
"capped": capped,
|
|
403
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""The `agents` settings section: context packs, Record explanations and the change feed.
|
|
2
|
+
|
|
3
|
+
Registered with the platform like `read` and `search`; see `config/lorebase.example.yaml`.
|
|
4
|
+
Graph caps of context packs are further bounded by `read.traverse`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Self
|
|
10
|
+
|
|
11
|
+
from pydantic import Field, model_validator
|
|
12
|
+
|
|
13
|
+
from lorebase_keeper.agents.budget import DEFAULT_TRIM_ORDER, TrimStep
|
|
14
|
+
from lorebase_keeper.reading.settings import Bounds
|
|
15
|
+
from lorebase_platform.settings import SectionModel, Settings, register_section
|
|
16
|
+
|
|
17
|
+
SECTION = "agents"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ContextSettings(SectionModel):
|
|
21
|
+
"""`POST /context`: token budget, graph caps and trimming order of context packs."""
|
|
22
|
+
|
|
23
|
+
default_budget_tokens: int = Field(default=4000, ge=1, description="Budget when omitted.")
|
|
24
|
+
max_budget_tokens: int = Field(default=32000, ge=1, description="Largest budget accepted.")
|
|
25
|
+
chars_per_token: float = Field(
|
|
26
|
+
default=4.0, gt=0, description="Characters per estimated token (tokens = chars / this)."
|
|
27
|
+
)
|
|
28
|
+
default_depth: int = Field(default=1, ge=0, description="Neighborhood depth when omitted.")
|
|
29
|
+
max_depth: int = Field(default=2, ge=0, description="Deepest neighborhood accepted.")
|
|
30
|
+
max_seeds: int = Field(default=20, ge=1, description="Seed Records of one pack.")
|
|
31
|
+
max_neighbors: int = Field(default=200, ge=0, description="Records around the seeds.")
|
|
32
|
+
max_links: int = Field(default=500, ge=0, description="Links of one pack.")
|
|
33
|
+
recent_changes_max: int = Field(
|
|
34
|
+
default=50, ge=1, description="Change entries read before trimming, newest first."
|
|
35
|
+
)
|
|
36
|
+
trim_order: tuple[TrimStep, ...] = Field(
|
|
37
|
+
default=DEFAULT_TRIM_ORDER,
|
|
38
|
+
description="Order in which sections are trimmed to fit the budget; steps left out are "
|
|
39
|
+
"never applied. Seed Records are never trimmed.",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
@model_validator(mode="after")
|
|
43
|
+
def _consistent(self) -> Self:
|
|
44
|
+
if self.default_budget_tokens > self.max_budget_tokens:
|
|
45
|
+
raise ValueError("default_budget_tokens cannot exceed max_budget_tokens")
|
|
46
|
+
if self.default_depth > self.max_depth:
|
|
47
|
+
raise ValueError("default_depth cannot exceed max_depth")
|
|
48
|
+
if len(set(self.trim_order)) != len(self.trim_order):
|
|
49
|
+
raise ValueError("trim_order lists a step twice")
|
|
50
|
+
return self
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ExplainSettings(SectionModel):
|
|
54
|
+
"""`GET .../records/{key}/explain`."""
|
|
55
|
+
|
|
56
|
+
max_links: int = Field(default=100, ge=0, description="Links listed with their origin.")
|
|
57
|
+
max_proposals: int = Field(default=20, ge=0, description="Proposals listed.")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class FeedSettings(SectionModel):
|
|
61
|
+
"""Change feed of a branch: Server-Sent Events and the polling fallback."""
|
|
62
|
+
|
|
63
|
+
poll_interval_s: float = Field(
|
|
64
|
+
default=1.0, gt=0, description="How often an open stream looks for new events."
|
|
65
|
+
)
|
|
66
|
+
heartbeat_s: float = Field(
|
|
67
|
+
default=15.0, gt=0, description="A comment line is sent after this much silence."
|
|
68
|
+
)
|
|
69
|
+
max_connection_s: float = Field(
|
|
70
|
+
default=300.0,
|
|
71
|
+
gt=0,
|
|
72
|
+
description="A stream is closed after this long; clients reconnect with Last-Event-ID.",
|
|
73
|
+
)
|
|
74
|
+
reauthorize_interval_s: float = Field(
|
|
75
|
+
default=60.0,
|
|
76
|
+
gt=0,
|
|
77
|
+
description="An open stream authenticates the caller and checks its grants again this "
|
|
78
|
+
"often, through the same caches as a request, and closes once that fails: a revoked "
|
|
79
|
+
"grant ends it within this interval plus `keeper.auth.authz.grants_cache_ttl_s`.",
|
|
80
|
+
)
|
|
81
|
+
retry_ms: int = Field(default=3000, ge=0, description="Reconnection delay advised to clients.")
|
|
82
|
+
batch_events: int = Field(default=100, ge=1, description="Events read per poll.")
|
|
83
|
+
changed_keys_max: int = Field(
|
|
84
|
+
default=100, ge=0, description="Changed Record keys listed per event; more are cut."
|
|
85
|
+
)
|
|
86
|
+
changes: Bounds = Field(
|
|
87
|
+
default_factory=lambda: Bounds(default_limit=50, max_limit=500),
|
|
88
|
+
description="Revisions per page of `GET .../changes`.",
|
|
89
|
+
)
|
|
90
|
+
max_scan: int = Field(
|
|
91
|
+
default=10_000,
|
|
92
|
+
ge=1,
|
|
93
|
+
description="First-parent Revisions walked back from the head to find `since`; an "
|
|
94
|
+
"older `since` is refused (409: read the current state, then follow from the head).",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class AgentSettings(SectionModel):
|
|
99
|
+
"""Agent-facing reads."""
|
|
100
|
+
|
|
101
|
+
context: ContextSettings = Field(default_factory=ContextSettings)
|
|
102
|
+
explain: ExplainSettings = Field(default_factory=ExplainSettings)
|
|
103
|
+
feed: FeedSettings = Field(default_factory=FeedSettings)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
register_section(SECTION, AgentSettings)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def agent_settings(settings: Settings) -> AgentSettings:
|
|
110
|
+
"""The validated `agents` section."""
|
|
111
|
+
return settings.section(SECTION, AgentSettings)
|