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,257 @@
|
|
|
1
|
+
"""Problem details (RFC 9457): the single error format of the API.
|
|
2
|
+
|
|
3
|
+
Every error response has the media type `application/problem+json` and the members `type`
|
|
4
|
+
(stable URI), `title`, `status`, `detail`, `instance` (the request path), `code` (the last part
|
|
5
|
+
of `type`), `request_id` and, when tracing is active, `trace_id`. Validation failures add
|
|
6
|
+
`errors`, a list of `{pointer, message, code}`. Responses never contain stack traces and never
|
|
7
|
+
echo request values.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from http import HTTPStatus
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from fastapi import FastAPI, Request
|
|
18
|
+
from fastapi.exceptions import RequestValidationError
|
|
19
|
+
from fastapi.responses import JSONResponse
|
|
20
|
+
from opentelemetry import trace
|
|
21
|
+
from pydantic import BaseModel, ConfigDict
|
|
22
|
+
from starlette.exceptions import HTTPException
|
|
23
|
+
|
|
24
|
+
PROBLEM_MEDIA_TYPE = "application/problem+json"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class ProblemType:
|
|
29
|
+
"""A kind of error: stable code, HTTP status and short title."""
|
|
30
|
+
|
|
31
|
+
code: str
|
|
32
|
+
status: int
|
|
33
|
+
title: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
BAD_REQUEST = ProblemType("bad-request", 400, "Bad request")
|
|
37
|
+
UNAUTHORIZED = ProblemType("unauthorized", 401, "Authentication required")
|
|
38
|
+
FORBIDDEN = ProblemType("forbidden", 403, "Forbidden")
|
|
39
|
+
NOT_FOUND = ProblemType("not-found", 404, "Not found")
|
|
40
|
+
METHOD_NOT_ALLOWED = ProblemType("method-not-allowed", 405, "Method not allowed")
|
|
41
|
+
CONFLICT = ProblemType("conflict", 409, "Conflict")
|
|
42
|
+
PAYLOAD_TOO_LARGE = ProblemType("payload-too-large", 413, "Request body too large")
|
|
43
|
+
VALIDATION_FAILED = ProblemType("validation-failed", 422, "Request validation failed")
|
|
44
|
+
RATE_LIMITED = ProblemType("rate-limited", 429, "Too many requests")
|
|
45
|
+
INTERNAL_ERROR = ProblemType("internal-error", 500, "Internal server error")
|
|
46
|
+
BAD_GATEWAY = ProblemType("bad-gateway", 502, "Bad gateway")
|
|
47
|
+
UNAVAILABLE = ProblemType("unavailable", 503, "Service unavailable")
|
|
48
|
+
|
|
49
|
+
_BY_STATUS = {
|
|
50
|
+
p.status: p
|
|
51
|
+
for p in (
|
|
52
|
+
BAD_REQUEST,
|
|
53
|
+
UNAUTHORIZED,
|
|
54
|
+
FORBIDDEN,
|
|
55
|
+
NOT_FOUND,
|
|
56
|
+
METHOD_NOT_ALLOWED,
|
|
57
|
+
CONFLICT,
|
|
58
|
+
PAYLOAD_TOO_LARGE,
|
|
59
|
+
VALIDATION_FAILED,
|
|
60
|
+
RATE_LIMITED,
|
|
61
|
+
INTERNAL_ERROR,
|
|
62
|
+
BAD_GATEWAY,
|
|
63
|
+
UNAVAILABLE,
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ProblemError(HTTPException):
|
|
69
|
+
"""Raise from any route or dependency to answer with a problem detail.
|
|
70
|
+
|
|
71
|
+
`extensions` adds members to the response body; `headers` adds response headers.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
problem: ProblemType,
|
|
77
|
+
detail: str | None = None,
|
|
78
|
+
*,
|
|
79
|
+
extensions: Mapping[str, Any] | None = None,
|
|
80
|
+
headers: Mapping[str, str] | None = None,
|
|
81
|
+
) -> None:
|
|
82
|
+
super().__init__(
|
|
83
|
+
status_code=problem.status, detail=detail, headers=dict(headers) if headers else None
|
|
84
|
+
)
|
|
85
|
+
self.problem = problem
|
|
86
|
+
self.problem_detail = detail
|
|
87
|
+
self.extensions = dict(extensions or {})
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class FieldProblem(BaseModel):
|
|
91
|
+
"""One invalid request member."""
|
|
92
|
+
|
|
93
|
+
pointer: str
|
|
94
|
+
message: str
|
|
95
|
+
code: str
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class Problem(BaseModel):
|
|
99
|
+
"""Problem details document (schema of every error response)."""
|
|
100
|
+
|
|
101
|
+
model_config = ConfigDict(extra="allow")
|
|
102
|
+
|
|
103
|
+
type: str
|
|
104
|
+
title: str
|
|
105
|
+
status: int
|
|
106
|
+
detail: str | None = None
|
|
107
|
+
instance: str | None = None
|
|
108
|
+
code: str
|
|
109
|
+
request_id: str | None = None
|
|
110
|
+
trace_id: str | None = None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class ValidationProblem(Problem):
|
|
114
|
+
"""Problem details of a rejected request body, query or path."""
|
|
115
|
+
|
|
116
|
+
errors: list[FieldProblem]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def current_trace_id() -> str | None:
|
|
120
|
+
"""Trace id of the active span, if tracing is recording one."""
|
|
121
|
+
context = trace.get_current_span().get_span_context()
|
|
122
|
+
return f"{context.trace_id:032x}" if context.is_valid else None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class ProblemRenderer:
|
|
126
|
+
"""Builds problem responses with the configured `type` URI base."""
|
|
127
|
+
|
|
128
|
+
def __init__(self, type_base: str) -> None:
|
|
129
|
+
self._type_base = type_base
|
|
130
|
+
|
|
131
|
+
def type_uri(self, problem: ProblemType) -> str:
|
|
132
|
+
"""Stable URI identifying the kind of error."""
|
|
133
|
+
return f"{self._type_base}{problem.code}"
|
|
134
|
+
|
|
135
|
+
def body(
|
|
136
|
+
self,
|
|
137
|
+
problem: ProblemType,
|
|
138
|
+
*,
|
|
139
|
+
detail: str | None = None,
|
|
140
|
+
instance: str | None = None,
|
|
141
|
+
request_id: str | None = None,
|
|
142
|
+
extensions: Mapping[str, Any] | None = None,
|
|
143
|
+
) -> dict[str, Any]:
|
|
144
|
+
"""The JSON document of a problem."""
|
|
145
|
+
body: dict[str, Any] = {
|
|
146
|
+
"type": self.type_uri(problem),
|
|
147
|
+
"title": problem.title,
|
|
148
|
+
"status": problem.status,
|
|
149
|
+
"code": problem.code,
|
|
150
|
+
}
|
|
151
|
+
if detail:
|
|
152
|
+
body["detail"] = detail
|
|
153
|
+
if instance:
|
|
154
|
+
body["instance"] = instance
|
|
155
|
+
if request_id:
|
|
156
|
+
body["request_id"] = request_id
|
|
157
|
+
if trace_id := current_trace_id():
|
|
158
|
+
body["trace_id"] = trace_id
|
|
159
|
+
if extensions:
|
|
160
|
+
body.update(extensions)
|
|
161
|
+
return body
|
|
162
|
+
|
|
163
|
+
def response(
|
|
164
|
+
self,
|
|
165
|
+
problem: ProblemType,
|
|
166
|
+
*,
|
|
167
|
+
detail: str | None = None,
|
|
168
|
+
instance: str | None = None,
|
|
169
|
+
request_id: str | None = None,
|
|
170
|
+
extensions: Mapping[str, Any] | None = None,
|
|
171
|
+
headers: Mapping[str, str] | None = None,
|
|
172
|
+
) -> JSONResponse:
|
|
173
|
+
"""A complete problem response."""
|
|
174
|
+
return JSONResponse(
|
|
175
|
+
self.body(
|
|
176
|
+
problem,
|
|
177
|
+
detail=detail,
|
|
178
|
+
instance=instance,
|
|
179
|
+
request_id=request_id,
|
|
180
|
+
extensions=extensions,
|
|
181
|
+
),
|
|
182
|
+
status_code=problem.status,
|
|
183
|
+
headers=dict(headers) if headers else None,
|
|
184
|
+
media_type=PROBLEM_MEDIA_TYPE,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def request_id_of(scope_state: Mapping[str, Any] | None) -> str | None:
|
|
189
|
+
"""The request id stored in an ASGI scope's state by the request context middleware."""
|
|
190
|
+
value = (scope_state or {}).get("request_id")
|
|
191
|
+
return value if isinstance(value, str) else None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _generic_problem(status: int) -> ProblemType:
|
|
195
|
+
known = _BY_STATUS.get(status)
|
|
196
|
+
if known is not None:
|
|
197
|
+
return known
|
|
198
|
+
return ProblemType(f"http-{status}", status, _phrase(status) or "Error")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def install_error_handlers(app: FastAPI, renderer: ProblemRenderer) -> None:
|
|
202
|
+
"""Route HTTP errors and request validation failures to problem details.
|
|
203
|
+
|
|
204
|
+
Unhandled exceptions are answered by the request context middleware, which sits outside
|
|
205
|
+
the framework's own error layer so that the response still carries the request id.
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
async def on_http_error(request: Request, exc: Exception) -> JSONResponse:
|
|
209
|
+
assert isinstance(exc, HTTPException)
|
|
210
|
+
request_id = request_id_of(request.scope.get("state"))
|
|
211
|
+
if isinstance(exc, ProblemError):
|
|
212
|
+
return renderer.response(
|
|
213
|
+
exc.problem,
|
|
214
|
+
detail=exc.problem_detail,
|
|
215
|
+
instance=request.url.path,
|
|
216
|
+
request_id=request_id,
|
|
217
|
+
extensions=exc.extensions,
|
|
218
|
+
headers=exc.headers,
|
|
219
|
+
)
|
|
220
|
+
problem = _generic_problem(exc.status_code)
|
|
221
|
+
raw: object = exc.detail # FastAPI's subclass also allows non-string details
|
|
222
|
+
detail = raw if isinstance(raw, str) and raw != _phrase(exc.status_code) else None
|
|
223
|
+
return renderer.response(
|
|
224
|
+
problem,
|
|
225
|
+
detail=detail,
|
|
226
|
+
instance=request.url.path,
|
|
227
|
+
request_id=request_id,
|
|
228
|
+
headers=exc.headers,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
async def on_validation_error(request: Request, exc: Exception) -> JSONResponse:
|
|
232
|
+
assert isinstance(exc, RequestValidationError)
|
|
233
|
+
errors = [
|
|
234
|
+
{
|
|
235
|
+
"pointer": "/" + "/".join(str(part) for part in error["loc"]),
|
|
236
|
+
"message": str(error["msg"]),
|
|
237
|
+
"code": str(error["type"]),
|
|
238
|
+
}
|
|
239
|
+
for error in exc.errors()
|
|
240
|
+
]
|
|
241
|
+
return renderer.response(
|
|
242
|
+
VALIDATION_FAILED,
|
|
243
|
+
detail="One or more request members are invalid.",
|
|
244
|
+
instance=request.url.path,
|
|
245
|
+
request_id=request_id_of(request.scope.get("state")),
|
|
246
|
+
extensions={"errors": errors},
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
app.add_exception_handler(HTTPException, on_http_error)
|
|
250
|
+
app.add_exception_handler(RequestValidationError, on_validation_error)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _phrase(status: int) -> str:
|
|
254
|
+
try:
|
|
255
|
+
return HTTPStatus(status).phrase
|
|
256
|
+
except ValueError:
|
|
257
|
+
return ""
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Liveness and readiness endpoints, served at the root (outside the API prefix)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Request
|
|
8
|
+
from fastapi.responses import JSONResponse
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
from lorebase_keeper.deps import KeeperSettingsDep
|
|
12
|
+
from lorebase_keeper.readiness import ReadinessCheck, run_checks
|
|
13
|
+
|
|
14
|
+
router = APIRouter(tags=["health"])
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Liveness(BaseModel):
|
|
18
|
+
"""The process is up."""
|
|
19
|
+
|
|
20
|
+
status: Literal["ok"] = "ok"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CheckReport(BaseModel):
|
|
24
|
+
"""Outcome of one dependency check."""
|
|
25
|
+
|
|
26
|
+
status: Literal["ok", "degraded", "not_configured", "failed"]
|
|
27
|
+
detail: str | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Readiness(BaseModel):
|
|
31
|
+
"""Whether the service can take traffic, with the outcome of each check."""
|
|
32
|
+
|
|
33
|
+
status: Literal["ready", "not_ready"]
|
|
34
|
+
checks: dict[str, CheckReport]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@router.get("/healthz", summary="Liveness", response_model=Liveness)
|
|
38
|
+
async def healthz() -> Liveness:
|
|
39
|
+
"""The process answers requests. Never touches dependencies."""
|
|
40
|
+
return Liveness()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@router.get(
|
|
44
|
+
"/readyz",
|
|
45
|
+
summary="Readiness",
|
|
46
|
+
response_model=Readiness,
|
|
47
|
+
responses={503: {"model": Readiness, "description": "A dependency check failed."}},
|
|
48
|
+
)
|
|
49
|
+
async def readyz(request: Request, keeper: KeeperSettingsDep) -> JSONResponse:
|
|
50
|
+
"""Dependencies are usable: the database is reachable and migrations are current."""
|
|
51
|
+
checks: list[ReadinessCheck] = request.app.state.readiness_checks
|
|
52
|
+
results = await run_checks(checks, keeper.readiness.timeout_s)
|
|
53
|
+
ready = all(result.passed for result in results.values())
|
|
54
|
+
report = Readiness(
|
|
55
|
+
status="ready" if ready else "not_ready",
|
|
56
|
+
checks={
|
|
57
|
+
name: CheckReport(status=result.status.value, detail=result.detail)
|
|
58
|
+
for name, result in results.items()
|
|
59
|
+
},
|
|
60
|
+
)
|
|
61
|
+
return JSONResponse(report.model_dump(), status_code=200 if ready else 503)
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Health reports of a Domain and of its Kinds (`GET .../health`).
|
|
2
|
+
|
|
3
|
+
`config` holds the settings (`read.health`), `models` the response shapes, `scan` the queries
|
|
4
|
+
over the materialized head of a branch, `report` the assembly and `markdown` the rendering for
|
|
5
|
+
agents.
|
|
6
|
+
"""
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Settings of health reports (`read.health`)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import Field
|
|
6
|
+
|
|
7
|
+
from lorebase_platform.settings import SectionModel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class HealthSettings(SectionModel):
|
|
11
|
+
"""Caps of `GET /domains/{d}/health` and `GET /domains/{d}/kinds/{kind}/health`.
|
|
12
|
+
|
|
13
|
+
A report is computed when asked for, from the materialized head of a branch, and kept in
|
|
14
|
+
the response cache under the Revision it describes. The caps keep one request affordable on
|
|
15
|
+
large Domains; what a cap cuts is reported (`sampled`, `skipped`, `truncated`).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
max_records_validated: int = Field(
|
|
19
|
+
default=5000,
|
|
20
|
+
ge=1,
|
|
21
|
+
description="Records of one Kind re-validated against the current schema. A larger "
|
|
22
|
+
"Kind is sampled: a fixed, evenly spread subset of about this size, the same one for "
|
|
23
|
+
"the same data.",
|
|
24
|
+
)
|
|
25
|
+
examples_per_issue: int = Field(
|
|
26
|
+
default=3, ge=0, description="Record keys listed as examples of each kind of violation."
|
|
27
|
+
)
|
|
28
|
+
max_issue_groups: int = Field(
|
|
29
|
+
default=50, ge=1, description="Kinds of violation listed per Kind; the rest are cut."
|
|
30
|
+
)
|
|
31
|
+
duplicates_max_records: int = Field(
|
|
32
|
+
default=200_000,
|
|
33
|
+
ge=0,
|
|
34
|
+
description="Kinds with more Records are not scanned for duplicates (reported as "
|
|
35
|
+
"skipped). 0 turns the duplicate check off.",
|
|
36
|
+
)
|
|
37
|
+
max_duplicate_groups: int = Field(
|
|
38
|
+
default=20, ge=1, description="Duplicate groups listed per Attribute, most Records first."
|
|
39
|
+
)
|
|
40
|
+
duplicate_keys_per_group: int = Field(
|
|
41
|
+
default=5, ge=1, description="Record keys listed in a duplicate group."
|
|
42
|
+
)
|
|
43
|
+
orphan_examples: int = Field(
|
|
44
|
+
default=5, ge=0, description="Orphan Links and Records with a gap listed per Relation."
|
|
45
|
+
)
|
|
46
|
+
statement_timeout_ms: int = Field(
|
|
47
|
+
default=15_000,
|
|
48
|
+
ge=0,
|
|
49
|
+
description="statement_timeout of the scans, instead of `read.statement_timeout_ms`; "
|
|
50
|
+
"0 disables. A scan that exceeds it is answered 503.",
|
|
51
|
+
)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Markdown rendering of health reports, meant to be pasted into a prompt.
|
|
2
|
+
|
|
3
|
+
Stable (same report, same bytes) and compact: plain ASCII, one table of Kinds, and a section
|
|
4
|
+
only for what has something to report. Revisions are shortened to `SHORT_REVISION` digits.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
|
|
11
|
+
from lorebase_keeper.kindhealth.models import (
|
|
12
|
+
DomainHealthOut,
|
|
13
|
+
KindHealth,
|
|
14
|
+
KindHealthOut,
|
|
15
|
+
RelationHealth,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
SHORT_REVISION = 12
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _row(cells: Sequence[object]) -> str:
|
|
22
|
+
return "| " + " | ".join(str(c).replace("|", "\\|") for c in cells) + " |"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _table(header: Sequence[str], rows: Sequence[Sequence[object]]) -> list[str]:
|
|
26
|
+
return [_row(header), "|" + " --- |" * len(header), *(_row(r) for r in rows)]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _verdict(ok: bool) -> str:
|
|
30
|
+
return "ok" if ok else "needs attention"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _kinds_table(kinds: Sequence[KindHealth]) -> list[str]:
|
|
34
|
+
rows = [
|
|
35
|
+
(
|
|
36
|
+
k.kind,
|
|
37
|
+
k.records,
|
|
38
|
+
k.violations.invalid_records,
|
|
39
|
+
k.duplicates.total_groups,
|
|
40
|
+
"stale" if k.derived is not None and k.derived.stale else "",
|
|
41
|
+
"" if k.last_change is None else k.last_change.revision[:SHORT_REVISION],
|
|
42
|
+
)
|
|
43
|
+
for k in kinds
|
|
44
|
+
]
|
|
45
|
+
return _table(
|
|
46
|
+
("kind", "records", "invalid", "duplicate groups", "derived", "last change"), rows
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _kind_details(kind: KindHealth) -> list[str]:
|
|
51
|
+
lines: list[str] = []
|
|
52
|
+
violations = kind.violations
|
|
53
|
+
if violations.issues:
|
|
54
|
+
scope = f"{violations.checked} of {kind.records} Records checked" + (
|
|
55
|
+
" (sample)" if violations.sampled else ""
|
|
56
|
+
)
|
|
57
|
+
lines += [f"### {kind.kind}: violations ({scope})"]
|
|
58
|
+
for issue in violations.issues:
|
|
59
|
+
where = f" on {issue.attribute}" if issue.attribute else ""
|
|
60
|
+
examples = f" (e.g. {', '.join(issue.examples)})" if issue.examples else ""
|
|
61
|
+
lines.append(f"- {issue.code}{where}: {issue.count}{examples}")
|
|
62
|
+
if violations.issues_truncated:
|
|
63
|
+
lines.append("- (more kinds of violation omitted)")
|
|
64
|
+
duplicates = kind.duplicates
|
|
65
|
+
if duplicates.groups or duplicates.hidden:
|
|
66
|
+
lines += [f"### {kind.kind}: duplicates ({duplicates.total_groups} groups)"]
|
|
67
|
+
for group in duplicates.groups:
|
|
68
|
+
value = "" if group.value is None else f' "{group.value}"'
|
|
69
|
+
flag = " [unique]" if group.unique else ""
|
|
70
|
+
lines.append(
|
|
71
|
+
f"- {group.attribute}{value}{flag}: {', '.join(group.keys)} ({group.count})"
|
|
72
|
+
)
|
|
73
|
+
for withheld in duplicates.hidden:
|
|
74
|
+
flag = " [unique]" if withheld.unique else ""
|
|
75
|
+
lines.append(
|
|
76
|
+
f"- {withheld.attribute}{flag}: {withheld.groups} groups, {withheld.records} "
|
|
77
|
+
"Records (not listed: sensitive)"
|
|
78
|
+
)
|
|
79
|
+
elif duplicates.skipped:
|
|
80
|
+
lines.append(f"### {kind.kind}: duplicates not checked ({duplicates.skipped})")
|
|
81
|
+
if kind.derived is not None and kind.derived.stale:
|
|
82
|
+
names = ", ".join(kind.derived.attributes)
|
|
83
|
+
lines.append(
|
|
84
|
+
f"### {kind.kind}: {kind.derived.records_missing_value} Records lack derived "
|
|
85
|
+
f"values ({names})"
|
|
86
|
+
)
|
|
87
|
+
return lines
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _relations(relations: Sequence[RelationHealth]) -> list[str]:
|
|
91
|
+
rows = [
|
|
92
|
+
(
|
|
93
|
+
r.relation,
|
|
94
|
+
f"{r.from_kind} -> {r.to_kind}",
|
|
95
|
+
r.links,
|
|
96
|
+
r.orphan_links.count,
|
|
97
|
+
"" if r.required_gaps is None else r.required_gaps.missing,
|
|
98
|
+
r.direction or "",
|
|
99
|
+
)
|
|
100
|
+
for r in relations
|
|
101
|
+
]
|
|
102
|
+
lines = [
|
|
103
|
+
"## Relations",
|
|
104
|
+
*_table(("relation", "ends", "links", "orphans", "gaps", "side"), rows),
|
|
105
|
+
]
|
|
106
|
+
for item in relations:
|
|
107
|
+
if item.orphan_links.examples:
|
|
108
|
+
pairs = ", ".join(f"{e.from_key}->{e.to_key}" for e in item.orphan_links.examples)
|
|
109
|
+
lines.append(f"- {item.relation} orphans: {pairs}")
|
|
110
|
+
if item.required_gaps is not None and item.required_gaps.missing:
|
|
111
|
+
lines.append(
|
|
112
|
+
f"- {item.relation} required, missing for: {', '.join(item.required_gaps.examples)}"
|
|
113
|
+
)
|
|
114
|
+
return lines
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _summary(report: DomainHealthOut | KindHealthOut) -> list[str]:
|
|
118
|
+
s = report.summary
|
|
119
|
+
return [
|
|
120
|
+
f"{_verdict(s.ok)}: {s.kinds} kinds, {s.records} records, {s.links} links",
|
|
121
|
+
f"- invalid records: {s.invalid_records}; orphan links: {s.orphan_links}; "
|
|
122
|
+
f"required-relation gaps: {s.required_relation_gaps}; duplicate groups: "
|
|
123
|
+
f"{s.duplicate_groups}; kinds with stale derived values: {s.stale_kinds}",
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def domain_markdown(report: DomainHealthOut) -> str:
|
|
128
|
+
"""A Domain report as markdown."""
|
|
129
|
+
lines = [
|
|
130
|
+
f"# Health of {report.domain} on {report.branch}",
|
|
131
|
+
f"revision {report.revision[:SHORT_REVISION]}",
|
|
132
|
+
"",
|
|
133
|
+
*_summary(report),
|
|
134
|
+
"",
|
|
135
|
+
"## Kinds",
|
|
136
|
+
*_kinds_table(report.kinds),
|
|
137
|
+
]
|
|
138
|
+
for kind in report.kinds:
|
|
139
|
+
details = _kind_details(kind)
|
|
140
|
+
if details:
|
|
141
|
+
lines += ["", *details]
|
|
142
|
+
if report.relations:
|
|
143
|
+
lines += ["", *_relations(report.relations)]
|
|
144
|
+
return "\n".join(lines) + "\n"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def kind_markdown(report: KindHealthOut) -> str:
|
|
148
|
+
"""A Kind report as markdown."""
|
|
149
|
+
kind = report.health
|
|
150
|
+
lines = [
|
|
151
|
+
f"# Health of {kind.kind} in {report.domain} on {report.branch}",
|
|
152
|
+
f"revision {report.revision[:SHORT_REVISION]}",
|
|
153
|
+
"",
|
|
154
|
+
*_summary(report),
|
|
155
|
+
"",
|
|
156
|
+
*_kinds_table([kind]),
|
|
157
|
+
]
|
|
158
|
+
details = _kind_details(kind)
|
|
159
|
+
if details:
|
|
160
|
+
lines += ["", *details]
|
|
161
|
+
if report.relations:
|
|
162
|
+
lines += ["", *_relations(report.relations)]
|
|
163
|
+
return "\n".join(lines) + "\n"
|