Metadata-Version: 2.4
Name: castleguard_sdk
Version: 0.33
Summary: A Python SDK for interacting with CastleGuard APIs
Author: Ravi Ramsaran
Author-email: ravi.ramsaran@nextria.ca
Description-Content-Type: text/markdown
Requires-Dist: requests
Requires-Dist: aiohttp
Dynamic: author
Dynamic: author-email
Dynamic: description
Dynamic: description-content-type
Dynamic: requires-dist
Dynamic: summary

# CastleGuard Python SDK

The CastleGuard Python SDK provides a convenient interface to interact with CastleGuard's API. This SDK allows you to authenticate, log messages, interact with the chatbot, translate text, perform named entity recognition (NER), transcribe audio files, manage document collections, work with vision-based models, and more.

## Features

- **Authentication**: Authenticate with an API key (no session required) or with a username and password.
- **Logging**: Send log messages to CastleGuard's logging endpoint.
- **Chatbot Integration**: Chat with CastleGuard's built-in models or a third-party LLM, with or without document collections; create, list, rename and archive chats, attach collections, files and AI Specialists, run completions directly or as background jobs you can wait for or cancel, and use the coder chat routes (`cg.coder`).
- **Translation**: Translate texts and documents between the configured languages (one target or a language group), translate files already in a collection, download originals, translations, PDF previews and batch ZIPs, list, group and delete translations, and manage languages and language groups.
- **Named Entity Recognition (NER)**: Perform NER on text to extract important entities.
- **Vision**: Perform image-based tasks using a vision model by uploading an image with a descriptive prompt.
- **Transcription**: Transcribe uploaded audio and video files or files already in a collection, list and group transcriptions, and download transcripts and SRT subtitles.
- **Anonymizer**: Anonymize documents by entity type (`EntityType`), a regular expression, or both, wait for the jobs, list and page through them, download the original, the anonymized file or a PDF preview, and delete them.
- **Collections**: Create, find, list, update and delete document collections, reindex them or move them to another RAG version, rebuild the vector index, and upload, list, resync, download and delete their documents.
- **Collection Windows files**: Add the files of a Windows folder to a collection, read under a Windows account, list them, resync them with their folders, and delete them.
- **SharePoint connections**: List the SharePoint Online and SharePoint Server connections and test a site, library or folder URL through them; administrators create, update and delete them.
- **Collection SharePoint sources**: Add a SharePoint site, library or folder to a collection, wait for its sync and file processing, list its files, download them or preview them as PDF, resync it, and delete files or the whole source.
- **Collection websites**: Add websites to a collection one at a time or from a .txt/.csv/.xlsx list of URLs, wait for their indexing, download their extracted text, rename, re-schedule or re-index them, and delete them.
- **AI Specialists**: Create, list, get, update and delete AI Specialists (one or several at once), upload, list, download and delete their documents, wait for the documents to be processed, and start training a specialist on them.
- **Heartbeat**: Fetch the health status of various system components.
- **Text Extraction**: Extract paragraphs from raw text or extract text from a document file.
- **Text Embedding**: Generate an embedding for a piece of text.
- **Third-party LLMs**: Register models at outside providers (OpenAI, Ollama, ...), list their model types and models, test connections, and list, update and delete them.
- **Evia training**: Manage the Evia model's checkpoints, acronyms and acronym imports, the processing queue and windows, the recurring training schedule, and training runs (see Evia Training below).
- **Helpers**: Split text into chunks, process a PDF with the RAG service, and convert a PDF to a Word document.

## Installation

Install the package via pip:

```bash
pip install castleguard-sdk
```

## Usage

### Initialization

The `CastleGuard` class supports two authentication modes. Pick one — you do not
need both.

**API key (recommended for services and integrations):**

```python
from castleguard_sdk import CastleGuard

cg = CastleGuard(base_url='https://your-castleguard-url', api_key='your-api-key')
```

**Username and password:**

```python
from castleguard_sdk import CastleGuard

cg = CastleGuard(base_url='https://your-castleguard-url', username='your-username', password='your-password')
```

`base_url` may be given with or without the API version. If no `/api/vN` suffix
is present, `/api/v1` is appended (override with `default_version='v2'`).

### Authentication

| Mode | Constructor arguments | Token exchange on init | Session |
| --- | --- | --- | --- |
| API key | `api_key=...` | None | Stateless |
| Username/password | `username=...`, `password=...` | `POST /auth/token` | Bearer token held for the life of the instance |

**API key mode.** The key is used directly as the credential and sent as
`Authorization: Bearer <api_key>` on every request. No call to `/auth/token` is
made, so there is no session to establish, expire, or refresh. This makes the
SDK usable from short-lived processes, serverless functions, and request
handlers where a login round-trip per instance would be wasteful.

**Username/password mode.** The SDK calls `/auth/token` during `__init__` and
holds the returned token on the instance. The token is not refreshed
automatically; construct a new `CastleGuard` instance if it expires.

Notes that apply to both modes:

- `api_key` takes precedence. If you pass `api_key` alongside `username` and
  `password`, the username and password are ignored and no token exchange
  happens.
- The constructor raises `ValueError` when credentials are missing. If you pass
  neither an `api_key` nor a complete username/password pair, no client is
  handed back, so a half-configured object can never reach your call sites.
- A client that holds no token sends **no** `Authorization` header, rather than
  a literal `Bearer None`. In username/password mode the token exchange happens
  in `__init__`, so check `cg.token` before relying on the client: if it is
  `None`, the credentials were rejected or the tenant was unreachable.
- Keep credentials out of source control. Read them from the environment:

  ```python
  import os
  from castleguard_sdk import CastleGuard

  cg = CastleGuard(
      base_url=os.environ['CASTLEGUARD_BASE_URL'],
      api_key=os.environ['CASTLEGUARD_API_KEY'],
  )
  ```

### Verifying your authentication setup

Four scripts in [`python/examples`](../examples) check that authentication is
wired up correctly — an offline check and a live smoke test for each credential
type.

The offline checks need no credentials and make no network calls. They stub the
HTTP layer and assert the behaviour described above:

```bash
python examples/api-key-offline-check.py
python examples/password-offline-check.py
```

`api-key-offline-check.py` covers API key mode: that it performs no token
exchange, that the key is sent as a bearer credential, and that it takes
precedence over a username and password.

`password-offline-check.py` covers username/password mode: that `__init__`
POSTs `{"account": ..., "password": ...}` to `/auth/token`, that the returned
token is stored and sent as `Authorization: Bearer <token>`, that rejected
credentials, a token-less response, and a network error each leave `token` as
`None` rather than raising, and that missing credentials raise `ValueError`.
Lines it prints as `GAP` are known weaknesses that are reported without failing
the run; there are none open at the moment.

The live smoke tests hit a real tenant and read credentials from the
environment:

```bash
export CASTLEGUARD_BASE_URL='https://your-castleguard-url'

export CASTLEGUARD_API_KEY='your-api-key'
python examples/api-key-test.py

export CASTLEGUARD_USERNAME='your-account'
export CASTLEGUARD_PASSWORD='your-password'
python examples/password-test.py
```

`api-key-test.py` calls `heartbeat()` with an API key and no username or
password, checks that `whoami()` reports the `ApiKey` scheme, and confirms that
an invalid key is rejected. `password-test.py` calls `heartbeat()` with an
account and password and no API key, checks that `whoami()` reports the
`Bearer` scheme, and confirms that a wrong password and an unknown account are
both rejected.

### Who am I

Check which account, roles and credential the server sees for this client:

```python
identity = cg.whoami()
print(identity['accountSid'], identity['authScheme'], identity['roles'])
```

##### Returns:
- **Dictionary** with `accountSid`, `username`, `authScheme` (`"ApiKey"` or
  `"Bearer"`), `apiKeyLabel` and `apiKeyExpiresAt` (ISO-8601 UTC; both `None`
  for a password sign-in, and the expiry `None` for a key that never expires),
  and `roles`. `None` if the request fails.

Run the live tests against a test tenant, not production.

### Logging

Log a message with a specific log level (default is 1):

```python
cg.log("This is a log message", logLevel=2)
```

### Chatbot Interaction

Send a prompt to the chatbot and get a response:

```python
response, chat_id = cg.chat("What is the weather today?")
print(response)
```

You can also chat within the context of a document collection:

```python
response, chat_id = cg.chat_with_collection("Summarize the document", collection_id="your-collection-id")
print(response)
```

`model` (on `chat`, `send_message_to_chat`, `chat_completion`, `submit_completion` and the others below) is `"wizard"`, `"tkm"` (administrators only; 403 otherwise), `"default"`, or the id, as a string, of a third-party LLM you can use: your own or a public one, not archived. Third-party LLM ids come from `get_third_party_llms_paged`; any other id gives 400 `unknown_model`. Leave it out (`None`) and the chat's saved model is used, with the same checks. `list_models()` lists the built-in models, each with `id`, `name`, `created`, `ownedBy`, `isDefault` and `capabilities`; `get_model("wizard")` returns one (`None` for an unknown one, 404 `unknown_model`).

#### Chats

```python
chat_id = cg.create_chat("Q3 review", collection_id=7, model="wizard")   # the id; None on 400/403/404
chat = cg.create_chat_with_details("Q3 review")                         # the whole chat
cg.set_chat_model(chat_id, "12")       # the chat, before it has messages, attachments or AI specialists (409 after)

page = cg.list_chats(page=1, page_size=20, name="review", collection_id=7)   # {"items", "total", "page", "pageSize"}
chats = cg.get_all_chats(collection_id=7)                               # every chat, newest first
chat = cg.get_chat(chat_id)                                             # None: 404 chat_not_found, 410 chat_expired
messages = cg.get_chat_messages(chat_id)                                # [{"id", "chatMessage", "isBot", "createdOn", …}]
cg.rename_chat(chat_id, "Q3 review (final)")                            # True if renamed
cg.get_suggested_question(chat_id)                                      # {"enabled", "question"}; one LLM request per call

cg.delete_chat(chat_id)                                                 # archives it; True also when already archived
cg.archive_chats([id1, id2])                                            # {"archivedIds": [...]}; IDs that aren't yours are skipped
```

- `set_chat_model` takes `"wizard"`, `"tkm"` or a third-party LLM id; vision and coder cannot be saved on a chat (400). Set it right after creating the chat.
- `get_suggested_question` returns `enabled: False` when an administrator has turned suggestions off, and `question: None` before the first answer.
- `archive_chats` returns `None` when none of the IDs is yours (404 `chat_not_found`), and without a request when no ID is given.

#### Attachments and AI specialists

```python
cg.attach_collections_to_chat(chat_id, [7, 8])                  # the chat; 400 invalid_state if files are attached
cg.attach_file_to_chat(chat_id, "./notes.pdf")                  # a path, bytes or a binary file object
cg.attach_file_to_chat(chat_id, csv_bytes, file_name="data.csv")
cg.remove_chat_attachment(chat_id, attachment_id)               # one file or collection, by its id
cg.add_ai_specialists_to_chat(chat_id, [3])                     # True if added
cg.remove_ai_specialists_from_chat(chat_id, [3])                # True if removed
```

A chat holds either collections or files, not both (400 `invalid_state`). Files can be docx, xlsx, pptx, pdf, txt, md, json, csv, srt, or a .zip of them; a zip with any unsupported file is refused whole, with nothing stored (400 `validation_failed`, with `details.unsupportedFiles` and `details.supportedFileFormats`), and a file over the size limit gives 413 `payload_too_large`. A file given as bytes, or as a file object without a name, needs `file_name`.

#### Completions

```python
answer = cg.chat_completion(chat_id, "What changed since Q2?", chat_options=ChatOptions(max_tokens=256))
answer = cg.chat_completion_with_file(chat_id, "Summarize: [ATTACHMENT_CONTENT]", "./minutes.txt")
print(answer["botMessage"]["chatMessage"])                      # both return {"userMessage", "botMessage"}
```

`chat_completion` answers in one of your chats, with its history and attachments; `store_in_db=False` sends `X-Persist-To-User-Store: false`, so nothing is stored. It returns `None` for 400 `unknown_model` or `validation_failed`, 403 (tkm), 404/410 (the chat), 502 `inference_failed` and 504 `inference_timeout`. `chat_completion_with_file` takes a .txt file only and replaces `[ATTACHMENT_CONTENT]` in the prompt with its text.

For callers that cannot hold a connection open, submit the completion and poll it:

```python
job_id = cg.submit_completion("Draft the summary", chat_id=chat_id)   # same arguments as chat()
state = cg.get_completion_status(job_id)    # {"id", "status": queued|running|succeeded|failed|cancelled, "result" or "error", …}
answer = cg.wait_for_completion(job_id, poll_interval=5, timeout=300)  # the result, or None if it failed, was cancelled or timed out
cg.cancel_completion(other_job_id)          # the job, status "cancelled"; None (409) if it already finished
```

`submit_completion` returns `None` when `stream` is set (400), the key has no account (400), for tkm as a non-administrator (403), or an unknown collection or AI specialist (404). A job that is not yours is 404.

#### Coder chat

`cg.coder` has every chat method above, from `chat` to `wait_for_completion`, sent to `api/v1/coder-chat-completion`, which has the same routes and rules:

```python
coder_chat_id = cg.coder.create_chat("Refactoring")
answer = cg.coder.chat_completion(coder_chat_id, "Rewrite this loop as a comprehension: …")
cg.coder.archive_chats([coder_chat_id])
```

### Translation

### 1. Instant Text Translation
Translate a single text with `translate_text`:

```python
translated_text = cg.translate_text("Hello, how are you?", source_lang="en", target_lang="fr")
print(translated_text)
```

#### Parameters
- **text**: The text string to be translated.
- **source_lang**: Source language code.
- **target_lang**: Target language code.

#### Returns
- The translated text, an empty string if the server returned none, or `None` if an error occurs.

### 2. Multiple Text Translations
Translate multiple texts simultaneously with `translate_texts`:

```python
texts = ["Hello", "Goodbye"]
source_langs = ["en", "eng"]
target_langs = ["fr", "deu"]
translated_texts = cg.translate_texts(texts, source_langs, target_langs)
print(translated_texts)
```

#### Parameters
- **texts**: List of text strings to translate.
- **source_langs**: List of source language codes, one for each text.
- **target_langs**: List of target language codes, one for each text.

#### Returns
- A list of dictionaries, where each dictionary contains:
  - **originalText**: The original text provided.
  - **translatedText**: The translated text.
  - **sourceLanguageCode**: The source language code.
  - **targetLanguageCode**: The target language code.
  
  Returns `None` if an error occurs.


**Note**: The lists for `texts`, `source_langs`, and `target_langs` must be of equal length.

### 3. Document Translation
Upload a document (`docx`, `xlsx`, `pptx`, `pdf`, `txt`, `json`, `csv` or `srt`) and wait until it is translated:

```python
document_id = cg.translate_document("path/to/file", source_lang="en", target_lang="fr")
print(f"Document ID: {document_id}")
```

#### Parameters
- **file_path**: Path to the file to be translated. This path can be relative to the file where this function is called (e.g., `.` or `../`) or an absolute path.
- **source_lang**: Source language code.
- **target_lang**: Target language code.
- **keep_original**: Keep the original pages in the translated document (default is `False`). Before this release the SDK sent this option under the wrong name, so the server always used `False`; the default keeps that behaviour.

The source and target must be a pair the server allows: `en` translates only to `fr` and `fr` only to `en`; any other configured source translates to any other configured language except `en` and `fr`. `get_target_languages(source_lang)` lists them. The defaults, `en` to `fr`, are that EVIA pair; before this release `translate_document` sent the languages in a way the server ignored, so it always translated `eng` to `fra` whatever was passed.

#### Returns
- A `document_id` for the created document. `None` if an error occurs or the translation failed, also when the file is over the server's `MAXIMUM_UPLOAD_FILE_SIZE` (413).

### 4. Translate Multiple Documents
Translate multiple documents:

```python
file_paths = ["/path/tofile1.txt", "/path/tofile2.docx"]
document_ids = cg.translate_documents(file_paths, source_lang="en", target_lang="fr")
print(f"Document IDs: {document_ids}")
```
#### Parameters
- **file_path_list**: List of file paths to be translated. Like the single-file method, each path can be relative to the file where this function is called (e.g., `.` or `../`) or an absolute path.

- **source_lang**: Source language code.
- **target_lang**: Target language code (default `"fr"` when no language group is given).
- **keep_original**: Keep the original pages in the translated documents (default is `False`).
- **target_language_group_id**: Translate into every language of this language group instead (see `get_target_languages`). Cannot be combined with `target_lang`, and needs a source other than `en` or `fr`. Each file's translations share a `batchId`.

The server saves none of the files if any of them is empty or of an unsupported type, or if one cannot be saved (500). It takes at most `MAXIMUM_UPLOAD_FILE_COUNT` files per call (400) and refuses a file over `MAXIMUM_UPLOAD_FILE_SIZE` (413); the method then returns `None`.

### Returns

The function returns a list of dictionaries, each corresponding to a document. Each dictionary contains the following keys:

- **`document_id`**: A unique identifier for the document.
- **`status`**: The outcome of the translation, either `"fail"` or `"success"`.
- **`status_code`**: A numerical status code (e.g., `2` for finished).
- **`status_name`**: A descriptive status name (e.g., `"finished"`).

In case of an error, the function will return `None`.


### 5. Downloading Documents
#### Original Document
```python
saved_original_path = cg.download_translation_original(document_id, save_path="./downloads", file_name="original_file.txt")
```

#### Translated Document
```python
saved_translated_path = cg.download_translation_translated(document_id, save_path="./downloads", file_name="translated_file.txt")
```

#### Parameters
- **document_id**: The ID of the document.
- **save_path**: The path where the document will be saved. This can be either a relative path (e.g., `.` or `../`) or an absolute path. If not provided, the file will be saved in the current working directory by default.
- **file_name**: Name for the saved file. If it has no extension, the downloaded file's extension is added. If not provided, the server's name is used: the original file name, or `<name>_translated_<language>.<ext>`.

#### Returns
- The **path** where the document was saved. If any error occurs, it will return `None`. The translated file is available only once the translation has finished successfully; before that the server refuses and this returns `None`.

#### Document as PDF
```python
preview_path = cg.download_translation_preview(document_id, save_path="./downloads")                   # the translation
original_preview = cg.download_translation_preview(document_id, save_path="./downloads", translated=False)
```
Saves the translated file, or the original, converted to PDF, named `<name>.pdf` by default. Call it twice for a side-by-side view.
### 6. Get Translation Document Status

#### Single Document
Check the status of a single document:

```python
status_code = cg.get_translation_document_status(document_id)
print(status_code)
```

#### Parameters
- **document_id**: The ID of the document.

#### Returns
- The **status code** (e.g., "success", "failed") if successful, `None` if an error occurs.

#### Multiple Documents
Check the status of multiple documents at once:

```python
document_ids = [document_id1, document_id2]
statuses = cg.get_translation_documents_status(document_ids)
print(statuses)
```

#### Parameters
- **document_ids**: A list of document IDs to check the status for.

It waits until every document has finished, however long that takes. An ID the server does not know is logged and left out instead of waited for; the same holds for `get_collection_translations_status`.

#### Returns
- A **list of dictionaries**, each containing:
  - `"document_id"`: Unique identifier for the document.
  - `"status"`: Translation outcome, either `"fail"` or `"success"`.
  - `"status_code"`: Numerical status code (e.g., `2` for finished, `0` for queued).
  - `"status_name"`: Status description (e.g., `"finished"`, `"queued"`).
- In case of an error, the function will return `None`.

### 7. Uploaded Documents
These return the documents as the server sends them, without waiting for them to be processed; follow each through its `"jobDetail"`.

```python
documents = cg.get_translation_documents()                       # all, one entry per target language
page = cg.get_translation_documents_paged(page=1, page_size=20, search="report")
document = cg.get_translation_document(document_id)
documents = cg.get_translation_documents_by_ids([document_id1, document_id2])

cg.delete_translation_document(document_id)                      # True if deleted
cg.delete_all_translation_documents()                            # uploaded documents only
deleted = cg.delete_translation_documents([document_id1, document_id2])   # IDs that are not found are skipped
```

Paged methods return a dict with `"items"`, `"page"`, `"pageSize"` and `"total"`. `page_size` defaults to 50 on the server and is clamped to 200. In a page, a language-group translation is one batch row (`"isBatch"`), with its per-language documents in `"batchDocuments"`.

### 8. Translating Files Already in a Collection
Queue documents of a collection for translation into one language or a language group. Each file is translated from its detected language. This does not wait:

```python
documents = cg.translate_collection_files(collection_id, [file_id1, file_id2], target_lang="fra", subfolder_name="translations")
collection_document_ids = [d["id"] for d in documents]
statuses = cg.get_collection_translations_status(collection_document_ids)
```

Set exactly one of `target_lang` and `target_language_group_id`, and at most one of `subfolder_name` and `file_name_suffix`; otherwise the method returns `None` without a request. `overwrite_existing` defaults to `True` on the server.

Collection translations have their own IDs and methods, mirroring those of uploaded documents:

```python
cg.get_collection_translation(collection_document_id)
cg.get_collection_translations([collection_document_id1, collection_document_id2])
cg.get_collection_translation_status(collection_document_id)
cg.download_collection_translation_original(collection_document_id, save_path="./downloads")   # named as the collection file, "originalFileName"
cg.download_collection_translation_translated(collection_document_id, save_path="./downloads")
cg.download_collection_translation_preview(collection_document_id, save_path="./downloads")
cg.delete_collection_translation(collection_document_id)         # the source file stays in the collection
deleted = cg.delete_collection_translations([collection_document_id1, collection_document_id2])
cg.delete_all_collection_translations()                          # collection translations only; uploads are kept
```

### 9. Lists, Groups and Language-Group Batches
These cover both uploaded documents and collection translations:

```python
page = cg.list_translations(page=1, page_size=20, search="report")

groups = cg.get_translation_collection_groups(include_uploads=True, documents_per_group=10)
page = cg.get_translation_collection_group_documents(collection_id, page=2)
groups = cg.get_translation_subfolder_groups(include_uploads=True, documents_per_group=10)
page = cg.get_translation_subfolder_group_documents("translations")   # None for the root folder

batch = cg.get_translation_batch(batch_id)                       # a language-group translation, with "batchDocuments"
zip_path = cg.download_translation_batch(batch_id, save_path="./downloads")   # the finished ones, as <date>_<name>.zip
cg.delete_translation_batch(batch_id)                            # every language of the batch
```

`delete_translation_batch` returns `False` when the batch was only partly deleted (500); the log then has `details.deleted`, with the `documentIds` and `collectionDocumentIds` that were removed.

### 10. Deleting Translations

```python
deleted = cg.delete_translations(document_ids=[1, 2], collection_document_ids=[3])
# -> {"documentIds": [1, 2], "collectionDocumentIds": [3]}

cg.delete_all_translations()                                     # uploaded and collection translations
cg.delete_translation_collection_groups(collection_ids=[5], include_uploads=False)
cg.delete_translation_subfolder_groups(subfolder_names=["translations"], include_root_folder=False, include_uploads=False)
```

Collection source files are never deleted. The bulk deletes skip IDs that are not found and return a dict like the one above. With nothing selected, `delete_translations`, `delete_translation_documents` and `delete_collection_translations` return `None` and the group deletes return `False`, without a request.

### 11. Languages and Language Groups

```python
cg.get_translation_availability()                # True when the service is ready
cg.get_supported_languages()                     # every language the server knows
cg.get_configured_languages()                    # the ones enabled on this server
cg.is_supported_language("deu")
cg.get_language_display_name("deu")
cg.get_target_languages("eng")                   # languages and groups "eng" can be translated into
cg.get_language_groups()
```

Targets and groups are dicts with `id`, `type` (`0` language, `1` language group), `displayName`, `description`, `iconUrl`, `enabled` and `languageCodes`. A group's `id` is a string; pass `int(group["id"])` as `target_language_group_id`.

Administrators can also manage languages and groups:

```python
cg.set_language_enabled("deu", True)             # for everyone
group = cg.create_language_group("Europe", ["deu", "spa"], description="EU languages")
group = cg.update_language_group(int(group["id"]), "Europe", ["deu", "spa", "ita"])
group = cg.set_language_group_icon(int(group["id"]), "./icon.svg")   # svg, png, jpg or jpeg, at most 1 MB
group = cg.remove_language_group_icon(int(group["id"]))
cg.delete_language_group(int(group["id"]))       # archives it
```

### Named Entity Recognition (NER)

Perform NER on text to extract entities:

### 2. Process Text
```python
entities = cg.named_entity_recognition("John Doe works at Google in Mountain View.")
print(entities)
```
#### Parameters
- **text**: The input text for performing NER. The function will analyze the provided text to identify entities such as names, locations, dates, and other key terms.

#### Returns
- A dictionary containing the original text and a list of extracted entities. The entities are structured as dictionaries with:
  - `entityType`: The type of entity (e.g., "PERSON", "GPE", "DATE", "ORG").
  - `value`: The actual entity extracted from the text.
  - `startPosition`: The start index of the entity in the text.
  - `endPosition`: The end index of the entity in the text.

#### Process Multiple Texts
To process multiple texts at once:

```python
texts = ["John Doe works at Google.", "Jane Smith is a doctor in New York."]
entities_list = cg.named_entity_recognition_multiple_text(texts)
print(entities_list)
```

#### Parameters
- **text_list**: A list of input texts for performing NER on multiple documents.

#### Returns
- A list of dictionaries, where each dictionary contains:
  - The `text` field with the original text.
  - The `entities` field, which is a list of extracted entities for that text.

#### Process Documents
Upload documents (docx, xlsx, pptx, pdf, txt, json, csv, srt) to have their entities extracted by a background job, wait for the job, then download the results:

```python
doc = cg.upload_ner_document("./report.pdf")            # or upload_ner_documents([...])
docs = cg.wait_for_ner_documents([doc["id"]], poll_interval=15, timeout=1800)
if docs and docs[0]["resultAvailable"]:
    cg.download_ner_result(doc["id"], "./downloads")          # {name}_result.json
    cg.download_ner_annotated_text(doc["id"], "./downloads")  # {name}_output.txt
    cg.download_ner_preview(doc["id"], "./downloads")         # annotated text as PDF
    cg.download_ner_original_document(doc["id"], "./downloads")
cg.delete_ner_document(doc["id"])
```

Each document carries `status` (`Queued`, `Running`, `Finished`, ...), `progressPercent`, `failed`, `statusDetails` and `resultAvailable`. Also available: `get_ner_document`, `get_ner_documents`, `get_ner_documents_paged(page, page_size)`, `delete_ner_documents(ids)` (returns the IDs actually deleted) and `delete_all_ner_documents()`. Methods return `None` (or `False` for deletes) on failure and log the reason; the result, annotated text and preview are refused until the job has finished successfully.
  
### Vision

Upload an image file and provide a descriptive prompt to perform vision-based tasks:

```python
vision_result = cg.vision(prompt="Detect objects in this image", file_path="/path/to/image.jpg")
print(vision_result)
```

### Transcription

`AudioTask` and `DiarizationConfig` hold the values of the `task_type` and `diarization_config` parameters:

```python
from castleguard_sdk.transcription import AudioTask, DiarizationConfig
```

- **AudioTask**: `TRANSCRIBE` (1), `DIARIZE` (2, the default). Only `DIARIZE` produces SRT captions; downloading the SRT of a `TRANSCRIBE` job fails.
- **DiarizationConfig**: `GENERAL` (1, the default), `MEETING` (2), `TELEPHONIC` (3).

#### 1. Transcribe a File
Upload an audio or video file and wait until it is transcribed:

```python
document_id = cg.transcribe("/path/to/audiofile.mp3", diarization_config=DiarizationConfig.MEETING)
```

#### Parameters
- **file_path**: Path to the file to transcribe. This path can be relative to the file where this function is called (e.g., `.` or `../`) or an absolute path.
- **diarization_config**: (Optional) A `DiarizationConfig` value. Default is `GENERAL`.
- **task_type**: (Optional) An `AudioTask` value. Default is `DIARIZE`.

#### Returns
- A `document_id` for the created document. `None` if an error occurs or the transcription failed, also when the file is over the server's `MAXIMUM_UPLOAD_FILE_SIZE` (413).


#### 2. Transcribe Multiple Documents
Upload several files in one request and wait until all are transcribed. The server saves none of them if any file is empty or of an unsupported type, or if one cannot be saved (500). It takes at most `MAXIMUM_UPLOAD_FILE_COUNT` files per call (400) and refuses a file over `MAXIMUM_UPLOAD_FILE_SIZE` (413); the method then returns `None`:

```python
file_paths = ["/path/to/audiofile1.mp3", "/path/to/audiofile2.mp3"]
transcription_status = cg.transcribe_multiple(file_paths)
print(transcription_status)
```

#### Parameters
- **file_path_list**: List of paths to the files to transcribe. Like the single-file method, each path can be relative to the file where this function is called (e.g., `.` or `../`) or an absolute path.
- **diarization_config**: (Optional) A `DiarizationConfig` value. Default is `GENERAL`.
- **task_type**: (Optional) An `AudioTask` value. Default is `DIARIZE`.

### Returns

The function returns a list of dictionaries, each corresponding to a document. Each dictionary contains the following keys:

- **`document_id`**: A unique identifier for the document.
- **`status`**: The outcome of the transcription, either `"fail"` or `"success"`.
- **`status_code`**: A numerical status code (e.g., `2` for finished).
- **`status_name`**: A descriptive status name (e.g., `"finished"`).

In case of an error, the function will return `None`.

#### 3. Downloading Documents
#### Original Document
```python
saved_file_path = cg.download_transcription_original_file(document_id, save_path="./transcriptions")
```

#### SRT Document
```python
saved_srt_path = cg.download_srt_file(document_id, save_path="./subtitles")
```

#### Transcription Document
```python
transcription_text_path = cg.download_transcription_file(document_id, save_path="./transcriptions")
```

#### Transcript as PDF
```python
preview_path = cg.download_transcription_preview(document_id, save_path="./transcriptions")
```
The server makes the PDF only once the transcription has finished successfully, for both task types; before that, or when the transcript is empty, this returns `None`.

#### Parameters
- **document_id**: The ID of the document.
- **save_path**: The directory where the document will be saved, created if missing. This can be either a relative path (e.g., `.` or `../`), resolved against the file where this function is called, or an absolute path. Default is that file's directory.
- **file_name**: Name for the saved file. If it has no extension, the extension of the downloaded rendition is added. If not provided, the server's name is used: the original file name, `<name>_transcript.txt`, `<name>_cc.srt` or `<name>_transcript.pdf`.

#### Returns
- The **path** where the document was saved. If any error occurs, it will return `None`.

#### 4. Download SRT Text Directly
To download the SRT text directly without saving it to a file, use `download_srt`:

```python
srt_text = cg.download_srt(document_id)
print(srt_text)
```

#### Parameters
- **document_id**: The ID of the transcription document.

#### Returns
- The **SRT text** if successful, `None` if an error occurs.


#### 5. Get Transcription Document Status

#### Single Document
Wait until a single document is processed:

```python
status_code = cg.get_transcription_document_status(document_id)
print(status_code)
```

#### Parameters
- **document_id**: The ID of the document.

#### Returns
- The **status code** (e.g., "success", "failed") if successful, `None` if an error occurs.

#### Multiple Documents
Wait until several documents are processed:


```python
document_ids = [document_id1, document_id2]
statuses = cg.get_transcription_documents_status(document_ids)
print(statuses)
```

#### Parameters
- **document_ids**: A list of document IDs to check the status for.

It waits until every document has finished, however long that takes. An ID the server does not know is logged and left out instead of waited for; the same holds for `get_collection_transcriptions_status`.

#### Returns
- A **list of dictionaries**, each containing:
  - `"document_id"`: Unique identifier for the document.
  - `"status"`: Transcription outcome, either `"fail"` or `"success"`.
  - `"status_code"`: Numerical status code (e.g., `2` for finished, `0` for queued).
  - `"status_name"`: Status description (e.g., `"finished"`, `"queued"`).
- In case of an error, the function will return `None`.

#### 6. Uploaded Documents
These return the documents as the server sends them, without waiting for them to be processed; follow each through its `"jobDetail"`.

```python
documents = cg.get_transcription_documents()                       # all uploaded documents
page = cg.get_transcription_documents_paged(page=1, page_size=20, search="meeting")
document = cg.get_transcription_document(document_id)
documents = cg.get_transcription_documents_by_ids([document_id1, document_id2])

cg.delete_transcription_document(document_id)                      # True if deleted
cg.delete_all_transcription_documents()                            # uploaded documents only
deleted = cg.delete_transcription_documents([document_id1, document_id2])   # IDs that are not found are skipped
```

Paged methods return a dict with `"items"`, `"page"`, `"pageSize"` and `"total"`. `page_size` defaults to 50 on the server and is clamped to 200. Every method returns `None` on failure, or `False` for the deletes that return `True` on success.

#### 7. Transcribing Files Already in a Collection
Queue audio or video documents of a collection for transcription. This does not wait:

```python
documents = cg.transcribe_collection_files(
    collection_id,
    [file_id1, file_id2],
    diarization_config=DiarizationConfig.GENERAL,
    subfolder_name="transcripts"
)
collection_document_ids = [d["id"] for d in documents]
statuses = cg.get_collection_transcriptions_status(collection_document_ids)
```

#### Parameters
- **collection_id**: The ID of the collection holding the files.
- **file_ids**: IDs of the collection documents to transcribe.
- **diarization_config**, **task_type**: As for `transcribe`.
- **subfolder_name**: (Optional) Subfolder the output is written to. Cannot be combined with `file_name_suffix`.
- **file_name_suffix**: (Optional) Suffix appended to the output file names. Cannot be combined with `subfolder_name`.
- **overwrite_existing**: (Optional) Overwrite existing output files. The server defaults to `True`.

#### Returns
- A list of collection transcription documents, one per file, or `None` on failure.

Collection transcriptions have their own IDs and methods, mirroring those of uploaded documents:

```python
cg.get_collection_transcription(collection_document_id)
cg.get_collection_transcriptions([collection_document_id1, collection_document_id2])
cg.get_collection_transcription_status(collection_document_id)
cg.download_collection_transcription_original_file(collection_document_id, save_path="./transcriptions")   # named as the collection file, "originalFileName"
cg.download_collection_transcription_file(collection_document_id, save_path="./transcriptions")
cg.download_collection_srt_file(collection_document_id, save_path="./subtitles")
cg.download_collection_transcription_preview(collection_document_id, save_path="./transcriptions")
srt_text = cg.download_collection_srt(collection_document_id)
cg.delete_collection_transcription(collection_document_id)         # the source file stays in the collection
deleted = cg.delete_collection_transcriptions([collection_document_id1, collection_document_id2])
cg.delete_all_collection_transcriptions()                          # collection transcriptions only; uploads are kept
```

#### 8. Listing All Transcriptions and Groups
These cover both uploaded documents and collection transcriptions:

```python
page = cg.list_transcriptions(page=1, page_size=20, search="meeting")

# Grouped by collection; uploaded files form their own group, first on page 1
groups = cg.get_transcription_collection_groups(include_uploads=True, documents_per_group=10)
page = cg.get_transcription_collection_group_documents(collection_id, page=2)

# Grouped by output subfolder
groups = cg.get_transcription_subfolder_groups(include_uploads=True, documents_per_group=10)
page = cg.get_transcription_subfolder_group_documents("transcripts")   # None for the root folder
```

Every group lists only its first `documents_per_group` documents (default 10, at most 200); page through the rest with the matching `..._group_documents` method.

#### 9. Deleting Transcriptions

```python
# By ID; IDs that are not found are skipped
deleted = cg.delete_transcriptions(document_ids=[1, 2], collection_document_ids=[3])
# -> {"documentIds": [1, 2], "collectionDocumentIds": [3]}

cg.delete_all_transcriptions()                                      # uploaded and collection transcriptions
cg.delete_transcription_collection_groups(collection_ids=[5], include_uploads=False)
cg.delete_transcription_subfolder_groups(subfolder_names=["transcripts"], include_root_folder=False, include_uploads=False)
```

Collection source files are never deleted. The bulk deletes skip IDs that are not found and return a dict like the one above. With nothing selected, `delete_transcriptions`, `delete_transcription_documents` and `delete_collection_transcriptions` return `None` and the group deletes return `False`, without a request.

### Anonymizer

#### 1. Anonymize Document
Upload a file for anonymization; the job runs on the server:

```python
document_id = cg.anonymize_document(
    '/path/to/file1.txt',
    [EntityType.ADDRESS.value],
)
```

#### Parameters
- **file_path**: Path to the file to anonymize. This path can be relative to the file where this function is called (e.g., `.` or `../`) or an absolute path.
- **options**: List of `EntityType` values to anonymize.
- **regex**: Regular expression to match and anonymize additional custom data.

Give `options`, a `regex`, or both: the server rejects an upload with neither.
#### Returns
- A `document_id` for the created document. `None` if an error occurs.

#### 2. Anonymize Multiple Documents
Upload several files and wait until every job has finished, checking every 15 seconds by default:
```python
documents_status = cg.anonymize_documents(
    ['/path/to/file1.txt', '/path/to/file2.txt',],
    [EntityType.PERSON.value, EntityType.WORK_OF_ART.value],
    "\\b2019\\b"
)

```

#### Parameters
- **file_paths**: List of paths to the files to anonymize. Like the single-file method, each path can be relative to the file where this function is called (e.g., `.` or `../`) or an absolute path.
- **options**: List of `EntityType` values to anonymize.
- **regex**: Regular expression to match and anonymize additional custom data. Give `options`, a `regex`, or both.
- **poll_interval**: Seconds between checks. Default is `15`.
- **timeout**: Seconds to wait in all, or `None` (the default) to wait indefinitely.
### Returns

The function returns a list of dictionaries, each corresponding to a document. Each dictionary contains the following keys:

- **`document_id`**: A unique identifier for the document.
- **`status`**: The job's status code (e.g. `"success"`).
- **`status_code`**: The job's numerical status (`2` once finished).
- **`status_name`**: The document's status name (e.g. `"Finished"`).

In case of an error, the function will return `None`.

### Entity Types for Anonymization
The following `EntityType` values are available for anonymization. You can specify a list of these entities when calling the anonymization methods:

```python
EntityType.ADDRESS
EntityType.CARDINAL
EntityType.COMPANY
EntityType.CRYPTO
EntityType.DATE
EntityType.DNS
EntityType.EMAIL
EntityType.EVENT
EntityType.FAC
EntityType.GPE
EntityType.IP
EntityType.LANGUAGE
EntityType.LAW
EntityType.LOC
EntityType.MONEY
EntityType.NORP
EntityType.ORDINAL
EntityType.ORG
EntityType.PERCENT
EntityType.PERSON
EntityType.PHONE
EntityType.PRODUCT
EntityType.QUANTITY
EntityType.TIME
EntityType.WORK_OF_ART
```

#### 3. Downloading Documents
#### Original Document
```python
saved_file_path = cg.download_anonymized_original_document(document_id, save_path="./downloads",file_name="original")
```
#### Anonymized Document
```python
saved_file_path = cg.download_anonymized_document(document_id, save_path="./downloads", file_name="anonymized")
```

#### Parameters
- **document_id**: The ID of the document.
- **save_path**: The path where the document will be saved. This can be either a relative path (e.g., `.` or `../`) or an absolute path. If not provided, the file will be saved in the current working directory by default.
- **file_name**: Name for the saved file. If not provided, it will be auto-generated based on the original file name and document type (e.g., 'original' or 'anonymized').

#### Returns
- The **path** where the document will be saved. If any error occurs, it will return `None`.

#### 4. Get Anonymized Document Status

These wait until the job has finished, checking every `poll_interval` seconds (default `15`), for at most `timeout` seconds (default `None`, i.e. no limit).

#### Single Document
Wait for a single document:


```python
document_status = cg.get_anonymized_document_status(document_id)
print(document_status)
```

#### Parameters
- **document_id**: The ID of the document.
- **poll_interval**, **timeout**: As above.

#### Returns
- The job's **status code** (e.g. `"success"`), or `None` if an error occurs.

#### Multiple Documents
Wait for several documents at once:


```python
document_ids = [document_id1, document_id2]
statuses = cg.get_anonymized_documents_status(document_ids)
print(statuses)
```

#### Parameters
- **document_ids**: A list of document IDs.
- **poll_interval**, **timeout**: As above.

#### Returns
- A **list of dictionaries**, each containing:
  - `"document_id"`: Unique identifier for the document.
  - `"status"`: The job's status code (e.g. `"success"`).
  - `"status_code"`: The job's numerical status (`2` once finished).
  - `"status_name"`: The document's status name (e.g. `"Finished"`).
- In case of an error, the function will return `None`.


#### 5. Uploads, Listing, Downloads and Deletes

List the entity types the server accepts as options. Pass their `label`, or the matching `EntityType` member:

```python
ner_options = cg.get_anonymizer_ner_options()   # [{"label": "PERSON", "description": "..."}, ...]
```

Upload one file, or several with the same options, and queue them for anonymization. Unlike `anonymize_document`, these return the whole queued document rather than its ID, and they do not wait. The job runs on the server, so wait for it, checking every 15 seconds by default:

```python
document = cg.upload_anonymizer_document(
    "./contract.docx",
    options=[EntityType.PERSON, "EMAIL"],   # EntityType members or labels, case-insensitive
    regex=r"\bACME-\d+\b"
)
documents = cg.upload_anonymizer_documents(
    ["./a.pdf", "./b.xlsx"],
    options=[EntityType.ORG]
)

documents = cg.wait_for_anonymized_documents(
    [d["id"] for d in documents],
    poll_interval=15,
    timeout=None,
    on_progress=lambda finished, total: print(f"{finished}/{total}")
)
```

The server refuses an upload, and the method returns `None` after logging why, when:
- a file is empty, too large, or not one of `docx`, `xlsx`, `pptx`, `pdf`, `txt`, `json`, `csv` or `srt` (`cg.ANONYMIZER_FILE_TYPES`)
- an option is unknown
- the regex is invalid or longer than 500 characters (`cg.ANONYMIZER_MAX_REGEXP_LENGTH`)
- neither options nor a regex are given

`regex` is a .NET regular expression, matched case-insensitively. `upload_anonymizer_documents` takes at most the server's upload count (10 by default) and saves nothing if any file is invalid. A path starting with `.` is resolved against the calling script's directory.

`wait_for_anonymized_documents` returns once every job has finished, successfully or not, so check each document's `failed` and `anonymizedAvailable` afterwards. `timeout` is in seconds (`None`: no limit), and `on_progress(finished, total)` is called after each check.

Get, list and page through your documents (newest first):

```python
document = cg.get_anonymized_document(document_id)
documents = cg.get_anonymized_documents()
page = cg.get_anonymized_documents_paged(page=1, page_size=50)
```

Download the original file, the anonymized file, or the anonymized file rendered as a PDF:

```python
original = cg.download_anonymized_original_document(document_id, save_path="./downloads")
anonymized = cg.download_anonymized_document(document_id, save_path="./downloads")
preview = cg.download_anonymized_preview(document_id, save_path="./downloads", file_name="contract")
```

A PDF is anonymized to DOCX. The anonymized download and the preview fail until the job has finished successfully. `save_path` defaults to `"."` and is created if missing. `file_name` defaults to:
- the uploaded file's name, for the original
- `{name}_anonymized.{ext}`, for the anonymized file
- `{name}_anonymized.pdf`, for the preview

A `file_name` without an extension gets the downloaded file's extension (`.pdf` for the preview).

Delete one document, several, or all of yours, with their original and anonymized files:

```python
deleted = cg.delete_anonymized_document(document_id)
deleted_ids = cg.delete_anonymized_documents([document_id1, document_id2])
deleted_all = cg.delete_all_anonymized_documents()
```

Documents are personal. A document of another account is treated as not found, and `delete_anonymized_documents` skips it.

#### Returns
- `get_anonymizer_ner_options`: a list of dicts with `label` and `description`, or `None` on failure.
- `upload_anonymizer_document`: the queued document; `upload_anonymizer_documents`: a list of them. `None` on failure.
- `get_anonymized_document`: the document with its job state (`status`, `progressPercent`, `failed`, `statusDetails` and `anonymizedAvailable`), or `None` if it was not found, belongs to another account, or the request failed. `status` is `"Finished"` once the job is done.
- `get_anonymized_documents`: a list of documents, or `None` on failure.
- `get_anonymized_documents_paged`: a dict with `items`, `page`, `pageSize` and `total`, or `None` on failure. `page` defaults to 1. `page_size` defaults to 50 on the server, with a maximum of 200.
- `wait_for_anonymized_documents`: the documents once all have finished, or `None` if one was not found, a request failed, or the wait timed out.
- The download methods: the path of the saved file, or `None` on failure.
- `delete_anonymized_document` and `delete_all_anonymized_documents`: `True` if deleted, `False` otherwise.
- `delete_anonymized_documents`: the list of IDs actually deleted, or `None` on failure.

### Collections

Create a collection, optionally with a specific LLM (a `value` from `get_collection_llm_options`). It returns the new collection's ID. When the third-party LLMs cannot be loaded, `get_collection_llm_options` lists only the built-in models:

```python
options = cg.get_collection_llm_options()        # each with value, text, disabled and disabledReason
collection_id = cg.create_collection(name="My Collection", description="A collection of legal documents")
collection_id = cg.create_collection("Contracts", "Legal documents", llm_option_id="12")
```

The name must be 3 to 50 characters; otherwise the server refuses it and `create_collection` returns `None`. With an `llm_option_id`, the server first loads its model list; if it cannot (502 `dependency_failed`), `create_collection` and `update_collection` return `None`. Without one, both work even when the third-party LLMs are down.

Read, find and list collections:

```python
collection = cg.get_collection(collection_id)    # counts, LLM, RAG status and indexing state
matches = cg.find_collections("contract")        # name contains the text, case-insensitive
everything = cg.get_collections()                # newest first
first_page = cg.get_collections(page=1, page_size=20)
page = cg.get_collections_paged(page=1, page_size=20, name="contract")   # {"items", "page", "pageSize", "total"}
```

In `find_collections` and `get_collections_paged`, `%` and `_` in the name act as wildcards on the server (any text and any one character). `get_collections` with paging returns only that page; use `get_collections_paged` when you need the total.

Edit a collection; only the fields you pass change:

```python
cg.update_collection(collection_id, display_name="Contracts 2024")
cg.update_collection(collection_id, description="")              # clears the description
cg.update_collection(collection_id, llm_option_id="")            # resets the LLM to the default
```

Changing the LLM removes the collection from your chats. Calling `update_collection` with nothing to change returns `None` without a request.

Archive collections. Archiving is a soft delete: a collection disappears from every list, while its documents stay. It is also removed from your chats, and its transcription and translation jobs are deleted for good:

```python
cg.delete_collection(collection_id)              # True if archived
cg.delete_collections([id1, id2])                # {"archivedIds": [...]}; IDs that aren't yours are skipped
```

#### Returns
- `get_collection`, `update_collection`: the collection as a dict with `id`, `displayName`, `description`, `createdOn`, `llmOptionId`, `llmOption`, `ragVersion`, the document, Windows file, website and SharePoint counts and sizes, `ragStatus` and `isIndexing`. `None` if it does not exist, belongs to another account, or the request fails.
- `get_collections`, `find_collections`: a list of such dicts; `get_collections_paged`: a dict with `items`, `page`, `pageSize` and `total`. `None` on failure.
- `delete_collection`: `True` if archived, `False` otherwise. `delete_collections`: a dict with `archivedIds`, or `None` on failure, also when none of the IDs is yours.

Re-index one of your collections, or move it to the other RAG version (which re-queues all its content):

```python
queued = cg.reindex_collection(collection_id)                      # {"totalQueued", "documentsQueued", …, "errors"}
queued = cg.move_collection_rag_version(collection_id, "2.0")      # refused while items are still processing…
queued = cg.move_collection_rag_version(collection_id, "2.0", force=True)   # …unless forced, by an administrator
```

- Both return `None` while items of the collection are still processing (409 `invalid_state`; jobs judged stuck do not count), for a collection that is missing or another account's (404), and when the server cannot check that (500).
- `force=True` is for administrators only; anyone else gets `None` (the log shows the server's `forbidden`). A move also returns `None` for the same or an unknown version (400), a version the server cannot serve yet (409), a failed readiness check (502), or when the server cannot check the collection's items or save its new version (500).

Administrators can rebuild the RAG 1.0 vector search index shared by every collection. It does not re-extract or re-embed anything:

```python
result = cg.rebuild_vector_index()                                 # {"rebuilt": True, "message": "…"}
```

`rebuilt` is `False` when there are too few embeddings to build the index. It returns `None` for a non-administrator (403), while RAG 2.0 is active, which has no such index (409 `invalid_state`), or when the rebuild fails (500).

#### Collection Documents

Upload a document to the collection and wait until it is processed, however long that takes. A zip adds every supported file inside it, and `True` means every one of them was processed successfully:

```python
upload_success = cg.upload_to_collection(collection_id, "/path/to/document.pdf")
print(f"Upload successful: {upload_success}")
```

Upload without waiting, one file (or zip) or several at once, then wait for the documents:

```python
documents = cg.upload_collection_document(collection_id, "./contracts.zip")        # the added documents
outcome = cg.upload_collection_documents(collection_id, ["./a.pdf", "./b.docx"])  # {"documents", "skipped"}
for skipped in outcome["skipped"]:
    print(skipped["fileName"], skipped["code"], skipped["reason"])
statuses = cg.get_collection_documents_status(collection_id, [d["id"] for d in outcome["documents"]])
```

- A zip's folders are flattened. Files in a zip that are unsupported or already in the collection are left out without notice by `upload_collection_document`; `upload_collection_documents` lists every skipped file with a `code`: `empty`, `no_extension`, `unsupported_type`, `already_exists`, `archive_too_large`, `invalid_archive` or `failed`.
- The server checks a zip's contents before storing anything. Its limits are set on the server: `MAXIMUM_ZIP_ENTRY_COUNT` (default 10000 entries), `MAXIMUM_ZIP_UNCOMPRESSED_SIZE` (default 10 GB) and `MAXIMUM_ZIP_NESTING_DEPTH` (default 3 levels of zips inside the uploaded zip).
- The server takes at most `MAXIMUM_UPLOAD_FILE_COUNT` files per `upload_collection_documents` call (50 in the shipped appsettings.json, 10 when the setting is missing; a zip counts as one). The method returns `None` when there are too many files (400, `details.maxFileCount`), a file is over the size limit (413, `details.maxBytes` and `details.files`), nothing was added because a file could not be saved (500), every file was already there (409), or otherwise nothing could be added (400); the log shows the server's details, such as the skipped list.
- A single upload returns `None` for content already in the collection, an unsupported or empty file, a zip that is empty, unreadable (400 `invalid_archive`) or over the zip limits (413 `payload_too_large`), a file over the configured size limit (413), or a file the server could not save, so that nothing was stored (500 `internal_error`).
- `get_collection_documents_status` returns `{document_id, status, status_code, status_name}` per finished document, `status` being `"success"` on success. It waits as long as processing takes; a document that is not in the collection is logged and left out instead of waited for.
- `get_collection_document_status_code(collection_id, document_id)` waits for one document and returns its status (`"success"` on success), or `None` if it is not in the collection.

Download a document, or see it as a PDF (audio and video show their transcription):

```python
path = cg.download_collection_document(document_id, save_path="./downloads")
path = cg.download_collection_document(document_id, save_path="./downloads", collection_id=collection_id)  # must be in that collection
pdf_path = cg.download_collection_document_preview(document_id, save_path="./downloads")
```

The preview is available only once the document has been processed successfully; before that it returns `None`.

Delete documents, along with their translations, transcriptions and search embeddings:

```python
cg.delete_collection_document(document_id)                         # True if deleted
cg.delete_collection_documents(collection_id, [id1, id2])          # {"deletedIds": [...]}; IDs not in the collection are skipped
cg.delete_all_collection_documents(collection_id)                  # {"deletedCount": n}; the collection stays
```

Resync uploaded documents, so they are extracted again and re-embedded where the text or the embedder changed. Nothing is deleted first, and websites, Windows files and SharePoint files are not touched (they have their own resync methods):

```python
queued = cg.resync_collection_documents(collection_id)             # every document; {"documentsQueued": n, …}
queued = cg.resync_collection_documents(collection_id, [id1, id2]) # only these; IDs not in the collection are skipped
statuses = cg.get_collection_documents_status(collection_id, [id1, id2])
```

It returns `None` when none of the IDs is in the collection (404 `document_not_found`), while a targeted document is still processing (409 `invalid_state`), for another account's collection (404 `collection_not_found`), or when the documents could not be read (500). An empty list returns `None` without a request.

List the documents of a collection, newest first, optionally only those whose file name contains a text:

```python
documents = cg.list_collection_documents(collection_id)
reports = cg.list_collection_documents(collection_id, search="report")
for document in reports or []:
    print(document["id"], document["fileName"], document["jobDetail"]["statusCode"])
```

#### Parameters
- **collection_id**: The ID of one of your collections.
- **search**: (Optional) Text the file names must contain, matched case-insensitively. Empty or whitespace lists every document. `%` and `_` act as wildcards on the server (any text and any one character), as in the web app's "Search files..." box.

#### Returns
- A list of documents, each a dict with `id`, `collectionId`, `fileId`, `fileName`, `fileExtension`, `fileSize`, `dateSubmitted` and `jobDetail` (with `status`, `statusCode` and `progressPercent`), among others. The whole list comes back in one response; it is not paged. `None` if the collection does not exist, belongs to another account, or the request fails.

### Collection Windows Files

Add the files of a Windows folder to a collection. The server reads the folder while impersonating the given Windows account:

```python
files = cg.upload_collection_windows_files(
    collection_id,
    r"\\fileserver\share\contracts",
    domain="CORP",
    username="svc-reader",
    password="...",
    replace_nested_folders=False
)
```

The server refuses a folder already covered by one added to the collection, and a folder containing ones already added unless `replace_nested_folders=True`, which replaces them.

List the windows files of a collection, resync them with their folders, delete one by its `id`, or delete them all:

```python
files = cg.get_collection_windows_files(collection_id)
files = cg.resync_collection_windows_files(collection_id)
deleted = cg.delete_collection_windows_file(files[0]["id"])
deleted_all = cg.delete_collection_windows_files(collection_id)
```

A resync reads each folder again with the Windows account saved at upload: it adds new files, replaces files whose size changed (under a new `id`), and removes files no longer there. Unchanged files are left as they are, even if their processing failed. It returns `None` if no folder was ever uploaded to the collection.

#### Returns
- `upload_collection_windows_files`, `get_collection_windows_files` and `resync_collection_windows_files`: a list of dicts with `id`, `collectionId`, `fileId`, `fileName`, `rootFolderName`, `fileExtension`, `fileSize`, `owner`, `dateSubmitted` and `jobDetail`, or `None` on failure. Each file is processed on the server after the upload; `jobDetail["status"]` is `2` once its job has finished, `jobDetail["statusCode"]` holds the result and `jobDetail["statusMessage"]` the reason for a failure.
- The delete methods: `True` if deleted, `False` otherwise.

### SharePoint Connections

SharePoint sources authenticate through a connection configured for their host and type (`"SharePointOnline"` or `"SharePointServer"`). List the configured connections, optionally of one type, and test a site, library or folder URL through the one for its host:

```python
connections = cg.get_sharepoint_connections()
online = cg.get_sharepoint_connections("SharePointOnline")

result = cg.test_sharepoint_connection(
    "SharePointOnline",
    "https://contoso.sharepoint.com/sites/hr/Shared Documents/Contracts",
    collection_id=collection_id  # optional: also reports whether a source already covers it
)
```

A failed test is still a result: check `result["success"]` and `result["outcome"]`.

Administrators can create, update and delete connections. The server tests the credential against the URL first and saves nothing unless the test succeeds:

```python
connection = cg.create_sharepoint_connection(
    "SharePointOnline",
    "https://contoso.sharepoint.com/sites/hr",
    tenant_id="00000000-0000-0000-0000-000000000000",
    client_id="11111111-1111-1111-1111-111111111111",
    client_secret="..."
)

connection = cg.create_sharepoint_connection(
    "SharePointServer",
    "https://sharepoint.corp.local/sites/hr",
    domain="CORP",
    username="svc-sharepoint",
    password="..."
)

connection = cg.update_sharepoint_connection(
    connection["connectionId"],
    "https://sharepoint.corp.local/sites/hr",
    domain="CORP",
    username="svc-sharepoint",
    password="new password"
)
deleted = cg.delete_sharepoint_connection(connection["connectionId"])
```

Send the credential fields of the connection's type; the others are ignored. One connection serves every site under a host, so creating a second one of the same type for a host is refused; update the existing one instead. An update keeps the connection's ID, type and host, and needs every credential field again, since stored secrets are never read back. Deleting a connection leaves its sources in place, but they cannot sync until a connection for their host is created again.

#### Returns
- `get_sharepoint_connections`: a list of dicts with `connectionId`, `connectionType`, `baseUrl`, `host`, `configuredOn` and `hasCredential`, or `None` on failure. Credentials are never returned.
- `create_sharepoint_connection` and `update_sharepoint_connection`: the connection as a dict of the same shape, or `None` on failure (missing field, unusable URL, not an administrator, existing connection for the host, failed connection test, or no internet access for SharePoint Online).
- `test_sharepoint_connection`: a dict with `success`, `outcome` (`connected`, `invalid_url`, `no_internet`, `connection_not_configured`, `credential_missing` or `connection_failed`), `message`, `resolvedLocation` (`siteName`, `siteUrl`, `libraryName`, `libraryUrl`, `folderPath`, `folderUrl`; set only on success), `connectionId` and `alreadyExists` (set only when `collection_id` is given), or `None` on failure.
- `delete_sharepoint_connection`: `True` if deleted, `False` otherwise.

### Collection SharePoint Sources

Add a SharePoint site, document library or folder to a collection. Its URL must be on a host with a configured SharePoint connection (see above). Adding a source queues a sync job that finds its files; each file is then processed on its own. Both run on the server, so wait for them, checking every 15 seconds by default:

```python
source = cg.add_collection_sharepoint_source(
    collection_id,
    "SharePointOnline",
    "https://contoso.sharepoint.com/sites/hr/Shared Documents/Contracts",
    include_subfolders=True
)
source = cg.wait_for_collection_sharepoint_source(source["sourceId"])     # until the sync job finishes
files = cg.wait_for_collection_sharepoint_files(source["sourceId"])       # until every file is processed
```

Both waits take `poll_interval` (seconds, default 15) and `timeout` (seconds, default `None`, i.e. no limit). `wait_for_collection_sharepoint_files` also takes `on_progress(processed, total)`.

List, download and remove:

```python
sources = cg.get_collection_sharepoint_sources(collection_id)
source = cg.get_collection_sharepoint_source(source_id)
files = cg.get_collection_sharepoint_files(source_id, search="contract")
page = cg.get_collection_sharepoint_files_paged(source_id, page=1, page_size=50)

path = cg.download_collection_sharepoint_file(file_id, save_path="./downloads")
pdf_path = cg.download_collection_sharepoint_file(file_id, save_path="./downloads", as_pdf=True)
pdf_bytes = cg.preview_collection_sharepoint_file(file_id)

deleted = cg.delete_collection_sharepoint_file(file_id)
deleted = cg.delete_collection_sharepoint_source(source_id)
```

Resync a source to pick up changes in SharePoint: new files are added, changed ones processed again, and removed ones dropped. A file removed with `delete_collection_sharepoint_file` is still in SharePoint, so a resync adds it back. Wait for a resync as for an add:

```python
cg.resync_collection_sharepoint_source(source_id)
cg.wait_for_collection_sharepoint_source(source_id)
files = cg.wait_for_collection_sharepoint_files(source_id)
```

Sources are personal: only the account that added a source sees it. A source or file of another account answers as if it did not exist. Deleting a source removes its files and their embeddings from the collection, but nothing from SharePoint.

#### Returns
- `get_collection_sharepoint_sources`, `get_collection_sharepoint_source`, `add_collection_sharepoint_source`, `resync_collection_sharepoint_source` and `wait_for_collection_sharepoint_source`: a source (or a list of them), with `sourceId`, `collectionId`, `connectionType`, `url`, `includeSubfolders`, `siteName`, `libraryName`, `folderPath`, `dateSubmitted`, `syncJobStatus` (`Queued`, `Running`, `Finished`, `Scheduled` or `AwaitingWindow`), `lastResyncedOn`, `syncFailed`, `syncFailedMessage` and the counts `fileCount`, `folderCount`, `syncedCount`, `pendingCount`, `failedCount` and `unsupportedCount`. `None` on failure. `add_collection_sharepoint_source` fails when the URL cannot be used, no connection is configured for its host, the collection already has a source of yours over the same location, or SharePoint cannot be reached.
- `get_collection_sharepoint_files` and `wait_for_collection_sharepoint_files`: a list of files, each with `fileId`, `sourceId`, `fileName`, `fileExtension`, `fileSize`, `folderPath`, `syncStatus` (`NotSynced`, `NeedsResync`, `InProgress`, `Synced`, `Failed` or `Unsupported`), `syncFailedMessage`, `lastModifiedDate`, `lastSyncDate`, `canDownload` and `canPreview`. `None` on failure.
- `get_collection_sharepoint_files_paged`: a dict with `items`, `page`, `pageSize` and `total`, or `None` on failure.
- `download_collection_sharepoint_file`: the path of the saved file, or `None` on failure. It works only for files with `canDownload`, or with `canPreview` when `as_pdf=True`.
- `preview_collection_sharepoint_file`: the PDF as bytes, or `None` on failure. It works only for files with `canPreview`.
- The delete methods: `True` if deleted, `False` otherwise.

### Collection Websites

Add a website to a collection by its URL. Adding it queues an indexing job on the server, so wait for it, checking every 15 seconds by default:

```python
website = cg.add_collection_website(
    collection_id,
    "https://www.example.com/policies",
    schedule_option=cg.WEBSITE_SCHEDULE_WEEKLY
)
websites = cg.wait_for_collection_websites(collection_id, website_ids=[website["websiteId"]])
```

`schedule_option` sets how often the website is re-indexed: `cg.WEBSITE_SCHEDULE_DAILY` (0), `cg.WEBSITE_SCHEDULE_WEEKLY` (1, the default) or `cg.WEBSITE_SCHEDULE_MONTHLY` (2). The URL must be an absolute http(s) URL resolving to a public address, and not already in the collection.

`wait_for_collection_websites` takes `website_ids` (default `None`, i.e. every website of the collection; a website no longer listed has been deleted and is not waited for), `poll_interval` (seconds, default 15), `timeout` (seconds, default `None`, i.e. no limit) and `on_progress(indexed, total)`. A finished job is not necessarily a successful one: check each website's `indexingFailed` and `indexingFailedMessage`.

Add many websites at once from a file: a .txt with one URL per line, or a .csv or .xlsx with the URLs in the first column (a header row is allowed). Download a sample of each format with `download_collection_websites_sample`:

```python
sample_path = cg.download_collection_websites_sample("csv", save_path="./downloads")

result = cg.upload_collection_websites(collection_id, "./urls.csv", schedule_option=cg.WEBSITE_SCHEDULE_DAILY)
added_ids = [w["websiteId"] for w in result["added"]]
print(result["invalidUrls"], result["duplicateUrls"])
websites = cg.wait_for_collection_websites(collection_id, website_ids=added_ids)
```

Entries that are not valid public http(s) URLs, and URLs already in the collection or repeated in the file, are skipped and reported in `invalidUrls` and `duplicateUrls` rather than failing the upload. `cg.WEBSITE_UPLOAD_FILE_TYPES` lists the accepted types, `("txt", "csv", "xlsx")`.

List, inspect, download and change websites:

```python
websites = cg.get_collection_websites(collection_id)
website = cg.get_collection_website(website_id)

path = cg.download_collection_website_content(website_id, save_path="./downloads")

website = cg.rename_collection_website(website_id, "Company policies")
website = cg.update_collection_website_schedule_option(website_id, cg.WEBSITE_SCHEDULE_MONTHLY)

website = cg.reindex_collection_website(website_id)
websites = cg.wait_for_collection_websites(collection_id, website_ids=[website_id])
```

`download_collection_website_content` saves the text extracted at the website's last successful indexing as a UTF-8 .txt file, named after the website unless `file_name` is given (`.txt` is added when it has no extension). It works only once the website has been indexed successfully (`canDownload`). Re-indexing is refused while the website's previous indexing job has not finished.

Delete one website, several of a collection by their IDs, or all of them. Each website's embeddings are deleted with it:

```python
deleted = cg.delete_collection_website(website_id)
deleted_ids = cg.delete_collection_websites_by_ids(collection_id, [website_id_1, website_id_2])
deleted_all = cg.delete_collection_websites(collection_id)
```

IDs passed to `delete_collection_websites_by_ids` that are not in that collection are skipped.

As with the other download methods, a `save_path` starting with `.` is resolved against the calling script's folder, and the folder is created if missing.

#### Returns
- `add_collection_website`, `get_collection_website`, `rename_collection_website`, `update_collection_website_schedule_option` and `reindex_collection_website`: the website as a dict, with `websiteId`, `websiteName`, `url`, `scheduleOption`, `indexingStatus` (`"Finished"` once its indexing job is done; `""` when it has no job), `progressPercent`, `indexingFailed`, `indexingFailedMessage`, `lastIndexedOn`, `wordCount` and `canDownload`. `None` on failure: the collection or website was not found or belongs to another account, or for `add_collection_website`, the URL is unusable or already in the collection.
- `get_collection_websites` and `wait_for_collection_websites`: a list of websites of the same shape, or `None` on failure. `wait_for_collection_websites` also returns `None` when it times out.
- `upload_collection_websites`: a dict with `added` (the added websites), `invalidUrls` and `duplicateUrls`, or `None` when the collection was not found or belongs to another account, or the file is empty, too large, of another type, unreadable, or contains no URLs.
- `download_collection_websites_sample` and `download_collection_website_content`: the path of the saved file, or `None` on failure.
- `delete_collection_websites_by_ids`: the list of IDs actually deleted, or `None` on failure.
- `delete_collection_website` and `delete_collection_websites`: `True` if deleted, `False` otherwise.

### Heartbeat

Fetch the health status of various system components:

```python
heartbeat_status = cg.heartbeat()
print(heartbeat_status)
```

##### Parameters:
- None

##### Returns:
- **Dictionary**: A dictionary with the status of each component. Each key represents a system component (e.g., `"SQL Server"`, `"Oracle DB"`) and contains the following fields:
  - **status**: The health status of the component, typically `"UP"` or `"DOWN"`.
  - **message**: A message providing more details about the component's health.
  - **sinceTimestamp**: A timestamp indicating when the status was last updated.



```python
health_status = cg.healthcheck()
print(health_status)
```

#### Parameters:
- None

#### Returns:
- **List of Tuples**: The returned list contains tuples where each tuple represents a system resource and its health status. Each tuple contains:
  - **resource name**: The name of the resource (e.g., `"SQL Server"`, `"Oracle DB"`, `"Training GRPC"`, `"Triton Grpc Server"`).
  - **status**: The health status of the resource, which can be either `"UP"` (healthy) or `"DOWN"` (unhealthy).
  
#### Example Response Structure:
```python
[
  ("SQL Server", "UP"),
  ("Oracle DB", "UP"),
  ("Training GRPC", "DOWN"),
  ("Triton Grpc Server", "UP")
]
```


### Text Extraction

Extract paragraphs from raw text:

```python
paragraphs = cg.text_extraction("This is a sample text that needs to be divided into paragraphs.")
print(paragraphs)
```

Extract text from a document file:

```python
extracted_text = cg.text_extraction_from_document("/path/to/document.pdf")
print(extracted_text)
```


### Text Embedding

Generate embeddings from raw text:

```python
result = cg.get_text_embedding("This is a sample text.")
print(result)
```


### AI Specialists

An AI Specialist is a named assistant with its own instruction (system prompt) and documents, trained on those documents. Attach one to a chat with `add_ai_specialists_to_chat` (see Chatbot Interaction).

Create, list, get, update and delete specialists:

```python
specialist = cg.create_ai_specialist(
    name="HR Policies",
    description="Answers questions about HR policy.",
    instruction="Answer questions using the uploaded documents.",
    editor_access=["Me"],
    viewer_access=["AnyUser"]
)

page = cg.get_ai_specialists_paged(page=1, page_size=50, name="hr")
specialist = cg.get_ai_specialist(specialist["id"])

result = cg.update_ai_specialist(specialist["id"], description="HR policy, 2026 edition")
deleted = cg.delete_ai_specialist(specialist["id"])
result = cg.delete_ai_specialists([12, 13, 14])
```

`editor_access` (who may edit the specialist) and `viewer_access` (who may view it) each take one option or a list of them: `"Me"`, `"AnyUser"`, `"Administrators"` and `"ApiAccess"` (case-insensitive), or their integer values `0` to `3`. An unknown option raises `ValueError`. On create they default to `"Me"` and `"AnyUser"`. Names must be unique, ignoring case.

`get_ai_specialists_paged` lists the specialists you can see, newest first. `page` is 1-based (default 1), `page_size` defaults to 50 and is capped at 200 by the server, and `name` filters on a case-insensitive part of the name.

`update_ai_specialist` changes only the fields you pass; any left as `None` keep their current value.

Deleting a specialist archives it and removes it from any chats it was attached to. `delete_ai_specialists` deletes several in one request; partial success is not a failure. Each distinct ID comes back once, either in `deletedIds` or in `errors` with a code: `forbidden` for one you can see but not edit, and `expert_not_found` for one that does not exist, is already deleted, or is not visible to you.

Upload documents to a specialist, then wait for them to be processed:

```python
documents = cg.upload_ai_specialist_documents(
    specialist["id"],
    ["./docs/handbook.pdf", "./docs/leave-policy.docx"]
)
statuses = cg.get_ai_specialist_documents_status([d["id"] for d in documents])
```

The server accepts `.docx`, `.pptx`, `.pdf` and `.txt` files. It refuses the whole upload if any file has another type, and it skips empty files. A path starting with `.` is resolved against the calling script's folder. Processing continues on the server after the upload returns; `get_ai_specialist_documents_status` waits until each document has been processed.

List, get, download and delete documents:

```python
documents = cg.get_ai_specialist_documents(specialist["id"])
document = cg.get_ai_specialist_document(documents[0]["id"])

path = cg.download_ai_specialist_document(document["id"], save_path="./downloads")
path = cg.download_ai_specialist_document(document["id"], save_path="./downloads", file_name="handbook")

deleted = cg.delete_ai_specialist_document(document["id"])
```

`download_ai_specialist_document` saves the original file in `save_path` (default: the calling script's folder), creating the folder if needed. The file keeps its original name unless you pass `file_name`; a `file_name` with no extension gets the original file's extension.

Train the specialist on its documents:

```python
started = cg.start_ai_specialist_training(specialist["id"])
stage = cg.get_ai_specialist(specialist["id"])["trainResult"]["stage"]
```

Training runs on the server after this returns. Follow it through the `trainResult` of `get_ai_specialist`: its `stage` moves through values such as `RUNNING` and `TRAINING` and ends at `COMPLETE` or `FAILED`, with any detail in `message`. Right after starting, the stage can still read `UNKNOWN` until the job is picked up. The server refuses to start training when training is already scheduled or running, the specialist has no documents, any document is still being processed, or every document failed processing.

#### Returns
- `create_ai_specialist` and `get_ai_specialist`: the specialist as a dict, with `id`, `name`, `description`, `viewerAccess` and `trainResult` among its fields, or `None` on failure. `get_ai_specialist` also returns `None` when the specialist does not exist or you cannot access it.
- `get_ai_specialists_paged`: a dict with `items`, `page`, `pageSize` and `total`, or `None` on failure.
- `update_ai_specialist`: the server's update result, or `None` on failure (for example, a name that clashes with another specialist's).
- `delete_ai_specialists`: a dict with `deletedIds` (a list of IDs) and `errors` (a list of dicts with `id`, `code` and `message`), or `None` on failure or when no IDs are given.
- `upload_ai_specialist_documents` and `get_ai_specialist_documents`: a list of documents, each with `id`, `fileName` and `fileSize`, or `None` on failure (including a file that is not found).
- `get_ai_specialist_document`: the document with its processing job, or `None` if it was not found or the request failed.
- `get_ai_specialist_documents_status`: a list of dicts with `document_id`, `status` (the job's status code, for example `"success"`), `status_code` and `status_name`, in the order the documents finished, or `None` on failure.
- `download_ai_specialist_document`: the path of the saved file, or `None` on failure.
- `start_ai_specialist_training`: `True` if training was started, `False` otherwise. The reason for a refusal is logged.
- The delete methods `delete_ai_specialist` and `delete_ai_specialist_document`: `True` if deleted, `False` otherwise.

### Third-party LLMs

Register models at an outside provider (OpenAI, Ollama, ...) so chats can use them. Every user sees their own third-party LLMs and the public ones; only the owner can edit or delete one, and only the owner is sent its API key. A third-party LLM's `id`, as a string, can be passed as the `model` of a chat (see Chatbot Interaction).

Find the providers the server can reach and the models one offers, and test a model before saving anything:

```python
model_types = cg.get_third_party_llm_model_types()   # e.g. ["openai_thirdparty", "ollama_thirdparty", ...]

models = cg.get_third_party_llm_supported_models(
    "ollama_thirdparty",
    "http://ollama.corp.local:11434"
)

result = cg.test_third_party_llm_connection(
    "openai_thirdparty",
    "gpt-4o-mini",
    api_key="..."
)
print(result["success"], result["message"])
```

`get_third_party_llm_supported_models` takes `model_type`, `base_url` (required, an absolute http(s) URL) and `api_key` (required for every provider but Ollama). Its list is empty for providers that cannot list their models (Azure AI, Vertex); type the model name in instead. `test_third_party_llm_connection(model_type, model_name, api_key=None, base_url=None)` sends the model a one-line prompt; the server gives up after 60 seconds. A model that does not answer is a result, not a failure: check `result["success"]` and `result["message"]`.

Register, read, list, update and delete:

```python
llm = cg.create_third_party_llm(
    "Team GPT",
    "openai_thirdparty",
    "gpt-4o-mini",
    api_key="...",
    description="Shared model for the support team",
    is_public=False,
    hex_color="#3366CC"
)

llm = cg.get_third_party_llm(llm["id"])
page = cg.get_third_party_llms_paged(page=1, page_size=50, name="gpt")
result = cg.test_saved_third_party_llm_connection(llm["id"])

llm = cg.update_third_party_llm(llm["id"], name="Support GPT", model_name="gpt-4o")
deleted = cg.delete_third_party_llm(llm["id"])
```

`create_third_party_llm` takes `name` (3-260 characters), `model_type` (one of `get_third_party_llm_model_types()`), `model_name`, and optionally `api_key` (may be omitted for Ollama only), `base_url`, `description`, `is_public` (the server defaults to `False`, visible to the owner only) and `hex_color` (`"#RRGGBB"`; the server defaults to `"#D3D3D3"`). Everything, the API key included, is sent in the request body, never in the URL. The server refuses to save one without internet access.

`test_saved_third_party_llm_connection` tests a saved LLM you can see with its stored details, so a public LLM can be checked without its API key.

`get_third_party_llms_paged` returns your own third-party LLMs and every public one, newest first. `page` is 1-based and `page_size` defaults to 50 on the server, at most 200; `name` is a case-insensitive substring filter.

`update_third_party_llm` changes only the fields given (`name`, `model_type`, `model_name`, `api_key`, `base_url`, `description`, `is_public`, `hex_color`). The server replaces every field with what it is sent, and clears the API key if it is left out, so the SDK reads the LLM first and sends it back with those fields changed. It works only on your own LLMs, with the same checks as `create_third_party_llm`. `delete_third_party_llm` archives one of your own LLMs.

#### Returns
- `get_third_party_llm_model_types`: a list of model type names, or `None` on failure.
- `get_third_party_llm_supported_models`: a list of model names (empty for providers that cannot list them), or `None` on failure.
- `test_third_party_llm_connection` and `test_saved_third_party_llm_connection`: a dict with `success` and `message`, or `None` when the request itself is refused (e.g. an unknown model type) or, for a saved one, it was not found.
- `create_third_party_llm`, `get_third_party_llm` and `update_third_party_llm`: the third-party LLM as a dict, with `id`, `name`, `modelType`, `description`, `isPublic`, `hexColor`, `apiKey` (empty unless you own it) and `settingsJson` (its `BaseUrl` and `ModelName`). `None` on failure, or when it was not found or is someone else's private LLM (for an update, someone else's LLM).
- `get_third_party_llms_paged`: a dict with `items`, `page`, `pageSize` and `total`, or `None` on failure.
- `delete_third_party_llm`: `True` if deleted, `False` if it was not found, belongs to someone else, or the request failed.

### Evia Training

Evia training covers the Evia model, its recurring training schedule, its checkpoints (training data versions), each checkpoint's acronyms, the CSV/XLS/XLSX acronym uploads, the acronym processing queue with its windows, and the training runs. The model, schedule, checkpoints and runs are shared by all users.

Administrators can always call these methods; everyone else only while training is enabled in the application settings. Otherwise the server answers 403 and the methods return `None` (or `False`).

Wherever a `checkpoint_id` is taken, it is a checkpoint's ID or the string `"active"` for the model's active checkpoint. Every allowed string value is also available as a constant on the client, for example `cg.CHECKPOINT_MODE_CATALOG`, `cg.ACRONYM_SCOPE_QUEUE`, `cg.SCHEDULE_MODE_NEXT` or `cg.RUN_STATUS_COMPLETE`.

#### Model and auto-train

```python
model = cg.get_training_evia_model()
saved = cg.set_training_evia_auto_train(True)   # train once a checkpoint's acronyms finish processing
```

Auto-train runs use the training schedule's checkpoint. Setting the current value changes nothing.

#### Schedule

```python
schedule = cg.get_training_evia_schedule()      # {} when no schedule has been set up yet

schedule = cg.update_training_evia_schedule(
    enabled=True,
    days_between_runs=7,
    time_of_day_utc="22:00",
    checkpoint_id="active"
)
```

`update_training_evia_schedule` creates the schedule or updates it, and only the arguments given change. Future planned runs are replaced: the next one is planned if the schedule is enabled. There is no way to delete a schedule; disable it with `enabled=False` instead.

- A new schedule is disabled unless `enabled=True`, and uses the default test data version (the active one, else the newest, else a new one).
- When `checkpoint_id` is not given, a new schedule uses the checkpoint active at that moment and an existing one keeps its checkpoint. Either way the checkpoint is fixed when the schedule is saved: activating another checkpoint later does not move it. Pass `"active"` to point it at the current active checkpoint.
- The server refuses (and the method returns `None`) when `days_between_runs` is negative, `time_of_day_utc` is not `"HH:mm"` (24h), or the checkpoint does not exist.

#### Checkpoints

```python
checkpoints = cg.get_training_evia_checkpoints()      # newest first
checkpoint = cg.get_training_evia_checkpoint("active")

checkpoint = cg.create_training_evia_checkpoint(
    mode="catalog",                                   # "blank", "catalog" or "full"
    source_checkpoint_id=checkpoints[0]["checkpointId"],
    description="Before the Q4 import",
    activate=False
)
checkpoint = cg.activate_training_evia_checkpoint(checkpoint["checkpointId"])
```

The server assigns a new checkpoint's version (the highest version plus one), and creates the Evia model if there is none yet. Checkpoints cannot be deleted. `mode` sets how much of the source checkpoint the new one starts with:

- `"blank"`: nothing; `source_checkpoint_id` is not needed.
- `"catalog"`: the source's completed acronyms only, with the uploads they came from and the training documents. The new checkpoint starts with an empty queue.
- `"full"` (the server's default): every acronym of the source, including queued, processing and failed ones, with their uploads and the training documents.

Copied acronyms are shared with the source checkpoint, not duplicated. `source_checkpoint_id` is required unless `mode` is `"blank"`, and `"active"` is not accepted there. `activate` defaults to `True` on the server. Activating the checkpoint that is already active changes nothing.

#### Acronyms

List a checkpoint's acronyms one page at a time. By default this is the catalog, the completed acronyms; `state` picks the queue or the failures instead, and `training` splits the catalog into acronyms in the deployed model and ones that still need training:

```python
result = cg.get_training_evia_acronyms(
    "active",
    state="completed",      # "completed" (default), "queued", "failed" or "all"
    training="required",    # "active" or "required"; only completed acronyms match
    source="imported",      # "manual" or "imported"
    batch_id=None,
    upload_id=None,
    search="RCMP",
    sort="-updated",        # "status", "wordEn", "wordFr", "meaningEn", "meaningFr", "language", "source" or "updated"; "-" for descending
    page=1,
    page_size=50
)
acronym = cg.get_training_evia_acronym("active", acronym_id)
```

`search` is matched, case-insensitively, against the words, meanings and batch ID. `page_size` defaults to 50 and is clamped to 200 by the server.

Add acronyms, or replace one's text. Each is scheduled for sample generation into a processing window:

```python
created = cg.create_training_evia_acronyms(
    "active",
    [{
        "wordEn": "RCMP",
        "wordFr": "GRC",
        "meaningEn": "Royal Canadian Mounted Police",
        "meaningFr": "Gendarmerie royale du Canada",
        "sentenceEn": "The RCMP opened an investigation.",   # optional
        "sentenceFr": "La GRC a ouvert une enquête."          # optional
    }],
    schedule_mode="next"
)

updated = cg.update_training_evia_acronym(
    "active", acronym_id,
    word_en="RCMP", word_fr="GRC",
    meaning_en="Royal Canadian Mounted Police",
    meaning_fr="Gendarmerie royale du Canada",
    sentence_en=None, sentence_fr=None,
    schedule_mode="auto"
)
```

- `create_training_evia_acronyms` takes one dict or a list. `wordEn` and `wordFr` are at most 20 characters, `meaningEn` and `meaningFr` at most 500, the sentences at most 4000; values are trimmed. If there are no items or any item is invalid, the whole request is refused and each field error is logged. The acronyms are created and placed in one step: if they cannot be placed, none are created.
- `update_training_evia_acronym` replaces every field: a sentence left out becomes empty. The acronym is scheduled again under a batch of its own. When another checkpoint shares the acronym, the edit creates a new acronym for this checkpoint only, and `forkedFromId` names the old one. When the text is unchanged nothing is regenerated and `unchanged` is `True`; with a `schedule_mode` the acronym is still rescheduled, and a completed one is generated again in the chosen window. It returns `None` when a field is invalid, the acronym is not in the checkpoint, it is being generated right now, or no window has room. If the edit was saved but could not be placed after all, the acronym is listed as failed and the method still returns `None`.

Place acronyms into a window, or rerun every failed one:

```python
result = cg.schedule_training_evia_acronyms("active", [101, 102], schedule_mode="window", window_id=42)
result = cg.schedule_training_evia_acronyms("active", 103, schedule_mode="at",
                                            window_start_utc="2026-10-20T22:00:00Z")
result = cg.rerun_failed_training_evia_acronyms("active", schedule_mode="next")
```

`schedule_mode`, `window_id` and `window_start_utc` work the same way on every method that places acronyms (create, update, schedule, rerun, upload, reschedule and retry):

- `"auto"` (the server's default): what the training page's Save button does, the next configured window, or the always-open window when acronyms are not forced into the schedule.
- `"next"`: the next window with room.
- `"window"`: the window with ID `window_id`. Overflow rolls forward into later windows.
- `"at"`: the window starting at `window_start_utc` (a `datetime` or an ISO 8601 string in UTC), which must be a window start of the configured schedule; otherwise the next window with room is used.

`schedule_training_evia_acronyms` moves pending acronyms, re-queues failed ones and reserves ones without a reservation. Completed acronyms are skipped with the reason `already_completed`, ones being generated with `processing`, and IDs not in the checkpoint with `not_in_checkpoint`. Each group (move, re-queue, reserve) is placed on its own: when one is refused for lack of room, its IDs are skipped and the others still count. Only when nothing could be placed does the method return `None`.

Remove acronyms from a checkpoint:

```python
deleted = cg.delete_training_evia_acronym("active", acronym_id)
result = cg.delete_training_evia_acronyms("active", [101, 102])
result = cg.delete_all_training_evia_acronyms("active", "queue")   # "all", "catalog" or "queue"
```

An acronym is removed from the given checkpoint only, and deleted once no checkpoint uses it. `delete_training_evia_acronym` refuses an acronym being generated right now; `delete_training_evia_acronyms` skips those with the reason `processing`, and IDs not in the checkpoint with `not_in_checkpoint`, without failing. `delete_all_training_evia_acronyms` requires a `scope`:

- `"all"`: every acronym, completed or not, and the checkpoint's uploads (the import files and their records).
- `"catalog"`: the completed acronyms only. Queued, processing and failed ones stay, and so do the uploads.
- `"queue"`: every acronym that is not completed (scheduled, processing, failed, and ones not in any window). Completed acronyms and the uploads stay.

It removes acronyms being generated too, with their window reservation and job; an acronym another checkpoint uses stays queued there.

#### Uploads

Import acronyms from CSV, XLS or XLSX files, at most 20 at once. The files need the columns `WordEN`, `WordFR`, `MeaningEN`, `MeaningFR`, `SentenceEN` and `SentenceFR`; the import template has them:

```python
template = cg.download_training_evia_upload_template(format="xlsx", save_path="./downloads")   # "csv" (default) or "xlsx"

result = cg.upload_training_evia_acronyms(
    "active",
    ["./acronyms.xlsx", "./more-acronyms.csv"],
    mode="append",          # "append" (default) or "replace"
    schedule_mode="auto"
)
```

The acronyms are scheduled for sample generation in window-sized batches named `Batch#{uploadId}` or `Batch#{uploadId}-{n}`. Invalid rows are skipped and reported per file in `errors`. A file the server cannot read, such as one with an unknown column, refuses the whole request (error code `import_invalid_header`), as does a request with no valid acronym.

- `"append"` adds to the checkpoint's acronyms; a file none of whose acronyms could be scheduled is removed again (its `uploadId` is `0`).
- `"replace"` swaps out the checkpoint's acronyms and uploads, but only if every new acronym could be scheduled; otherwise the checkpoint is left unchanged and the method returns `None`.

XLS files are read, but there is no XLS template. List, inspect, download and remove uploads:

```python
page = cg.get_training_evia_uploads(checkpoint_id="active", search="Batch#12", page=1, page_size=50)
upload = cg.get_training_evia_upload(upload_id)
acronyms = cg.get_training_evia_upload_acronyms(upload_id)
path = cg.download_training_evia_upload(upload_id, save_path="./downloads")
result = cg.delete_training_evia_upload("active", upload_id)
```

`get_training_evia_uploads` lists every checkpoint's uploads, newest first, unless `checkpoint_id` is given; `search` is matched against the file name and the batch IDs. `delete_training_evia_upload` removes the upload and the acronyms it created from the checkpoint. Acronyms another checkpoint still uses are kept there, and the upload itself is deleted once no checkpoint uses it.

For both downloads, a `save_path` starting with `.` is resolved against the calling script's directory (the default), and the directory is created if missing. A `file_name` without an extension gets the original file's extension; by default the original file name is used (`acronym.csv` or `acronym.xlsx` for the template).

#### Processing queue

The queue holds acronyms whose latest reservation is scheduled, processing or failed, soonest window first. Completed and cancelled reservations are not on it, and unlike the acronym list it holds only acronyms that are in a window. Acronyms can be shared between checkpoints, so the checkpoint is a filter here:

```python
page = cg.get_training_evia_queue(
    checkpoint_id="active",   # the whole queue when not given
    bucket="failed",          # "scheduled", "processing" or "failed"
    batch_id=None,
    search=None,
    page=1,
    page_size=50
)
summary = cg.get_training_evia_queue_summary(checkpoint_id="active")

result = cg.reschedule_training_evia_queue([101, 102], schedule_mode="next")
result = cg.retry_training_evia_queue([103], schedule_mode="auto")
result = cg.cancel_training_evia_queue([104])
```

- `reschedule_training_evia_queue` moves scheduled acronyms to another window; overflow rolls into later windows. Acronyms not in the scheduled column are skipped with the reason `not_scheduled`.
- `retry_training_evia_queue` re-queues failed or cancelled acronyms. Acronyms not in the failed column are skipped with the reason `not_failed`.
- `cancel_training_evia_queue` cancels queued or running acronyms. They end in the failed column as `Cancelled`, from where `retry_training_evia_queue` can re-queue them. Acronyms that are not queued are skipped with the reason `not_queued`.

The release job runs every 15 minutes and releases pending acronyms whose window has started; `nextReleaseCheckUtc` in the summary says when it next runs.

#### Processing windows

```python
from datetime import datetime, timedelta, timezone

start = datetime.now(timezone.utc)
windows = cg.get_training_evia_windows(start, start + timedelta(days=7), checkpoint_id="active")
upcoming = cg.get_training_evia_upcoming_windows(count=10)    # 1 to 96
config = cg.get_training_evia_window_config()
```

`get_training_evia_windows` lists the windows starting in `[from_utc, to_utc)`, a range of at most 7 days, with their occupancy and the acronyms in them; `checkpoint_id` limits `items` to that checkpoint's acronyms. Both times can be `datetime`s (a naive one is taken as UTC) or ISO 8601 strings in UTC. `get_training_evia_upcoming_windows` lists the next windows with their free capacity, for picking where to place acronyms. A window without a `windowId` has no row yet: place acronyms into it with `schedule_mode="at"` and its `startUtc`.

#### Training runs

Start a manual run on a checkpoint's completed acronyms and training documents, now or later, and wait for it to finish, checking every 15 seconds by default:

```python
run = cg.start_training_evia_run("active")                                          # now
later = cg.start_training_evia_run("active", scheduled_at_utc="2026-10-20T22:00:00Z")

run = cg.wait_for_training_evia_run(
    run["id"],
    poll_interval=15,
    timeout=30 * 60,
    on_progress=lambda r: print(r["status"], r["progressPercent"])
)
if run and run["status"] == cg.RUN_STATUS_COMPLETE:
    run = cg.load_training_evia_run(run["id"])
```

- `start_training_evia_run` creates a test data version and a training schedule first if the model has none (the schedule disabled, at 22:00 UTC). It returns `None` when `scheduled_at_utc` is in the past, the checkpoint does not exist or has nothing completed to train on, or another run is already queued or training (error code `training_in_progress`). A run planned for later does not count as queued until its start time.
- `wait_for_training_evia_run` returns once the run's `status` is `"COMPLETE"` or `"FAILED"`. A finished run is not necessarily a successful one: check its `status` and `details`. It takes `poll_interval` (seconds, default 15), `timeout` (seconds, default `None`, i.e. no limit) and `on_progress(run)`, called after each check.
- `load_training_evia_run` loads a completed run's model into the trainer and marks it as the loaded run. Loading an older run rolls the model back to it.

List, inspect, cancel and delete runs:

```python
result = cg.get_training_evia_runs(page=1, page_size=50)     # newest first
run = cg.get_training_evia_run(run_id)
acronyms = cg.get_training_evia_run_acronyms(run_id)
cancelled = cg.cancel_training_evia_run(run_id)
deleted = cg.delete_training_evia_run(run_id)
```

`get_training_evia_run_acronyms` lists the acronyms a run was trained on: the snapshot taken when it started, not the checkpoint as it is now. `cancel_training_evia_run` stops a queued, planned or training run: one the trainer has not picked up yet is cancelled at once, otherwise the trainer is asked to stop it, and either way the run ends as `"FAILED"` with the details `"Cancelled"`. `delete_training_evia_run` deletes only a finished run (`"COMPLETE"` or `"FAILED"`) that is not the loaded one; cancel an unfinished run first.

#### Returns
- `get_training_evia_model`: a dict with `id`, `name`, `version`, `autoTrainAfterAcronymProcessing`, `activeCheckpointId` and `loadedRunId` (the last two `None` when there is none), or `None` if there is no Evia model yet or on failure.
- `set_training_evia_auto_train`: `True` if saved, `False` otherwise.
- `get_training_evia_schedule` and `update_training_evia_schedule`: a dict with `id`, `enabled`, `daysBetweenRuns`, `timeOfDayUtc` (`"HH:mm"`), `checkpointId`, `checkpointVersion` and `nextRunUtc` (`None` when no run is planned), or `None` on failure. `get_training_evia_schedule` returns `{}` when no schedule has been set up yet.
- `get_training_evia_checkpoints`, `get_training_evia_checkpoint`, `create_training_evia_checkpoint` and `activate_training_evia_checkpoint`: a checkpoint (or a list of them) with `checkpointId`, `version`, `createdOn`, `description` and `isActive`, or `None` on failure.
- `get_training_evia_acronyms`: a dict with `page` (`items`, `page`, `pageSize` and `total`) and `counts` (the catalog's `all`, `active` and `trainingRequired`, whatever the filters), or `None` on failure. Each acronym has `id`, `externalId`, `wordEn`, `wordFr`, `meaningEn`, `meaningFr`, `sentenceEn`, `sentenceFr`, `processingStatus`, `state` (`scheduled`, `processing`, `failed` or `completed`), `isActive`, `source`, `uploadId`, `batchId`, `createdOn`, `updatedOn`, `owner`, `reservation` (`None`, or its `status`, `windowId`, `windowStartUtc`, `retryCount`, `nextRetryAt`, `lastError` and `isAsap`), `progressPercent` and `details`. `get_training_evia_acronym` returns one such acronym.
- `create_training_evia_acronyms`: a dict with `acronyms` (the created acronyms) and `firstWindowStartUtc` (the start of the first window they were placed in), or `None` on failure.
- `update_training_evia_acronym`: a dict with `acronym`, `forkedFromId` (`None` unless a new acronym was created), `firstWindowStartUtc` and `unchanged`, or `None` on failure.
- `schedule_training_evia_acronyms`, `rerun_failed_training_evia_acronyms`, `reschedule_training_evia_queue` and `retry_training_evia_queue`: a dict with `affected` (the IDs placed), `skipped` (a list of `{"id", "reason"}`) and `firstWindowStartUtc`, or `None` on failure. Both lists are empty when `rerun_failed_training_evia_acronyms` finds nothing failed.
- `delete_training_evia_acronyms` and `cancel_training_evia_queue`: a dict with `affected` (the IDs removed or cancelled) and `skipped` (a list of `{"id", "reason"}`), or `None` if no IDs were given or on failure.
- `delete_all_training_evia_acronyms`: a dict with `scope` and `removed` (the number of acronyms removed from the checkpoint), or `None` on failure.
- `upload_training_evia_acronyms`: a dict with `uploads` (one per file with valid rows: `uploadId` (`0` if it was removed again), `fileName`, `totalRows`, `importedRows`, `failedRows`, `errors` and `batchIds`), `scheduled`, `failedToSchedule` and `firstWindowStartUtc`; or `None` if a file is missing, empty, too large or not CSV/XLS/XLSX, the checkpoint was not found, nothing could be scheduled, or on failure.
- `get_training_evia_uploads`: a dict with `items`, `page`, `pageSize` and `total`, or `None` on failure. Each upload has `id`, `fileName`, `extension`, `size`, `owner`, `dateSubmitted`, `originCheckpointId`, `checkpointIds`, `totalRows`, `parseFailed`, `success`, `generationFailed`, `processing`, `successRate`, `batchIds` and `status` (`processing`, `completedWithWarnings`, `failed` or `completed`). `get_training_evia_upload` returns one such upload.
- `get_training_evia_upload_acronyms`: a list of acronyms (as above), or `None` on failure.
- `download_training_evia_upload` and `download_training_evia_upload_template`: the path of the saved file, or `None` on failure.
- `delete_training_evia_upload`: a dict with `removedAcronyms` (the number removed from the checkpoint), or `None` on failure.
- `get_training_evia_queue`: a dict with `items`, `page`, `pageSize` and `total`, or `None` on failure. Each item has `acronymId`, `wordEn`, `wordFr`, `meaning`, `reservationStatus`, `bucket`, `retryCount`, `nextRetryAt`, `lastError`, `windowStartUtc`, `batchId`, `jobDetailId`, `progressPercent` and `isAsap`.
- `get_training_evia_queue_summary`: a dict with `scheduled`, `processing`, `failed` and `nextReleaseCheckUtc`, or `None` on failure.
- `get_training_evia_windows`: a list of windows with `windowId` (`None` for a window that has no row yet), `startUtc`, `endUtc`, `maxItems`, `activeItems`, `free`, `level` (`green`, `yellow` or `red`) and `items` (each `acronymId`, `wordEn`, `meaningEn`, `batchId` and `status`), or `None` on failure. `get_training_evia_upcoming_windows` returns the same without `items`.
- `get_training_evia_window_config`: a dict with `timesOfDay` (the window start times), `timeZone`, `durationMinutes`, `maxItems`, `maxDurationMinutes`, `forceIntoSchedule` and `maxRetries`, or `None` on failure.
- `get_training_evia_runs`: a dict with `page` (`items`, `page`, `pageSize` and `total`), `isTrainingInProgress` and `loadedRunId` (the run whose model is loaded, or `None`), or `None` on failure. Each run has `id`, `externalId`, `isManual`, `checkpointId`, `checkpointVersion`, `scheduledUtc`, `status` (for example `PLANNED`, `TRAINING`, `COMPLETE` or `FAILED`; while it runs it also passes through stages such as `RECEIVED`, `PREPARING`, `READY` and `RUNNING`), `score`, `durationSec`, `isLoaded`, `fromRunId`, `acronymCount`, `progressPercent` and `details` (`"Cancelled"` for a cancelled run, else `None` or the trainer's message).
- `get_training_evia_run`, `start_training_evia_run` and `load_training_evia_run`: a run (as above), or `None` on failure. `wait_for_training_evia_run` returns the run once it has finished, or `None` if getting it failed or the wait timed out.
- `get_training_evia_run_acronyms`: a list of dicts with `acronymId`, `externalId`, `exists` (`False` once the acronym has been deleted), `wordEn` and `wordFr` (`None` when it no longer exists), or `None` on failure.
- `cancel_training_evia_run` and `delete_training_evia_run`: `True` if accepted or deleted, `False` otherwise.

### Helpers

Split text into chunks on the server:

```python
chunks = cg.split_text("A long text to split into chunks...")
```

Process a PDF with the RAG service:

```python
result = cg.rag_pdf("./report.pdf")
```

Convert a PDF to a Word document. Despite its name, `pdf_to_text` returns the bytes of a `.docx` file, not text:

```python
converted = cg.pdf_to_text("./report.pdf")
if converted:
    with open(converted["filename"] or "report.docx", "wb") as f:
        f.write(converted["content"])
```

In `rag_pdf` and `pdf_to_text`, a `file_path` starting with `.` is resolved against the calling script's directory.

#### Returns
- `split_text`: the server's list of chunks, or `None` on failure.
- `rag_pdf`: the server's JSON response, or `None` on failure.
- `pdf_to_text`: a dict with `filename` (the name the server suggests, or `None`) and `content` (the DOCX as bytes), or `None` on failure.
