From 840b8a6b76bf1486aa8d7af46e5851307863d839 Mon Sep 17 00:00:00 2001 From: Adam Smith Date: Fri, 30 Oct 2026 09:52:10 +0800 Subject: [PATCH 1/3] Security and conduct contacts at klv.ai, not klavi.ai klavi.ai is another company's domain: reports sent to security@klavi.ai or conduct@klavi.ai would have gone to them. security@klv.ai is the contact security.txt already publishes. Docs only, no release. --- CODE_OF_CONDUCT.md | 2 +- SECURITY.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 8526277..93262f3 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -23,7 +23,7 @@ professional setting. ## Enforcement -Report unacceptable behaviour to **conduct@klavi.ai**. All complaints will be +Report unacceptable behaviour to **conduct@klv.ai**. All complaints will be reviewed and investigated promptly and fairly, and the privacy and security of the reporter will be respected. diff --git a/SECURITY.md b/SECURITY.md index 5a4c468..ce790ae 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,7 @@ ## Reporting a vulnerability -Please report security issues privately to **security@klavi.ai**, or through +Please report security issues privately to **security@klv.ai**, or through GitHub's private vulnerability reporting on this repository. Do not open a public issue. From b3bd7408972f2e3ae1e6f632eed95d685edf214f Mon Sep 17 00:00:00 2001 From: Adam Smith Date: Sat, 31 Oct 2026 09:31:25 +0800 Subject: [PATCH 2/3] CI: set pytest-asyncio's loop scope, so the suite runs again pytest-asyncio 0.24+ warns when asyncio_default_fixture_loop_scope is unset, and filterwarnings = error turned that warning into an INTERNALERROR before a single test ran, on every Python version. Also moves the workflows to the Node 24 majors of checkout and setup-python. Also carries the version bump to 0.2.0. --- .github/workflows/ci.yml | 8 ++++---- .github/workflows/release.yml | 4 ++-- pyproject.toml | 5 ++++- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ed2970..caa99d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,8 +15,8 @@ jobs: # what is coming. python: ["3.10", "3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python }} cache: pip @@ -32,8 +32,8 @@ jobs: runs-on: ubuntu-latest if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 with: python-version: "3.12" cache: pip diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 37dc94f..1dd8337 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,8 +15,8 @@ jobs: # provenance on the Node SDK. id-token: write steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 with: python-version: "3.12" - run: pip install -e ".[dev]" build diff --git a/pyproject.toml b/pyproject.toml index c8b781a..061c06a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "klavi-experts" -version = "0.1.0" +version = "0.2.0" description = "Official Python SDK for the Klavi Experts API — conversations, streaming, experts, and knowledge." readme = "README.md" license = "MIT" @@ -51,6 +51,9 @@ testpaths = ["tests"] # The live suite needs a running install and a real key; it is opt-in. addopts = "--ignore=tests/live" asyncio_mode = "auto" +# Explicit, because pytest-asyncio 0.24+ warns when it is unset and the +# filterwarnings below turns that warning into a configuration error. +asyncio_default_fixture_loop_scope = "function" filterwarnings = ["error"] [tool.ruff] From 56d5d0c0a71567cc85b2677414e27cfb35424085 Mon Sep 17 00:00:00 2001 From: Adam Smith Date: Sat, 31 Oct 2026 09:31:41 +0800 Subject: [PATCH 3/3] 0.2.0: ready for Experts 1.1 Knowledge works. The 0.1 upload sent `file` and `collection_uid` (the server reads `files` and `collection`: a 422), waited by polling a document row that never says it is indexed, and searched a conversation-context route with the wrong body (a 422). Measured against a 1.1 install, and the same at 1.0.49: - upload() answers with the indexing job (UploadJob), on the non-deprecated /documents/job-queue-upload. - wait_for_processing(job) polls the job and returns an UploadStatus: truthy when every file is searchable, with each file's document uid or error. - search() is /similarity_search/ and returns SearchHit passages. - list_documents filters by `collection` and maps doc_name/doc_type/summary. - New: upload_status(), delete_document(), list_documents(skip, search). 1.1 behaviour: - LicenseReadOnlyError (a LicenseError, read_only=True) for the 30-day read-only window's `license_expired_readonly`. - ExpertNotAvailableError for /chat's 403 expert_not_available, read from its {"detail": {...}} body; send() settles the placeholder so it does not type forever. - guest_visible also requires no org-profile scope (Expert.scope_uids). - Mesh: a `queued` event while a busy member's model has the request in line; served_by, mesh_fallback, mesh_unavailable and model on the result. - data_artifacts and generated_images on the result; split_uid and start_ms/end_ms on source documents. Fixes: - A failed streamed request (a refused chat turn, an upload) crashed with httpx.ResponseNotRead before reporting the server's error. - A timeout or dropped connection mid-answer is a StreamError, not httpx's. - The knowledge service's 401 "insufficient permissions" is a permission error, not an auth error. - cancel() returns whether the server stopped anything. - stream_timeout is a client option (a mesh turn can wait in line). The vendored spec is regenerated at 1.1.0, with the knowledge routes added. Live suite (now with an upload, index, search, delete round trip) passes against 1.1.0-rc.127. --- README.md | 50 +- examples/django_ingest/ingest.py | 32 +- examples/fastapi_chatbox/app.py | 8 +- spec/openapi.json | 3414 +++++++++++++++--- src/klavi_experts/__init__.py | 20 +- src/klavi_experts/_chat_stream.py | 83 +- src/klavi_experts/_stream.py | 26 + src/klavi_experts/_transport.py | 28 +- src/klavi_experts/async_client.py | 112 +- src/klavi_experts/client.py | 159 +- src/klavi_experts/errors.py | 66 +- src/klavi_experts/resources/conversations.py | 18 + src/klavi_experts/resources/knowledge.py | 160 +- src/klavi_experts/types.py | 235 +- tests/live/test_integration.py | 36 +- tests/test_client.py | 107 +- tests/test_knowledge.py | 183 + tests/test_stream.py | 61 + 18 files changed, 4145 insertions(+), 653 deletions(-) create mode 100644 tests/test_knowledge.py diff --git a/README.md b/README.md index 993324d..ff07e76 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ with experts.conversations.send(conversation.uid, "Hello") as stream: match event.type: case "token": print(event.content, end="", flush=True) case "thinking": ... # reasoning tokens, when the model exposes them - case "action": ... # "rag_search", "tool_call" — for a status line + case "queued": ... # waiting in line for another mesh server's model case "done": print(event.usage, event.source_docs) ``` @@ -80,12 +80,16 @@ result.source_docs # documents retrieved, with similarity scores result.tools_used # e.g. ["web_search"] result.usage.total_ms # milliseconds (the API speaks nanoseconds) result.error # set when generation failed inside a 200 — always check +result.model # the model that answered +result.data_artifacts # tables, charts and sheet edits a tool produced ``` +On an [Experts Mesh](https://klv.ai/mesh), an answer can come from another server's model. `result.served_by` names that server; `result.mesh_fallback` says when the expert's own model was out of reach and this server's answered instead. While a busy server's model has the request in line, the stream sends `queued` events (`event.ahead`, `event.name`) and nothing else. Raise `stream_timeout` on the client if yours wait longer than five minutes. + ### Stopping ```python -stream.cancel() # stops the model +stream.cancel() # stops the model; True if the server found it running stream.detach() # stop reading; let it finish and persist ``` @@ -105,7 +109,9 @@ return {"token": session.token} The browser then talks to the install directly, using [`@klv-ai/experts/browser`](https://www.npmjs.com/package/@klv-ai/experts) or plain `fetch`. Your server is involved once, to mint — you are not proxying every token. -**The expert must be guest-visible.** A session token carries the guest role, and an expert above that floor resolves to nothing server-side — the chat then answers on the site's default model with no persona and no knowledge, silently, with a normal 200. `sessions.create()` refuses such an expert up front and tells you how to fix it. `experts.experts.list_guest_visible()` gives you the ones that will work. +**The expert must be guest-visible**: minimum role Guest, not private, and not limited to org profiles. A session token carries the guest role, so `sessions.create()` refuses any other expert up front and tells you how to fix it. `experts.experts.list_guest_visible()` gives you the ones that will work. + +**On an Experts Mesh, the browser talks to the server you minted on.** A session lives on the server that issued it; another member of the mesh does not know it and answers 401. ## Knowledge @@ -113,30 +119,43 @@ The browser then talks to the install directly, using [`@klv-ai/experts/browser` collections = experts.knowledge.list_collections() with open("policy.pdf", "rb") as f: - doc = experts.knowledge.upload(f, collection=collections[0].uid, filename="policy.pdf") + job = experts.knowledge.upload(f, collection=collections[0].uid, filename="policy.pdf") -# Uploading is asynchronous: the file is accepted, then split, embedded and -# indexed by a worker. It is NOT searchable until that finishes. -experts.knowledge.wait_for_processing(doc.uid) +# Uploading is asynchronous: the file is accepted into an indexing job, then +# split, embedded and indexed by a worker. It is NOT searchable until that +# finishes — and the job is where the document's uid and any failure appear. +status = experts.knowledge.wait_for_processing(job) +if not status: + print(status.failed) # e.g. a password-protected PDF, and why -hits = experts.knowledge.search("refund policy") +for hit in experts.knowledge.search("refund policy", collections=[collections[0].uid]): + print(hit.document_name, hit.similarity, hit.text) ``` +`search()` returns passages, closest first, each with the document it came from. `list_documents(collection=..., search=...)` lists and filters documents by name, title or summary; `delete_document(uid)` removes one. Allow longer than the two-minute default for large files, and much longer for recordings, which are transcribed before they become documents. + ## Errors ```python -from klavi_experts import RateLimitError, LicenseError +from klavi_experts import ExpertNotAvailableError, LicenseError, RateLimitError try: - experts.conversations.ask("hi").text() + experts.conversations.ask("hi", expert=EXPERT_UID).text() except RateLimitError as e: time.sleep(e.retry_after or 5) -except LicenseError: - # The INSTALL's licence has lapsed. Nothing about your request is wrong and - # no retry will help — its administrator has to renew. +except ExpertNotAvailableError: + # This key's user may not use that expert (its role, privacy or org + # profiles). Pick another from experts.experts.list(). + ... +except LicenseError as e: + # The INSTALL's licence is not valid. Nothing about your request is wrong + # and no retry will help — its administrator has to act. e.read_only is + # True for a recently expired licence: reads still work, writes do not. ... ``` +For 30 days after its licence expires, an install is read-only: anything sent as a GET still works, and everything else raises `LicenseReadOnlyError` (a `LicenseError`). That includes sending a message and `knowledge.search()`, which read but go out as a POST. + 429 and 5xx are retried automatically with backoff that honours `Retry-After`. A 4xx never is. Neither is a chat turn — retrying one bills twice and can produce two answers. ## Things worth knowing @@ -152,6 +171,9 @@ These are the parts of the API that surprise people. The SDK handles each of the | **404 can mean "forbidden"** | A conversation you may not see answers 404 so a uid probe reveals nothing. The SDK does not guess which it was. | | **Casing is mixed by design** | Conversation rows are camelCase; message and expert rows are snake_case; knowledge responses are wrapped in an envelope. All normalised. | | **Streaming is plain HTTP** | NDJSON over `POST`. No WebSocket is required for chat, contrary to some older notes. | +| **A refused turn leaves a placeholder behind** | `/chat` updates a message row you created first. When it refuses to start, nothing settles that row, and the conversation shows the reply "typing" forever. The SDK settles it with the reason. | +| **An expert's instructions are for its managers** | Since 1.1, `Expert.instructions` is only filled in for the expert's owner and administrators. | +| **Knowledge says "forbidden" as 401** | The knowledge service answers a document you may not read with 401. The SDK raises `ExpertsPermissionError`, not `AuthError`: the key is fine. | ## OpenAI-compatible endpoint @@ -176,7 +198,7 @@ Retrieval still happens; source documents come back under a `klavi` key on the f ## Requirements -Python 3.10 or later, and an install running build pack **1.0.49** or later. +Python 3.10 or later, and an install running build pack **1.0.49** or later. Tested against **1.1**; the mesh fields and the read-only licence error are only sent by a 1.1 install. ## Contributing diff --git a/examples/django_ingest/ingest.py b/examples/django_ingest/ingest.py index 699f577..75f3eb5 100644 --- a/examples/django_ingest/ingest.py +++ b/examples/django_ingest/ingest.py @@ -1,8 +1,9 @@ """Feed a corpus into an expert's knowledge, and wait for it to be searchable. -Uploading is ASYNCHRONOUS. The file is accepted, then split, embedded and -indexed by a worker — so a document that has just uploaded is not retrievable -yet, and querying immediately returns nothing with no error to explain it. +Uploading is ASYNCHRONOUS. The file is accepted into an indexing job, then +split, embedded and indexed by a worker — so a document that has just uploaded +is not retrievable yet, and querying immediately returns nothing with no error +to explain it. The job is also where a file that could not be read says why. python ingest.py ./policies/*.pdf """ @@ -36,22 +37,31 @@ def main() -> int: collection = collections[0].uid print(f"Using collection {collections[0].name} ({collection})") - pending = [] + jobs = [] for path in paths: with path.open("rb") as handle: - doc = experts.knowledge.upload( + job = experts.knowledge.upload( handle, collection=collection, filename=path.name ) - print(f"uploaded {path.name} -> {doc.uid}") - pending.append(doc) + print(f"uploaded {path.name} (job {job.job_id})") + jobs.append(job) # Accepted is not the same as searchable. - for doc in pending: - ready = experts.knowledge.wait_for_processing(doc.uid, timeout=300) - print(f"{'indexed ' if ready else 'TIMEOUT '} {doc.name}") + failures = 0 + for job in jobs: + status = experts.knowledge.wait_for_processing(job, timeout=300) + for file in status.files: + if file.searchable: + print(f"indexed {file.name} -> {file.document_uid}") + elif file.status == "failed": + failures += 1 + print(f"FAILED {file.name}: {file.error}") + else: + failures += 1 + print(f"TIMEOUT {file.name} (still {file.status})") experts.close() - return 0 + return 1 if failures else 0 if __name__ == "__main__": diff --git a/examples/fastapi_chatbox/app.py b/examples/fastapi_chatbox/app.py index d12b228..5994ab7 100644 --- a/examples/fastapi_chatbox/app.py +++ b/examples/fastapi_chatbox/app.py @@ -11,11 +11,9 @@ 1. Register the origin against your API key in the install's admin. That registration is also what supplies CORS for the surface. - 2. Make the expert guest-visible — minimum role Guest, not private. A session - token IS a guest, and an expert above that floor resolves to nothing - server-side: the chat would answer on the site default model with no - persona and no knowledge, silently, with a 200. sessions.create() refuses - such an expert and says so. + 2. Make the expert guest-visible — minimum role Guest, not private, and not + limited to org profiles. A session token IS a guest, so sessions.create() + refuses any other expert and says so. """ from __future__ import annotations diff --git a/spec/openapi.json b/spec/openapi.json index 0e5d2a7..b122066 100644 --- a/spec/openapi.json +++ b/spec/openapi.json @@ -2,8 +2,8 @@ "openapi": "3.1.0", "info": { "title": "Klavi Experts API", - "version": "1.0.49", - "description": "The HTTP API of a Klavi Experts installation.\n\n## Authentication\n\nSend your API key as `Authorization: Bearer sk-...`. `X-API-Key: sk-...` is\nalso accepted, but send exactly ONE of them \u2014 the gateway checks\n`Authorization` first and that check is terminal, so a stale bearer token\ndecides the request even when a valid key is present in the other header.\n\nA key is a full user identity on the install. It belongs on a server. For\nanything browser-facing, mint a session token with\n`POST /api/v1/public/sessions`.\n\n## Streaming\n\n`POST /api/v1/conversations/chat` returns `application/x-ndjson` over plain\nHTTP \u2014 one JSON object per line, terminated by one carrying `done: true`. No\nWebSocket is required.\n\nTwo things about it surprise people:\n\n* **Sending a message is three calls.** `/chat` never CREATES message rows; it\n updates one you make first. Create the user's turn and an assistant\n placeholder with `POST /api/v1/responses/`, then pass the placeholder's uid\n as `response`. Omit it and the model still answers, but nothing persists.\n* **HTTP 200 is not proof of an answer.** Generation reports its own failures\n in the terminal event's `error` field, not in the status code.\n\nDurations in the terminal event are NANOSECONDS.\n\n## Errors\n\n`403` with `{\"error\": \"license_expired\"}` is an installation-level failure,\nnot a permission problem: nothing about the request is wrong, and only the\ninstall's administrator can resolve it.\n", + "version": "1.1.0", + "description": "The HTTP API of a Klavi Experts installation.\n\n## Authentication\n\nSend your API key as `Authorization: Bearer sk-...`. `X-API-Key: sk-...` is\nalso accepted, but send exactly ONE of them \u2014 the gateway checks\n`Authorization` first and that check is terminal, so a stale bearer token\ndecides the request even when a valid key is present in the other header.\n\nA key is a full user identity on the install. It belongs on a server. For\nanything browser-facing, mint a session token with\n`POST /api/v1/public/sessions`.\n\n## Streaming\n\n`POST /api/v1/conversations/chat` returns `application/x-ndjson` over plain\nHTTP \u2014 one JSON object per line, terminated by one carrying `done: true`. No\nWebSocket is required.\n\nTwo things about it surprise people:\n\n* **Sending a message is three calls.** `/chat` never CREATES message rows; it\n updates one you make first. Create the user's turn and an assistant\n placeholder with `POST /api/v1/responses/`, then pass the placeholder's uid\n as `response`. Omit it and the model still answers, but nothing persists.\n* **HTTP 200 is not proof of an answer.** Generation reports its own failures\n in the terminal event's `error` field, not in the status code.\n\nDurations in the terminal event are NANOSECONDS.\n\n## Errors\n\n`403` with `{\"error\": \"license_expired\"}` is an installation-level failure,\nnot a permission problem: nothing about the request is wrong, and only the\ninstall's administrator can resolve it.\n\nFor 30 days after a licence expires the install is read-only rather than\ndark: GET and HEAD are served, and every other method answers `403` with\n`{\"error\": \"license_expired_readonly\"}`. That includes the reads sent as a\nPOST: a chat turn and `/api/v1/similarity_search/`.\n\n`403` with `{\"detail\": {\"reason\": \"expert_not_available\"}}` from `/chat` means\nthe caller may not use that expert (its role, privacy or org profiles). Before\n1.1 the turn silently answered on the site default model instead.\n", "license": { "name": "Proprietary" } @@ -38,6 +38,35 @@ } }, "schemas": { + "AccessFailedRequest": { + "properties": { + "provider": { + "type": "string", + "title": "Provider" + }, + "reason": { + "type": "string", + "title": "Reason" + }, + "detail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Detail" + } + }, + "type": "object", + "required": [ + "provider", + "reason" + ], + "title": "AccessFailedRequest" + }, "AddDocumentToCollectionResponse": { "properties": { "timestamp": { @@ -68,6 +97,45 @@ "title": "AddDocumentToCollectionResponse", "description": "Returns the UUID of the newly created document collection link row" }, + "AdminNotice": { + "properties": { + "task": { + "type": "string", + "maxLength": 120, + "minLength": 1, + "title": "Task" + }, + "stage": { + "type": "string", + "enum": [ + "failed", + "in-progress", + "completed" + ], + "title": "Stage" + }, + "message": { + "type": "string", + "maxLength": 2000, + "minLength": 1, + "title": "Message" + }, + "path": { + "type": "string", + "maxLength": 200, + "pattern": "^/[A-Za-z0-9/_-]*$", + "title": "Path", + "default": "/" + } + }, + "type": "object", + "required": [ + "task", + "stage", + "message" + ], + "title": "AdminNotice" + }, "AdoptContextRequest": { "properties": { "conversation_uid": { @@ -692,6 +760,123 @@ ], "title": "Body_job_queue_file_upload_api_v1_documents_upload_post" }, + "Body_job_queue_upload_complete_api_v1_documents_job_queue_upload_complete_post": { + "properties": { + "upload_id": { + "type": "string", + "title": "Upload Id" + }, + "total": { + "type": "integer", + "title": "Total" + }, + "file_name": { + "type": "string", + "title": "File Name" + }, + "ml_model_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Ml Model Name" + }, + "split_strategy_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Split Strategy Id" + }, + "collection": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Collection" + }, + "auto_create_collection": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Auto Create Collection" + }, + "min_role_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Min Role Id" + }, + "job_queue_run_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Job Queue Run Id" + } + }, + "type": "object", + "required": [ + "upload_id", + "total", + "file_name" + ], + "title": "Body_job_queue_upload_complete_api_v1_documents_job_queue_upload_complete_post" + }, + "Body_patch_api_v1_styles_decks_patch_post": { + "properties": { + "base": { + "type": "string", + "format": "binary", + "title": "Base" + }, + "deck": { + "type": "string", + "title": "Deck" + }, + "images": { + "items": { + "type": "string", + "format": "binary" + }, + "type": "array", + "title": "Images", + "default": [] + } + }, + "type": "object", + "required": [ + "base", + "deck" + ], + "title": "Body_patch_api_v1_styles_decks_patch_post" + }, "Body_replace_document_content_api_v1_documents__document_uid__replace_content_post": { "properties": { "file": { @@ -746,6 +931,49 @@ ], "title": "Body_update_document_collection_role_api_v1_documents_collection__collection_uid___document_uid__role_patch" }, + "Body_upload_api_v1_styles_templates_upload_post": { + "properties": { + "style_uid": { + "type": "string", + "format": "uuid", + "title": "Style Uid" + }, + "doc_type": { + "type": "string", + "enum": [ + "general", + "proposal", + "report", + "letter", + "presentation" + ], + "title": "Doc Type", + "default": "general" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "file": { + "type": "string", + "format": "binary", + "title": "File" + } + }, + "type": "object", + "required": [ + "style_uid", + "file" + ], + "title": "Body_upload_api_v1_styles_templates_upload_post" + }, "Body_upload_files_api_v1_documents_app_post": { "properties": { "files": { @@ -794,6 +1022,36 @@ ], "title": "Body_upload_images_batch_api_v1_conversations_images_upload_batch_post" }, + "Body_upload_logo_api_v1_styles_families__style_uid__logos_upload_post": { + "properties": { + "file": { + "type": "string", + "format": "binary", + "title": "File" + }, + "role": { + "anyOf": [ + { + "type": "string", + "enum": [ + "primary", + "on_dark", + "mark" + ] + }, + { + "type": "null" + } + ], + "title": "Role" + } + }, + "type": "object", + "required": [ + "file" + ], + "title": "Body_upload_logo_api_v1_styles_families__style_uid__logos_upload_post" + }, "Body_upload_pack_api_v1_language_packs_upload_post": { "properties": { "file": { @@ -808,59 +1066,161 @@ ], "title": "Body_upload_pack_api_v1_language_packs_upload_post" }, - "BulkDeleteRequest": { + "Body_upload_part_api_v1_documents_upload_part_post": { "properties": { - "document_uids": { - "items": { - "type": "string", - "format": "uuid" - }, - "type": "array", - "title": "Document Uids" + "file": { + "type": "string", + "format": "binary", + "title": "File" + }, + "upload_id": { + "type": "string", + "title": "Upload Id" + }, + "index": { + "type": "integer", + "title": "Index" + }, + "total": { + "type": "integer", + "title": "Total" } }, "type": "object", "required": [ - "document_uids" + "file", + "upload_id", + "index", + "total" ], - "title": "BulkDeleteRequest", - "description": "Request model for bulk deleting documents by their UIDs" + "title": "Body_upload_part_api_v1_documents_upload_part_post" }, - "BulkDeleteResponse": { + "Box": { "properties": { - "timestamp": { - "type": "integer", - "title": "Timestamp" + "x": { + "type": "number", + "title": "X" }, - "status": { - "type": "integer", - "title": "Status", - "default": 200 + "y": { + "type": "number", + "title": "Y" }, - "message": { - "type": "string", - "title": "Message", - "default": "OK" + "w": { + "type": "number", + "title": "W" }, - "results": { - "items": { - "$ref": "#/components/schemas/BulkDeleteResultItem" - }, - "type": "array", - "title": "Results", - "default": [] + "h": { + "type": "number", + "title": "H" } }, - "additionalProperties": true, "type": "object", - "title": "BulkDeleteResponse", - "description": "Response model for bulk delete containing per-document results" + "required": [ + "x", + "y", + "w", + "h" + ], + "title": "Box" }, - "BulkDeleteResultItem": { + "BuildRequest": { "properties": { - "uid": { - "type": "string", - "format": "uuid", + "deck": { + "$ref": "#/components/schemas/Deck" + }, + "template_uid": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Template Uid" + }, + "org_profile_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Org Profile Id" + }, + "values": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "title": "Values", + "default": {} + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "deck" + ], + "title": "BuildRequest" + }, + "BulkDeleteRequest": { + "properties": { + "document_uids": { + "items": { + "type": "string", + "format": "uuid" + }, + "type": "array", + "title": "Document Uids" + } + }, + "type": "object", + "required": [ + "document_uids" + ], + "title": "BulkDeleteRequest", + "description": "Request model for bulk deleting documents by their UIDs" + }, + "BulkDeleteResponse": { + "properties": { + "timestamp": { + "type": "integer", + "title": "Timestamp" + }, + "status": { + "type": "integer", + "title": "Status", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "OK" + }, + "results": { + "items": { + "$ref": "#/components/schemas/BulkDeleteResultItem" + }, + "type": "array", + "title": "Results", + "default": [] + } + }, + "additionalProperties": true, + "type": "object", + "title": "BulkDeleteResponse", + "description": "Response model for bulk delete containing per-document results" + }, + "BulkDeleteResultItem": { + "properties": { + "uid": { + "type": "string", + "format": "uuid", "title": "Uid" }, "success": { @@ -1160,6 +1520,16 @@ } ], "title": "Contextrule" + }, + "sheet": { + "anyOf": [ + { + "$ref": "#/components/schemas/SheetEditContext" + }, + { + "type": "null" + } + ] } }, "type": "object", @@ -1269,6 +1639,21 @@ "type": "boolean", "title": "Mcp Banned", "default": false + }, + "scope_uids": { + "anyOf": [ + { + "items": { + "type": "string", + "format": "uuid" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Scope Uids" } }, "type": "object", @@ -1897,6 +2282,21 @@ "type": "boolean", "title": "Is Public", "default": true + }, + "scope_uids": { + "anyOf": [ + { + "items": { + "type": "string", + "format": "uuid" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Scope Uids" } }, "type": "object", @@ -1907,6 +2307,58 @@ "title": "CreateSmartCollectionRequest", "description": "Request to create a new smart collection watching a filesystem path." }, + "Deck": { + "properties": { + "width_emu": { + "type": "integer", + "title": "Width Emu" + }, + "height_emu": { + "type": "integer", + "title": "Height Emu" + }, + "theme": { + "$ref": "#/components/schemas/Theme" + }, + "slides": { + "items": { + "$ref": "#/components/schemas/Slide" + }, + "type": "array", + "title": "Slides" + }, + "presets": { + "additionalProperties": { + "items": { + "$ref": "#/components/schemas/Slot" + }, + "type": "array" + }, + "type": "object", + "title": "Presets" + }, + "preset_layouts": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "title": "Preset Layouts" + }, + "layout_art": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "title": "Layout Art" + } + }, + "type": "object", + "required": [ + "width_emu", + "height_emu" + ], + "title": "Deck" + }, "DedupResult": { "type": "string", "enum": [ @@ -1941,6 +2393,77 @@ ], "title": "DeleteCollectionResponse" }, + "DirectoryAccessRemoveRequest": { + "properties": { + "provider": { + "type": "string", + "title": "Provider" + }, + "actorUid": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Actoruid" + } + }, + "type": "object", + "required": [ + "provider" + ], + "title": "DirectoryAccessRemoveRequest" + }, + "DirectoryAccessRequest": { + "properties": { + "provider": { + "type": "string", + "title": "Provider" + }, + "credentialEnc": { + "type": "string", + "title": "Credentialenc" + }, + "account": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Account" + }, + "canReadUsers": { + "type": "boolean", + "title": "Canreadusers", + "default": false + }, + "actorUid": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Actoruid" + } + }, + "type": "object", + "required": [ + "provider", + "credentialEnc" + ], + "title": "DirectoryAccessRequest" + }, "DirectoryItem": { "properties": { "name": { @@ -2029,6 +2552,68 @@ "title": "DirectoryListingResponse", "description": "Response containing a directory listing for the filesystem browser." }, + "DiscoverRequest": { + "properties": { + "collection_uids": { + "anyOf": [ + { + "items": { + "type": "string", + "format": "uuid" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Collection Uids" + }, + "brands": { + "anyOf": [ + { + "items": { + "type": "string", + "maxLength": 100 + }, + "type": "array", + "maxItems": 20 + }, + { + "type": "null" + } + ], + "title": "Brands" + }, + "use_org_profiles": { + "type": "boolean", + "title": "Use Org Profiles", + "default": true + } + }, + "additionalProperties": false, + "type": "object", + "title": "DiscoverRequest" + }, + "DismissRequest": { + "properties": { + "uids": { + "items": { + "type": "string", + "format": "uuid" + }, + "type": "array", + "maxItems": 1000, + "title": "Uids" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "uids" + ], + "title": "DismissRequest" + }, "DocUploadFlavorId": { "type": "integer", "enum": [ @@ -2062,6 +2647,16 @@ "title": "File Available", "default": false }, + "original": { + "anyOf": [ + { + "$ref": "#/components/schemas/OriginalStatus" + }, + { + "type": "null" + } + ] + }, "splits": { "items": { "$ref": "#/components/schemas/TextSplitItem" @@ -2847,8 +3442,43 @@ } ], "title": "Metadata Json" - } - }, + }, + "kind": { + "type": "string", + "maxLength": 16, + "title": "Kind", + "default": "manual" + }, + "status": { + "type": "string", + "maxLength": 16, + "title": "Status", + "default": "active" + }, + "brand_kit": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Brand Kit" + }, + "org_profile_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Org Profile Id" + } + }, "type": "object", "required": [ "owner_uid", @@ -3182,6 +3812,67 @@ "type": "object", "title": "ExecRequest" }, + "FamilyPatch": { + "properties": { + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "status": { + "anyOf": [ + { + "type": "string", + "enum": [ + "active", + "archived" + ] + }, + { + "type": "null" + } + ], + "title": "Status" + }, + "org_profile_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Org Profile Id" + }, + "clear_org_profile": { + "type": "boolean", + "title": "Clear Org Profile", + "default": false + } + }, + "additionalProperties": false, + "type": "object", + "title": "FamilyPatch" + }, "FolderCreateRequest": { "properties": { "name": { @@ -3233,6 +3924,93 @@ ], "title": "ForgotPasswordRequest" }, + "FromDocumentRequest": { + "properties": { + "style_uid": { + "type": "string", + "format": "uuid", + "title": "Style Uid" + }, + "document_uid": { + "type": "string", + "format": "uuid", + "title": "Document Uid" + }, + "doc_type": { + "type": "string", + "enum": [ + "general", + "proposal", + "report", + "letter", + "presentation" + ], + "title": "Doc Type", + "default": "general" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "style_uid", + "document_uid" + ], + "title": "FromDocumentRequest" + }, + "GenerateRequest": { + "properties": { + "style_uid": { + "type": "string", + "format": "uuid", + "title": "Style Uid" + }, + "doc_type": { + "type": "string", + "enum": [ + "general", + "proposal", + "report", + "letter", + "presentation" + ], + "title": "Doc Type", + "default": "general" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "assist": { + "type": "boolean", + "title": "Assist", + "default": false + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "style_uid" + ], + "title": "GenerateRequest" + }, "HTTPValidationError": { "properties": { "detail": { @@ -3978,6 +4756,47 @@ ], "title": "LoginRequest" }, + "LogoSelection": { + "properties": { + "primary": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Primary" + }, + "on_dark": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "On Dark" + }, + "mark": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Mark" + } + }, + "additionalProperties": false, + "type": "object", + "title": "LogoSelection", + "description": "Only the roles present are changed: a candidate's sha, 'none' for no\nlogo in that role, or null to clear the choice (back to the guess)." + }, "MaintenanceRequest": { "properties": { "run_id": { @@ -4006,6 +4825,20 @@ "type": "object", "title": "MaintenanceRequest" }, + "ManualMember": { + "properties": { + "userUid": { + "type": "string", + "format": "uuid", + "title": "Useruid" + } + }, + "type": "object", + "required": [ + "userUid" + ], + "title": "ManualMember" + }, "McpCollectionUpdate": { "properties": { "mcp_banned": { @@ -4035,6 +4868,43 @@ "title": "McpCollectionUpdate", "description": "Partial by design \u2014 send only what's changing (the ban toggle, the role,\nor both). Omitted fields are left untouched." }, + "MembershipRequest": { + "properties": { + "add": { + "anyOf": [ + { + "items": { + "type": "string", + "format": "uuid" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Add" + }, + "remove": { + "anyOf": [ + { + "items": { + "type": "string", + "format": "uuid" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Remove" + } + }, + "additionalProperties": false, + "type": "object", + "title": "MembershipRequest" + }, "MemoryCreateRequest": { "properties": { "category": { @@ -4108,55 +4978,74 @@ "type": "object", "title": "MemorySettingsUpdateRequest" }, - "PartialCollection": { + "OriginalStatus": { "properties": { - "uid": { + "state": { + "type": "string", + "enum": [ + "here", + "elsewhere", + "fetching", + "failed", + "missing" + ], + "title": "State" + }, + "phase": { "anyOf": [ { - "type": "string", - "format": "uuid" + "type": "string" }, { "type": "null" } ], - "title": "Uid" + "title": "Phase" }, - "active": { + "controller": { "anyOf": [ { - "type": "boolean" + "type": "string" }, { "type": "null" } ], - "title": "Active" + "title": "Controller" }, - "min_role_id": { + "holder": { "anyOf": [ { - "type": "integer" + "type": "string" }, { "type": "null" } ], - "title": "Min Role Id" + "title": "Holder" }, - "owner_uid": { + "bytes": { + "type": "integer", + "title": "Bytes", + "default": 0 + }, + "total": { + "type": "integer", + "title": "Total", + "default": 0 + }, + "code": { "anyOf": [ { - "type": "string", - "format": "uuid" + "type": "string" }, { "type": "null" } ], - "title": "Owner Uid" + "title": "Code" }, - "name": { + "error": { "anyOf": [ { "type": "string" @@ -4165,10 +5054,161 @@ "type": "null" } ], - "title": "Name" - }, - "description": { - "anyOf": [ + "title": "Error" + } + }, + "type": "object", + "required": [ + "state" + ], + "title": "OriginalStatus", + "description": "Where a document's original is (experts-mesh \u00a79a): `here`; `elsewhere`, on\nanother member of the mesh, which this install can fetch; `fetching`;\n`failed`, the last fetch (code: unreachable when no controller could be\nasked, not_found when none found a member holding it); or `missing`.\nWhile fetching: `phase` (asking, waiting, pulling \u2014 the controller gets it\nfrom `holder` first \u2014 or receiving from `controller`), and `bytes` of\n`total` in that phase. Members are named as the mesh names them." + }, + "Paragraph": { + "properties": { + "runs": { + "items": { + "$ref": "#/components/schemas/Run" + }, + "type": "array", + "title": "Runs" + }, + "level": { + "type": "integer", + "title": "Level", + "default": 0 + }, + "bullet": { + "anyOf": [ + { + "type": "string", + "enum": [ + "none", + "bullet", + "number" + ] + }, + { + "type": "null" + } + ], + "title": "Bullet" + }, + "align": { + "anyOf": [ + { + "type": "string", + "enum": [ + "left", + "center", + "right", + "justify" + ] + }, + { + "type": "null" + } + ], + "title": "Align" + }, + "space_before": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Space Before" + }, + "space_after": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Space After" + }, + "line": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Line" + } + }, + "type": "object", + "title": "Paragraph" + }, + "PartialCollection": { + "properties": { + "uid": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Uid" + }, + "active": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Active" + }, + "min_role_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Min Role Id" + }, + "owner_uid": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Owner Uid" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "description": { + "anyOf": [ { "type": "string" }, @@ -4221,6 +5261,21 @@ } ], "title": "Mcp Banned" + }, + "scope_uids": { + "anyOf": [ + { + "items": { + "type": "string", + "format": "uuid" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Scope Uids" } }, "additionalProperties": false, @@ -4595,6 +5650,29 @@ ], "title": "Llmbackendid" }, + "llmBackendUid": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Llmbackenduid" + }, + "meshFallback": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Meshfallback" + }, "modelFile": { "anyOf": [ { @@ -4675,7 +5753,8 @@ "orgProfileId": { "anyOf": [ { - "type": "integer" + "type": "string", + "format": "uuid" }, { "type": "null" @@ -4737,6 +5816,20 @@ "type": "boolean", "title": "Private", "default": false + }, + "scopeUids": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Scopeuids" } }, "type": "object", @@ -4806,6 +5899,17 @@ } ], "title": "Transcript" + }, + "format": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Format" } }, "type": "object", @@ -4898,6 +6002,31 @@ ], "title": "QueuePullRequest" }, + "RenderRequest": { + "properties": { + "values": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "title": "Values", + "default": {} + }, + "starter": { + "type": "boolean", + "title": "Starter", + "default": false + }, + "prompts": { + "type": "boolean", + "title": "Prompts", + "default": true + } + }, + "additionalProperties": false, + "type": "object", + "title": "RenderRequest" + }, "RerankStrategy": { "type": "string", "enum": [ @@ -5175,6 +6304,71 @@ ], "title": "ResponseVoteRequest" }, + "ReverifyApplyRequest": { + "properties": { + "provider": { + "type": "string", + "title": "Provider" + }, + "results": { + "items": { + "$ref": "#/components/schemas/ReverifyResult" + }, + "type": "array", + "title": "Results" + } + }, + "type": "object", + "required": [ + "provider" + ], + "title": "ReverifyApplyRequest" + }, + "ReverifyResult": { + "properties": { + "subject": { + "type": "string", + "title": "Subject" + }, + "tenantId": { + "type": "string", + "title": "Tenantid" + }, + "outcome": { + "type": "string", + "title": "Outcome" + }, + "refreshTokenEnc": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Refreshtokenenc" + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Reason" + } + }, + "type": "object", + "required": [ + "subject", + "tenantId", + "outcome" + ], + "title": "ReverifyResult" + }, "RoomRequest": { "properties": { "text": { @@ -5211,30 +6405,129 @@ ], "title": "RoomRequest" }, - "SemanticDocumentSearchRequest": { + "Run": { "properties": { - "user_uid": { + "text": { + "type": "string", + "title": "Text", + "default": "" + }, + "b": { "anyOf": [ { - "type": "string", - "format": "uuid4" + "type": "boolean" }, { "type": "null" } ], - "title": "User Uid" + "title": "B" }, - "ml_model_name": { + "i": { "anyOf": [ { - "type": "string" + "type": "boolean" }, { "type": "null" } ], - "title": "Ml Model Name" + "title": "I" + }, + "u": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "U" + }, + "color": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Color" + }, + "size": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Size" + }, + "font": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Font" + }, + "spacing": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Spacing" + }, + "link": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Link" + } + }, + "type": "object", + "title": "Run" + }, + "SemanticDocumentSearchRequest": { + "properties": { + "user_uid": { + "anyOf": [ + { + "type": "string", + "format": "uuid4" + }, + { + "type": "null" + } + ], + "title": "User Uid" + }, + "ml_model_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Ml Model Name" }, "webhook": { "anyOf": [ @@ -5494,6 +6787,21 @@ "title": "SemanticSearchDocumentItem", "description": "A SourceDocument enriched with relevance score and best matching text split." }, + "SheetEditContext": { + "properties": { + "sheets": { + "items": { + "type": "string" + }, + "type": "array", + "maxItems": 200, + "title": "Sheets" + } + }, + "type": "object", + "title": "SheetEditContext", + "description": "Sent by the CopyWrite sheet editor's Ask panel: the open workbook's\ntabs. With it \u2014 and the tabular feature, and the sheet's dataset attached\nby its hash \u2014 the expert gets sheet_write to propose edits to the sheet." + }, "SimilarityDistanceCTXResult": { "properties": { "group_key": { @@ -5640,6 +6948,28 @@ ], "title": "Source Location" }, + "start_ms": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Start Ms" + }, + "end_ms": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "End Ms" + }, "match_source": { "type": "string", "title": "Match Source", @@ -6153,26 +7483,13 @@ "title": "SimilaritySearchTextsResponse", "description": "A list of similarity search results" }, - "SmartCollectionItemResponse": { + "Slide": { "properties": { - "timestamp": { - "type": "integer", - "title": "Timestamp" - }, - "status": { - "type": "integer", - "title": "Status", - "default": 200 - }, - "message": { + "id": { "type": "string", - "title": "Message", - "default": "OK" + "title": "Id" }, - "data": { - "$ref": "#/components/schemas/SmartCollectionResponseItem" - }, - "job_queue_run_id": { + "source_id": { "anyOf": [ { "type": "integer" @@ -6181,67 +7498,9 @@ "type": "null" } ], - "title": "Job Queue Run Id" - } - }, - "additionalProperties": true, - "type": "object", - "required": [ - "data" - ], - "title": "SmartCollectionItemResponse", - "description": "Response containing a single SmartCollection with optional job queue run id." - }, - "SmartCollectionListResponse": { - "properties": { - "timestamp": { - "type": "integer", - "title": "Timestamp" - }, - "status": { - "type": "integer", - "title": "Status", - "default": 200 - }, - "message": { - "type": "string", - "title": "Message", - "default": "OK" - }, - "count": { - "type": "integer", - "title": "Count", - "default": 0 - }, - "data": { - "items": { - "$ref": "#/components/schemas/SmartCollectionResponseItem" - }, - "type": "array", - "title": "Data" - }, - "total": { - "type": "integer", - "title": "Total", - "default": 0 - } - }, - "additionalProperties": true, - "type": "object", - "required": [ - "data" - ], - "title": "SmartCollectionListResponse", - "description": "Response containing a list of SmartCollections." - }, - "SmartCollectionResponseItem": { - "properties": { - "uid": { - "type": "string", - "format": "uuid", - "title": "Uid" + "title": "Source Id" }, - "created_at": { + "dup_of": { "anyOf": [ { "type": "integer" @@ -6250,75 +7509,50 @@ "type": "null" } ], - "title": "Created At" + "title": "Dup Of" }, - "updated_at": { + "layout": { + "type": "string", + "title": "Layout", + "default": "custom" + }, + "layout_name": { "anyOf": [ { - "type": "integer" + "type": "string" }, { "type": "null" } ], - "title": "Updated At" - }, - "active": { - "type": "boolean", - "title": "Active", - "default": true - }, - "min_role_id": { - "type": "integer", - "title": "Min Role Id", - "default": 2 - }, - "owner_uid": { - "type": "string", - "format": "uuid", - "title": "Owner Uid" - }, - "collection_uid": { - "type": "string", - "format": "uuid", - "title": "Collection Uid" - }, - "source_type": { - "type": "string", - "title": "Source Type", - "default": "filesystem" - }, - "watch_path": { - "type": "string", - "title": "Watch Path" + "title": "Layout Name" }, - "sync_enabled": { - "type": "boolean", - "title": "Sync Enabled", - "default": true - }, - "last_sync_at": { + "art": { "anyOf": [ { - "type": "integer" + "type": "string" }, { "type": "null" } ], - "title": "Last Sync At" + "title": "Art" }, - "file_count": { - "type": "integer", - "title": "File Count", - "default": 0 + "slots": { + "items": { + "$ref": "#/components/schemas/Slot" + }, + "type": "array", + "title": "Slots" }, - "sync_status": { - "type": "string", - "title": "Sync Status", - "default": "idle" + "notes": { + "items": { + "$ref": "#/components/schemas/Paragraph" + }, + "type": "array", + "title": "Notes" }, - "last_error": { + "notes_hash": { "anyOf": [ { "type": "string" @@ -6327,21 +7561,522 @@ "type": "null" } ], - "title": "Last Error" + "title": "Notes Hash" }, - "name": { + "design": { "anyOf": [ { - "type": "string" + "type": "object" }, { "type": "null" } ], - "title": "Name" - }, - "description": { - "anyOf": [ + "title": "Design" + } + }, + "type": "object", + "required": [ + "id" + ], + "title": "Slide" + }, + "Slot": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "role": { + "type": "string", + "enum": [ + "title", + "subtitle", + "body", + "text", + "quote", + "caption", + "image", + "notes", + "kicker", + "statement", + "subtext", + "panel" + ], + "title": "Role", + "default": "text" + }, + "shape_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Shape Id" + }, + "box": { + "$ref": "#/components/schemas/Box" + }, + "style": { + "$ref": "#/components/schemas/SlotStyle" + }, + "paragraphs": { + "items": { + "$ref": "#/components/schemas/Paragraph" + }, + "type": "array", + "title": "Paragraphs" + }, + "markdown": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Markdown" + }, + "image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Image" + }, + "prompt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Prompt" + }, + "orig_hash": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Orig Hash" + } + }, + "type": "object", + "required": [ + "id", + "box" + ], + "title": "Slot" + }, + "SlotStyle": { + "properties": { + "font": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Font" + }, + "size": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Size" + }, + "color": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Color" + }, + "bold": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Bold" + }, + "italic": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Italic" + }, + "caps": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Caps" + }, + "align": { + "anyOf": [ + { + "type": "string", + "enum": [ + "left", + "center", + "right", + "justify" + ] + }, + { + "type": "null" + } + ], + "title": "Align" + }, + "anchor": { + "type": "string", + "enum": [ + "top", + "middle", + "bottom" + ], + "title": "Anchor", + "default": "top" + }, + "insets": { + "anyOf": [ + { + "prefixItems": [ + { + "type": "number" + }, + { + "type": "number" + }, + { + "type": "number" + }, + { + "type": "number" + } + ], + "type": "array", + "maxItems": 4, + "minItems": 4 + }, + { + "type": "null" + } + ], + "title": "Insets" + }, + "line": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Line" + }, + "font_scale": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Font Scale" + }, + "bullet": { + "anyOf": [ + { + "type": "string", + "enum": [ + "none", + "bullet", + "number" + ] + }, + { + "type": "null" + } + ], + "title": "Bullet" + }, + "fill": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Fill" + }, + "shape": { + "anyOf": [ + { + "type": "string", + "enum": [ + "rect", + "round" + ] + }, + { + "type": "null" + } + ], + "title": "Shape" + } + }, + "type": "object", + "title": "SlotStyle", + "description": "What the slot's text looks like when a run says nothing \u2014 resolved\nthrough the pptx inheritance chain at import, so the editor renders it\nwithout knowing about masters and layouts." + }, + "SmartCollectionItemResponse": { + "properties": { + "timestamp": { + "type": "integer", + "title": "Timestamp" + }, + "status": { + "type": "integer", + "title": "Status", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "OK" + }, + "data": { + "$ref": "#/components/schemas/SmartCollectionResponseItem" + }, + "job_queue_run_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Job Queue Run Id" + } + }, + "additionalProperties": true, + "type": "object", + "required": [ + "data" + ], + "title": "SmartCollectionItemResponse", + "description": "Response containing a single SmartCollection with optional job queue run id." + }, + "SmartCollectionListResponse": { + "properties": { + "timestamp": { + "type": "integer", + "title": "Timestamp" + }, + "status": { + "type": "integer", + "title": "Status", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "OK" + }, + "count": { + "type": "integer", + "title": "Count", + "default": 0 + }, + "data": { + "items": { + "$ref": "#/components/schemas/SmartCollectionResponseItem" + }, + "type": "array", + "title": "Data" + }, + "total": { + "type": "integer", + "title": "Total", + "default": 0 + } + }, + "additionalProperties": true, + "type": "object", + "required": [ + "data" + ], + "title": "SmartCollectionListResponse", + "description": "Response containing a list of SmartCollections." + }, + "SmartCollectionResponseItem": { + "properties": { + "uid": { + "type": "string", + "format": "uuid", + "title": "Uid" + }, + "created_at": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Created At" + }, + "updated_at": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Updated At" + }, + "active": { + "type": "boolean", + "title": "Active", + "default": true + }, + "min_role_id": { + "type": "integer", + "title": "Min Role Id", + "default": 2 + }, + "owner_uid": { + "type": "string", + "format": "uuid", + "title": "Owner Uid" + }, + "collection_uid": { + "type": "string", + "format": "uuid", + "title": "Collection Uid" + }, + "source_type": { + "type": "string", + "title": "Source Type", + "default": "filesystem" + }, + "watch_path": { + "type": "string", + "title": "Watch Path" + }, + "sync_enabled": { + "type": "boolean", + "title": "Sync Enabled", + "default": true + }, + "last_sync_at": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Last Sync At" + }, + "file_count": { + "type": "integer", + "title": "File Count", + "default": 0 + }, + "sync_status": { + "type": "string", + "title": "Sync Status", + "default": "idle" + }, + "last_error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last Error" + }, + "scope_uids": { + "anyOf": [ + { + "items": { + "type": "string", + "format": "uuid" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Scope Uids" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "description": { + "anyOf": [ { "type": "string" }, @@ -6454,31 +8189,152 @@ "type": "null" } ], - "title": "Doc Hash" + "title": "Doc Hash" + }, + "file_mtime": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "File Mtime" + }, + "file_size": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "File Size" + }, + "summary": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Summary" + }, + "storage_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Storage Key" + } + }, + "type": "object", + "required": [ + "owner_uid", + "doc_uri", + "doc_name", + "doc_type" + ], + "title": "SourceDocument", + "description": "Table that represents an uploaded document. `doc_uri` specifies a local (or\nremote) location for a doc, `source` is a string that specfies where the doc\noriginated. Usually NULL but could be the url of a document sourced via a\nweb-search to ensure we capture where it has originated from for citation." + }, + "SsoCompleteRequest": { + "properties": { + "provider": { + "type": "string", + "title": "Provider" + }, + "tenantId": { + "type": "string", + "title": "Tenantid" + }, + "subject": { + "type": "string", + "title": "Subject" + }, + "email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Email" + }, + "upn": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Upn" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" }, - "file_mtime": { + "groups": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Groups" + }, + "roles": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Roles" + }, + "groupsOverage": { + "type": "boolean", + "title": "Groupsoverage", + "default": false + }, + "hostedDomain": { "anyOf": [ { - "type": "integer" + "type": "string" }, { "type": "null" } ], - "title": "File Mtime" + "title": "Hosteddomain" }, - "file_size": { + "ip": { "anyOf": [ { - "type": "integer" + "type": "string" }, { "type": "null" } ], - "title": "File Size" + "title": "Ip" }, - "summary": { + "refreshTokenEnc": { "anyOf": [ { "type": "string" @@ -6487,18 +8343,16 @@ "type": "null" } ], - "title": "Summary" + "title": "Refreshtokenenc" } }, "type": "object", "required": [ - "owner_uid", - "doc_uri", - "doc_name", - "doc_type" + "provider", + "tenantId", + "subject" ], - "title": "SourceDocument", - "description": "Table that represents an uploaded document. `doc_uri` specifies a local (or\nremote) location for a doc, `source` is a string that specfies where the doc\noriginated. Usually NULL but could be the url of a document sourced via a\nweb-search to ensure we capture where it has originated from for citation." + "title": "SsoCompleteRequest" }, "StringListResponse": { "properties": { @@ -6633,6 +8487,140 @@ "title": "StyleListResponse", "description": "A list of DocumentStyle objects" }, + "SyncApplyRequest": { + "properties": { + "provider": { + "type": "string", + "title": "Provider" + }, + "trigger": { + "type": "string", + "title": "Trigger", + "default": "schedule" + }, + "actorUid": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Actoruid" + }, + "force": { + "type": "boolean", + "title": "Force", + "default": false + }, + "results": { + "items": { + "$ref": "#/components/schemas/SyncResultItem" + }, + "type": "array", + "title": "Results" + }, + "notes": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Notes" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error" + }, + "errorDetail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Errordetail" + }, + "secretExpiresAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Secretexpiresat" + } + }, + "type": "object", + "required": [ + "provider" + ], + "title": "SyncApplyRequest" + }, + "SyncPlanRequest": { + "properties": { + "provider": { + "type": "string", + "title": "Provider" + } + }, + "type": "object", + "required": [ + "provider" + ], + "title": "SyncPlanRequest" + }, + "SyncResultItem": { + "properties": { + "subject": { + "type": "string", + "title": "Subject" + }, + "groups": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Groups" + }, + "roles": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Roles" + }, + "state": { + "type": "string", + "title": "State", + "default": "unknown" + } + }, + "type": "object", + "required": [ + "subject" + ], + "title": "SyncResultItem" + }, "SystemEmailTestRequest": { "properties": { "to": { @@ -6644,6 +8632,64 @@ "type": "object", "title": "SystemEmailTestRequest" }, + "TemplatePatch": { + "properties": { + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "doc_type": { + "anyOf": [ + { + "type": "string", + "enum": [ + "general", + "proposal", + "report", + "letter", + "presentation" + ] + }, + { + "type": "null" + } + ], + "title": "Doc Type" + }, + "spec": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Spec" + }, + "is_default": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Is Default" + } + }, + "additionalProperties": false, + "type": "object", + "title": "TemplatePatch" + }, "TextClusterItem": { "properties": { "index": { @@ -7073,6 +9119,39 @@ "split_text": { "type": "string", "title": "Split Text" + }, + "start_ms": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Start Ms" + }, + "end_ms": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "End Ms" + }, + "original_text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Original Text" } }, "type": "object", @@ -7084,6 +9163,26 @@ "title": "TextSplitItem", "description": "Structured text split for the document viewer." }, + "Theme": { + "properties": { + "colors": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "title": "Colors" + }, + "fonts": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "title": "Fonts" + } + }, + "type": "object", + "title": "Theme" + }, "TwoFactorEnrollVerifyRequest": { "properties": { "challenge_id": { @@ -7201,6 +9300,21 @@ } ], "title": "Description" + }, + "scope_uids": { + "anyOf": [ + { + "items": { + "type": "string", + "format": "uuid" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Scope Uids" } }, "type": "object", @@ -7330,11 +9444,47 @@ "type": "null" } ], - "title": "Email" + "title": "Email" + } + }, + "type": "object", + "title": "ValidityCheckRequest" + }, + "VisibilityBody": { + "properties": { + "visibleTo": { + "anyOf": [ + { + "items": { + "type": "string", + "format": "uuid" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Visibleto" + }, + "sees": { + "anyOf": [ + { + "items": { + "type": "string", + "format": "uuid" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Sees" } }, "type": "object", - "title": "ValidityCheckRequest" + "title": "VisibilityBody" }, "WebhookRequest": { "properties": { @@ -7687,7 +9837,263 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ConversationUpdateRequest" + "$ref": "#/components/schemas/ConversationUpdateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Conversations" + ], + "summary": "Delete Conversation", + "operationId": "delete_conversation_api_v1_conversations__delete", + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConversationDeleteRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/conversations/chat": { + "post": { + "tags": [ + "Chat" + ], + "summary": "Chat", + "description": "Chat endpoint with streaming response.\nStreams newline-delimited JSON matching the Ollama format:\n{\"message\": {\"content\": \"token\"}, \"done\": false, \"model\": \"llama3.2\"}", + "operationId": "chat_api_v1_conversations_chat_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChatRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ] + } + }, + "/api/v1/conversations/chat/{response_uid}/cancel": { + "post": { + "tags": [ + "Chat" + ], + "summary": "Cancel Chat", + "description": "Stop a detached generation. Client disconnects no longer cancel the\nmodel (generation is deliberately connection-independent), so the Stop\nbutton calls this to actually halt it. The cancelled task persists its\npartial text and publishes a done event on the conversation stream.", + "operationId": "cancel_chat_api_v1_conversations_chat__response_uid__cancel_post", + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "parameters": [ + { + "name": "response_uid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Response Uid" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/conversations/folders": { + "get": { + "tags": [ + "Conversation Folders" + ], + "summary": "List Folders", + "operationId": "list_folders_api_v1_conversations_folders_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ] + }, + "post": { + "tags": [ + "Conversation Folders" + ], + "summary": "Create Folder", + "operationId": "create_folder_api_v1_conversations_folders_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FolderCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ] + } + }, + "/api/v1/conversations/folders/{folder_uid}": { + "patch": { + "tags": [ + "Conversation Folders" + ], + "summary": "Rename Folder", + "operationId": "rename_folder_api_v1_conversations_folders__folder_uid__patch", + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "parameters": [ + { + "name": "folder_uid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Folder Uid" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FolderRenameRequest" } } } @@ -7715,25 +10121,27 @@ }, "delete": { "tags": [ - "Conversations" + "Conversation Folders" ], - "summary": "Delete Conversation", - "operationId": "delete_conversation_api_v1_conversations__delete", + "summary": "Delete Folder", + "operationId": "delete_folder_api_v1_conversations_folders__folder_uid__delete", "security": [ { "OAuth2PasswordBearer": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConversationDeleteRequest" - } + "parameters": [ + { + "name": "folder_uid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Folder Uid" } } - }, + ], "responses": { "200": { "description": "Successful Response", @@ -7756,19 +10164,18 @@ } } }, - "/api/v1/conversations/chat": { + "/api/v1/conversations/start": { "post": { "tags": [ - "Chat" + "Conversations" ], - "summary": "Chat", - "description": "Chat endpoint with streaming response.\nStreams newline-delimited JSON matching the Ollama format:\n{\"message\": {\"content\": \"token\"}, \"done\": false, \"model\": \"llama3.2\"}", - "operationId": "chat_api_v1_conversations_chat_post", + "summary": "Start Conversation", + "operationId": "start_conversation_api_v1_conversations_start_post", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ChatRequest" + "$ref": "#/components/schemas/ConversationStartRequest" } } }, @@ -7801,14 +10208,13 @@ ] } }, - "/api/v1/conversations/chat/{response_uid}/cancel": { - "post": { + "/api/v1/conversations/{uid}": { + "get": { "tags": [ - "Chat" + "Conversations" ], - "summary": "Cancel Chat", - "description": "Stop a detached generation. Client disconnects no longer cancel the\nmodel (generation is deliberately connection-independent), so the Stop\nbutton calls this to actually halt it. The cancelled task persists its\npartial text and publishes a done event on the conversation stream.", - "operationId": "cancel_chat_api_v1_conversations_chat__response_uid__cancel_post", + "summary": "Get Conversation", + "operationId": "get_conversation_api_v1_conversations__uid__get", "security": [ { "OAuth2PasswordBearer": [] @@ -7816,13 +10222,13 @@ ], "parameters": [ { - "name": "response_uid", + "name": "uid", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid", - "title": "Response Uid" + "title": "Uid" } } ], @@ -7848,44 +10254,40 @@ } } }, - "/api/v1/conversations/folders": { - "get": { + "/api/v1/conversations/{uid}/fork": { + "post": { "tags": [ - "Conversation Folders" + "Conversations" ], - "summary": "List Folders", - "operationId": "list_folders_api_v1_conversations_folders_get", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - } - }, + "summary": "Fork Conversation", + "description": "Fork (branch) a conversation into a new one.\n\nCopies messages up to and including body.response_uid (or all of them),\noptionally into another user's account (fork-to-teammate \u2014 the source\nconversation stays with the caller, and the fork carries forked_by\nlineage so the recipient's UI can say who sent it).", + "operationId": "fork_conversation_api_v1_conversations__uid__fork_post", "security": [ { "OAuth2PasswordBearer": [] } - ] - }, - "post": { - "tags": [ - "Conversation Folders" ], - "summary": "Create Folder", - "operationId": "create_folder_api_v1_conversations_folders_post", + "parameters": [ + { + "name": "uid", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Uid" + } + } + ], "requestBody": { + "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FolderCreateRequest" + "$ref": "#/components/schemas/ConversationForkRequest" } } - }, - "required": true + } }, "responses": { "200": { @@ -7906,21 +10308,17 @@ } } } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ] + } } }, - "/api/v1/conversations/folders/{folder_uid}": { - "patch": { + "/api/v1/conversations/{uid}/fork/title": { + "post": { "tags": [ - "Conversation Folders" + "Conversations" ], - "summary": "Rename Folder", - "operationId": "rename_folder_api_v1_conversations_folders__folder_uid__patch", + "summary": "Suggest Fork Title", + "description": "Suggested name for a fork of this conversation.", + "operationId": "suggest_fork_title_api_v1_conversations__uid__fork_title_post", "security": [ { "OAuth2PasswordBearer": [] @@ -7928,26 +10326,16 @@ ], "parameters": [ { - "name": "folder_uid", + "name": "uid", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid", - "title": "Folder Uid" + "title": "Uid" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FolderRenameRequest" - } - } - } - }, "responses": { "200": { "description": "Successful Response", @@ -7968,13 +10356,16 @@ } } } - }, - "delete": { + } + }, + "/api/v1/conversations/{uid}/usage": { + "get": { "tags": [ - "Conversation Folders" + "Conversations" ], - "summary": "Delete Folder", - "operationId": "delete_folder_api_v1_conversations_folders__folder_uid__delete", + "summary": "Conversation Usage", + "description": "Token usage summary for a conversation, scoped to the calling user.", + "operationId": "conversation_usage_api_v1_conversations__uid__usage_get", "security": [ { "OAuth2PasswordBearer": [] @@ -7982,13 +10373,13 @@ ], "parameters": [ { - "name": "folder_uid", + "name": "uid", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid", - "title": "Folder Uid" + "title": "Uid" } } ], @@ -8014,29 +10405,94 @@ } } }, - "/api/v1/conversations/start": { - "post": { + "/api/v1/documents": { + "get": { "tags": [ - "Conversations" + "Documents" ], - "summary": "Start Conversation", - "operationId": "start_conversation_api_v1_conversations_start_post", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConversationStartRequest" - } + "summary": "Get All Documents", + "description": "Return a list of documents that the user has permission to view. These will\nbe either owned by or at the same or lower role as, the logged in user.\nTo retrive documents from a collection, specify a `collection` param as a UUID.\nTo further filter [search] by keyword(s) in the document name, title or summary\nadd a `searchTerm` param containing the keyword(s) to filter by. If limit\nand/or skip are specified they are always applied as filters as the last\noperations on the query to allow for paging.\n\nReturns\nA data object containing a list of matching documents.", + "operationId": "get_all_documents_api_v1_documents_get", + "security": [ + { + "OAuth2PasswordBearer": [] + } + ], + "parameters": [ + { + "name": "searchTerm", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Searchterm" } }, - "required": true - }, + { + "name": "collection", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "title": "Collection" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Limit" + } + }, + { + "name": "skip", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Skip" + } + } + ], "responses": { "200": { "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/DocumentListResponse" + } } } }, @@ -8050,21 +10506,18 @@ } } } - }, - "security": [ - { - "OAuth2PasswordBearer": [] - } - ] + } } }, - "/api/v1/conversations/{uid}": { + "/api/v1/documents/job-queue-status/{run_id}": { "get": { "tags": [ - "Conversations" + "Documents", + "Files" ], - "summary": "Get Conversation", - "operationId": "get_conversation_api_v1_conversations__uid__get", + "summary": "Job Queue Status", + "description": "Returns the current state of a job queue run plus a BOUNDED page of its\nqueue entries. Used by the frontend to poll for status when the socket\nmay have missed messages (e.g. fast dedup processing that completes\nbefore socket connects) and for the upload modal's initial state.\n\nPreviously returned jqr.toDict \u2014 the run with EVERY queue row \u2014 which\nis O(run size) (6.9 MB on a 47k-file run). A parameterless call now\nreturns the first 1000 rows in the same shape, plus `queue_total` and\n`has_more` for callers that need the rest.", + "operationId": "job_queue_status_api_v1_documents_job_queue_status__run_id__get", "security": [ { "OAuth2PasswordBearer": [] @@ -8072,14 +10525,54 @@ ], "parameters": [ { - "name": "uid", + "name": "run_id", "in": "path", "required": true, "schema": { - "type": "string", - "format": "uuid", - "title": "Uid" + "type": "integer", + "title": "Run Id" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 5000, + "minimum": 1, + "default": 1000, + "title": "Limit" } + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "default": 0, + "title": "Offset" + } + }, + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional filter: 'unfinished' returns only pending/working/embeddingComplete rows.", + "title": "Status" + }, + "description": "Optional filter: 'unfinished' returns only pending/working/embeddingComplete rows." } ], "responses": { @@ -8104,47 +10597,33 @@ } } }, - "/api/v1/conversations/{uid}/fork": { + "/api/v1/documents/job-queue-upload": { "post": { "tags": [ - "Conversations" - ], - "summary": "Fork Conversation", - "description": "Fork (branch) a conversation into a new one.\n\nCopies messages up to and including body.response_uid (or all of them),\noptionally into another user's account (fork-to-teammate \u2014 the source\nconversation stays with the caller, and the fork carries forked_by\nlineage so the recipient's UI can say who sent it).", - "operationId": "fork_conversation_api_v1_conversations__uid__fork_post", - "security": [ - { - "OAuth2PasswordBearer": [] - } - ], - "parameters": [ - { - "name": "uid", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid", - "title": "Uid" - } - } + "Documents", + "Files" ], + "summary": "Job Queue File Upload", + "description": "#### Introduction.\nPOST receptor for a front-end HTML form:file upload component that sends\nit's data in `multipart/form-data` format. Not suitable for large scale bulk\nuploads, since each file needs storing in the webserver before being processed.\nPrefer to use `api/v*/upload` with a list of files/folders in that case. All\nfiles in upload will have their `min_role_id` set to the value passed in the\nrequest or the user's role otherwise.\n\n\n\n#### Document-collection handling.\nFiles uploaded to this endpoint will be added to a collection if one is specified.\nIf you pass a UUID to the `collection` param and the collection doesn't exist,\n \nthe upload will fail. You should create the collection first by POSTing to the `api/v*/collections` endpoint.\n \nIf you specify a name for the collection, you can only add documents to a\ncollection where you are already the owner or the upload will fail. If a\ncollection with the name doesn't exist one will be created if the\n`auto_create_collection` parameter is supplied and True, otherwise the\nupload will fail.\n\n\n\n#### Returns.\nThe number of items being processed and the JobQueueRunId. Open a websocket at\n`/api/v*/progress/{job queue id}` to receive status updates.", + "operationId": "job_queue_file_upload_api_v1_documents_job_queue_upload_post", "requestBody": { - "required": true, "content": { - "application/json": { + "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/ConversationForkRequest" + "$ref": "#/components/schemas/Body_job_queue_file_upload_api_v1_documents_job_queue_upload_post" } } - } + }, + "required": true }, "responses": { "200": { "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/JobQueueTaskResponse" + } } } }, @@ -8158,17 +10637,22 @@ } } } - } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ] } }, - "/api/v1/conversations/{uid}/fork/title": { - "post": { + "/api/v1/documents/{document_uid}": { + "get": { "tags": [ - "Conversations" + "Documents" ], - "summary": "Suggest Fork Title", - "description": "Suggested name for a fork of this conversation.", - "operationId": "suggest_fork_title_api_v1_conversations__uid__fork_title_post", + "summary": "Get Single Document", + "description": "Returns the Document object specified by the UUID with an array of text\nsplits populated if the `splits` query parameter is supplied and True.\nIf `splits` is not supplied or false, the splits param in the return\nstructure will be an empty array. If `splits` is supplied and True, the\ncaller can choose to reflow the unsplit texts such that each sentence is\n[approx] the same length.\n\n#### Raises:\n- 404 if a document with the specified UUID was not found\n- 401 if the user doesn't have sufficent permissions", + "operationId": "get_single_document_api_v1_documents__document_uid__get", "security": [ { "OAuth2PasswordBearer": [] @@ -8176,13 +10660,45 @@ ], "parameters": [ { - "name": "uid", + "name": "document_uid", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid", - "title": "Uid" + "title": "Document Uid" + } + }, + { + "name": "with_splits", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "With Splits" + } + }, + { + "name": "reflow_splits", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Reflow Splits" } } ], @@ -8191,7 +10707,9 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/DocWithSplitTextsResponse" + } } } }, @@ -8206,16 +10724,14 @@ } } } - } - }, - "/api/v1/conversations/{uid}/usage": { - "get": { + }, + "patch": { "tags": [ - "Conversations" + "Documents" ], - "summary": "Conversation Usage", - "description": "Token usage summary for a conversation, scoped to the calling user.", - "operationId": "conversation_usage_api_v1_conversations__uid__usage_get", + "summary": "Update Document", + "description": "Update an existing document. Returns the newly updated Document if\nsuccessful, raises otherwise:\n- 422-Unprocessable entity if the entity is not updatedable\n- 404-Not found if the specified resource wasn't found [in the db]", + "operationId": "update_document_api_v1_documents__document_uid__patch", "security": [ { "OAuth2PasswordBearer": [] @@ -8223,22 +10739,34 @@ ], "parameters": [ { - "name": "uid", + "name": "document_uid", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid", - "title": "Uid" + "title": "Document Uid" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PartialDocument" + } + } + } + }, "responses": { "200": { "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/DocumentItemResponse" + } } } }, @@ -8253,16 +10781,14 @@ } } } - } - }, - "/api/v1/documents": { - "get": { + }, + "delete": { "tags": [ "Documents" ], - "summary": "Get All Documents", - "description": "Return a list of documents that the user has permission to view. These will\nbe either owned by or at the same or lower role as, the logged in user.\nTo retrive documents from a collection, specify a `collection` param as a UUID.\nTo further filter [search] by keyword(s) in the document name, title or summary\nadd a `searchTerm` param containing the keyword(s) to filter by. If limit\nand/or skip are specified they are always applied as filters as the last\noperations on the query to allow for paging.\n\nReturns\nA data object containing a list of matching documents.", - "operationId": "get_all_documents_api_v1_documents_get", + "summary": "Delete Document", + "description": "Delete the specified resource. Returns 204-No Content if successful, raises\notherwise:\n- 500-Internal server error if the DB failed to update\n- 404-Not found if the specified resource wasn't found [in the db]", + "operationId": "delete_document_api_v1_documents__document_uid__delete", "security": [ { "OAuth2PasswordBearer": [] @@ -8270,68 +10796,13 @@ ], "parameters": [ { - "name": "searchTerm", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Searchterm" - } - }, - { - "name": "collection", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string", - "format": "uuid" - }, - { - "type": "null" - } - ], - "title": "Collection" - } - }, - { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Limit" - } - }, - { - "name": "skip", - "in": "query", - "required": false, + "name": "document_uid", + "in": "path", + "required": true, "schema": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Skip" + "type": "string", + "format": "uuid", + "title": "Document Uid" } } ], @@ -8341,7 +10812,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DocumentListResponse" + "$ref": "#/components/schemas/BackgroundTaskResponse" } } } @@ -8409,7 +10880,7 @@ "Profiles" ], "summary": "List Profiles", - "description": "List the model profiles this user may see.\n\nVisibility is `_visible_to` \u2014 see it for the rule. In short: a user sees\nshared experts at or below their role, plus everything they own; admins\nsee the whole shared surface; nobody but the owner sees an owner-only\n(\"Only me\") expert. The chat expert-picker reads the same list, so this\nis also what scopes which experts a user can chat with \u2014 if none are\nvisible the client falls back to the site default model.", + "description": "List the model profiles this user may see \u2014 expert_access has the\nrule (role floor, \"Only me\", org-profile limits, chatbox sessions,\nservices acting for someone). The chat expert-picker reads the same list,\nand chat refuses an expert that isn't in it. An expert's instructions and\ntools are left out for anyone who can't manage it.", "operationId": "list_profiles_api_v1_profiles__get", "responses": { "200": { @@ -8808,6 +11279,53 @@ } ] } + }, + "/api/v1/similarity_search/": { + "post": { + "tags": [ + "RAG" + ], + "summary": "Similiarity Search", + "description": "Run a similarity search for the top `k` documents matching the source text.", + "operationId": "similiarity_search_api_v1_similarity_search__post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SimilaritySearchRAGRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SimilaritySearchRAGResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "OAuth2PasswordBearer": [] + } + ] + } } } } diff --git a/src/klavi_experts/__init__.py b/src/klavi_experts/__init__.py index d7e4ebf..c64ee17 100644 --- a/src/klavi_experts/__init__.py +++ b/src/klavi_experts/__init__.py @@ -21,9 +21,11 @@ AbortError, AuthError, BadRequestError, + ExpertNotAvailableError, ExpertsError, ExpertsPermissionError, LicenseError, + LicenseReadOnlyError, NotFoundError, RateLimitError, ServerError, @@ -41,14 +43,21 @@ Expert, KeepaliveEvent, KnowledgeDocument, + MeshFallback, Message, + QueuedEvent, + SearchHit, + ServedBy, SourceDocument, ThinkingEvent, TokenEvent, + UploadedFile, + UploadJob, + UploadStatus, Usage, ) -__version__ = "0.1.0" +__version__ = "0.2.0" # Grouped by what a reader is looking for — clients, then errors, then wire # types — rather than alphabetised. RUF022 wants the latter; the grouping @@ -67,7 +76,9 @@ "ExpertsError", "AuthError", "ExpertsPermissionError", + "ExpertNotAvailableError", "LicenseError", + "LicenseReadOnlyError", "NotFoundError", "RateLimitError", "ServerError", @@ -87,9 +98,16 @@ "ThinkingEvent", "ActionEvent", "KeepaliveEvent", + "QueuedEvent", "DoneEvent", + "ServedBy", + "MeshFallback", "Collection", "KnowledgeDocument", + "SearchHit", + "UploadJob", + "UploadStatus", + "UploadedFile", "BrowserSession", "__version__", ] diff --git a/src/klavi_experts/_chat_stream.py b/src/klavi_experts/_chat_stream.py index 7101217..d0f6109 100644 --- a/src/klavi_experts/_chat_stream.py +++ b/src/klavi_experts/_chat_stream.py @@ -46,15 +46,22 @@ def observe(self, event: ChatEvent) -> None: finish_reason=event.finish_reason, aborted=event.aborted, error=event.error, + model=event.model, + served_by=event.served_by, + mesh_fallback=event.mesh_fallback, + mesh_unavailable=event.mesh_unavailable, + generated_images=event.generated_images, + data_artifacts=event.data_artifacts, ) - def incomplete(self) -> StreamError: + def incomplete(self, cause: Exception | None = None) -> StreamError: # The answer very likely completed on the server regardless — # generation is detached — so say what recovery actually looks like. + how = f" ({type(cause).__name__}: {cause})" if cause else "" return StreamError( - "The stream ended without a terminal event. The answer may still " - "have completed on the server: re-read the conversation rather " - "than resending.", + f"The stream ended without a terminal event{how}. The answer may " + "still have completed on the server: re-read the conversation " + "rather than resending.", code="incomplete_stream", ) @@ -73,7 +80,7 @@ def __init__( *, conversation_uid: str, response_uid: str | None, - cancel: Callable[[str | None], None], + cancel: Callable[[str | None], bool], ) -> None: self._response = response self._cancel = cancel @@ -104,15 +111,24 @@ def __iter__(self) -> Iterator[ChatEvent]: buffer = LineBuffer() try: - for chunk in self._response.iter_bytes(): - for raw in buffer.feed(chunk): - event = to_event(raw) - if event is None: - continue - self._acc.observe(event) - yield event - if isinstance(event, DoneEvent): - return + try: + for chunk in self._response.iter_bytes(): + for raw in buffer.feed(chunk): + event = to_event(raw) + if event is None: + continue + self._acc.observe(event) + yield event + if isinstance(event, DoneEvent): + return + except httpx.HTTPError as exc: + # A timeout or a dropped connection mid-answer. Raised as the + # SDK's own error, with what recovery looks like, rather than + # leaking httpx's — unless the caller stopped reading on + # purpose, which closes the connection under the loop. + if self._cancelled or self._detached: + return + raise self._acc.incomplete(exc) from exc for raw in buffer.flush(): event = to_event(raw) if event is None: @@ -138,16 +154,20 @@ def text(self) -> str: """The assistant's reply as a string.""" return self.result().text - def cancel(self) -> None: - """Stop the model. + def cancel(self) -> bool: + """Stop the model. True when the server found it running and stopped it. Not the same as walking away from the stream: generation is detached server-side, so an abandoned request keeps generating, keeps costing and still persists its answer. This is the only thing that stops it. + + False means the server had nothing to stop: the answer had already + finished, or — behind a load balancer with several conversation + workers — it is running on another one. """ self._cancelled = True self.close() - self._cancel(self.response_uid) + return self._cancel(self.response_uid) def detach(self) -> None: """Stop reading, deliberately leaving the answer to finish server-side. @@ -171,7 +191,7 @@ def __init__( *, conversation_uid: str, response_uid: str | None, - cancel: Callable[[str | None], Awaitable[None]], + cancel: Callable[[str | None], Awaitable[bool]], ) -> None: self._response = response self._cancel = cancel @@ -201,15 +221,20 @@ async def __aiter__(self) -> AsyncIterator[ChatEvent]: buffer = LineBuffer() try: - async for chunk in self._response.aiter_bytes(): - for raw in buffer.feed(chunk): - event = to_event(raw) - if event is None: - continue - self._acc.observe(event) - yield event - if isinstance(event, DoneEvent): - return + try: + async for chunk in self._response.aiter_bytes(): + for raw in buffer.feed(chunk): + event = to_event(raw) + if event is None: + continue + self._acc.observe(event) + yield event + if isinstance(event, DoneEvent): + return + except httpx.HTTPError as exc: + if self._cancelled or self._detached: + return + raise self._acc.incomplete(exc) from exc for raw in buffer.flush(): event = to_event(raw) if event is None: @@ -233,10 +258,10 @@ async def result(self) -> ChatResult: async def text(self) -> str: return (await self.result()).text - async def cancel(self) -> None: + async def cancel(self) -> bool: self._cancelled = True await self.aclose() - await self._cancel(self.response_uid) + return await self._cancel(self.response_uid) def detach(self) -> None: self._detached = True diff --git a/src/klavi_experts/_stream.py b/src/klavi_experts/_stream.py index aaacf5f..3018523 100644 --- a/src/klavi_experts/_stream.py +++ b/src/klavi_experts/_stream.py @@ -32,6 +32,9 @@ ChatEvent, DoneEvent, KeepaliveEvent, + MeshFallback, + QueuedEvent, + ServedBy, SourceDocument, ThinkingEvent, TokenEvent, @@ -103,6 +106,17 @@ def to_event(raw: dict[str, Any]) -> ChatEvent | None: if kind == "thinking": return ThinkingEvent(content=content) + if kind == "mesh_waiting": + # Waiting in line for another mesh member's model. Live only, never + # stored, and never content. + ahead = raw.get("ahead") + return QueuedEvent( + ahead=int(ahead) if isinstance(ahead, (int, float)) else 0, + node=_str_or_none(raw.get("node")), + name=_str_or_none(raw.get("name")), + rerouting=raw.get("rerouting") is True, + ) + if kind == "action": return ActionEvent( action=str(raw.get("action") or ""), @@ -126,6 +140,18 @@ def to_event(raw: dict[str, Any]) -> ChatEvent | None: error=error if isinstance(error, str) and error else None, context_overflow=raw.get("context_overflow") is True, response_uid=_str_or_none(raw.get("response")), + model=_str_or_none(raw.get("model")), + served_by=ServedBy.from_wire(raw.get("served_by")), + mesh_fallback=MeshFallback.from_wire(raw.get("mesh_fallback")), + mesh_unavailable=( + raw["mesh_unavailable"] + if isinstance(raw.get("mesh_unavailable"), dict) + else None + ), + generated_images=list(raw.get("generated_images") or []), + data_artifacts=[ + a for a in (raw.get("data_artifacts") or []) if isinstance(a, dict) + ], ) if not content: diff --git a/src/klavi_experts/_transport.py b/src/klavi_experts/_transport.py index 9f80b4c..d7b8dce 100644 --- a/src/klavi_experts/_transport.py +++ b/src/klavi_experts/_transport.py @@ -41,7 +41,9 @@ DEFAULT_MAX_RETRIES = 3 #: Streaming has no meaningful overall deadline — a long answer is not a hung #: request — but a stalled connection still has to end. httpx's read timeout -#: applies between chunks, which is the right knob. +#: applies between chunks, which is the right knob. On an Experts Mesh a turn +#: can wait silently in line for a busy member's model, so it is a client +#: option (``stream_timeout``) as well. STREAM_READ_TIMEOUT = 300.0 @@ -127,6 +129,7 @@ def __init__( timeout: float = DEFAULT_TIMEOUT, max_retries: int = DEFAULT_MAX_RETRIES, headers: Mapping[str, str] | None = None, + stream_timeout: float = STREAM_READ_TIMEOUT, ) -> None: if not base_url: raise ValueError("base_url is required") @@ -140,6 +143,7 @@ def __init__( self._timeout = timeout self._max_retries = max(1, max_retries) self._extra_headers = dict(headers or {}) + self._stream_timeout = stream_timeout def _headers(self, request: Request) -> dict[str, str]: headers = {"accept": "application/json"} @@ -203,7 +207,7 @@ def send(self, request: Request) -> httpx.Response: # body is not read into memory before we see the status. req = self._client.build_request( **self._build(request), - timeout=httpx.Timeout(None, read=STREAM_READ_TIMEOUT), + timeout=httpx.Timeout(None, read=self._stream_timeout), ) response = self._client.send(req, stream=True) else: @@ -215,14 +219,16 @@ def send(self, request: Request) -> httpx.Response: continue raise ServerError(str(exc) or "Network request failed") from exc + if not response.is_success: + # Read and release before deciding: a streamed response's body + # has not been read yet, and it is where the error's message + # is. Left open, it would also hold the connection for the + # whole retry window. + response.read() + response.close() error = _outcome(response) if error is None: return response - - # Read and release before deciding, or a streamed error response - # holds the connection open for the whole retry window. - response.read() - response.close() if attempt < attempts and response.status_code in RETRYABLE: last = error time.sleep(_backoff(attempt, _retry_after(response))) @@ -262,7 +268,7 @@ async def send(self, request: Request) -> httpx.Response: if request.stream: req = self._client.build_request( **self._build(request), - timeout=httpx.Timeout(None, read=STREAM_READ_TIMEOUT), + timeout=httpx.Timeout(None, read=self._stream_timeout), ) response = await self._client.send(req, stream=True) else: @@ -274,12 +280,12 @@ async def send(self, request: Request) -> httpx.Response: continue raise ServerError(str(exc) or "Network request failed") from exc + if not response.is_success: + await response.aread() + await response.aclose() error = _outcome(response) if error is None: return response - - await response.aread() - await response.aclose() if attempt < attempts and response.status_code in RETRYABLE: last = error await asyncio.sleep(_backoff(attempt, _retry_after(response))) diff --git a/src/klavi_experts/async_client.py b/src/klavi_experts/async_client.py index 7edcdd9..ba59971 100644 --- a/src/klavi_experts/async_client.py +++ b/src/klavi_experts/async_client.py @@ -17,13 +17,14 @@ # it. `builtins.list` says what is meant, and keeps `.list()` as the method # name every other SDK uses. import builtins +import contextlib from typing import Any, BinaryIO import httpx from . import _chat_stream from ._transport import AsyncTransport, Request -from .errors import NotFoundError, StreamError +from .errors import ExpertsError, NotFoundError, StreamError from .resources import conversations as _conv from .resources import experts as _experts from .resources import knowledge as _knowledge @@ -35,6 +36,9 @@ ConversationWithMessages, Expert, KnowledgeDocument, + SearchHit, + UploadJob, + UploadStatus, ) @@ -145,17 +149,23 @@ async def send( if not response_uid: raise StreamError("The server did not return a placeholder response uid") - response = await self._t.send( - _conv.chat_request( - conversation_uid, - question, - response_uid=response_uid, - expert=expert, - model=model, - images=images, - context=context, + try: + response = await self._t.send( + _conv.chat_request( + conversation_uid, + question, + response_uid=response_uid, + expert=expert, + model=model, + images=images, + context=context, + ) ) - ) + except ExpertsError as exc: + # Refused before it started: settle the placeholder, as the sync + # twin does, or the conversation shows it typing forever. + await self._settle(response_uid, exc) + raise return _chat_stream.AsyncChatStream( response, conversation_uid=conversation_uid, @@ -163,9 +173,16 @@ async def send( cancel=self._cancel, ) - async def _cancel(self, response_uid: str | None) -> None: - if response_uid: + async def _settle(self, response_uid: str, exc: ExpertsError) -> None: + with contextlib.suppress(ExpertsError): + await self._t.request(_conv.settle_request(response_uid, exc.message)) + + async def _cancel(self, response_uid: str | None) -> bool: + if not response_uid: + return False + return _conv.was_cancelled( await self._t.request(_conv.cancel_request(response_uid)) + ) async def ask( self, @@ -194,49 +211,64 @@ async def list_collections(self) -> list[Collection]: ) async def list_documents( - self, *, collection: str | None = None, limit: int | None = None + self, + *, + collection: str | None = None, + limit: int | None = None, + skip: int | None = None, + search: str | None = None, ) -> list[KnowledgeDocument]: + """Documents this credential may read — see the sync twin.""" return _knowledge.to_documents( await self._t.request( - _knowledge.documents_request(collection=collection, limit=limit) - ) + _knowledge.documents_request( + collection=collection, limit=limit, skip=skip, search=search + ) + ), + collection=collection, ) async def upload( self, file: BinaryIO, *, collection: str, filename: str = "upload" - ) -> KnowledgeDocument: - """Accepted, not yet searchable — see the sync twin.""" - response = await self._t.send( - _knowledge.upload_request( - file=file, filename=filename, collection=collection + ) -> UploadJob: + """Accepted and queued, not yet searchable — see the sync twin.""" + return _knowledge.to_upload_job( + await self._t.request( + _knowledge.upload_request( + file=file, filename=filename, collection=collection + ) ) ) - try: - await response.aread() - return _knowledge.to_document(_knowledge.unwrap(response.json())) - finally: - await response.aclose() + + async def upload_status(self, job: UploadJob | int) -> UploadStatus: + job_id = _knowledge.job_id_of(job) + return _knowledge.to_upload_status( + job_id, await self._t.request(_knowledge.upload_status_request(job_id)) + ) + + async def wait_for_processing( + self, job: UploadJob | int, *, timeout: float = 120.0, interval: float = 2.0 + ) -> UploadStatus: + """Poll until every file is searchable or has failed — see the sync twin.""" + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while True: + status = await self.upload_status(job) + if status.done or loop.time() >= deadline: + return status + await asyncio.sleep(interval) async def search( self, query: str, *, collections: list[str] | None = None, limit: int = 8 - ) -> list[KnowledgeDocument]: - return _knowledge.to_documents( + ) -> list[SearchHit]: + return _knowledge.to_hits( await self._t.request( _knowledge.search_request(query, collections=collections, limit=limit) ) ) - async def wait_for_processing( - self, uid: str, *, timeout: float = 120.0, interval: float = 2.0 - ) -> bool: - loop = asyncio.get_running_loop() - deadline = loop.time() + timeout - while loop.time() < deadline: - raw = await self._t.request(_knowledge.document_request(uid)) - if _knowledge.is_processed(raw): - return True - await asyncio.sleep(interval) - return False + async def delete_document(self, uid: str) -> None: + await self._t.request(_knowledge.delete_document_request(uid)) class AsyncSessions: @@ -280,6 +312,7 @@ def __init__( timeout: float = 60.0, max_retries: int = 3, headers: dict[str, str] | None = None, + stream_timeout: float = 300.0, http_client: httpx.AsyncClient | None = None, ) -> None: self._transport = AsyncTransport( @@ -289,6 +322,7 @@ def __init__( timeout=timeout, max_retries=max_retries, headers=headers, + stream_timeout=stream_timeout, client=http_client, ) self.experts = AsyncExperts(self._transport) diff --git a/src/klavi_experts/client.py b/src/klavi_experts/client.py index 66d9987..8abdc2d 100644 --- a/src/klavi_experts/client.py +++ b/src/klavi_experts/client.py @@ -18,6 +18,7 @@ # it. `builtins.list` says what is meant, and keeps `.list()` as the method # name every other SDK uses. import builtins +import contextlib import time from typing import Any, BinaryIO @@ -25,7 +26,7 @@ from . import _chat_stream from ._transport import Request, SyncTransport -from .errors import NotFoundError, StreamError +from .errors import ExpertsError, NotFoundError, StreamError from .resources import conversations as _conv from .resources import experts as _experts from .resources import knowledge as _knowledge @@ -37,6 +38,9 @@ ConversationWithMessages, Expert, KnowledgeDocument, + SearchHit, + UploadJob, + UploadStatus, ) @@ -57,10 +61,12 @@ def get(self, uid: str) -> Expert: def list_guest_visible(self) -> builtins.list[Expert]: """Experts a browser session may actually use. - A session token carries the guest role, and an expert above that floor - resolves to nothing server-side — the chat then answers on the site - default model with no persona and no knowledge, silently. Use this to - build a picker that only offers experts that will work. + A session token carries the guest role, so ``sessions.create()`` + refuses an expert above that floor, a private one, or one limited to + org profiles (a visitor is in none of them). Before build pack 1.1 such + an expert silently answered on the site default model with no persona + and no knowledge instead. Use this to build a picker that only offers + experts that will work. """ return [e for e in self.list() if e.guest_visible] @@ -162,17 +168,24 @@ def send( raise StreamError("The server did not return a placeholder response uid") # 3. Generate. Streams NDJSON over plain HTTP. - response = self._t.send( - _conv.chat_request( - conversation_uid, - question, - response_uid=response_uid, - expert=expert, - model=model, - images=images, - context=context, + try: + response = self._t.send( + _conv.chat_request( + conversation_uid, + question, + response_uid=response_uid, + expert=expert, + model=model, + images=images, + context=context, + ) ) - ) + except ExpertsError as exc: + # Refused before it started (an expert this key may not use, a + # read-only licence). Nothing will ever write the placeholder, so + # settle it with the reason, or the conversation shows it typing. + self._settle(response_uid, exc) + raise return _chat_stream.ChatStream( response, conversation_uid=conversation_uid, @@ -180,9 +193,16 @@ def send( cancel=self._cancel, ) - def _cancel(self, response_uid: str | None) -> None: - if response_uid: - self._t.request(_conv.cancel_request(response_uid)) + def _settle(self, response_uid: str, exc: ExpertsError) -> None: + # Best effort: the refusal is what the caller needs to see, never a + # failure to tidy up after it. + with contextlib.suppress(ExpertsError): + self._t.request(_conv.settle_request(response_uid, exc.message)) + + def _cancel(self, response_uid: str | None) -> bool: + if not response_uid: + return False + return _conv.was_cancelled(self._t.request(_conv.cancel_request(response_uid))) def ask( self, @@ -210,33 +230,73 @@ def list_collections(self) -> list[Collection]: ) def list_documents( - self, *, collection: str | None = None, limit: int | None = None + self, + *, + collection: str | None = None, + limit: int | None = None, + skip: int | None = None, + search: str | None = None, ) -> list[KnowledgeDocument]: + """Documents this credential may read, optionally in one collection. + + ``search`` matches the file name, title or summary — it is a filter, + not a semantic search; for that, use :meth:`search`. + """ return _knowledge.to_documents( self._t.request( - _knowledge.documents_request(collection=collection, limit=limit) - ) + _knowledge.documents_request( + collection=collection, limit=limit, skip=skip, search=search + ) + ), + collection=collection, ) def upload( self, file: BinaryIO, *, collection: str, filename: str = "upload" - ) -> KnowledgeDocument: + ) -> UploadJob: """Upload a file into a collection. - Returns as soon as the file is accepted. It is NOT searchable yet — the - worker still has to split, embed and index it. Follow with - :meth:`wait_for_processing` if the next thing you do is query. + Returns as soon as the file is accepted, with the indexing job it + joined. It is NOT searchable yet — the worker still has to split, + embed and index it. Follow with :meth:`wait_for_processing` if the + next thing you do is query; that is also where the new document's uid + and any read failure come back. """ - response = self._t.send( - _knowledge.upload_request( - file=file, filename=filename, collection=collection + return _knowledge.to_upload_job( + self._t.request( + _knowledge.upload_request( + file=file, filename=filename, collection=collection + ) ) ) - try: - response.read() - return _knowledge.to_document(_knowledge.unwrap(response.json())) - finally: - response.close() + + def upload_status(self, job: UploadJob | int) -> UploadStatus: + """Where an upload's indexing job has got to, without waiting.""" + job_id = _knowledge.job_id_of(job) + return _knowledge.to_upload_status( + job_id, self._t.request(_knowledge.upload_status_request(job_id)) + ) + + def wait_for_processing( + self, job: UploadJob | int, *, timeout: float = 120.0, interval: float = 2.0 + ) -> UploadStatus: + """Poll until every file in the job is searchable or has failed. + + Returns the last status either way: truthy when every file is + searchable, falsy on a failure or on timeout (check ``.done`` and + ``.failed`` to tell which). There is no push signal for this, so + polling is the honest answer rather than a workaround. + + Large files take longer than the default allows, and a recording is + transcribed before it becomes a document at all — raise ``timeout`` + for those. + """ + deadline = time.monotonic() + timeout + while True: + status = self.upload_status(job) + if status.done or time.monotonic() >= deadline: + return status + time.sleep(interval) def search( self, @@ -244,28 +304,21 @@ def search( *, collections: list[str] | None = None, limit: int = 8, - ) -> list[KnowledgeDocument]: - return _knowledge.to_documents( + ) -> list[SearchHit]: + """The passages that best match ``query``, closest first. + + Searches every collection this credential may read unless + ``collections`` narrows it. + """ + return _knowledge.to_hits( self._t.request( _knowledge.search_request(query, collections=collections, limit=limit) ) ) - def wait_for_processing( - self, uid: str, *, timeout: float = 120.0, interval: float = 2.0 - ) -> bool: - """Poll until a document is embedded. True when ready, False on timeout. - - There is no push signal for this, so polling is the honest answer - rather than a workaround. - """ - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - raw = self._t.request(_knowledge.document_request(uid)) - if _knowledge.is_processed(raw): - return True - time.sleep(interval) - return False + def delete_document(self, uid: str) -> None: + """Delete a document, from every collection it is in.""" + self._t.request(_knowledge.delete_document_request(uid)) class Sessions: @@ -303,6 +356,10 @@ class ExpertsClient: """Talk to a Klavi Experts installation. ``api_key`` is a full user identity on the install. Keep it on a server. + + ``stream_timeout`` is the longest silence, in seconds, tolerated between + two pieces of a streamed answer. On an Experts Mesh a turn can wait in + line for a busy member's model without a word; raise it if yours do. """ def __init__( @@ -314,6 +371,7 @@ def __init__( timeout: float = 60.0, max_retries: int = 3, headers: dict[str, str] | None = None, + stream_timeout: float = 300.0, http_client: httpx.Client | None = None, ) -> None: self._transport = SyncTransport( @@ -323,6 +381,7 @@ def __init__( timeout=timeout, max_retries=max_retries, headers=headers, + stream_timeout=stream_timeout, client=http_client, ) self.experts = Experts(self._transport) diff --git a/src/klavi_experts/errors.py b/src/klavi_experts/errors.py index f3b6458..9cbdd6c 100644 --- a/src/klavi_experts/errors.py +++ b/src/klavi_experts/errors.py @@ -2,8 +2,8 @@ The distinctions here are the ones a caller has to act on differently, not a taxonomy for its own sake. `LicenseError` in particular is not a variety of -"forbidden": it says the *installation's* licence has lapsed, so no amount of -fixing the request will help and the person who can fix it is the install's +"forbidden": it says the *installation's* licence is not valid, so no amount +of fixing the request will help and the person who can fix it is the install's administrator, not whoever is reading the traceback. """ @@ -53,15 +53,51 @@ class PermissionError_(ExpertsError): ExpertsPermissionError = PermissionError_ +class ExpertNotAvailableError(PermissionError_): + """403 ``expert_not_available`` — this credential may not use that expert. + + Raised by a chat turn naming an expert above the caller's role, private to + someone else, or limited to org profiles the caller is not in. Before build + pack 1.1 the turn answered on the site default model instead, with no + persona and no knowledge; now it is refused, and the SDK settles the + placeholder message so the conversation does not show it typing forever. + """ + + class LicenseError(ExpertsError): - """403 with ``{"error": "license_expired"}`` — the INSTALL's licence lapsed. + """403 with ``{"error": "license_expired"}`` — the INSTALL's licence is not valid. Separate from a permission error on purpose. Nothing about the request is wrong and no retry or credential change will help; the install's - administrator has to renew. Reporting "forbidden" here sends a developer to + administrator has to act. Reporting "forbidden" here sends a developer to debug their own code for an hour. + + Expiry is the usual cause but not the only one: an install with no key, a + server clock set backwards, or one that has not reached Klavi for longer + than its licence allows all answer the same way. ``message`` carries the + install's own reason. + """ + + #: True when the install still serves reads. See `LicenseReadOnlyError`. + read_only: bool = False + + +class LicenseReadOnlyError(LicenseError): + """403 with ``{"error": "license_expired_readonly"}`` — a write, refused + because the licence lapsed recently. + + For a window after its licence expires (30 days), an install stays + read-only rather than going dark: GETs are served, so conversations, + experts and knowledge can still be listed, read and exported. Everything + else is refused with this, including the calls that read but go out as a + POST — sending a message and ``knowledge.search()`` among them. + + A subclass of `LicenseError`, so code that already catches that keeps + working. Past the window the install answers `LicenseError` for reads too. """ + read_only = True + class NotFoundError(ExpertsError): """404 — or a resource this credential may not see. @@ -134,6 +170,14 @@ def _message_from(body: Any, status: int) -> tuple[str, str | None]: detail = body.get("detail") if isinstance(detail, str): return detail, None + # A structured refusal: {"detail": {"reason": "...", "message": "..."}} + if isinstance(detail, dict): + reason = detail.get("reason") or detail.get("error") + text = detail.get("message") or detail.get("detail") or reason + return ( + str(text or f"Request failed with status {status}"), + reason if isinstance(reason, str) else None, + ) if isinstance(detail, list): import json @@ -159,12 +203,24 @@ def error_from_response( } if status == 401: + # The knowledge service answers "you may not see this document" with + # 401, not 403. The credential is fine; the permission is not. + if "insufficient permissions" in message.lower(): + return PermissionError_(message, **common) return AuthError(message, **common) if status == 403: + if code == "expert_not_available": + return ExpertNotAvailableError(message, **common) # An install-level failure wearing a request-level status code. + if code == "license_expired_readonly": + return LicenseReadOnlyError( + f"{message} (the installation's licence has expired and it is " + f"read-only until renewed — reads still work, this write cannot)", + **common, + ) if code == "license_expired": return LicenseError( - f"{message} (the installation's licence has expired — this is not " + f"{message} (the installation's licence is not valid — this is not " f"something your request or credentials can fix)", **common, ) diff --git a/src/klavi_experts/resources/conversations.py b/src/klavi_experts/resources/conversations.py index e1cd794..e1c54d6 100644 --- a/src/klavi_experts/resources/conversations.py +++ b/src/klavi_experts/resources/conversations.py @@ -170,6 +170,24 @@ def cancel_request(response_uid: str) -> Request: ) +def was_cancelled(raw: Any) -> bool: + """The cancel endpoint's ``{"cancelled": bool}``. + + False when there was nothing running to stop — already finished, or + running in another conversations process than the one asked. + """ + return isinstance(raw, dict) and raw.get("cancelled") is True + + +def settle_request(response_uid: str, error: str) -> Request: + """Mark a placeholder finished, with the reason it never got an answer.""" + return Request( + method="PUT", + path="/api/v1/responses/", + json={"uid": response_uid, "done": True, "text": "", "error": error}, + ) + + # --- response shaping ---------------------------------------------------- diff --git a/src/klavi_experts/resources/knowledge.py b/src/klavi_experts/resources/knowledge.py index d51f48c..13b543e 100644 --- a/src/klavi_experts/resources/knowledge.py +++ b/src/klavi_experts/resources/knowledge.py @@ -6,8 +6,11 @@ snake_case with epoch-millisecond timestamps. Unwrapped and normalised here so callers see the same shape as everywhere else. -Uploading is asynchronous. A document is accepted, then split, embedded and -indexed by a worker — so a file that has just uploaded is NOT yet retrievable. +Uploading is asynchronous. A file is accepted into an indexing JOB, then +split, embedded and indexed by a worker — so a file that has just uploaded is +NOT yet retrievable, and the upload's answer is the job, not a document. The +job's status is where the document's uid appears, and where a file that could +not be read says why. """ from __future__ import annotations @@ -16,7 +19,14 @@ from typing import Any from .._transport import Request -from ..types import Collection, KnowledgeDocument +from ..types import ( + Collection, + KnowledgeDocument, + SearchHit, + UploadedFile, + UploadJob, + UploadStatus, +) def unwrap(response: Any) -> Any: @@ -48,37 +58,62 @@ def collections_request() -> Request: return Request(path="/api/v1/collections") -def documents_request(*, collection: str | None, limit: int | None) -> Request: +def documents_request( + *, + collection: str | None, + limit: int | None, + skip: int | None = None, + search: str | None = None, +) -> Request: return Request( path="/api/v1/documents", - params={"collection_uid": collection, "limit": limit}, + params={ + "collection": collection, + "limit": limit, + "skip": skip, + # Matches the file name, title or summary. + "searchTerm": search, + }, ) -def document_request(uid: str) -> Request: - return Request(path=f"/api/v1/documents/{uid}") +def delete_document_request(uid: str) -> Request: + # Not retried: a repeat after a delete that did land answers 404. + return Request( + method="DELETE", path=f"/api/v1/documents/{uid}", idempotent=False + ) -def upload_request( - *, file: Any, filename: str, collection: str -) -> Request: +def upload_request(*, file: Any, filename: str, collection: str) -> Request: return Request( method="POST", - path="/api/v1/documents/upload", - files={"file": (filename, file)}, - data={"collection_uid": collection}, + path="/api/v1/documents/job-queue-upload", + # `files`, plural: the endpoint takes several in one job. + files={"files": (filename, file)}, + data={"collection": collection}, # An upload is never retried — a repeat ingests the file twice, which # then shows up as duplicate retrieval hits. idempotent=False, - stream=True, ) -def search_request(query: str, *, collections: list[str] | None, limit: int) -> Request: +def upload_status_request(job_id: int) -> Request: + return Request(path=f"/api/v1/documents/job-queue-status/{job_id}") + + +def search_request( + query: str, *, collections: list[str] | None, limit: int +) -> Request: + body: dict[str, Any] = {"prompt": query, "top_k": limit} + # Only when there are some. An empty list is not "everything": the + # service turns it into a query that fails. + if collections: + body["collections"] = collections return Request( method="POST", - path="/api/v1/conversations/similarity-search", - json={"query": query, "collections": collections or [], "limit": limit}, + # The trailing slash is part of the route. + path="/api/v1/similarity_search/", + json=body, ) @@ -91,31 +126,94 @@ def to_collections(raw: Any) -> list[Collection]: file_count=int(c.get("file_count") or 0), created_at=_iso(c.get("created_at")), updated_at=_iso(c.get("updated_at")), + min_role_id=int(c.get("min_role_id") or 2), + is_public=bool(c.get("is_public")), + scope_uids=[str(u) for u in (c.get("scope_uids") or [])], ) for c in (unwrap(raw) or []) ] -def to_documents(raw: Any) -> list[KnowledgeDocument]: - return [to_document(d) for d in (unwrap(raw) or [])] +def to_documents(raw: Any, *, collection: str | None = None) -> list[KnowledgeDocument]: + return [to_document(d, collection=collection) for d in (unwrap(raw) or [])] -def to_document(raw: Any) -> KnowledgeDocument: +def to_document(raw: Any, *, collection: str | None = None) -> KnowledgeDocument: raw = raw or {} + summary = raw.get("summary") + size = raw.get("file_size") return KnowledgeDocument( uid=str(raw.get("uid", "")), - name=raw.get("name") or "", - type=raw.get("type"), - description=raw.get("description"), - collection_uid=raw.get("collection_uid"), + name=raw.get("doc_name") or "", + type=raw.get("doc_type"), + description=summary, + collection_uid=collection, created_at=_iso(raw.get("created_at")), + title=raw.get("title"), + summary=summary, + size=int(size) if isinstance(size, (int, float)) else None, + ) + + +def to_hits(raw: Any) -> list[SearchHit]: + hits = [] + for h in unwrap(raw) or []: + distance = h.get("cosine_dist") + is_num = isinstance(distance, (int, float)) + hits.append( + SearchHit( + text=h.get("split_text") or "", + document_uid=str(h.get("source_doc_uid") or ""), + document_name=h.get("doc_name") or "", + title=h.get("title"), + summary=h.get("summary"), + distance=distance if is_num else None, + similarity=(1 - distance) if is_num else None, + page_number=h.get("page_number"), + location=h.get("source_location"), + start_ms=h.get("start_ms"), + end_ms=h.get("end_ms"), + rerank_score=h.get("rerank_score"), + split_uid=h.get("text_split_uid"), + ) + ) + return hits + + +def to_upload_job(raw: Any) -> UploadJob: + raw = raw if isinstance(raw, dict) else {} + return UploadJob( + job_id=int(raw.get("job_queue_run_id") or 0), + message=str(raw.get("message") or ""), ) -def is_processed(raw: Any) -> bool: - doc = unwrap(raw) - if isinstance(doc, list): - doc = doc[0] if doc else {} - if not isinstance(doc, dict): - return False - return bool(doc.get("processed") or doc.get("embedded")) +def job_id_of(job: UploadJob | int) -> int: + return job.job_id if isinstance(job, UploadJob) else int(job) + + +# The job queue's status ids, as names a caller can read. +_STATUS = {-1: "failed", 0: "pending", 1: "working", 2: "searchable", 3: "finished"} + + +def to_upload_status(job_id: int, raw: Any) -> UploadStatus: + # Not enveloped, unlike the rest of this service. + raw = raw if isinstance(raw, dict) else {} + files = [ + UploadedFile( + name=str(q.get("object_name") or ""), + document_uid=str(q["object_uid"]) if q.get("object_uid") else None, + status=_STATUS.get(int(q.get("status_id") or 0), "pending"), + error=q.get("extra") or None, + ) + for q in (raw.get("queue") or []) + if isinstance(q, dict) + ] + total = int(raw.get("total_files") or 0) + # Every file accounted for, and none still in the pipeline. "searchable" + # counts as an end: the document answers searches while its summary is + # still being written. + settled = bool(files) and len(files) >= total and all( + f.status in ("searchable", "finished", "failed") for f in files + ) + return UploadStatus(job_id=job_id, files=files, done=settled) diff --git a/src/klavi_experts/types.py b/src/klavi_experts/types.py index 5d4788c..676c4a1 100644 --- a/src/klavi_experts/types.py +++ b/src/klavi_experts/types.py @@ -24,6 +24,9 @@ class Expert: name: str description: str | None = None #: The persona/system prompt. ``model_file`` on the wire. + #: + #: Only the expert's owner and administrators see it (build pack 1.1); for + #: anyone else this is None. instructions: str | None = None model: str | None = None avatar: str | None = None @@ -36,16 +39,21 @@ class Expert: output_language: str | None = None #: Minimum role that may use this expert. 1 = guest. #: - #: Load-bearing for browser sessions: a session token is a guest, and an - #: expert above this floor silently resolves to the site default model with - #: no persona. ``sessions.create()`` refuses such an expert up front. + #: Load-bearing for browser sessions: a session token is a guest, and + #: ``sessions.create()`` refuses an expert above this floor up front. + #: Chatting with one is refused too (`ExpertNotAvailableError`); before + #: build pack 1.1 it silently answered on the site default model instead. min_role_id: int = 2 private: bool = False + #: Org profiles this expert is limited to; empty means everyone who meets + #: ``min_role_id``. A browser visitor belongs to none, so a limited expert + #: cannot back a chatbox. + scope_uids: list[UUIDStr] = field(default_factory=list) @property def guest_visible(self) -> bool: """Whether a browser session token could actually use this expert.""" - return not self.private and self.min_role_id <= 1 + return not self.private and self.min_role_id <= 1 and not self.scope_uids @classmethod def from_wire(cls, raw: dict[str, Any]) -> Expert: @@ -63,6 +71,7 @@ def from_wire(cls, raw: dict[str, Any]) -> Expert: output_language=raw.get("output_language"), min_role_id=int(raw.get("min_role_id") or 2), private=bool(raw.get("private")), + scope_uids=[str(s) for s in (raw.get("scope_uids") or [])], ) @@ -112,6 +121,11 @@ class SourceDocument: distance: float | None = None #: ``1 - distance``, because "distance" reads backwards when ranking. similarity: float | None = None + #: The passage that was cited, for opening a viewer at it. + split_uid: str | None = None + #: For a recording: where in it the passage was said, in milliseconds. + start_ms: int | None = None + end_ms: int | None = None @classmethod def from_wire(cls, raw: dict[str, Any]) -> SourceDocument: @@ -125,9 +139,20 @@ def from_wire(cls, raw: dict[str, Any]) -> SourceDocument: description=raw.get("description") or "", distance=distance if isinstance(distance, (int, float)) else None, similarity=(1 - distance) if isinstance(distance, (int, float)) else None, + split_uid=_opt_str(raw.get("split_uid")), + start_ms=_opt_int(raw.get("start_ms")), + end_ms=_opt_int(raw.get("end_ms")), ) +def _opt_str(value: Any) -> str | None: + return str(value) if value else None + + +def _opt_int(value: Any) -> int | None: + return int(value) if isinstance(value, (int, float)) else None + + @dataclass(slots=True) class Message: uid: UUIDStr @@ -254,6 +279,85 @@ class KeepaliveEvent: type: Literal["keepalive"] = "keepalive" +@dataclass(slots=True) +class QueuedEvent: + """The answer is waiting in line for another server's model (a mesh). + + Sent when the expert's model runs on another member of an Experts Mesh and + that member is busy, each time the place in line moves. Nothing streams + while it waits, so this is the event to show "waiting on Tokyo, 2 ahead". + Never content. + """ + + #: Requests ahead of this one. 0 with ``rerouting`` when another route + #: failed and the request is being sent a different way. + ahead: int = 0 + #: The member whose model will answer: its node id and display name. + node: str | None = None + name: str | None = None + rerouting: bool = False + type: Literal["queued"] = "queued" + + +@dataclass(slots=True) +class ServedBy: + """Another server's model answered (an Experts Mesh member).""" + + node: str = "" + #: The member's display name, e.g. "Tokyo". + name: str = "" + label: str = "" + model: str = "" + #: The members the request passed through, in order, when it was relayed. + route: list[str] = field(default_factory=list) + + @classmethod + def from_wire(cls, raw: Any) -> ServedBy | None: + if not isinstance(raw, dict): + return None + return cls( + node=str(raw.get("node") or ""), + name=str(raw.get("name") or ""), + label=str(raw.get("label") or ""), + model=str(raw.get("model") or ""), + route=[ + str(r.get("name") or r.get("node") or "") + for r in (raw.get("route") or []) + if isinstance(r, dict) + ], + ) + + +@dataclass(slots=True) +class MeshFallback: + """The expert's own model was out of reach, so this server's answered. + + Only for an expert set to fall back; a strict one fails instead, with + ``ChatResult.error`` set and the details in ``mesh_unavailable``. + """ + + #: The member whose model was out of reach. + node: str = "" + name: str = "" + model: str = "" + #: "unreachable", or "not_shared" when that member stopped sharing it. + reason: str = "" + #: The model that answered instead. + answered_with: str = "" + + @classmethod + def from_wire(cls, raw: Any) -> MeshFallback | None: + if not isinstance(raw, dict): + return None + return cls( + node=str(raw.get("node") or ""), + name=str(raw.get("name") or ""), + model=str(raw.get("model") or ""), + reason=str(raw.get("reason") or ""), + answered_with=str(raw.get("answered_with") or ""), + ) + + @dataclass(slots=True) class DoneEvent: """The end. @@ -270,11 +374,31 @@ class DoneEvent: error: str | None = None context_overflow: bool = False response_uid: str | None = None + #: The model that answered. + model: str | None = None + #: Set when another mesh member's model answered. + served_by: ServedBy | None = None + #: Set when the expert's own mesh model was out of reach and this server's + #: answered instead. + mesh_fallback: MeshFallback | None = None + #: A strict expert's mesh model was out of reach: what was unreachable. + #: ``error`` carries the message. + mesh_unavailable: dict[str, Any] | None = None + #: Images a tool generated for this answer. + generated_images: list[Any] = field(default_factory=list) + #: Tables, charts and proposed sheet edits a tool produced, each a dict + #: with ``id`` and ``kind`` ("query", "chart" or "sheet_proposal"). + data_artifacts: list[dict[str, Any]] = field(default_factory=list) type: Literal["done"] = "done" ChatEvent = ( - TokenEvent | ThinkingEvent | ActionEvent | KeepaliveEvent | DoneEvent + TokenEvent + | ThinkingEvent + | ActionEvent + | KeepaliveEvent + | QueuedEvent + | DoneEvent ) @@ -296,6 +420,13 @@ class ChatResult: #: event, so a caller checking only the status code sees an empty answer #: and no reason. Always worth checking. error: str | None = None + #: The model that answered, and — on an Experts Mesh — whose it was. + model: str | None = None + served_by: ServedBy | None = None + mesh_fallback: MeshFallback | None = None + mesh_unavailable: dict[str, Any] | None = None + generated_images: list[Any] = field(default_factory=list) + data_artifacts: list[dict[str, Any]] = field(default_factory=list) @dataclass(slots=True) @@ -303,19 +434,113 @@ class Collection: uid: UUIDStr name: str = "" description: str | None = None + #: Not reported by the API, so always 0. Kept so existing code does not + #: break; count with ``list_documents(collection=...)`` instead. file_count: int = 0 created_at: str | None = None updated_at: str | None = None + #: Minimum role that may read it, when it is public. + min_role_id: int = 2 + is_public: bool = False + #: Org profiles it is limited to; empty means no limit. + scope_uids: list[UUIDStr] = field(default_factory=list) @dataclass(slots=True) class KnowledgeDocument: uid: UUIDStr + #: The file name, e.g. "policy.pdf". name: str = "" + #: The file type, e.g. "pdf". type: str | None = None + #: The summary written after indexing. Same as ``summary``. description: str | None = None + #: The collection it was listed from, when ``list_documents()`` was + #: filtered by one. A document can sit in several, and its row names none. collection_uid: UUIDStr | None = None created_at: str | None = None + title: str | None = None + summary: str | None = None + #: Bytes. + size: int | None = None + + +@dataclass(slots=True) +class SearchHit: + """One passage that matched a search, with the document it came from.""" + + #: The passage itself. + text: str + document_uid: UUIDStr = "" + document_name: str = "" + title: str | None = None + summary: str | None = None + #: Cosine distance — lower is closer. + distance: float | None = None + #: ``1 - distance``. + similarity: float | None = None + page_number: int | None = None + #: Where in the document, when the format has one (a slide, a sheet). + location: str | None = None + #: For a recording: where the passage was said, in milliseconds. + start_ms: int | None = None + end_ms: int | None = None + #: The reranker's score, when the install reranks. + rerank_score: float | None = None + #: The id of the passage. + split_uid: str | None = None + + +@dataclass(slots=True) +class UploadJob: + """An upload the server has accepted and queued for indexing.""" + + #: The indexing job. Pass this object (or the id) to + #: ``knowledge.wait_for_processing()`` or ``knowledge.upload_status()``. + job_id: int + message: str = "" + + +@dataclass(slots=True) +class UploadedFile: + """One file in an indexing job.""" + + name: str + #: The document's uid, once the server has made one. + document_uid: UUIDStr | None = None + #: "pending", "working", "searchable" (embedded; the summary is still being + #: written), "finished", or "failed". + status: str = "pending" + error: str | None = None + + @property + def searchable(self) -> bool: + return self.status in ("searchable", "finished") + + +@dataclass(slots=True) +class UploadStatus: + """Where an indexing job has got to. + + Truthy when every file is searchable, so + ``if knowledge.wait_for_processing(job):`` reads the way it sounds. + """ + + job_id: int + files: list[UploadedFile] = field(default_factory=list) + #: Every file has finished one way or the other: searchable or failed. + done: bool = False + + @property + def document_uids(self) -> list[UUIDStr]: + return [f.document_uid for f in self.files if f.searchable and f.document_uid] + + @property + def failed(self) -> list[UploadedFile]: + return [f for f in self.files if f.status == "failed"] + + def __bool__(self) -> bool: + return self.done and bool(self.files) and not self.failed @dataclass(slots=True) diff --git a/tests/live/test_integration.py b/tests/live/test_integration.py index ab2ecdf..e42d4d2 100644 --- a/tests/live/test_integration.py +++ b/tests/live/test_integration.py @@ -11,7 +11,9 @@ from __future__ import annotations import contextlib +import io import os +import uuid import pytest @@ -56,7 +58,11 @@ def test_the_shape_is_what_the_sdk_claims(self, experts): def test_guest_visibility_is_computable(self, experts): for expert in experts: - expected = not expert.private and expert.min_role_id <= 1 + expected = ( + not expert.private + and expert.min_role_id <= 1 + and not expert.scope_uids + ) assert expert.guest_visible == expected @@ -140,6 +146,34 @@ def test_the_envelope_is_unwrapped(self, client): assert isinstance(collections[0].uid, str) assert isinstance(collections[0].file_count, int) + def test_upload_index_search_delete(self, client): + # The round trip the 0.1 release shipped without ever exercising: + # every step of it was against the wrong field or the wrong route. + collections = client.knowledge.list_collections() + if not collections: + pytest.skip("this key can see no collection to upload into") + collection = collections[0].uid + word = f"sdkprobe{uuid.uuid4().hex[:10]}" + body = f"Klavi SDK live test. The probe word is {word}.".encode() + + job = client.knowledge.upload( + io.BytesIO(body), collection=collection, filename=f"{word}.txt" + ) + assert job.job_id > 0 + status = client.knowledge.wait_for_processing(job, timeout=180) + assert status, f"not indexed: {status}" + [doc_uid] = status.document_uids + try: + listed = client.knowledge.list_documents(collection=collection) + doc = next(d for d in listed if d.uid == doc_uid) + assert doc.name == f"{word}.txt" + assert doc.collection_uid == collection + + hits = client.knowledge.search(word, collections=[collection], limit=3) + assert any(h.document_uid == doc_uid and word in h.text for h in hits) + finally: + client.knowledge.delete_document(doc_uid) + class TestSessions: def test_a_token_is_bound_and_revocable(self, client, experts): diff --git a/tests/test_client.py b/tests/test_client.py index 79c3652..cf387e6 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -16,11 +16,15 @@ from klavi_experts import ( AuthError, BadRequestError, + ExpertNotAvailableError, ExpertsClient, + ExpertsPermissionError, LicenseError, + LicenseReadOnlyError, NotFoundError, RateLimitError, ServerError, + StreamError, ) KEY = "sk-" + "k" * 48 @@ -56,10 +60,10 @@ def body(self, index: int) -> dict: def make_client(replies: list[httpx.Response], **kw) -> tuple[ExpertsClient, Recorder]: recorder = Recorder(replies) transport = httpx.MockTransport(recorder.handler) + kw.setdefault("max_retries", 1) client = ExpertsClient( base_url="https://install.example", api_key=KEY, - max_retries=1, http_client=httpx.Client(transport=transport), **kw, ) @@ -174,6 +178,12 @@ def test_cancel_calls_the_endpoint_rather_than_just_dropping(self): stream.cancel() assert rec.paths[-1] == "POST /api/v1/conversations/chat/stub-row/cancel" + def test_cancel_reports_whether_there_was_anything_to_stop(self): + client, _ = make_client([*SEND_REPLIES, ok({"cancelled": False})]) + assert client.conversations.send(CONV, "hi").cancel() is False + client, _ = make_client([*SEND_REPLIES, ok({"cancelled": True})]) + assert client.conversations.send(CONV, "hi").cancel() is True + def test_detach_does_not_call_cancel(self): client, rec = make_client(SEND_REPLIES) stream = client.conversations.send(CONV, "hi") @@ -181,6 +191,64 @@ def test_detach_does_not_call_cancel(self): assert not any("cancel" in p for p in rec.paths) +REFUSAL = {"detail": {"reason": "expert_not_available", + "message": "This expert isn't available to you."}} + + +class TestRefusedTurns: + """A chat turn the server refuses before it starts (build pack 1.1).""" + + def refused(self, status, payload) -> list[httpx.Response]: + # A real streamed response: its body is not read until asked, which + # is what the 0.1 transport tripped over (httpx.ResponseNotRead). + return [ + ok({"uid": "user-row"}), + ok({"uid": "stub-row"}), + httpx.Response( + status, + stream=httpx.ByteStream(json.dumps(payload).encode()), + headers={"content-type": "application/json"}, + ), + ok({"uid": "stub-row", "done": True}), + ] + + def test_an_expert_the_key_may_not_use_is_its_own_error(self): + client, _ = make_client(self.refused(403, REFUSAL)) + with pytest.raises(ExpertNotAvailableError, match="isn't available") as exc: + client.conversations.send(CONV, "hi", expert="e-locked") + assert isinstance(exc.value, ExpertsPermissionError) + assert exc.value.code == "expert_not_available" + + def test_the_placeholder_is_settled_so_it_does_not_type_forever(self): + client, rec = make_client(self.refused(403, REFUSAL)) + with pytest.raises(ExpertNotAvailableError): + client.conversations.send(CONV, "hi", expert="e-locked") + assert rec.paths[-1] == "PUT /api/v1/responses/" + assert rec.body(3) == {"uid": "stub-row", "done": True, "text": "", + "error": "This expert isn't available to you."} + + def test_a_streamed_error_body_is_read_not_crashed_on(self): + client, _ = make_client(self.refused(404, {"detail": "Conversation not found"})) + with pytest.raises(NotFoundError, match="Conversation not found"): + client.conversations.send(CONV, "hi") + + +class TestDroppedStreams: + class Broken(httpx.SyncByteStream): + def __iter__(self): + yield b'{"type":"stream","message":{"content":"Hel"},"done":false}\n' + raise httpx.ReadTimeout("timed out") + + def test_a_timeout_mid_answer_is_a_stream_error(self): + # e.g. a mesh turn waiting silently in line past stream_timeout. + client, _ = make_client([ok({"uid": "u"}), ok({"uid": "stub-row"}), + httpx.Response(200, stream=self.Broken())]) + stream = client.conversations.send(CONV, "hi") + with pytest.raises(StreamError, match="re-read the conversation") as exc: + stream.text() + assert exc.value.code == "incomplete_stream" + + class TestErrors: @pytest.mark.parametrize( "status,payload,expected", @@ -204,8 +272,34 @@ def test_an_expired_licence_is_not_merely_a_permission_error(self): httpx.Response(403, json={"error": "license_expired", "message": "Your license has expired."}) ]) - with pytest.raises(LicenseError, match="installation's licence"): + with pytest.raises(LicenseError, match="installation's licence") as exc: client.experts.list() + assert not exc.value.read_only + + def test_a_write_in_the_read_only_window_is_still_a_licence_error(self): + # For 30 days after expiry an install serves reads and refuses writes + # with its own code. Callers catching LicenseError must still see it. + client, _ = make_client([ + httpx.Response(403, json={ + "error": "license_expired_readonly", + "message": "This licence has expired. The server is read-only " + "until it is renewed; you can still view and export " + "your data.", + }) + ]) + with pytest.raises(LicenseReadOnlyError, match="read-only") as exc: + client.conversations.create(title="T") + assert isinstance(exc.value, LicenseError) + assert exc.value.read_only + assert exc.value.code == "license_expired_readonly" + + def test_a_knowledge_permission_refusal_is_not_an_auth_error(self): + # The knowledge service says "you may not see this" with a 401. The + # key is fine; re-authenticating would not help. + client, _ = make_client([httpx.Response( + 401, json={"detail": "User has insufficient permissions"})]) + with pytest.raises(ExpertsPermissionError): + client.knowledge.list_documents() def test_retry_after_is_exposed(self): client, _ = make_client([ @@ -247,9 +341,16 @@ def test_guest_visible_filters_what_a_session_could_use(self): {"uid": "guest-ok", "name": "A", "min_role_id": 1, "private": False}, {"uid": "too-high", "name": "B", "min_role_id": 2, "private": False}, {"uid": "is-private", "name": "C", "min_role_id": 1, "private": True}, + # Limited to org profiles: a visitor is in none of them, and + # minting a session for it is refused. + {"uid": "scoped", "name": "D", "min_role_id": 1, "private": False, + "scope_uids": ["22222222-2222-2222-2222-222222222222"]}, + {"uid": "unscoped", "name": "E", "min_role_id": 1, "private": False, + "scope_uids": None}, ]) ]) - assert [e.uid for e in client.experts.list_guest_visible()] == ["guest-ok"] + assert [e.uid for e in client.experts.list_guest_visible()] == [ + "guest-ok", "unscoped"] def test_message_rows_keep_their_snake_case_source(self): client, _ = make_client([ diff --git a/tests/test_knowledge.py b/tests/test_knowledge.py new file mode 100644 index 0000000..9d6c9e5 --- /dev/null +++ b/tests/test_knowledge.py @@ -0,0 +1,183 @@ +"""Knowledge, against the wire shapes the embedding module actually sends. + +Every payload here is copied from a 1.1 install (build pack 1.1.0-rc.127). The +0.1 release mocked the shapes it expected instead, which is how its upload, +search and processing wait shipped without ever having worked. +""" + +from __future__ import annotations + +import io +from urllib.parse import parse_qs + +import httpx +import pytest +from test_client import KEY, make_client, ok + +from klavi_experts import AsyncExpertsClient, UploadJob + +COLLECTION = "d0f5ac46-0000-4000-8000-000000000001" +DOC = "54137bfb-f499-4502-81d3-8bf1ab0970a6" + + +def envelope(data, **extra): + return {"timestamp": 1793409212047, "status": 200, "message": "OK", + "count": len(data), "data": data, **extra} + + +def job_status(*queue, total=1): + # Not enveloped, unlike the rest of the knowledge service. + return {"id": 24, "kind": 0, "total_files": total, "error_count": 0, + "loaded_files": sum(1 for q in queue if q["status_id"] >= 2), + "queue": list(queue), "queue_total": len(queue), "has_more": False} + + +def entry(status_id, *, uid=DOC, extra=None): + return {"status_id": status_id, "object_uid": uid, + "object_name": "policy.txt", "extra": extra} + + +class TestDocuments: + def test_the_collection_filter_is_the_parameter_the_server_reads(self): + client, rec = make_client([ok(envelope([]))]) + client.knowledge.list_documents(collection=COLLECTION, limit=5) + query = parse_qs(rec.calls[0].url.query.decode()) + assert query == {"collection": [COLLECTION], "limit": ["5"]} + + def test_document_rows_are_mapped_from_their_wire_names(self): + client, _ = make_client([ok(envelope([{ + "uid": DOC, "doc_name": "policy.pdf", "doc_type": "pdf", + "title": "Refund policy", "summary": "How refunds work.", + "file_size": 2048, "created_at": 1_700_000_000_000, + }]))]) + [doc] = client.knowledge.list_documents(collection=COLLECTION) + assert doc.name == "policy.pdf" + assert doc.type == "pdf" + assert doc.title == "Refund policy" + assert doc.summary == doc.description == "How refunds work." + assert doc.size == 2048 + # The row names no collection; the one it was listed from is kept. + assert doc.collection_uid == COLLECTION + + def test_delete(self): + client, rec = make_client([ok({"status": 204, "message": "Deleted"})]) + client.knowledge.delete_document(DOC) + assert rec.paths == [f"DELETE /api/v1/documents/{DOC}"] + + +class TestUpload: + def test_the_form_fields_are_the_ones_the_server_reads(self): + client, rec = make_client([ok({ + "timestamp": 1, "status": 200, + "message": "Processing 1 files on the embedding queue", + "job_queue_run_id": 24, + })]) + job = client.knowledge.upload( + io.BytesIO(b"hello"), collection=COLLECTION, filename="policy.txt") + assert job == UploadJob(job_id=24, + message="Processing 1 files on the embedding queue") + body = rec.calls[0].content + # `files`, plural, and `collection` — anything else is a 422, or a + # file that lands in no collection. + assert b'name="files"; filename="policy.txt"' in body + assert b'name="collection"' in body and COLLECTION.encode() in body + + def test_an_upload_is_never_retried(self): + client, rec = make_client( + [httpx.Response(503, json={"detail": "busy"})], max_retries=3) + with pytest.raises(Exception): # noqa: B017 - the type is tested elsewhere + client.knowledge.upload(io.BytesIO(b"x"), collection=COLLECTION) + assert len(rec.calls) == 1 + + def test_waiting_polls_the_job_until_the_file_is_searchable(self): + client, rec = make_client([ + ok(job_status(total=1)), # accepted, not yet queued + ok(job_status(entry(1))), # working + ok(job_status(entry(2))), # searchable, summary pending + ]) + status = client.knowledge.wait_for_processing(24, interval=0) + assert status + assert status.done + assert status.document_uids == [DOC] + assert rec.paths == ["GET /api/v1/documents/job-queue-status/24"] * 3 + + def test_a_file_that_failed_says_why_and_is_falsy(self): + client, _ = make_client([ok(job_status( + entry(-1, uid=None, extra="This PDF is password-protected.")))]) + status = client.knowledge.wait_for_processing(UploadJob(job_id=24)) + assert status.done + assert not status + [failed] = status.failed + assert failed.error == "This PDF is password-protected." + + def test_a_timeout_returns_the_last_status_rather_than_raising(self): + client, _ = make_client([ok(job_status(entry(1)))]) + status = client.knowledge.wait_for_processing(24, timeout=0, interval=0) + assert not status.done + assert not status + assert status.files[0].status == "working" + + +HIT = { + "cosine_dist": 0.25, "split_text": "Refunds are issued within 14 days.", + "split_order": 0, "text_split_uid": "split-1", "source_doc_uid": DOC, + "doc_name": "policy.txt", "title": None, "summary": None, + "doc_uri": "", "page_number": 3, "source_location": None, + "start_ms": None, "end_ms": None, "match_source": "both", + "rrf_score": None, "rerank_score": 0.9, +} + + +class TestSearch: + def test_the_request_is_the_rag_search(self): + client, rec = make_client([ok(envelope([]))]) + client.knowledge.search("refunds", collections=[COLLECTION], limit=3) + assert rec.paths == ["POST /api/v1/similarity_search/"] + assert rec.body(0) == {"prompt": "refunds", "top_k": 3, + "collections": [COLLECTION]} + + def test_no_collections_means_the_field_is_left_out(self): + # An empty list is not "everything" to the service: it builds a + # query that fails with a 500. + client, rec = make_client([ok(envelope([]))]) + client.knowledge.search("refunds") + assert "collections" not in rec.body(0) + + def test_hits_are_passages_with_their_document(self): + client, _ = make_client([ok(envelope([HIT]))]) + [hit] = client.knowledge.search("refunds") + assert hit.text == "Refunds are issued within 14 days." + assert hit.document_uid == DOC + assert hit.document_name == "policy.txt" + assert hit.similarity == pytest.approx(0.75) + assert hit.page_number == 3 + assert hit.rerank_score == 0.9 + assert hit.split_uid == "split-1" + + +class TestAsyncKnowledge: + async def test_upload_and_wait(self): + replies = iter([ + ok({"job_queue_run_id": 7, "message": "queued"}), + ok(job_status(entry(3))), + ]) + calls: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + calls.append(request) + return next(replies) + + async with AsyncExpertsClient( + base_url="https://install.example", api_key=KEY, max_retries=1, + http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)), + ) as client: + job = await client.knowledge.upload( + io.BytesIO(b"x"), collection=COLLECTION, filename="a.txt") + status = await client.knowledge.wait_for_processing(job, interval=0) + assert job.job_id == 7 + assert status and status.document_uids == [DOC] + assert [c.url.path for c in calls] == [ + "/api/v1/documents/job-queue-upload", + "/api/v1/documents/job-queue-status/7", + ] + diff --git a/tests/test_stream.py b/tests/test_stream.py index 4ecec6c..a5ea8a7 100644 --- a/tests/test_stream.py +++ b/tests/test_stream.py @@ -17,6 +17,7 @@ ActionEvent, DoneEvent, KeepaliveEvent, + QueuedEvent, ThinkingEvent, TokenEvent, ) @@ -157,6 +158,66 @@ def test_a_terminal_event_with_no_counters_is_fine(self): assert event.source_docs == [] +class TestMesh: + """Experts Mesh (build pack 1.1): who answered, and waiting in line.""" + + def test_waiting_in_line_is_its_own_event_not_content(self): + event = to_event({"type": "mesh_waiting", "node": "n-tokyo", "name": "Tokyo", + "ahead": 2, "done": False, "response": "r"}) + assert event == QueuedEvent(ahead=2, node="n-tokyo", name="Tokyo") + + def test_a_reroute_is_flagged(self): + event = to_event({"type": "mesh_waiting", "node": "n", "name": "Tokyo", + "ahead": 0, "rerouting": True, "done": False}) + assert isinstance(event, QueuedEvent) and event.rerouting + + def test_another_members_model_is_named(self): + event = to_event({**TERMINAL, "model": "gemma4:12b", "served_by": { + "node": "n-tokyo", "name": "Tokyo", "label": "Tokyo GPU", + "model": "gemma4:12b", + "route": [{"node": "n-lis", "name": "Lisbon"}, + {"node": "n-tokyo", "name": "Tokyo"}]}}) + assert event.model == "gemma4:12b" + assert event.served_by is not None + assert event.served_by.name == "Tokyo" + assert event.served_by.route == ["Lisbon", "Tokyo"] + assert event.mesh_fallback is None + + def test_a_fallback_says_what_was_out_of_reach(self): + event = to_event({**TERMINAL, "mesh_fallback": { + "node": "n-chi", "name": "Chicago", "label": "", "model": "llama3:70b", + "reason": "unreachable", "self_name": "Dili", + "answered_with": "gemma4:12b"}}) + assert event.mesh_fallback is not None + assert (event.mesh_fallback.name, event.mesh_fallback.reason, + event.mesh_fallback.answered_with) == ( + "Chicago", "unreachable", "gemma4:12b") + + def test_a_strict_expert_that_cannot_reach_its_model_is_an_error(self): + event = to_event({"type": "stream", "message": {"content": ""}, "done": True, + "error": "Chicago is unreachable", + "mesh_unavailable": {"node": "n-chi", "name": "Chicago"}, + "model": "llama3:70b", "response": "r"}) + assert event.error == "Chicago is unreachable" + assert event.mesh_unavailable == {"node": "n-chi", "name": "Chicago"} + + +class TestToolOutputs: + def test_data_artifacts_and_images_are_carried(self): + event = to_event({**TERMINAL, + "data_artifacts": [{"id": "a1", "kind": "chart"}, "junk"], + "generated_images": [{"url": "/img/1.png"}]}) + assert event.data_artifacts == [{"id": "a1", "kind": "chart"}] + assert event.generated_images == [{"url": "/img/1.png"}] + + def test_a_cited_recording_passage_keeps_its_time(self): + event = to_event({**TERMINAL, "source_docs": [{ + "uid": "d", "name": "call.m4a", "cosine_dist": 0.2, + "split_uid": "s-9", "start_ms": 61000, "end_ms": 64500}]}) + doc = event.source_docs[0] + assert (doc.split_uid, doc.start_ms, doc.end_ms) == ("s-9", 61000, 64500) + + class TestGuestStrippedFields: def test_absent_source_docs_are_an_empty_list_not_none(self): # Guests never receive source_docs. A None here would make every