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,497 @@
1
+ """Documents of a Domain (design 9.2 step 1): intake for extraction, listing, details.
2
+
3
+ | Method and path | Permission | Notes |
4
+ |-----------------|------------|-------|
5
+ | `POST /domains/{d}/documents` | `document.write` | 201 new, 200 same bytes stored before |
6
+ | `GET /domains/{d}/documents` | `document.read` | newest first |
7
+ | `GET /domains/{d}/documents/{id}` | `document.read` | with its extraction jobs |
8
+ | `GET /domains/{d}/documents/{id}/text` | `document.read` | parsed text, cached by its hash |
9
+
10
+ Both permissions need a Domain-wide grant: documents are raw input, not Records of a Kind.
11
+
12
+ The upload is `multipart/form-data` with the bytes in `file` (optional fields `extract`,
13
+ `true` or `false`, and `tiers`, a JSON list such as `[0, 1]`), or JSON naming a URL:
14
+ `{"url": "https://docs.example.com/c.pdf", "filename": "c.pdf", "extract": true, "tiers":
15
+ [0, 1, 2]}`. URLs must use an allowed scheme and an allowlisted host
16
+ (`keeper.documents.url_hosts`) that resolves to global unicast addresses only; Keeper connects
17
+ to those checked addresses without following redirects (`services/document_urls.py`). The media
18
+ type comes from the file name suffix (`keeper.documents.media_types`) and is checked against
19
+ the content (PDF and DOCX signatures, UTF-8 text). Documents are stored content addressed: the
20
+ same bytes uploaded again return the stored document. Unless `extract` is false (default
21
+ `keeper.documents.extract_on_upload`), an `extraction` job is queued for Scribe; its id is in
22
+ the answer. Uploads are audited (`document.upload`).
23
+
24
+ The text route serves the text Scribe parsed, which the evidence offsets of extraction items
25
+ index (Unicode code points). It answers 404 until Scribe parsed the document, is cut at
26
+ `keeper.documents.max_text_chars` (`truncated`), and is cached under the text's hash: the
27
+ `ETag` changes only when the text does.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import re
34
+ from datetime import datetime
35
+ from pathlib import PurePosixPath
36
+ from typing import Annotated, Any
37
+ from urllib.parse import unquote, urlsplit
38
+
39
+ from fastapi import APIRouter, Depends, Request, Response
40
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError
41
+ from starlette.datastructures import UploadFile
42
+
43
+ from lorebase_core.model import new_ulid
44
+ from lorebase_keeper.auth.audit import annotate
45
+ from lorebase_keeper.auth.deps import Access, require
46
+ from lorebase_keeper.auth.permissions import Permission
47
+ from lorebase_keeper.context import StoreDep, require_domain
48
+ from lorebase_keeper.deps import KeeperSettingsDep
49
+ from lorebase_keeper.errors import (
50
+ BAD_GATEWAY,
51
+ BAD_REQUEST,
52
+ NOT_FOUND,
53
+ PAYLOAD_TOO_LARGE,
54
+ VALIDATION_FAILED,
55
+ Problem,
56
+ ProblemError,
57
+ )
58
+ from lorebase_keeper.pagination import Page, PageParamsDep, encode_cursor
59
+ from lorebase_keeper.reading.caching import respond
60
+ from lorebase_keeper.reading.runtime import ReadRuntimeDep
61
+ from lorebase_keeper.services import document_urls
62
+ from lorebase_keeper.services.config import DocumentSettings
63
+ from lorebase_store.blobs import TEXT_REF_KEY, BlobNotFoundError, PostgresBlobStore, sha256
64
+ from lorebase_store.jobs import EXTRACTION_JOB, enqueue_job, extraction_payload
65
+ from lorebase_store.repositories import (
66
+ DocumentRepository,
67
+ DocumentRow,
68
+ ExtractionJobRepository,
69
+ ExtractionJobRow,
70
+ )
71
+
72
+ router = APIRouter(tags=["documents"])
73
+
74
+ DOCUMENTS = "/domains/{domain_id}/documents"
75
+ DOCUMENT_PREFIX = "doc"
76
+ MULTIPART = "multipart/form-data"
77
+ _TIERS = frozenset({0, 1, 2})
78
+ _SIGNATURES = {"application/pdf": b"%PDF-", "application/zip": b"PK\x03\x04"}
79
+
80
+ _ERRORS: dict[int | str, dict[str, Any]] = {
81
+ 403: {"model": Problem, "description": "The caller lacks the permission Domain-wide."},
82
+ 404: {"model": Problem, "description": "Domain or document not found."},
83
+ 413: {"model": Problem, "description": "The document is too large."},
84
+ 422: {"model": Problem, "description": "Unsupported or invalid document, URL or fields."},
85
+ 502: {"model": Problem, "description": "The URL could not be fetched."},
86
+ }
87
+ Write = Annotated[Access, Depends(require(Permission.DOCUMENT_WRITE))]
88
+ Read = Annotated[Access, Depends(require(Permission.DOCUMENT_READ))]
89
+
90
+
91
+ def document_upload_path(api_prefix: str) -> re.Pattern[str]:
92
+ """Path of the upload route, for the request body limit."""
93
+ return re.compile(re.escape(api_prefix) + r"/domains/[^/]+/documents")
94
+
95
+
96
+ class DocumentFromUrl(BaseModel):
97
+ """JSON body: a document fetched from an allowlisted URL."""
98
+
99
+ model_config = ConfigDict(extra="forbid")
100
+
101
+ url: str = Field(min_length=1, max_length=2000)
102
+ filename: str | None = Field(
103
+ default=None, max_length=255, description="Name to store; default: the URL's last segment."
104
+ )
105
+ extract: bool | None = Field(default=None, description="Queue an extraction job.")
106
+ tiers: list[int] | None = Field(default=None, description="Extraction tiers (0, 1, 2).")
107
+
108
+
109
+ _BODY: dict[str, Any] = {
110
+ "requestBody": {
111
+ "required": True,
112
+ "content": {
113
+ MULTIPART: {
114
+ "schema": {
115
+ "type": "object",
116
+ "properties": {
117
+ "file": {"type": "string", "format": "binary"},
118
+ "extract": {"type": "boolean"},
119
+ "tiers": {"type": "string", "description": "JSON list, e.g. [0, 1]"},
120
+ },
121
+ "required": ["file"],
122
+ }
123
+ },
124
+ "application/json": {"schema": DocumentFromUrl.model_json_schema()},
125
+ },
126
+ }
127
+ }
128
+
129
+
130
+ class ExtractionJobOut(BaseModel):
131
+ id: str
132
+ status: str
133
+ model: str
134
+ prompt_version: str
135
+ branch: str | None = None
136
+ proposal_id: str | None = None
137
+ created_at: datetime | None = None
138
+ finished_at: datetime | None = None
139
+ usage: dict[str, Any]
140
+ stats: dict[str, Any]
141
+ error: dict[str, Any] | None = None
142
+
143
+
144
+ class DocumentOut(BaseModel):
145
+ id: str
146
+ filename: str
147
+ media_type: str
148
+ sha256: str
149
+ size: int
150
+ text_sha256: str | None = Field(default=None, description="Set once Scribe parsed the text.")
151
+ metadata: dict[str, Any]
152
+ created_at: datetime | None = None
153
+ created_by: str
154
+
155
+
156
+ class DocumentDetailOut(DocumentOut):
157
+ extraction_jobs: list[ExtractionJobOut] = Field(description="Newest first.")
158
+
159
+
160
+ class DocumentTextOut(BaseModel):
161
+ document_id: str
162
+ text_sha256: str = Field(description="SHA-256 of the whole parsed text (UTF-8).")
163
+ text: str = Field(description="The parsed text, cut at `keeper.documents.max_text_chars`.")
164
+ length: int = Field(description="Characters (code points) of the whole parsed text.")
165
+ truncated: bool = Field(description="`text` holds only the first characters.")
166
+
167
+
168
+ class DocumentUploaded(BaseModel):
169
+ document: DocumentOut
170
+ duplicate: bool = Field(description="The same bytes were stored before.")
171
+ job_id: int | None = Field(default=None, description="Queued extraction job, if any.")
172
+
173
+
174
+ def _document(row: DocumentRow) -> DocumentOut:
175
+ return DocumentOut(
176
+ id=row.id,
177
+ filename=row.filename,
178
+ media_type=row.media_type,
179
+ sha256=row.sha256.hex(),
180
+ size=row.size,
181
+ text_sha256=None if row.text_hash is None else row.text_hash.hex(),
182
+ metadata=row.metadata,
183
+ created_at=row.created_at,
184
+ created_by=row.created_by,
185
+ )
186
+
187
+
188
+ def _job(row: ExtractionJobRow) -> ExtractionJobOut:
189
+ return ExtractionJobOut(
190
+ id=row.id,
191
+ status=row.status.value,
192
+ model=row.model,
193
+ prompt_version=row.prompt_version,
194
+ branch=row.branch,
195
+ proposal_id=row.proposal_id,
196
+ created_at=row.created_at,
197
+ finished_at=row.finished_at,
198
+ usage=row.usage,
199
+ stats=row.stats,
200
+ error=row.error,
201
+ )
202
+
203
+
204
+ def _invalid(pointer: str, message: str) -> ProblemError:
205
+ """422 naming one invalid request member."""
206
+ return ProblemError(
207
+ VALIDATION_FAILED,
208
+ "One or more request members are invalid.",
209
+ extensions={"errors": [{"pointer": pointer, "message": message, "code": "invalid"}]},
210
+ )
211
+
212
+
213
+ def _tiers(raw: Any, pointer: str) -> list[int] | None:
214
+ if raw is None:
215
+ return None
216
+ if isinstance(raw, str):
217
+ try:
218
+ raw = json.loads(raw)
219
+ except ValueError:
220
+ raise _invalid(pointer, "must be a JSON list of tiers") from None
221
+ if (
222
+ not isinstance(raw, list)
223
+ or not raw
224
+ or not all(isinstance(t, int) and t in _TIERS for t in raw)
225
+ ):
226
+ raise _invalid(pointer, "must be a non-empty list of tiers among 0, 1 and 2")
227
+ return sorted(set(raw))
228
+
229
+
230
+ def _flag(raw: Any, pointer: str) -> bool | None:
231
+ if raw is None:
232
+ return None
233
+ if isinstance(raw, str) and raw.lower() in ("true", "false"):
234
+ return raw.lower() == "true"
235
+ raise _invalid(pointer, "must be true or false")
236
+
237
+
238
+ def _media_type(filename: str, data: bytes, settings: DocumentSettings) -> str:
239
+ """The media type of an accepted document; 422 when unsupported or not what it claims."""
240
+ suffix = PurePosixPath(filename).suffix.lower()
241
+ media_type = settings.media_types.get(suffix)
242
+ if media_type is None:
243
+ accepted = ", ".join(sorted(settings.media_types))
244
+ raise _invalid("/body/file", f"unsupported file type {suffix or '(none)'}; use {accepted}")
245
+ if not data:
246
+ raise _invalid("/body/file", "the document is empty")
247
+ if media_type == "application/pdf":
248
+ valid = data.startswith(_SIGNATURES["application/pdf"])
249
+ elif media_type.endswith("wordprocessingml.document"):
250
+ valid = data.startswith(_SIGNATURES["application/zip"])
251
+ elif media_type.startswith("text/"):
252
+ try:
253
+ data.decode("utf-8-sig")
254
+ valid = b"\x00" not in data
255
+ except UnicodeDecodeError:
256
+ valid = False
257
+ else:
258
+ valid = True
259
+ if not valid:
260
+ raise _invalid("/body/file", f"the content is not a valid {media_type} document")
261
+ return media_type
262
+
263
+
264
+ def _safe_name(filename: str) -> str:
265
+ name = PurePosixPath(filename.replace("\\", "/")).name.strip()
266
+ if not name or name in (".", ".."):
267
+ raise _invalid("/body/filename", "a file name is required")
268
+ return name[:255]
269
+
270
+
271
+ async def _upload(
272
+ request: Request, settings: DocumentSettings
273
+ ) -> tuple[str, bytes, bool | None, list[int] | None, dict[str, Any]]:
274
+ try:
275
+ form = await request.form(max_files=1, max_fields=2, max_part_size=settings.max_bytes + 1)
276
+ except Exception as error: # the multipart parser reports limits and malformed bodies
277
+ raise ProblemError(BAD_REQUEST, f"Unreadable multipart body: {error}") from None
278
+ try:
279
+ upload = form.get("file")
280
+ if not isinstance(upload, UploadFile):
281
+ raise _invalid("/body/file", "a file is required")
282
+ data = await upload.read(settings.max_bytes + 1)
283
+ if len(data) > settings.max_bytes:
284
+ raise ProblemError(
285
+ PAYLOAD_TOO_LARGE, f"The document exceeds {settings.max_bytes} bytes."
286
+ )
287
+ extract = _flag(form.get("extract"), "/body/extract")
288
+ tiers = _tiers(form.get("tiers"), "/body/tiers")
289
+ return _safe_name(upload.filename or ""), data, extract, tiers, {"source": "upload"}
290
+ finally:
291
+ await form.close()
292
+
293
+
294
+ async def _fetch(body: DocumentFromUrl, settings: DocumentSettings) -> tuple[str, bytes]:
295
+ try:
296
+ data = await document_urls.fetch(body.url, settings)
297
+ except document_urls.UrlRefusedError as error:
298
+ raise _invalid("/body/url", str(error)) from None
299
+ except document_urls.DocumentTooLargeError:
300
+ raise ProblemError(
301
+ PAYLOAD_TOO_LARGE, f"The document exceeds {settings.max_bytes} bytes."
302
+ ) from None
303
+ except document_urls.UrlUnavailableError as error:
304
+ raise ProblemError(BAD_GATEWAY, f"{str(error).capitalize()}; nothing was stored.") from None
305
+ name = body.filename or unquote(PurePosixPath(urlsplit(body.url).path).name)
306
+ return _safe_name(name), data
307
+
308
+
309
+ @router.post(
310
+ DOCUMENTS,
311
+ status_code=201,
312
+ summary="Upload a document for extraction",
313
+ responses={**_ERRORS, 200: {"model": DocumentUploaded}},
314
+ response_model=DocumentUploaded,
315
+ openapi_extra=_BODY,
316
+ )
317
+ async def upload_document(
318
+ domain_id: str,
319
+ request: Request,
320
+ response: Response,
321
+ access: Write,
322
+ store: StoreDep,
323
+ keeper: KeeperSettingsDep,
324
+ ) -> DocumentUploaded:
325
+ """Store a document (multipart upload or allowlisted URL) and queue its extraction."""
326
+ settings = keeper.documents
327
+ if request.headers.get("content-type", "").startswith(MULTIPART):
328
+ filename, data, extract, tiers, metadata = await _upload(request, settings)
329
+ else:
330
+ try:
331
+ body = DocumentFromUrl.model_validate(await request.json())
332
+ except ValidationError as error:
333
+ first = error.errors()[0]
334
+ raise _invalid(
335
+ "/body/" + "/".join(str(p) for p in first["loc"]), str(first["msg"])
336
+ ) from None
337
+ except ValueError:
338
+ raise _invalid("/body", "send multipart/form-data or a JSON object") from None
339
+ if not settings.url_hosts:
340
+ raise _invalid("/body/url", "URL intake is disabled (keeper.documents.url_hosts)")
341
+ filename, data = await _fetch(body, settings)
342
+ extract = body.extract
343
+ tiers = _tiers(body.tiers, "/body/tiers")
344
+ metadata = {"source": "url", "url": body.url}
345
+ media_type = _media_type(filename, data, settings)
346
+ digest = sha256(data)
347
+ extract = settings.extract_on_upload if extract is None else extract
348
+ job_id: int | None = None
349
+ async with store.sessions() as session, session.begin():
350
+ await require_domain(session, domain_id)
351
+ ref = await PostgresBlobStore().put(session, domain_id, data)
352
+ row, created = await DocumentRepository(session, domain_id).add(
353
+ DocumentRow(
354
+ id=f"{DOCUMENT_PREFIX}_{new_ulid()}",
355
+ filename=filename,
356
+ media_type=media_type,
357
+ sha256=digest,
358
+ size=len(data),
359
+ blob_ref=ref,
360
+ created_by=access.principal.id,
361
+ metadata=metadata,
362
+ )
363
+ )
364
+ if extract:
365
+ job_id = await enqueue_job(
366
+ session,
367
+ EXTRACTION_JOB,
368
+ domain_id=domain_id,
369
+ payload=extraction_payload([row.id], tiers=tiers, requested_by=access.principal.id),
370
+ unique=True,
371
+ )
372
+ annotate(
373
+ request,
374
+ action="document.upload",
375
+ target=row.id,
376
+ sha256=digest.hex(),
377
+ size=len(data),
378
+ media_type=media_type,
379
+ duplicate=not created,
380
+ job=job_id,
381
+ )
382
+ if not created:
383
+ response.status_code = 200
384
+ response.headers["Location"] = f"{request.url.path.rstrip('/')}/{row.id}"
385
+ return DocumentUploaded(document=_document(row), duplicate=not created, job_id=job_id)
386
+
387
+
388
+ @router.get(DOCUMENTS, summary="List documents", responses=_ERRORS)
389
+ async def list_documents(
390
+ domain_id: str, access: Read, store: StoreDep, params: PageParamsDep
391
+ ) -> Page[DocumentOut]:
392
+ """Documents of the Domain, newest first."""
393
+ after = None
394
+ if params.position is not None:
395
+ try:
396
+ after = (
397
+ datetime.fromisoformat(str(params.position["at"])),
398
+ str(params.position["id"]),
399
+ )
400
+ except (KeyError, ValueError):
401
+ raise ProblemError(VALIDATION_FAILED, "The cursor is not a document cursor.") from None
402
+ async with store.sessions() as session, session.begin():
403
+ await require_domain(session, domain_id)
404
+ rows = await DocumentRepository(session, domain_id).page(
405
+ limit=params.limit + 1, after=after
406
+ )
407
+ more = len(rows) > params.limit
408
+ rows = rows[: params.limit]
409
+ last = rows[-1] if rows else None
410
+ next_cursor = (
411
+ encode_cursor({"at": last.created_at.isoformat(), "id": last.id})
412
+ if more and last is not None and last.created_at is not None
413
+ else None
414
+ )
415
+ return Page[DocumentOut](items=[_document(r) for r in rows], next_cursor=next_cursor)
416
+
417
+
418
+ @router.get(DOCUMENTS + "/{document_id}", summary="Get a document", responses=_ERRORS)
419
+ async def get_document(
420
+ domain_id: str,
421
+ document_id: str,
422
+ access: Read,
423
+ store: StoreDep,
424
+ keeper: KeeperSettingsDep,
425
+ ) -> DocumentDetailOut:
426
+ """The document's metadata and its extraction jobs, newest first."""
427
+ async with store.sessions() as session, session.begin():
428
+ await require_domain(session, domain_id)
429
+ row = await DocumentRepository(session, domain_id).get(document_id)
430
+ if row is None:
431
+ raise ProblemError(NOT_FOUND, f"Document {document_id!r} not found.")
432
+ jobs = await ExtractionJobRepository(session, domain_id).of_document(
433
+ document_id, limit=keeper.documents.jobs_listed
434
+ )
435
+ return DocumentDetailOut(**_document(row).model_dump(), extraction_jobs=[_job(j) for j in jobs])
436
+
437
+
438
+ @router.get(
439
+ DOCUMENTS + "/{document_id}/text",
440
+ summary="Get the parsed text of a document",
441
+ responses={
442
+ 200: {"model": DocumentTextOut},
443
+ 304: {"description": "Not modified (`If-None-Match`)."},
444
+ 403: _ERRORS[403],
445
+ 404: {"model": Problem, "description": "Domain or document not found, or not parsed."},
446
+ },
447
+ response_model=DocumentTextOut,
448
+ )
449
+ async def get_document_text(
450
+ request: Request,
451
+ domain_id: str,
452
+ document_id: str,
453
+ access: Read,
454
+ store: StoreDep,
455
+ runtime: ReadRuntimeDep,
456
+ keeper: KeeperSettingsDep,
457
+ ) -> Response:
458
+ """The text Scribe parsed from the document; extraction evidence offsets index it."""
459
+ async with store.sessions() as session, session.begin():
460
+ await require_domain(session, domain_id)
461
+ row = await DocumentRepository(session, domain_id).get(document_id)
462
+ if row is None:
463
+ raise ProblemError(NOT_FOUND, f"Document {document_id!r} not found.")
464
+ ref = row.metadata.get(TEXT_REF_KEY)
465
+ if row.text_hash is None or not isinstance(ref, str):
466
+ raise ProblemError(
467
+ NOT_FOUND, f"The text of document {document_id!r} has not been parsed yet."
468
+ )
469
+ text_hash = row.text_hash.hex()
470
+ limit = keeper.documents.max_text_chars
471
+
472
+ async def produce() -> DocumentTextOut:
473
+ async with store.sessions() as session, session.begin():
474
+ await require_domain(session, domain_id)
475
+ try:
476
+ data = await PostgresBlobStore().get(session, domain_id, ref)
477
+ except BlobNotFoundError:
478
+ raise ProblemError(
479
+ NOT_FOUND, f"The text of document {document_id!r} is not stored."
480
+ ) from None
481
+ text = data.decode("utf-8")
482
+ return DocumentTextOut(
483
+ document_id=document_id,
484
+ text_sha256=text_hash,
485
+ text=text[:limit],
486
+ length=len(text),
487
+ truncated=len(text) > limit,
488
+ )
489
+
490
+ return await respond(
491
+ request,
492
+ runtime.cache,
493
+ domain_id=domain_id,
494
+ revision=text_hash,
495
+ produce=produce,
496
+ variant=f"chars={limit}",
497
+ )
@@ -0,0 +1,59 @@
1
+ """Routers of the Domain-scoped endpoints (Domains, schema suggestions, schema, Operations,
2
+ reads, Derivation and Source previews, agent reads, webhooks, Sources, vault erasure, change feed,
3
+ health reports, Proposals, Revisions, branches), in registration order.
4
+
5
+ Routes taking a hierarchical branch name (`{branch:path}`) match any path suffix, so the
6
+ routers holding them come after routers with more specific branch sub-resources, and the
7
+ application includes these routers after its other Domain routers.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from fastapi import APIRouter
13
+
14
+ from lorebase_keeper.routes import (
15
+ agents,
16
+ branches,
17
+ derivations,
18
+ documents,
19
+ domains,
20
+ events,
21
+ kind_health,
22
+ operations,
23
+ proposal_items,
24
+ proposals,
25
+ read,
26
+ revisions,
27
+ schema,
28
+ schema_inference,
29
+ schema_suggestions,
30
+ source_preview,
31
+ sources,
32
+ vault,
33
+ webhooks,
34
+ )
35
+
36
+
37
+ def domain_routers() -> list[APIRouter]:
38
+ """Routers to include under the API prefix, most specific paths first."""
39
+ return [
40
+ domains.router,
41
+ schema_suggestions.router, # `/schema/suggestions` before `/schema/{section}/...`
42
+ schema.router,
43
+ schema_inference.router,
44
+ operations.router,
45
+ read.router,
46
+ derivations.router,
47
+ kind_health.router,
48
+ agents.router,
49
+ webhooks.router,
50
+ source_preview.router,
51
+ sources.router,
52
+ vault.router,
53
+ documents.router,
54
+ events.router, # `/branches/{branch:path}/events` and `/changes` before the catch-all
55
+ proposal_items.router,
56
+ proposals.router,
57
+ revisions.router, # `/branches/{branch:path}/log` before the branch catch-all
58
+ branches.router,
59
+ ]