ragnarok-api 0.1.0__tar.gz

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.
@@ -0,0 +1,22 @@
1
+ # Local dependencies, builds and credentials
2
+ .venv/
3
+ .venv-*/
4
+ __pycache__/
5
+ *.py[cod]
6
+ .pytest_cache/
7
+ .ruff_cache/
8
+ *.egg-info/
9
+ node_modules
10
+ /target/
11
+ target/
12
+ dist/
13
+ build/
14
+ build-test/
15
+ .env
16
+ .env.*
17
+ !.env.example
18
+ *.token
19
+ *.tokens
20
+ *.pem
21
+ untracked/
22
+ .DS_Store
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Laurent-Philippe Albou
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,361 @@
1
+ Metadata-Version: 2.5
2
+ Name: ragnarok-api
3
+ Version: 0.1.0
4
+ Summary: Python SDK for the RAGnarok HTTP API (/api/v1): auth, streaming queries, ingest, graph and sources.
5
+ Author: Laurent-Philippe Albou
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Laurent-Philippe Albou
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+ License-File: LICENSE
28
+ Keywords: graphrag,knowledge-graph,rag,ragnarok,sdk,sse
29
+ Classifier: Development Status :: 3 - Alpha
30
+ Classifier: Intended Audience :: Developers
31
+ Classifier: License :: OSI Approved :: MIT License
32
+ Classifier: Programming Language :: Python :: 3
33
+ Classifier: Programming Language :: Python :: 3 :: Only
34
+ Classifier: Programming Language :: Python :: 3.10
35
+ Classifier: Programming Language :: Python :: 3.11
36
+ Classifier: Programming Language :: Python :: 3.12
37
+ Classifier: Typing :: Typed
38
+ Requires-Python: >=3.10
39
+ Requires-Dist: httpx>=0.27
40
+ Requires-Dist: pydantic>=2
41
+ Provides-Extra: test
42
+ Requires-Dist: pytest>=8; extra == 'test'
43
+ Description-Content-Type: text/markdown
44
+
45
+ # ragnarok-api (Python)
46
+
47
+ The Python SDK for the RAGnarok HTTP API (`/api/v1`). It is a thin layer over the
48
+ published contract (`specs/openapi.json` in this repository): typed
49
+ models, the SSE event stream of `/query` and of ingest jobs, multipart upload with
50
+ progress, binary downloads, typed errors and cursor paging. It holds no business
51
+ logic. Caps, roles, concurrency and model allow-lists are enforced by the server
52
+ and come back to you as typed errors.
53
+
54
+ - Runtime dependencies: `httpx>=0.27`, `pydantic>=2`. Python 3.10+.
55
+ - Sync `Client` and async `AsyncClient` with the same surface.
56
+ - Versioning: the SDK major follows the API major (`1.x` speaks `/api/v1`; `0.x` is the
57
+ pre-release line for v1). `client.check_api_version()` refuses a server whose
58
+ `/api/health` reports another major.
59
+
60
+ ```bash
61
+ pip install -e ./sdks/python
62
+ ```
63
+
64
+ ## Connect
65
+
66
+ ```python
67
+ from ragnarok_api import Client, Token
68
+
69
+ client = Client("https://ragnarok.example", Token("rgk_...")) # personal API token
70
+ print(client.health().status, client.check_api_version().api_version)
71
+ print(client.auth.whoami().email)
72
+ ```
73
+
74
+ `api_prefix` defaults to `/api/v1`. Pass `api_prefix="/api"` for a server that
75
+ predates the versioned mount. `timeout` defaults to 240 s. SSE streams use
76
+ a 480 s read backstop (`stream_read_timeout`). Any httpx transport works, including
77
+ the in-process `httpx.ASGITransport(app=app)`.
78
+
79
+ ## 1. Query with streaming
80
+
81
+ ```python
82
+ from ragnarok_api import AnswerEvent, DoneEvent, ErrorEvent, SourcesEvent, StatusEvent
83
+
84
+ for event in client.query.stream("What is the approved indication?", workspace="epar", mode="quick"):
85
+ if isinstance(event, StatusEvent):
86
+ print("..", event.message)
87
+ elif isinstance(event, AnswerEvent):
88
+ print(event.text)
89
+ elif isinstance(event, SourcesEvent):
90
+ for src in event.vector_sources:
91
+ print(f" [{src.citation_label}] {src.chunk_id}")
92
+ elif isinstance(event, ErrorEvent):
93
+ print("error:", event.code, event.message)
94
+ elif isinstance(event, DoneEvent):
95
+ print("done, success =", event.success)
96
+
97
+ # Or collect everything (raises QueryFailed on an `error` event):
98
+ result = client.query.ask("What is the approved indication?", workspace="epar")
99
+ print(result.answer, len(result.sources.vector_sources), result.stats.elapsed_ms)
100
+ ```
101
+
102
+ Events: `status, answer, answer_delta, citation_map, references, notices, sources,
103
+ entities, graph, stats, error, done`. An event the SDK cannot type (a new event name,
104
+ or data that is not JSON) arrives as `UnknownEvent`. It is never dropped. If the
105
+ stream ends without `done`, the SDK yields a client-generated `error`
106
+ (`code="query_network_error"`) and then `done(success=False)`, as the web UI does.
107
+
108
+ The server records every completed answer in the query history, shared by the web UI,
109
+ the `ragnarok` app and the SDKs: `DoneEvent.history_id` (and `result.history_id` from
110
+ `ask`) names the entry; it is `None` with `history_error` set when recording failed.
111
+ Pass `record_history=False` to keep a query out of the history (evaluations, scripted
112
+ batches), or `history_id="..."` to record under your own id (a retry with the same id
113
+ updates the entry, never duplicates it).
114
+
115
+ Async:
116
+
117
+ ```python
118
+ import asyncio
119
+ from ragnarok_api import AsyncClient, Session
120
+
121
+ async def main():
122
+ async with AsyncClient("https://ragnarok.example") as client:
123
+ await Session.alogin(client, "me@example.com", "secret")
124
+ async for event in client.query.stream("Summarise the safety profile", workspace="epar"):
125
+ print(event.event)
126
+
127
+ asyncio.run(main())
128
+ ```
129
+
130
+ ## 2. Ingest a folder
131
+
132
+ ```python
133
+ from ragnarok_api import collect_folder
134
+
135
+ folder = collect_folder("./dossiers") # .pdf .txt .md .rmd, relative upload names
136
+ print(len(folder), "files;", len(folder.skipped), "skipped:", folder.skipped)
137
+
138
+ estimate = client.ingest.estimate(folder, workspace="my-workspace", level="BALANCED")
139
+ print(estimate.full_ingest["totals"])
140
+
141
+ def progress(sent: int, total: int | None) -> None:
142
+ print(f"\rupload {sent}/{total or '?'} bytes", end="")
143
+
144
+ job = client.ingest.submit(folder, workspace="my-workspace", level="BALANCED",
145
+ run_postprocess=True, doc_workers=3, progress=progress).job
146
+ for ev in client.ingest.jobs.events(job.id): # replays history, then tails until done
147
+ print(ev.event, ev.progress, ev.message)
148
+ print(client.ingest.jobs.get(job.id).status)
149
+ ```
150
+
151
+ Uploads are sent as `multipart/form-data` with the same field names the web client
152
+ uses: `workspace` and a repeated `files`, plus `level`, `run_postprocess`,
153
+ `doc_workers`, `document_prefix` and the rest of the documented options. An option
154
+ name that the route does not accept raises `TypeError` instead of being ignored.
155
+ Ingest requires the `data_manager` role on the workspace (or `admin`).
156
+
157
+ Concurrency of a run: `doc_workers` (documents in parallel, 1-64), `llm_concurrency` and
158
+ `vlm_concurrency` (calls per document, 1-1000); without them the server uses the Settings
159
+ `ingest.concurrency.*` (3 documents x 10 / 10). `client.ingest.plan(ws, doc_workers=…,
160
+ llm_concurrency=…)` answers `plan.concurrency`: per role the calls in flight (`effective`) and the
161
+ limit that binds (`run`, or the endpoint's `endpoint_ceiling`).
162
+
163
+ ### One job for a large folder (uploads in several requests)
164
+
165
+ A long ingest is ONE server job: created with every run option (frozen on the job),
166
+ filled by as many upload requests as the server's limits need, then submitted with the
167
+ full manifest. Each file travels with its sha256 (computed locally); staging is
168
+ idempotent per name + sha256, so a retried or resumed upload never duplicates a file.
169
+
170
+ ```python
171
+ from ragnarok_api import UploadInterrupted, ManifestMismatch
172
+
173
+ ref = load_or_create_my_ref() # e.g. ragnarok_api.new_client_ref(), persisted by you
174
+ try:
175
+ result = client.ingest.upload_job(folder, workspace="my-workspace", level="BALANCED", client_ref=ref,
176
+ progress=lambda p: print(p.batch, p.batches, p.staged_files))
177
+ except UploadInterrupted as exc: # a request kept failing: the job keeps what it staged
178
+ result = client.ingest.upload_job(folder, workspace="my-workspace", client_ref=exc.client_ref)
179
+ for ev in client.ingest.events(result.job_id, after=0): # after=<last seq> on a re-attach
180
+ print(ev.step, ev.accounting) # store_done / doc_skipped / doc_failed carry the counts
181
+ ```
182
+
183
+ `client_ref` (generated when not given; on the result and on `UploadInterrupted`) names the
184
+ job for the caller: a later call with the same ref finds it (`job_by_client_ref`) and
185
+ continues it, and a create repeated after a lost response answers the same job
186
+ (`existing`), never a second one. With `job_id` (or a ref) of a job already submitted it
187
+ only re-attaches (`already_submitted=True`, nothing sent). The low-level calls are `limits()`,
188
+ `create_job()` (`submit=false`, `client_ref=`), `upload_files()`, `remove_files()`, `submit_job()` and
189
+ `uploading_jobs()`. Typed refusals: `ManifestMismatch` (`missing`, `unexpected`,
190
+ `sha256_differs`), `StagedNameConflict`, `ChecksumMismatch`, `StagingQuotaExceeded`
191
+ (`used_bytes`, `limit_bytes`), `FilesTooLarge` (a file no request can carry; nothing sent).
192
+ Counts come from the server only: `job.accounting` (pages, chunks, tables, figures,
193
+ documents) and `IngestDoneEvent.summary.accounting`.
194
+
195
+ A job found that already ended `failed`/`cancelled` (e.g. `upload_abandoned`) raises
196
+ `UploadJobFinished` (start over with a NEW `client_ref`); a `paused` one is re-attached
197
+ (`paused=True`). A repeated create with other options raises `ClientRefConflict`. Against a
198
+ server before resumable uploads, `upload_job` sends ONE one-shot request
199
+ (`legacy_single_upload=True`, `message`), or raises `ServerTooOldForUpload` when the files
200
+ cannot fit in one request.
201
+
202
+ ### Persistent jobs
203
+
204
+ Jobs and their events live in MongoDB, so they outlive an API restart. Admins see every
205
+ job; other callers see the jobs they submitted.
206
+
207
+ ```python
208
+ from ragnarok_api import Conflict, IngestDoneEvent, IngestErrorEvent
209
+
210
+ for job in client.ingest.jobs.list(workspace="my-workspace", status="running", limit=20):
211
+ print(job.id, job.status, job.progress, job.owner_email)
212
+
213
+ client.ingest.jobs.cancel(job.id) # cooperative: the job ends `cancelled`
214
+ try:
215
+ client.ingest.jobs.cancel(job.id)
216
+ except Conflict as exc: # 409 job_not_running once it has finished
217
+ print(exc.code, exc.detail["status"])
218
+
219
+ for ev in client.ingest.jobs.events(job.id):
220
+ if isinstance(ev, IngestDoneEvent): # compact: counts + the job path, not the result
221
+ print(ev.success, ev.cancelled, ev.summary.documents_committed, ev.job_path)
222
+ elif isinstance(ev, IngestErrorEvent):
223
+ print(ev.code, ev.error_message) # e.g. code="access_revoked"
224
+ full = client.ingest.jobs.get(job.id).result # the full per-document result
225
+
226
+ report = client.workspaces.repair("my-workspace") # admin; dry run
227
+ print(report.rollback_count, [(f.document_name, f.kind) for f in report.findings])
228
+ client.workspaces.repair("my-workspace", apply=True) # roll back the leftovers
229
+ ```
230
+
231
+ `client.ingest.job(id)` and `client.ingest.events(id)` remain as aliases.
232
+
233
+ ### Ingest models
234
+
235
+ Per role (extraction, vision) a run uses the request's choice, else the model the workspace
236
+ remembers from its last run, else the Settings ingest profile. Ask the server before uploading:
237
+
238
+ ```python
239
+ from ragnarok_api import BadRequest, Conflict
240
+
241
+ plan = client.ingest.plan("my-workspace", level="BALANCED",
242
+ semantic_extraction_llm_model="lm::qwen3", # endpoint::model, optional
243
+ llm_reasoning_effort="low") # also vlm_model, vlm_reasoning_effort
244
+ x = plan.extraction # roles[extraction_role]; plan.vlm is roles[vision_role]
245
+ print(x.model, x.source, x.reasoning_effort, x.status) # source: request | remembered | profile
246
+ print(plan.embedding.model, plan.embedding.dimensions, plan.embedding.locked)
247
+ for notice in plan.run_notices: # the notices about this run's roles
248
+ print("!", notice)
249
+
250
+ try: # the same four fields are form options of estimate() and submit()
251
+ client.ingest.submit(folder, workspace="my-workspace", vlm_model="gw::vision")
252
+ except BadRequest as exc: # 422 ingest_model_invalid: the server's reason
253
+ print(exc.code, exc.message)
254
+
255
+ try: # the embedder changes only while the workspace holds no document
256
+ client.workspaces.set_embedding("new-workspace", "lm::embed-2", 768)
257
+ except Conflict as exc: # 409 embedding_locked
258
+ print(exc.message)
259
+ ```
260
+
261
+ `estimate.models`, the `data.models` of a job's `init` and `run_start` status events and
262
+ `job.result["models"]` have the same shape (`IngestPlan.model_validate(...)` reads them); a
263
+ `model_notice` status event announces a remembered model the server replaced.
264
+
265
+ ## 3. Read the knowledge graph
266
+
267
+ ```python
268
+ hits = client.graph.search("pembrolizumab", workspace="epar").results
269
+ entity = client.graph.entity(hits[0].id, workspace="epar", include_description=True, include_neighbors=True)
270
+ sub = client.graph.neighborhood(hits[0].id, workspace="epar", hops=2)
271
+ print(entity.entity["display_name"], len(sub.entities), len(sub.relationships))
272
+
273
+ chunks = client.search.chunks("overall survival", workspace="epar", limit=20).chunks
274
+ texts = client.sources.batch([c.chunk_id for c in chunks], workspace="epar").contents
275
+ pdf = client.sources.file(chunks[0].document_name, workspace="epar") # bytes
276
+ ```
277
+
278
+ Cursor-paged routes, such as the planned KG API, can use
279
+ `ragnarok_api.iterate(fetch_page, cursor_field="next_cursor")`.
280
+
281
+ ## 4. Manage access
282
+
283
+ ```python
284
+ from ragnarok_api import Client, Session, TermsRequired
285
+
286
+ client = Client("https://ragnarok.example")
287
+ try:
288
+ session = Session.login(client, "admin@example.com", "secret")
289
+ except TermsRequired as exc: # first login, or terms older than the re-accept window
290
+ session = exc.session.accept_terms() # explicit user act; the server issues a fresh token
291
+
292
+ me = client.auth.whoami()
293
+ print(me.email, me.account_type, me.is_admin, me.role_on("epar")) # "admin", "owner", "viewer"... or None
294
+ print([(g.workspace, g.role) for g in me.grants])
295
+ print([(w.name, w.role) for w in client.workspaces.list()]) # your role on each workspace
296
+
297
+ # Admin: accounts with per-workspace roles (viewer < data_manager < owner)
298
+ client.admin.create_user("analyst@example.com", "a-long-password", grants=[("epar", "viewer")])
299
+ client.admin.update_user("analyst@example.com", grants=[("epar", "viewer"), ("has", "data_manager")])
300
+
301
+ # Owner session, or admin credential allowed by the server privileged-write policy
302
+ grants = client.workspaces.grants.list("epar")
303
+ print(grants.your_role, [(g.email, g.role) for g in grants.grants], grants.admins)
304
+ change = client.workspaces.grants.put("epar", "analyst@example.com", "data_manager")
305
+ print(change.previous_role, "->", change.role, change.changed)
306
+ client.workspaces.grants.delete("epar", "analyst@example.com")
307
+ print(client.workspaces.meta("epar").owners) # data_manager or more
308
+
309
+ client.admin.deactivate_user("analyst@example.com")
310
+ session.logout() # revokes the token server-side
311
+ ```
312
+
313
+ Roles are per workspace. An admin holds every role everywhere and has
314
+ no grants. An owner may grant `viewer` or `data_manager` only, so granting `owner` raises
315
+ `Forbidden` with `code="role_ceiling"`. Other refusals are `admin_only_workspace`,
316
+ `self_grant` and `owner_grant`. A route above your role raises
317
+ `Forbidden(code="workspace_role_required")`, whose `detail` holds `required` and `role`.
318
+ A concurrent grant change raises `Conflict(code="concurrent_update")`.
319
+ `allowed_workspaces` is deprecated: pass `grants`, and never both (422
320
+ `grants_conflict`).
321
+
322
+ `Token` and `Session` never show their secret in `repr()`. The password is sent once
323
+ and is not stored.
324
+
325
+ ## Errors
326
+
327
+ All errors subclass `RagnarokError` and carry `status`, `code`, `detail` and
328
+ `request_id` (from `x-request-id`, else the error body's `request_id`):
329
+
330
+ | Error | When |
331
+ |---|---|
332
+ | `Unauthorized` | 401 |
333
+ | `Forbidden` | 403. `code` gives the reason, e.g. `model_not_allowed`, `workspace_role_required`, `role_ceiling`, `admin_only_workspace`, `session_required` |
334
+ | `TermsRequired` | 403 `terms_required`, or a login that needs terms acceptance. Carries `.session` |
335
+ | `EmbeddingMismatch` | 403 `embedding_mismatch` |
336
+ | `NotFound` / `Conflict` / `BadRequest` | 404 / 409 (e.g. `job_not_running`, `concurrent_update`) / 400 and 422 |
337
+ | `RateLimited` | 429. `retry_after` is in seconds |
338
+ | `ServerError` | 5xx |
339
+ | `TransportError` | no HTTP response (connection refused or reset, timeout) |
340
+ | `ApiVersionMismatch` | the server's API major is not 1 |
341
+ | `QueryFailed` | the query stream emitted `error`. Carries `.error` and `.result` |
342
+
343
+ Retries: only idempotent GETs, and only on connection failures (at most 2 retries,
344
+ with jitter). The SDK does no caching.
345
+
346
+ ## Development tests
347
+
348
+ From the repository root:
349
+
350
+ ```sh
351
+ python -m pip install -e './sdks/python[test]'
352
+ python -m pytest -c sdks/python/pyproject.toml sdks/python/tests -q
353
+ ```
354
+
355
+ These tests use recorded fixtures and mocked HTTP transports; no server or database is needed.
356
+
357
+ ## License
358
+
359
+ MIT
360
+
361
+ Query modes are `talk`, `quick`, `balanced` and `deepdive`; availability and authorization are enforced by the server.