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,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
|
+
]
|