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,313 @@
1
+ """The `keeper` settings section, registered with `lorebase_platform`.
2
+
3
+ Every value has a code default for local runs; deployments override them through the YAML file
4
+ (`keeper:`) or environment variables (`LOREBASE__KEEPER__PORT`, ...). See
5
+ `config/lorebase.example.yaml`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Literal, Self
11
+
12
+ from pydantic import Field, field_validator, model_validator
13
+
14
+ from lorebase_keeper.auth.config import AuthSettings, is_loopback
15
+ from lorebase_keeper.client_address import normalize_networks
16
+ from lorebase_keeper.ratelimit.config import QuotaSettings, RateLimitSettings
17
+ from lorebase_keeper.services.config import (
18
+ AgentWriteSettings,
19
+ BranchSettings,
20
+ DocumentSettings,
21
+ DomainSettings,
22
+ OperationSettings,
23
+ ProposalSettings,
24
+ SchemaChangeSettings,
25
+ SchemaInferenceSettings,
26
+ SchemaSuggestionSettings,
27
+ )
28
+ from lorebase_keeper.tracing.config import TraceSettings
29
+ from lorebase_platform.settings import SectionModel, Settings, register_section
30
+
31
+ SECTION = "keeper"
32
+
33
+
34
+ REQUIRED_CORS_REQUEST_HEADERS = (
35
+ "Authorization",
36
+ "Content-Type",
37
+ "Idempotency-Key",
38
+ "If-None-Match",
39
+ )
40
+ """Request headers the clients send, always allowed cross-origin (plus the request id, session
41
+ CSRF and agent trace headers Keeper is configured with)."""
42
+
43
+ REQUIRED_CORS_RESPONSE_HEADERS = ("ETag", "Lorebase-Revision")
44
+ """Response headers always readable cross-origin (plus the request id and rate limit headers):
45
+ the web app revalidates reads with the ETag and follows the Revision a read was served from."""
46
+
47
+
48
+ class CorsSettings(SectionModel):
49
+ """Cross-origin access for browser clients. Disabled while `allow_origins` is empty."""
50
+
51
+ allow_origins: tuple[str, ...] = Field(
52
+ default=(), description="Allowed origins, e.g. https://lorebase.example.com. Empty: off."
53
+ )
54
+ allow_methods: tuple[str, ...] = ("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
55
+ allow_headers: tuple[str, ...] = Field(
56
+ default=(),
57
+ description="Further request headers to allow. Always allowed besides them: "
58
+ "Authorization, Content-Type, Idempotency-Key, If-None-Match, the request id header, "
59
+ "the session CSRF header and the agent trace headers.",
60
+ )
61
+ expose_headers: tuple[str, ...] = Field(
62
+ default=REQUIRED_CORS_RESPONSE_HEADERS,
63
+ description="Response headers readable by cross-origin scripts. ETag and "
64
+ "Lorebase-Revision (conditional reads), the request id and the rate limit headers are "
65
+ "always exposed.",
66
+ )
67
+ allow_credentials: bool = False
68
+ max_age_s: int = Field(default=600, ge=0, description="Preflight cache lifetime.")
69
+
70
+ @model_validator(mode="after")
71
+ def _no_wildcard_with_credentials(self) -> Self:
72
+ if self.allow_credentials and "*" in self.allow_origins:
73
+ raise ValueError("allow_origins cannot contain '*' when allow_credentials is true")
74
+ return self
75
+
76
+
77
+ class HstsSettings(SectionModel):
78
+ """`Strict-Transport-Security`, which only makes sense when the connection is TLS."""
79
+
80
+ enabled: Literal["auto", "always", "never"] = Field(
81
+ default="auto",
82
+ description="auto: send it when the request arrived over https (directly, or through a "
83
+ "trusted proxy that sets X-Forwarded-Proto: https); always; never.",
84
+ )
85
+ max_age_s: int = Field(default=31_536_000, ge=0, description="max-age directive.")
86
+ include_subdomains: bool = True
87
+ preload: bool = Field(
88
+ default=False,
89
+ description="Add the preload directive; needs include_subdomains and a max_age_s of at "
90
+ "least one year.",
91
+ )
92
+
93
+ @model_validator(mode="after")
94
+ def _preload_requirements(self) -> Self:
95
+ if self.preload and (not self.include_subdomains or self.max_age_s < 31_536_000):
96
+ raise ValueError("preload needs include_subdomains and max_age_s of at least 31536000")
97
+ return self
98
+
99
+ @property
100
+ def header_value(self) -> str:
101
+ parts = [f"max-age={self.max_age_s}"]
102
+ if self.include_subdomains:
103
+ parts.append("includeSubDomains")
104
+ if self.preload:
105
+ parts.append("preload")
106
+ return "; ".join(parts)
107
+
108
+
109
+ class SecurityHeadersSettings(SectionModel):
110
+ """Response headers that limit what a browser does with Keeper's responses. A header a
111
+ route sets itself is never replaced."""
112
+
113
+ enabled: bool = Field(default=True, description="Master switch for all of these headers.")
114
+ hsts: HstsSettings = Field(default_factory=HstsSettings)
115
+ frame_options: Literal["DENY", "SAMEORIGIN", "off"] = Field(
116
+ default="DENY",
117
+ description="X-Frame-Options, and the matching CSP frame-ancestors ('none' or 'self'). "
118
+ "off sends neither.",
119
+ )
120
+ referrer_policy: str = Field(
121
+ default="no-referrer", description="Referrer-Policy value. Empty: not sent."
122
+ )
123
+ content_type_nosniff: bool = Field(
124
+ default=True, description="Send X-Content-Type-Options: nosniff."
125
+ )
126
+
127
+
128
+ class LimitSettings(SectionModel):
129
+ """Request size limits, rate limits and daily quotas."""
130
+
131
+ max_request_body_bytes: int = Field(
132
+ default=1_048_576, ge=1, description="Larger request bodies are rejected with 413."
133
+ )
134
+ rate: RateLimitSettings = Field(default_factory=RateLimitSettings)
135
+ quota: QuotaSettings = Field(default_factory=QuotaSettings)
136
+
137
+
138
+ class PaginationSettings(SectionModel):
139
+ """Cursor pagination bounds shared by list endpoints."""
140
+
141
+ default_limit: int = Field(default=50, ge=1)
142
+ max_limit: int = Field(default=500, ge=1)
143
+
144
+ @model_validator(mode="after")
145
+ def _default_within_max(self) -> Self:
146
+ if self.default_limit > self.max_limit:
147
+ raise ValueError("default_limit cannot exceed max_limit")
148
+ return self
149
+
150
+
151
+ class ReadCacheSettings(SectionModel):
152
+ """In-process cache of GET read responses, keyed by the Revision they were served from.
153
+
154
+ Entries never go stale (a new commit means a new Revision, hence a new key); they leave
155
+ by size, count or time to live.
156
+ """
157
+
158
+ enabled: bool = True
159
+ max_entries: int = Field(default=1024, ge=1, description="Responses kept.")
160
+ max_bytes: int = Field(
161
+ default=32 * 1024 * 1024, ge=1, description="Total size of kept response bodies."
162
+ )
163
+ ttl_s: float = Field(default=300, gt=0, description="Lifetime of an entry in seconds.")
164
+ max_body_bytes: int = Field(
165
+ default=1024 * 1024, ge=1, description="Larger responses are not cached."
166
+ )
167
+ time_travel_max_entries: int = Field(
168
+ default=128,
169
+ ge=0,
170
+ description="Entries for reads served from the tree at another Revision; once reached, "
171
+ "such reads are not cached. 0 never caches them.",
172
+ )
173
+
174
+
175
+ class SensitivitySettings(SectionModel):
176
+ """How reads show values of sensitive Attributes (design 10.2, 13.5).
177
+
178
+ A caller sees the values of an Attribute tagged `pii`, `confidential` or `restricted` only
179
+ with the matching clearance (`pii.read`, `confidential.read`, `restricted.read`) on its
180
+ Kind; other callers get `masked_value`, or no value at all with `omit`.
181
+ """
182
+
183
+ omit: bool = Field(
184
+ default=False,
185
+ description="Leave masked values out of Records instead of showing masked_value. "
186
+ "Values shown outside Records (history, lineage, diffs, Conflicts) are always masked.",
187
+ )
188
+ masked_value: str = Field(
189
+ default="***", description="What a value is replaced by when it is masked."
190
+ )
191
+ erased_value: str = Field(
192
+ default="[erased]",
193
+ description="What cleared callers see for a vault value that was erased.",
194
+ )
195
+
196
+
197
+ class ReadsSettings(SectionModel):
198
+ """Read responses: authorization of Revisions named by id or tag, response cache, and the
199
+ database serving them (design 15.3)."""
200
+
201
+ cache: ReadCacheSettings = Field(default_factory=ReadCacheSettings)
202
+ sensitivity: SensitivitySettings = Field(default_factory=SensitivitySettings)
203
+ reachability_max_depth: int = Field(
204
+ default=1000,
205
+ ge=1,
206
+ description="Parent hops followed from branch heads when checking that a Revision or "
207
+ "tag named by a caller with branch-scoped grants is reachable from a branch the caller "
208
+ "may read. A Revision further back than this is refused (403).",
209
+ )
210
+ prefer_replica: bool = Field(
211
+ default=False,
212
+ description="Serve reads from the read replicas named by `database.read_dsn` when one "
213
+ "is fresh enough; otherwise, or when false, from the primary. Without replicas "
214
+ "configured it has no effect.",
215
+ )
216
+
217
+
218
+ class ReadinessSettings(SectionModel):
219
+ """How `/readyz` finds and probes the database."""
220
+
221
+ timeout_s: float = Field(default=2.0, gt=0, description="Time budget of each check.")
222
+ database_section: str = Field(
223
+ default="database",
224
+ min_length=1,
225
+ description="Settings section holding the database connection. When it is absent "
226
+ "the database check reports not_configured.",
227
+ )
228
+ database_dsn_key: str = Field(
229
+ default="dsn", min_length=1, description="Key of the connection string in that section."
230
+ )
231
+
232
+
233
+ class OpenApiSettings(SectionModel):
234
+ """OpenAPI document and interactive docs, served under the API prefix."""
235
+
236
+ title: str = "Lorebase Keeper"
237
+ description: str = (
238
+ "REST API of Lorebase: versioned ontology and knowledge graph. Errors use RFC 9457 "
239
+ "problem details."
240
+ )
241
+ docs_enabled: bool = Field(default=True, description="Serve /docs and the OpenAPI document.")
242
+
243
+
244
+ class KeeperSettings(SectionModel):
245
+ """Settings of the Keeper service."""
246
+
247
+ host: str = Field(default="127.0.0.1", min_length=1, description="Bind address.")
248
+ port: int = Field(default=8080, ge=1, le=65535)
249
+ api_prefix: str = Field(default="/api/v1", description="Version prefix of every API route.")
250
+ request_id_header: str = Field(
251
+ default="X-Request-ID", min_length=1, description="Header carrying the request id."
252
+ )
253
+ problem_type_base: str = Field(
254
+ default="urn:lorebase:problem:",
255
+ min_length=1,
256
+ description="Prefix of the stable `type` URI of problem details; the error code is "
257
+ "appended.",
258
+ )
259
+ trusted_proxies: tuple[str, ...] = Field(
260
+ default=(),
261
+ description="Networks of the reverse proxies in front of Keeper, e.g. 10.0.0.0/8. Only "
262
+ "when the socket peer is in one of them is X-Forwarded-For read (right to left, "
263
+ "skipping trusted hops) to find the client address used by API key allowlists and "
264
+ "failed authentication limits. Empty: the socket peer is the client.",
265
+ )
266
+ cors: CorsSettings = Field(default_factory=CorsSettings)
267
+ security_headers: SecurityHeadersSettings = Field(default_factory=SecurityHeadersSettings)
268
+ limits: LimitSettings = Field(default_factory=LimitSettings)
269
+ pagination: PaginationSettings = Field(default_factory=PaginationSettings)
270
+ reads: ReadsSettings = Field(default_factory=ReadsSettings)
271
+ readiness: ReadinessSettings = Field(default_factory=ReadinessSettings)
272
+ auth: AuthSettings = Field(default_factory=AuthSettings)
273
+ openapi: OpenApiSettings = Field(default_factory=OpenApiSettings)
274
+ domains: DomainSettings = Field(default_factory=DomainSettings)
275
+ branches: BranchSettings = Field(default_factory=BranchSettings)
276
+ schema_changes: SchemaChangeSettings = Field(default_factory=SchemaChangeSettings)
277
+ schema_inference: SchemaInferenceSettings = Field(default_factory=SchemaInferenceSettings)
278
+ schema_suggestions: SchemaSuggestionSettings = Field(default_factory=SchemaSuggestionSettings)
279
+ operations: OperationSettings = Field(default_factory=OperationSettings)
280
+ proposals: ProposalSettings = Field(default_factory=ProposalSettings)
281
+ agent_writes: AgentWriteSettings = Field(default_factory=AgentWriteSettings)
282
+ documents: DocumentSettings = Field(default_factory=DocumentSettings)
283
+ trace: TraceSettings = Field(default_factory=TraceSettings)
284
+
285
+ @field_validator("trusted_proxies")
286
+ @classmethod
287
+ def _proxy_networks(cls, value: tuple[str, ...]) -> tuple[str, ...]:
288
+ return normalize_networks(value)
289
+
290
+ @field_validator("api_prefix")
291
+ @classmethod
292
+ def _prefix_shape(cls, value: str) -> str:
293
+ if not value.startswith("/") or (len(value) > 1 and value.endswith("/")):
294
+ raise ValueError("must start with '/' and not end with '/'")
295
+ return "" if value == "/" else value
296
+
297
+ @model_validator(mode="after")
298
+ def _no_anonymous_access_off_loopback(self) -> Self:
299
+ if self.auth.mode == "none" and not self.auth.allow_insecure and not is_loopback(self.host):
300
+ raise ValueError(
301
+ f"auth.mode 'none' accepts every request as an administrator and host "
302
+ f"{self.host!r} is not a loopback address: choose another auth.mode, bind to "
303
+ "127.0.0.1, or set auth.allow_insecure to true"
304
+ )
305
+ return self
306
+
307
+
308
+ register_section(SECTION, KeeperSettings)
309
+
310
+
311
+ def keeper_settings(settings: Settings) -> KeeperSettings:
312
+ """The validated `keeper` section of loaded settings."""
313
+ return settings.section(SECTION, KeeperSettings)
@@ -0,0 +1 @@
1
+ """Agent trace: opt-in record of how agent sessions navigate the graph (`keeper.trace`)."""
@@ -0,0 +1,350 @@
1
+ """What a trace step keeps of a request and its response: pure functions over JSON.
2
+
3
+ - `query_of`: the query as structured data. The request body of a POST read, or the query
4
+ parameters of a GET read. Values compared by filters (`attr` and `derived` predicates) are
5
+ replaced by their type, except on fields the read found not sensitive (`plain`, with
6
+ `capture_filter_values: non_sensitive`), where scalars and lists of scalars are kept, cut
7
+ to `max_text_chars` and `max_filter_value_items`; `capture_literals` keeps them all. Search
8
+ text (`q`, `search`, `match`) is kept unless `capture_search_text` is off; query vectors
9
+ are replaced by their dimension and cursors dropped. Long strings are cut.
10
+ - `result_of`: counts of the lists of a response, plus the search mode and corrections, the
11
+ context pack budget and truncation flags. No values.
12
+ - `refs_of`: the Records (Kind and key) and Links (Relation and both ends) a response
13
+ touched. Members named `values` are never walked, so values of Records are never read
14
+ even when they hold references.
15
+ - `proposal_of`: the Proposal a write opened or acted on.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ from collections.abc import Callable, Iterable, Mapping
22
+ from typing import Any
23
+
24
+ from lorebase_keeper.tracing.config import TraceSettings
25
+
26
+ TEXT_MEMBERS = frozenset({"q", "search", "match"})
27
+ SKIPPED_PARAMS = frozenset({"cursor"})
28
+ COMPARED_FIELDS = ("attr", "derived")
29
+ MAX_LIST_ITEMS = 100
30
+ MAX_CORRECTIONS = 20
31
+ MAX_DEPTH = 32
32
+
33
+ RecordRef = tuple[str, str]
34
+ LinkRef = tuple[str, str, str, str, str]
35
+
36
+
37
+ def _type_of(value: Any) -> str:
38
+ if value is None:
39
+ return "null"
40
+ if isinstance(value, bool):
41
+ return "boolean"
42
+ if isinstance(value, int | float):
43
+ return "number"
44
+ if isinstance(value, str):
45
+ return "string"
46
+ if isinstance(value, list):
47
+ return "list"
48
+ return "object"
49
+
50
+
51
+ def _redacted(value: Any) -> dict[str, Any]:
52
+ out: dict[str, Any] = {"redacted": _type_of(value)}
53
+ if isinstance(value, list):
54
+ out["count"] = len(value)
55
+ return out
56
+
57
+
58
+ def _scalar(value: Any) -> bool:
59
+ return value is None or isinstance(value, bool | int | float | str)
60
+
61
+
62
+ class _Redactor:
63
+ def __init__(self, settings: TraceSettings, plain: Callable[[str], bool] | None = None) -> None:
64
+ self._settings = settings
65
+ self._plain = plain if settings.capture_filter_values == "non_sensitive" else None
66
+
67
+ def _plain_field(self, comparison: Mapping[str, Any]) -> bool:
68
+ """Whether a predicate compares a field the read reported as not sensitive (every
69
+ field it names, should it name both `attr` and `derived`)."""
70
+ plain = self._plain
71
+ fields = [comparison[name] for name in COMPARED_FIELDS if name in comparison]
72
+ return (
73
+ plain is not None
74
+ and bool(fields)
75
+ and all(isinstance(field, str) and plain(field) for field in fields)
76
+ )
77
+
78
+ def literal(self, value: Any) -> Any:
79
+ """A compared value as kept: a scalar (strings cut) or a list of scalars (cut to
80
+ `max_filter_value_items`); anything else by its type."""
81
+ if isinstance(value, str):
82
+ return self.text(value)
83
+ if _scalar(value):
84
+ return value
85
+ if isinstance(value, list) and all(_scalar(item) for item in value):
86
+ limit = self._settings.max_filter_value_items
87
+ items: list[Any] = [
88
+ self.text(item) if isinstance(item, str) else item for item in value[:limit]
89
+ ]
90
+ if len(value) > limit:
91
+ items.append({"truncated": len(value) - limit})
92
+ return items
93
+ return _redacted(value)
94
+
95
+ def text(self, value: str) -> str:
96
+ limit = self._settings.max_text_chars
97
+ return value if len(value) <= limit else value[:limit] + "…"
98
+
99
+ def node(self, value: Any, depth: int = 0) -> Any:
100
+ if depth > MAX_DEPTH:
101
+ return {"truncated": True}
102
+ if isinstance(value, Mapping):
103
+ return self.mapping(value, depth)
104
+ if isinstance(value, list):
105
+ items = [self.node(item, depth + 1) for item in value[:MAX_LIST_ITEMS]]
106
+ if len(value) > MAX_LIST_ITEMS:
107
+ items.append({"truncated": len(value) - MAX_LIST_ITEMS})
108
+ return items
109
+ if isinstance(value, str):
110
+ return self.text(value)
111
+ return value
112
+
113
+ def mapping(self, value: Mapping[str, Any], depth: int) -> dict[str, Any]:
114
+ comparison = any(name in value for name in COMPARED_FIELDS) and "value" in value
115
+ out: dict[str, Any] = {}
116
+ for name, member in value.items():
117
+ if name == "query_vector" and isinstance(member, list):
118
+ out[name] = {"dims": len(member)}
119
+ elif name in SKIPPED_PARAMS:
120
+ continue
121
+ elif name == "value" and comparison:
122
+ if self._settings.capture_literals:
123
+ out[name] = self.node(member, depth + 1)
124
+ elif self._plain_field(value):
125
+ out[name] = self.literal(member)
126
+ else:
127
+ out[name] = _redacted(member)
128
+ elif name in TEXT_MEMBERS and isinstance(member, str):
129
+ out[name] = (
130
+ self.text(member)
131
+ if self._settings.capture_search_text
132
+ else {"redacted": "text", "length": len(member)}
133
+ )
134
+ else:
135
+ out[name] = self.node(member, depth + 1)
136
+ return out
137
+
138
+
139
+ def _parse(raw: bytes) -> Any:
140
+ try:
141
+ return json.loads(raw)
142
+ except ValueError:
143
+ return None
144
+
145
+
146
+ def query_of(
147
+ settings: TraceSettings,
148
+ *,
149
+ params: Iterable[tuple[str, str]],
150
+ body: bytes | None,
151
+ body_complete: bool,
152
+ plain: Callable[[str], bool] | None = None,
153
+ ) -> dict[str, Any] | None:
154
+ """The recorded query of a read: `{"body": ...}` and/or `{"params": ...}`. `plain` tells
155
+ the fields whose compared values may be kept (see the module doc); None: none."""
156
+ redactor = _Redactor(settings, plain)
157
+ out: dict[str, Any] = {}
158
+ collected: dict[str, Any] = {}
159
+ for name, value in params:
160
+ if name in SKIPPED_PARAMS:
161
+ continue
162
+ if name == "where":
163
+ parsed = _parse(value.encode())
164
+ collected[name] = (
165
+ redactor.node(parsed) if parsed is not None else {"redacted": "string"}
166
+ )
167
+ elif name in TEXT_MEMBERS:
168
+ collected[name] = redactor.mapping({name: value}, 0)[name]
169
+ else:
170
+ collected[name] = redactor.text(value)
171
+ if collected:
172
+ out["params"] = collected
173
+ if body:
174
+ if not body_complete:
175
+ out["body"] = {"truncated": True, "bytes": len(body)}
176
+ else:
177
+ parsed = _parse(body)
178
+ if isinstance(parsed, Mapping):
179
+ out["body"] = redactor.mapping(parsed, 0)
180
+ if not out:
181
+ return None
182
+ if len(json.dumps(out)) > settings.max_query_bytes:
183
+ return {"truncated": True}
184
+ return out
185
+
186
+
187
+ def branch_of(query: Mapping[str, Any] | None) -> str | None:
188
+ """The branch a recorded query names, if any."""
189
+ if not query:
190
+ return None
191
+ for part in ("body", "params"):
192
+ section = query.get(part)
193
+ if isinstance(section, Mapping) and isinstance(section.get("branch"), str):
194
+ return str(section["branch"])
195
+ return None
196
+
197
+
198
+ def result_of(document: Any, settings: TraceSettings) -> dict[str, Any] | None:
199
+ """Counts and shape of a JSON response; never values. Search corrections name the query
200
+ words, so they are kept only with `capture_search_text`."""
201
+ if not isinstance(document, Mapping):
202
+ return None
203
+ counts: dict[str, int] = {}
204
+ for name, member in document.items():
205
+ if name == "corrections":
206
+ continue
207
+ if isinstance(member, list):
208
+ counts[name] = len(member)
209
+ elif (
210
+ isinstance(member, Mapping)
211
+ and member
212
+ and all(isinstance(v, list) for v in member.values())
213
+ ):
214
+ counts[name] = sum(len(v) for v in member.values())
215
+ hops = document.get("hops")
216
+ if isinstance(hops, list):
217
+ counts["hop_records"] = sum(
218
+ len(h.get("records") or ()) for h in hops if isinstance(h, Mapping)
219
+ )
220
+ out: dict[str, Any] = {"counts": counts}
221
+ if isinstance(document.get("mode"), str):
222
+ out["mode"] = document["mode"]
223
+ corrections = document.get("corrections")
224
+ if isinstance(corrections, list) and corrections and settings.capture_search_text:
225
+ out["corrections"] = [
226
+ {"word": c.get("word"), "replacements": list(c.get("replacements") or ())[:10]}
227
+ for c in corrections[:MAX_CORRECTIONS]
228
+ if isinstance(c, Mapping)
229
+ ]
230
+ budget = document.get("budget")
231
+ if isinstance(budget, Mapping):
232
+ out["budget"] = {
233
+ name: budget.get(name)
234
+ for name in ("limit_tokens", "used_tokens", "over_budget", "capped")
235
+ if name in budget
236
+ }
237
+ if isinstance(document.get("truncated"), bool):
238
+ out["truncated"] = document["truncated"]
239
+ if "next_cursor" in document:
240
+ out["has_more"] = document.get("next_cursor") is not None
241
+ aggregate = document.get("aggregate")
242
+ if isinstance(aggregate, Mapping) and isinstance(aggregate.get("counts"), list):
243
+ counts["aggregate_groups"] = len(aggregate["counts"])
244
+ return out
245
+
246
+
247
+ def _ref(value: Any) -> RecordRef | None:
248
+ if isinstance(value, Mapping):
249
+ kind, key = value.get("kind"), value.get("key")
250
+ if isinstance(kind, str) and isinstance(key, str):
251
+ return kind, key
252
+ return None
253
+ if isinstance(value, str) and "/" in value:
254
+ kind, _, key = value.partition("/")
255
+ if kind and key:
256
+ return kind, key
257
+ return None
258
+
259
+
260
+ class _Refs:
261
+ def __init__(self, limit: int) -> None:
262
+ self.limit = limit
263
+ self.records: dict[RecordRef, None] = {}
264
+ self.links: dict[LinkRef, None] = {}
265
+ self.truncated = False
266
+
267
+ def record(self, ref: RecordRef | None) -> None:
268
+ if ref is None or ref in self.records:
269
+ return
270
+ if len(self.records) + len(self.links) >= self.limit:
271
+ self.truncated = True
272
+ return
273
+ self.records[ref] = None
274
+
275
+ def link(self, relation: str, source: RecordRef | None, target: RecordRef | None) -> None:
276
+ if source is None or target is None:
277
+ return
278
+ self.record(source)
279
+ self.record(target)
280
+ key = (relation, *source, *target)
281
+ if key in self.links:
282
+ return
283
+ if len(self.records) + len(self.links) >= self.limit:
284
+ self.truncated = True
285
+ return
286
+ self.links[key] = None
287
+
288
+ def walk(self, node: Any, depth: int = 0) -> None:
289
+ if depth > MAX_DEPTH:
290
+ return
291
+ if isinstance(node, list):
292
+ for item in node:
293
+ self.walk(item, depth + 1)
294
+ return
295
+ if not isinstance(node, Mapping):
296
+ return
297
+ self.record(_ref(node))
298
+ ref = node.get("ref")
299
+ if isinstance(ref, str):
300
+ self.record(_ref(ref))
301
+ relation = node.get("relation")
302
+ if isinstance(relation, str):
303
+ self.link(relation, _ref(node.get("from")), _ref(node.get("to")))
304
+ for name, member in node.items():
305
+ if name == "values":
306
+ continue
307
+ if name == "links" and isinstance(member, Mapping):
308
+ self._pack_links(member)
309
+ continue
310
+ if isinstance(member, Mapping | list):
311
+ self.walk(member, depth + 1)
312
+
313
+ def _pack_links(self, links: Mapping[str, Any]) -> None:
314
+ """Context pack Links: `{relation: [{"from": "Kind/key", "to": "Kind/key"}]}`."""
315
+ for relation, items in links.items():
316
+ for item in items if isinstance(items, list) else ():
317
+ if isinstance(item, Mapping):
318
+ self.link(relation, _ref(item.get("from")), _ref(item.get("to")))
319
+
320
+
321
+ def refs_of(document: Any, limit: int) -> dict[str, Any] | None:
322
+ """Records and Links a JSON response touched, at most `limit` in all."""
323
+ refs = _Refs(limit)
324
+ refs.walk(document)
325
+ if not refs.records and not refs.links:
326
+ return None
327
+ out: dict[str, Any] = {
328
+ "records": [list(ref) for ref in refs.records],
329
+ "links": [list(link) for link in refs.links],
330
+ }
331
+ if refs.truncated:
332
+ out["truncated"] = True
333
+ return out
334
+
335
+
336
+ def proposal_of(document: Any) -> str | None:
337
+ """The id of the Proposal a write response names (`proposal.id` or `proposal_id`)."""
338
+ if not isinstance(document, Mapping):
339
+ return None
340
+ proposal = document.get("proposal")
341
+ if isinstance(proposal, Mapping) and isinstance(proposal.get("id"), str):
342
+ return str(proposal["id"])
343
+ if isinstance(document.get("proposal_id"), str):
344
+ return str(document["proposal_id"])
345
+ return None
346
+
347
+
348
+ def parse_json(raw: bytes) -> Any:
349
+ """A JSON body, or None when it is not JSON."""
350
+ return _parse(raw) if raw else None