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,220 @@
1
+ """Domains (design 5.2, 11.1): create, list, read, update display metadata, delete.
2
+
3
+ Creating a Domain needs the installation-level `domain.create` permission; the creator gets
4
+ the configured role (`keeper.domains.creator_role`, default `admin`) in the new Domain.
5
+ Deleting one needs `domain.delete`, which only the Domain's `admin` role holds, and the Domain
6
+ id repeated in `confirm`: the deletion removes every Revision, branch and grant of the Domain
7
+ and cannot be undone (the audit log keeps its entries).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from datetime import datetime
13
+ from typing import Annotated, Any
14
+
15
+ from fastapi import APIRouter, Depends, Query, Request, Response
16
+ from pydantic import BaseModel, Field, JsonValue
17
+
18
+ from lorebase_core.canonical import hex
19
+ from lorebase_core.model import Domain
20
+ from lorebase_keeper.auth.audit import annotate
21
+ from lorebase_keeper.auth.deps import (
22
+ Access,
23
+ AuthDep,
24
+ PrincipalDep,
25
+ limited_kinds,
26
+ require,
27
+ require_installation,
28
+ )
29
+ from lorebase_keeper.auth.permissions import Permission
30
+ from lorebase_keeper.context import StoreDep
31
+ from lorebase_keeper.deps import IdempotencyKeyDep, KeeperSettingsDep
32
+ from lorebase_keeper.errors import VALIDATION_FAILED, Problem, ProblemError, ValidationProblem
33
+ from lorebase_keeper.pagination import Page, PageParamsDep, encode_cursor
34
+ from lorebase_keeper.services import domains as service
35
+ from lorebase_keeper.services.branches import check_ref_name
36
+ from lorebase_store.repositories import DomainRow
37
+
38
+ router = APIRouter(tags=["domains"])
39
+
40
+ DOMAIN_ID_PATTERN = r"^[a-z][a-z0-9_-]*$"
41
+ REPLAYED_HEADER = "Idempotent-Replayed"
42
+
43
+ _ERRORS: dict[int | str, dict[str, Any]] = {
44
+ 403: {"model": Problem, "description": "The caller lacks the permission named in the body."},
45
+ 404: {"model": Problem, "description": "Domain not found."},
46
+ 409: {"model": Problem, "description": "The Domain already exists."},
47
+ 422: {"model": ValidationProblem, "description": "Invalid request."},
48
+ }
49
+
50
+
51
+ class DomainCreate(BaseModel):
52
+ """A new Domain."""
53
+
54
+ id: str = Field(pattern=DOMAIN_ID_PATTERN, max_length=63, description="Immutable slug.")
55
+ display_name: str = Field(default="", max_length=200)
56
+ description: str = Field(default="", max_length=4000)
57
+ default_branch: str = Field(default="main", min_length=1, max_length=200)
58
+ settings: dict[str, JsonValue] = Field(
59
+ default_factory=dict, description="Search language, materialization and merge policies."
60
+ )
61
+
62
+
63
+ class DomainUpdate(BaseModel):
64
+ """Display metadata; omitted members are left unchanged."""
65
+
66
+ display_name: str | None = Field(default=None, min_length=1, max_length=200)
67
+ description: str | None = Field(default=None, max_length=4000)
68
+
69
+
70
+ class DomainOut(BaseModel):
71
+ """A Domain and the head Revision of its default branch."""
72
+
73
+ id: str
74
+ display_name: str
75
+ description: str | None
76
+ default_branch: str
77
+ settings: dict[str, Any]
78
+ created_at: datetime
79
+ created_by: str
80
+ head: str | None = Field(description="Head Revision of the default branch.")
81
+
82
+
83
+ def _out(row: DomainRow, head: bytes | None) -> DomainOut:
84
+ return DomainOut(
85
+ id=row.id,
86
+ display_name=row.display_name,
87
+ description=row.description,
88
+ default_branch=row.default_branch,
89
+ settings=dict(row.settings),
90
+ created_at=row.created_at,
91
+ created_by=row.created_by,
92
+ head=None if head is None else hex(head),
93
+ )
94
+
95
+
96
+ @router.post("/domains", status_code=201, summary="Create a Domain", responses=_ERRORS)
97
+ async def create_domain(
98
+ body: DomainCreate,
99
+ request: Request,
100
+ response: Response,
101
+ store: StoreDep,
102
+ keeper: KeeperSettingsDep,
103
+ auth: AuthDep,
104
+ idempotency_key: IdempotencyKeyDep,
105
+ access: Annotated[Access, Depends(require_installation(Permission.DOMAIN_CREATE))],
106
+ ) -> DomainOut:
107
+ """Create a Domain with an initial Revision on its default branch (protected by default).
108
+
109
+ With an `Idempotency-Key`, retrying a create that succeeded returns the Domain with
110
+ status 200 and `Idempotent-Replayed: true`.
111
+ """
112
+ check_ref_name(body.default_branch, keeper.branches, "/body/default_branch")
113
+ domain = Domain(
114
+ id=body.id,
115
+ display_name=body.display_name,
116
+ description=body.description,
117
+ default_branch=body.default_branch,
118
+ settings=body.settings,
119
+ )
120
+ created = await service.create(
121
+ store, domain, access.principal, keeper.domains, idempotency_key=idempotency_key
122
+ )
123
+ auth.authorizer.invalidate()
124
+ annotate(request, domain_id=body.id, action="domain.create", target=body.id)
125
+ if created.replayed:
126
+ response.status_code = 200
127
+ response.headers[REPLAYED_HEADER] = "true"
128
+ response.headers["Location"] = f"{keeper.api_prefix}/domains/{body.id}"
129
+ return _out(created.domain, created.head)
130
+
131
+
132
+ @router.get("/domains", summary="List Domains", responses=_ERRORS)
133
+ async def list_domains(
134
+ principal: PrincipalDep, auth: AuthDep, store: StoreDep, params: PageParamsDep
135
+ ) -> Page[DomainOut]:
136
+ """Domains whose schema the caller may read (also through grants narrowed to Kinds that
137
+ are invisible to it otherwise), ordered by id."""
138
+ after = None if params.position is None else str(params.position.get("id", ""))
139
+ items: list[DomainOut] = []
140
+ more = True
141
+ # Domains the caller cannot see are skipped: read further batches until the page is full.
142
+ while more and len(items) < params.limit:
143
+ rows = await service.page(store, limit=params.limit, after=after)
144
+ more = len(rows) == params.limit
145
+ for position, row in enumerate(rows):
146
+ after = row.id
147
+ if await auth.authorizer.allows(
148
+ principal, Permission.SCHEMA_READ, row.id
149
+ ) or await limited_kinds(auth.authorizer, principal, Permission.SCHEMA_READ, row.id):
150
+ items.append(_out(row, None))
151
+ if len(items) == params.limit:
152
+ more = more or position < len(rows) - 1
153
+ break
154
+ next_cursor = encode_cursor({"id": after}) if more and after is not None else None
155
+ return Page[DomainOut](items=items, next_cursor=next_cursor)
156
+
157
+
158
+ @router.get("/domains/{domain_id}", summary="Get a Domain", responses=_ERRORS)
159
+ async def get_domain(
160
+ domain_id: str,
161
+ store: StoreDep,
162
+ _: Annotated[Access, Depends(require(Permission.SCHEMA_READ, kind_limited=True))],
163
+ ) -> DomainOut:
164
+ """The Domain and the head Revision of its default branch."""
165
+ row, head = await service.get(store, domain_id)
166
+ return _out(row, head)
167
+
168
+
169
+ @router.patch("/domains/{domain_id}", summary="Update Domain display metadata", responses=_ERRORS)
170
+ async def update_domain(
171
+ domain_id: str,
172
+ body: DomainUpdate,
173
+ request: Request,
174
+ store: StoreDep,
175
+ _: Annotated[Access, Depends(require(Permission.DOMAIN_SETTINGS))],
176
+ ) -> DomainOut:
177
+ """Change the display name or description. They are not versioned."""
178
+ await service.update(
179
+ store, domain_id, display_name=body.display_name, description=body.description
180
+ )
181
+ annotate(
182
+ request,
183
+ action="domain.update",
184
+ target=domain_id,
185
+ fields=sorted(body.model_dump(exclude_unset=True)),
186
+ )
187
+ row, head = await service.get(store, domain_id)
188
+ return _out(row, head)
189
+
190
+
191
+ @router.delete(
192
+ "/domains/{domain_id}", status_code=204, summary="Delete a Domain", responses=_ERRORS
193
+ )
194
+ async def delete_domain(
195
+ domain_id: str,
196
+ request: Request,
197
+ store: StoreDep,
198
+ auth: AuthDep,
199
+ confirm: Annotated[str, Query(description="The Domain id, repeated to confirm.")],
200
+ _: Annotated[Access, Depends(require(Permission.DOMAIN_DELETE))],
201
+ ) -> Response:
202
+ """Delete the Domain with all its Revisions, branches, materialized rows and grants."""
203
+ if confirm != domain_id:
204
+ raise ProblemError(
205
+ VALIDATION_FAILED,
206
+ "One or more request members are invalid.",
207
+ extensions={
208
+ "errors": [
209
+ {
210
+ "pointer": "/query/confirm",
211
+ "message": "must repeat the Domain id",
212
+ "code": "invalid",
213
+ }
214
+ ]
215
+ },
216
+ )
217
+ await service.delete(store, domain_id)
218
+ auth.authorizer.invalidate()
219
+ annotate(request, action="domain.delete", target=domain_id)
220
+ return Response(status_code=204)
@@ -0,0 +1,187 @@
1
+ """Change feed of a branch (`agents.feed`): Server-Sent Events and the polling fallback.
2
+
3
+ Both are history reads: they need `data.read` on the branch through a grant not narrowed to a
4
+ Kind, like the branch log. An open stream repeats that check (authentication included) every
5
+ `agents.feed.reauthorize_interval_s` and closes when it fails.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import replace
11
+ from typing import Annotated, Any
12
+
13
+ from fastapi import APIRouter, Depends, Header, Query, Request, Response
14
+ from fastapi.responses import StreamingResponse
15
+
16
+ from lorebase_keeper.agents.feed import (
17
+ EVENT_STREAM,
18
+ ChangesFeedPage,
19
+ changes_since,
20
+ kind_filter,
21
+ parse_sequence,
22
+ stream,
23
+ )
24
+ from lorebase_keeper.auth.deps import AuthDep
25
+ from lorebase_keeper.client_address import client_address
26
+ from lorebase_keeper.errors import Problem, ProblemError, ValidationProblem
27
+ from lorebase_keeper.reading.caching import respond
28
+ from lorebase_keeper.reading.points import (
29
+ REVISION_HEADER,
30
+ branch_schema,
31
+ open_branch,
32
+ open_domain,
33
+ open_revision,
34
+ read_guard,
35
+ )
36
+ from lorebase_keeper.reading.problems import invalid
37
+ from lorebase_keeper.reading.runtime import ReadRuntimeDep, ReadSessionDep, begin_read
38
+ from lorebase_keeper.routes.agents import AgentSettingsDep
39
+ from lorebase_keeper.services.reads import ReadGuard
40
+ from lorebase_store.repositories import OutboxRepository
41
+
42
+ router = APIRouter(tags=["agents"])
43
+
44
+ BASE = "/domains/{domain_id}"
45
+ _ERRORS: dict[int | str, dict[str, Any]] = {
46
+ 403: {"model": Problem, "description": "The caller lacks data.read for the branch."},
47
+ 404: {"model": Problem, "description": "Domain, branch or Revision not found."},
48
+ 422: {"model": ValidationProblem, "description": "Invalid request."},
49
+ }
50
+
51
+ DataRead = Annotated[ReadGuard, Depends(read_guard())]
52
+ Kinds = Annotated[
53
+ list[str] | None,
54
+ Query(description="Only events changing these Kinds (repeated or comma-separated)."),
55
+ ]
56
+
57
+
58
+ @router.get(
59
+ f"{BASE}/branches/{{branch:path}}/events",
60
+ summary="Stream the Revisions of a branch (Server-Sent Events)",
61
+ response_class=StreamingResponse,
62
+ responses={
63
+ **_ERRORS,
64
+ 200: {
65
+ "content": {EVENT_STREAM: {}},
66
+ "description": "`event: revision` messages whose `data` is a RevisionEventOut and "
67
+ "whose `id` is the event sequence; comment lines as heartbeats.",
68
+ },
69
+ },
70
+ )
71
+ async def branch_events(
72
+ domain_id: str,
73
+ branch: str,
74
+ access: DataRead,
75
+ runtime: ReadRuntimeDep,
76
+ settings: AgentSettingsDep,
77
+ auth: AuthDep,
78
+ request: Request,
79
+ kinds: Kinds = None,
80
+ last_event_id: Annotated[
81
+ str | None,
82
+ Query(description="Resume after this event id (for clients that cannot set headers)."),
83
+ ] = None,
84
+ last_event_header: Annotated[str | None, Header(alias="Last-Event-ID")] = None,
85
+ ) -> StreamingResponse:
86
+ """Revisions committed on the branch from now on, or after `Last-Event-ID`."""
87
+ async with runtime.sessions() as session, session.begin():
88
+ await begin_read(session, runtime)
89
+ domain = await open_domain(session, access)
90
+ ref = await open_branch(session, access, domain, branch)
91
+ wanted = kind_filter(await branch_schema(session, runtime, domain.id, ref.name), kinds)
92
+ start = parse_sequence(last_event_header or last_event_id)
93
+ if start is None:
94
+ start = await OutboxRepository(session).latest(domain.id)
95
+
96
+ async def still_allowed() -> bool:
97
+ """The checks above, again: the credential (revoked or expired keys and tokens, the
98
+ networks of a key), then `data.read` on the branch. Grants come through the
99
+ authorizer's cache."""
100
+ try:
101
+ principal = await auth.authenticator.authenticate(
102
+ request.headers, client_address(request)
103
+ )
104
+ guard = replace(access, principal=principal)
105
+ await guard.precheck()
106
+ await guard.require_branch(ref.name)
107
+ except ProblemError:
108
+ return False
109
+ return True
110
+
111
+ return StreamingResponse(
112
+ stream(
113
+ runtime,
114
+ domain.id,
115
+ ref.name,
116
+ start,
117
+ wanted,
118
+ settings.feed,
119
+ request.is_disconnected,
120
+ still_allowed,
121
+ ),
122
+ media_type=EVENT_STREAM,
123
+ headers={
124
+ "Cache-Control": "no-cache",
125
+ "X-Accel-Buffering": "no",
126
+ REVISION_HEADER: ref.head.hex(),
127
+ },
128
+ )
129
+
130
+
131
+ @router.get(
132
+ f"{BASE}/branches/{{branch:path}}/changes",
133
+ summary="Revisions of a branch since a Revision",
134
+ response_model=ChangesFeedPage,
135
+ responses={
136
+ **_ERRORS,
137
+ 304: {"description": "Not modified: If-None-Match names the current ETag."},
138
+ 409: {"model": Problem, "description": "`since` is not reachable from the head."},
139
+ },
140
+ )
141
+ async def branch_changes(
142
+ domain_id: str,
143
+ branch: str,
144
+ access: DataRead,
145
+ runtime: ReadRuntimeDep,
146
+ session: ReadSessionDep,
147
+ settings: AgentSettingsDep,
148
+ request: Request,
149
+ since: Annotated[
150
+ str | None,
151
+ Query(description="Revision id or tag; default: the latest Revisions."),
152
+ ] = None,
153
+ kinds: Kinds = None,
154
+ limit: Annotated[int | None, Query(ge=1, description="Page size.")] = None,
155
+ ) -> Response:
156
+ """First-parent Revisions after `since`, oldest first; continue with `next_since`."""
157
+ domain = await open_domain(session, access)
158
+ ref = await open_branch(session, access, domain, branch)
159
+ bounds = settings.feed.changes
160
+ effective = bounds.default_limit if limit is None else limit
161
+ if effective > bounds.max_limit:
162
+ raise invalid("/query/limit", f"must not exceed {bounds.max_limit}", "limit_exceeded")
163
+ since_row = None if since is None else await open_revision(session, access, domain, since)
164
+ wanted = kind_filter(await branch_schema(session, runtime, domain.id, ref.name), kinds)
165
+
166
+ async def produce() -> ChangesFeedPage:
167
+ return await changes_since(
168
+ session,
169
+ runtime,
170
+ domain.id,
171
+ ref.name,
172
+ ref.head,
173
+ since_row,
174
+ limit=effective,
175
+ wanted=wanted,
176
+ settings=settings.feed,
177
+ )
178
+
179
+ return await respond(
180
+ request,
181
+ runtime.cache,
182
+ domain_id=domain.id,
183
+ revision=ref.head.hex(),
184
+ produce=produce,
185
+ headers={REVISION_HEADER: ref.head.hex()},
186
+ variant="" if since_row is None else since_row.id.hex(),
187
+ )
@@ -0,0 +1,166 @@
1
+ """Health reports (design 14): how sound the data of a Domain, or of one Kind, is.
2
+
3
+ Computed on request from the materialized head of a branch, or with `at` from a snapshot of an
4
+ earlier Revision (`reading.snapshots`), and kept in the response cache under the Revision they
5
+ describe, so asking again costs nothing until the branch moves. Callers with
6
+ Kind-narrowed grants get a report limited to those Kinds. `format=markdown` renders the report
7
+ for pasting into a prompt.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Annotated, Any, Literal
13
+
14
+ from fastapi import APIRouter, Depends, Query, Request, Response
15
+
16
+ from lorebase_keeper.agents.context import JSON, MARKDOWN
17
+ from lorebase_keeper.errors import Problem, ValidationProblem
18
+ from lorebase_keeper.kindhealth import markdown, report
19
+ from lorebase_keeper.kindhealth.models import DomainHealthOut, KindHealthOut
20
+ from lorebase_keeper.reading.caching import cache_key, respond_with
21
+ from lorebase_keeper.reading.points import REVISION_HEADER, read_guard, resolve_point
22
+ from lorebase_keeper.reading.records import require_kind
23
+ from lorebase_keeper.reading.runtime import ReadRuntimeDep, ReadSessionDep
24
+ from lorebase_keeper.reading.snapshots import with_rows
25
+ from lorebase_keeper.reading.views import served
26
+ from lorebase_keeper.services.reads import ReadGuard
27
+
28
+ router = APIRouter(tags=["health"])
29
+
30
+ BASE = "/domains/{domain_id}"
31
+ _ERRORS: dict[int | str, dict[str, Any]] = {
32
+ 403: {"model": Problem, "description": "The caller lacks data.read for the branch."},
33
+ 404: {"model": Problem, "description": "Domain, branch or Kind not found."},
34
+ 422: {"model": ValidationProblem, "description": "Invalid request."},
35
+ 503: {"model": Problem, "description": "The scan exceeded its statement timeout."},
36
+ }
37
+
38
+ DataRead = Annotated[ReadGuard, Depends(read_guard())]
39
+ Branch = Annotated[
40
+ str | None, Query(description="Branch to examine; default: the Domain's default branch.")
41
+ ]
42
+ At = Annotated[
43
+ str | None,
44
+ Query(description="Revision id or tag to examine (time travel, served from a snapshot)."),
45
+ ]
46
+ Format = Annotated[
47
+ Literal["json", "markdown"], Query(description="`markdown` renders the report as text.")
48
+ ]
49
+
50
+
51
+ @router.get(
52
+ f"{BASE}/health",
53
+ summary="Health of a Domain",
54
+ responses={
55
+ **_ERRORS,
56
+ 200: {
57
+ "model": DomainHealthOut,
58
+ "content": {JSON: {}, MARKDOWN: {}},
59
+ "description": "The report in JSON, or markdown with `format=markdown`.",
60
+ },
61
+ 304: {"description": "Not modified: If-None-Match names the current ETag."},
62
+ },
63
+ )
64
+ async def domain_health(
65
+ domain_id: str,
66
+ access: DataRead,
67
+ runtime: ReadRuntimeDep,
68
+ session: ReadSessionDep,
69
+ request: Request,
70
+ branch: Branch = None,
71
+ at: At = None,
72
+ format: Format = "json",
73
+ ) -> Response:
74
+ """Per Kind: Records, Links by Relation, constraint violations against the current schema,
75
+ required-relation gaps, orphan Links, duplicates on unique and title Attributes, stale
76
+ derived values and the last change; for the Kinds the caller may read."""
77
+ point = await resolve_point(session, runtime, access, branch=branch, at=at)
78
+
79
+ async def produce() -> tuple[bytes, str]:
80
+ rows = await with_rows(session, runtime, point, "Health reports")
81
+ found = await report.scan_branch(
82
+ session,
83
+ rows,
84
+ runtime.read.health,
85
+ report.visible_kinds(point),
86
+ report.visible_relations(point),
87
+ )
88
+ out = DomainHealthOut(
89
+ **served(point),
90
+ domain=point.domain.id,
91
+ branch=point.branch,
92
+ summary=found.summary,
93
+ kinds=found.kinds,
94
+ relations=found.relations,
95
+ )
96
+ if format == "markdown":
97
+ return markdown.domain_markdown(out).encode(), MARKDOWN
98
+ return out.model_dump_json().encode(), JSON
99
+
100
+ return await respond_with(
101
+ request,
102
+ runtime.cache,
103
+ cache_key(request, point.domain.id, point.revision_hex, point.variant),
104
+ produce,
105
+ time_travel=not point.materialized,
106
+ headers={REVISION_HEADER: point.revision_hex},
107
+ )
108
+
109
+
110
+ @router.get(
111
+ f"{BASE}/kinds/{{kind}}/health",
112
+ summary="Health of a Kind",
113
+ responses={
114
+ **_ERRORS,
115
+ 200: {
116
+ "model": KindHealthOut,
117
+ "content": {JSON: {}, MARKDOWN: {}},
118
+ "description": "The report in JSON, or markdown with `format=markdown`.",
119
+ },
120
+ 304: {"description": "Not modified: If-None-Match names the current ETag."},
121
+ },
122
+ )
123
+ async def kind_health(
124
+ domain_id: str,
125
+ kind: str,
126
+ access: DataRead,
127
+ runtime: ReadRuntimeDep,
128
+ session: ReadSessionDep,
129
+ request: Request,
130
+ branch: Branch = None,
131
+ at: At = None,
132
+ format: Format = "json",
133
+ ) -> Response:
134
+ """The checks of the Domain report for one Kind, with the Relations it takes part in."""
135
+ point = await resolve_point(session, runtime, access, branch=branch, at=at)
136
+ found_kind = require_kind(point, kind)
137
+
138
+ async def produce() -> tuple[bytes, str]:
139
+ rows = await with_rows(session, runtime, point, "Health reports")
140
+ found = await report.scan_branch(
141
+ session,
142
+ rows,
143
+ runtime.read.health,
144
+ [found_kind],
145
+ report.visible_relations(point, found_kind),
146
+ )
147
+ out = KindHealthOut(
148
+ **served(point),
149
+ domain=point.domain.id,
150
+ branch=point.branch,
151
+ summary=found.summary,
152
+ health=found.kinds[0],
153
+ relations=report.with_direction(found.relations, found_kind),
154
+ )
155
+ if format == "markdown":
156
+ return markdown.kind_markdown(out).encode(), MARKDOWN
157
+ return out.model_dump_json().encode(), JSON
158
+
159
+ return await respond_with(
160
+ request,
161
+ runtime.cache,
162
+ cache_key(request, point.domain.id, point.revision_hex, point.variant),
163
+ produce,
164
+ time_travel=not point.materialized,
165
+ headers={REVISION_HEADER: point.revision_hex},
166
+ )