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