Files
qbit-assistant/README.md
james.maesandCodex 9adc28f0d9 fix(attachments): validate image data and preserve host PDF fonts
Decode images within frame and pixel limits before provider use, contain malformed attachments per file, and preserve the host PDFBox font mapper.

Co-Authored-By: Codex <noreply@openai.com>
Signed-off-by: James Maes <james@kof22.com>
2026-10-19 07:50:25 -05:00

796 lines
52 KiB
Markdown

# QBit Assistant
An in-app AI assistant for any QQQ application. The user asks a question in a chat panel in the Next UI. The assistant reads the application's data **with exactly that user's permissions**, answers with numbers taken from the data, and links to the records and lists it used. This first version only reads data and opens screens. It cannot change anything.
Design spec: `QRun-IO/qqq` `docs/specs/2026-10-11-qqq-assistant-qbit-design.md`.
## Modules
| Artifact | What it is |
|---|---|
| `com.kingsrook.qbits:qbit-assistant-provider-api` | Provider SPI, connection settings, messages, usage and pricing types; only Jackson annotations at runtime. |
| `com.kingsrook.qbits:qbit-assistant` | The QBit: conversation tables, turn engine, `/qqq/v1/assistant` routes (JSON + Server-Sent Events), budgets, kill switch, the provider registry, and a scripted provider for tests and demos. |
| `com.kingsrook.qbits:qbit-assistant-provider-anthropic` | The Claude provider (`com.anthropic:anthropic-java`). Add it to use Claude. The core QBit has no LLM SDK dependency. |
| `com.kingsrook.qbits:qbit-assistant-provider-openai` | OpenAI-compatible chat and embedding providers. |
| `com.kingsrook.qbits:qbit-assistant-provider-bedrock` | Claude through AWS Bedrock. |
| `com.kingsrook.qbits:qbit-assistant-provider-vertex` | Claude and Gemini through Google Vertex AI. |
| `com.kingsrook.qbits:qbit-assistant-rag-pgvector` | JDBC vector store; retains the runtime dependency for `VectorStore` and `ChunkVector`. |
### Implementing a provider outside qbit-assistant
Depend on `com.kingsrook.qbits:qbit-assistant-provider-api` at the same version as
QBit Assistant, and implement `AssistantModelProvider` and optionally
`AssistantProviderType`. No assistant runtime dependency is needed. All API types, including
`ModelPricing`, live exclusively in `com.kingsrook.qbits.assistant.spi`. External
consumers such as qbit-marketing allow that package in their G1 guardrails;
qbit-bom will add it to the G1 defaults. See [migration imports](UPGRADE.md#provider-api-extraction-30).
`AssistantProviderType.validate(ProviderSettings)` receives unresolved settings
(including `apiKeyEnvName`), without a database entity or resolved credentials.
Hosts register providers through `AssistantQBitConfig` in `qbit-assistant`.
Data tools use the QQQ UI API (`/qqq/v1`) with the signed-in user's session. The UI API enforces permissions and record locks; assistant data-access policy and field exposure can only narrow access. Only record-free navigation and help use the core AI tool kernel. Every tool result reaches the model wrapped as untrusted data.
## Transcript privacy
QQQ **4.1.0-RC.7** supplies the secured-data tokens and log hook (`qqq#1182`).
Stored transcripts never contain the raw PII/PHI values read by a turn or supplied
in classified tool criteria/fields. Messages, tool logs, replay events, knowledge
snippets and pending payloads store `ClassifiedValueTokens` tokens; scheduled
summaries use the redacted message. The model and live user stream retain the
values permitted by PHI routing. History and replay show tokens instead.
Content is finalized at turn completion; interrupted servers leave value-free
placeholders. Proposals requiring redacted parameters are rejected and must be
completed in the application. Existing transcripts are not migrated. See
[transcript redaction](docs/DESIGN.md#transcript-redaction-secured-data-15).
The dependency upgrade stays draft until RC.7 is published; local verification:
```powershell
.\mvnw.cmd -q verify "-Dqqq.snapshot.version=4.1.0-RC7PRE-SNAPSHOT"
```
## Host audit attribution (X1/Y1)
Use QQQ **4.1.0-RC.6 or later**. The QBit explicitly denies every assistant
configuration table, process, report and widget to the `assistant` channel;
runtime initialization fails if an assistant object is exposed. The same human
can still administer it through Next UI (`next-ui`). Core's security-surface
deny patterns remain in place.
Enable the audit client field in the host's core audit provider **after adding
the backing column**:
```java
new AuditsMetaDataProvider().withClientChannelField(true)
.defineAll(qInstance, backendName, null);
```
Add nullable `audit.client_channel VARCHAR(100)` using the host's migration and
column-naming conventions. For kof22 this requires a feature-named changeset
under `changesets/features/`; this QBit does not migrate the host's audit table.
The actor remains the signed-in human; `clientChannel` distinguishes
`assistant/<turnId>`, `assistant/scheduled/<taskId>` and `next-ui`.
Apply the X1/Y1 DDL in [UPGRADE.md](UPGRADE.md) to widen the existing
`assistant_tool_call.audit_href` column to TEXT for encoded filters.
Successful record writes store `assistant_tool_call.auditHref` as an `/app/audit`
filter link over `recordId`, `auditTable.name` and `clientChannel`. The human
opens this link with their UI permissions; the assistant cannot read audits by
default. Without the audit channel field, the QBit leaves `auditHref` empty.
Confirmed scheduled actions use their original turn id when the human approves.
## RAG setup
Knowledge retrieval is optional. The vector index stores **no record content**:
only source/table/key/field/chunk identifiers, SHA-256 hashes and embeddings.
Indexing reads through the UI API as the clicking administrator. Retrieval
re-reads each hit through that same API as the asking user, silently filters
403/404 results, applies `get_record` field exposure/masking/redaction and returns
fresh snippets with `/app/...` citations. `search_knowledge` follows `allowedTools`
and is offered only with an enabled source the user may read in the UI.
1. Register the OpenAI-compatible provider module and configure the embedding
endpoint through the existing provider settings/URL allowlist. Discover or add
a model, set **Model kind = EMBEDDING** and its dimensions. Known discovery
defaults are `bge-m3` (1024) and `nomic-embed-text` (768); these remain editable.
CHAT models cannot be selected for embedding, and EMBEDDING models cannot answer
chat turns. Each embedding batch contains at most 64 inputs.
2. Use the default `InMemoryVectorStore` for tests/small hosts, or add
`com.kingsrook.qbits:qbit-assistant-rag-pgvector` at the QBit's version for
persistence. The host supplies its JDBC driver and DataSource; this module
does not depend on QQQ RDBMS internals.
3. For pgvector, have the DBA install the extension once in the chosen database:
`CREATE EXTENSION IF NOT EXISTS vector`. Give the application a distinct schema
per QQQ instance. Configure the store before producing the QBit:
```java
PgVectorStore vectors = new PgVectorStore(dataSource, 1024)
.withSchema("assistant_rag_my_instance");
vectors.ensureSchema();
config.withVectorStore(vectors);
config.withUiApiBaseUrl("http://127.0.0.1:8000");
```
The UI API URL is this server's own listening address. Configure it explicitly
so Index now works before any chat request has taught the runtime its local
port. The store creates a content-free `assistant_rag_chunk` table and an HNSW
cosine index. Its fixed dimension must match every source using the store;
different models are searched separately. No credentials go in source rows.
See the [pgvector installation/index documentation](https://github.com/pgvector/pgvector)
and the reference DDL in [schema-postgres.sql](docs/schema-postgres.sql).
4. Open **Assistant → Knowledge sources** as an `assistant.admin`. Add the table,
comma-separated text fields, optional title field and QQQ query-filter JSON,
embedding model, and chunk size/overlap (defaults 1500/200). Tables with AI data
access NONE, `assistant_*` tables, and fields with NONE/METADATA_ONLY exposure
are refused. MASKED fields are masked before embedding and in snippets.
5. Select **Index now**. Review makes no embedding calls; Result reports records
seen, chunks embedded, unchanged, removed and errors. Re-running unchanged
content sends no embeddings. A complete successful scan removes disappeared
records; failed scans preserve unvisited entries. **Clear index** removes only
that source's vectors. Changes after indexing are read fresh, but semantic
ranking becomes current only after re-indexing.
Embedding usage has purpose `EMBED`, counts toward budgets, and observes the kill
switch and provider concurrency limits. PHI sources and queries in PHI
conversations require an eligible embedding provider; the eligibility flag is an
operator assertion, as for chat. Automatic budget downgrades cannot change an
embedding model's vector space, so an incompatible downgrade stops the call.
Scheduling is deferred to P-g. Its designated-user runner can call
`RagService.index` within that user's authenticated context; P-f creates no system
session, stored admin credentials, scheduler or background content crawler.
Data tools call the QQQ UI API through `UiApiToolbox` and `UiApiGateway` with the signed-in user's session. The UI enforces permissions and record security; the assistant further narrows access and caps and redacts returned values. Data tool results reach the model wrapped as untrusted data. Only record-free navigation and help use the core AI tool kernel.
## Requirements
- Java 21.
- **QQQ 4.1 with the AI tool kernel (QRun-IO/qqq#1078) and the developer-panel contract (QRun-IO/qqq#1076).** The first QQQ 4.1 RC that contains both is the minimum. `qqq-middleware-javalin` is needed for the routes. Until that RC exists, build against a local QQQ build. See [Build](#build).
- A backend for the assistant's tables, e.g. the app's RDBMS backend.
- For Claude: an Anthropic API key held in the application's secret store.
## Install
```xml
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-assistant</artifactId>
<version>${qbit-assistant.version}</version>
</dependency>
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-assistant-provider-anthropic</artifactId>
<version>${qbit-assistant.version}</version>
</dependency>
```
```java
AssistantQBitConfig config = new AssistantQBitConfig()
.withBackendName("rdbms") // where the assistant_* tables live
.withTableMetaDataCustomizer(snakeCaseCustomizer) // optional: backend names for your schema
.withModelProvider(AnthropicModelProvider.fromEnvironment()) // fallback while no provider row exists
.withProviderType(new AnthropicProviderType()); // offered on the Providers settings screen
new AssistantQBitProducer().withQBitConfig(config).produce(qInstance);
```
`produce` does the following:
- Registers the QBit.
- Adds the tables, the `assistant_user_id` security key type, the `assistantRetention` and **Test connection** processes, and the **Assistant** app with its home.
- Builds the runtime.
- Registers the `/qqq/v1/assistant/*` routes when Javalin is on the classpath.
- Registers the record Developer-screen panel when QQQ core has the developer-panel contract.
Create the tables in your database: see [Tables](#tables). Then grant the permissions.
### The API key
The key never goes in config, tables or logs. It lives in the application's secret store and reaches the server as an environment variable (for kof22: Infisical, then an ExternalSecret, then the pod env).
- **Provider settings rows** (recommended) name the variable: `assistant_provider.apiKeyEnvName`, default the type's own (`ANTHROPIC_API_KEY`). The server reads the variable when it builds the client.
- Without a row, the provider registered in code is used: `AnthropicModelProvider.fromEnvironment()` reads `ANTHROPIC_API_KEY`, and `AnthropicModelProvider.builder().apiKey(valueFromYourSecretStore).build()` takes the key from your own secret store (it also accepts `"${env.NAME}"`).
The provider's `toString` never contains the key. Its errors carry only the HTTP status and the API error type.
### Starter defaults
Include `db/liquibase/qbit-assistant/assistant-defaults.changelog.sql` (shipped in
the jar) after your assistant DDL. A new installation then has a policy row, a
default profile with four starter skills, a $50/month instance budget, current
Anthropic prices and a disabled example scheduled task. Every row is insert-only,
so administrators can edit them freely. The home's **Setup** card shows what is
set up and the next step for the rest (usually: add a provider).
## Provider settings (the Providers table)
Admins set up providers on the **Providers** screen (`assistant_provider`), a standard QQQ table:
| Field | Meaning |
|---|---|
| Name, Type, Model | The type is one of the registered provider types (`withProviderType`; Anthropic now, others plug in the same way). The model list comes from the type and the price table. An empty model uses the policy's main model. |
| Enabled, Default | The assistant uses the default enabled provider, else the first enabled one. Marking one default unmarks the others. With no enabled row, the provider registered in code is used. |
| API Key Variable | The **name** of the environment variable that holds the key, never the key. Only allowed names are accepted: each registered type's default plus `allowedApiKeyEnvNames`. A pasted key (or `DATABASE_PASSWORD`) is refused and nothing is stored. |
| API Key | Read-only, computed on every read: `Set • …abcd (from ANTHROPIC_API_KEY)` or `Not set (...)`. It shows the last four characters at most. No read path (screen, API, query, export) returns the key. |
| Base URL Override | Empty uses the provider's own API. Any other URL must be listed in `allowedBaseUrls`, so a row cannot send the key to a host of an admin's choosing. |
| Monthly Cost Cap (USD) | When this provider's cost this month (UTC) reaches the cap, new requests are refused (429 `BUDGET`) and a running answer stops before its next model call. |
| Max Output Tokens Per Request, Max Concurrent Requests | Caps one model call's output, and how many calls run at once on this server. |
| Last test | Status, time, latency, the model that answered, and the error text, from **Test connection**. |
**Why a reference and not an encrypted column.** QQQ core has no encrypted or secret field type. The key-provider and cipher work in the QQQ 4.1.0-RC.6 batch (QRun-IO/qqq#1123, #1107) encrypts backup artifacts, not table columns, and is not released. The design spec (§2.8) also says keys never go in tables. A reference keeps the key in the secret store that already holds it, so rotating it there is the "replace key" step, and the database never holds one.
**Test connection** is a record action on a provider (select one or more). It is a standard streamed ETL: Review lists what will be called and anything wrong with the row (missing key, disallowed variable or URL) before any call. Result has one line per provider: `connected to claude-sonnet-5-5 in 812 ms`, or the error with its kind and HTTP status. It makes one short live call (a 1-sentence prompt, at most 32 output tokens, effort low), saves the result on the row, and records the call in `assistant_usage` with purpose `TEST`.
## Health checks
The producer registers dynamic central checks in component `assistant`, category `AI`:
| Name | Kind | Interval | Timeout |
| --- | --- | --- | --- |
| `assistant.provider.<id>` | DEPENDENCY | 300s; 60s for LOCAL providers | 10s |
| `assistant.model.<id>` | DEPENDENCY | 21600s (6 hours) | 30s |
Suppliers include enabled provider/model rows and refresh with the central registry.
Provider checks reuse `AssistantProviderType.checkHealth`. Unsupported probes use
circuit-breaker state only when less than ten minutes old, otherwise return UNKNOWN.
Model checks reuse `ConnectionTest` (embeddings for embedding models), respect the
provider's monthly cost cap and the kill switch, and leave manual `lastTest*` fields
unchanged. A capped check returns UNKNOWN, `skipped: cost cap`, even on demand.
The automated runner records usage under its own owner key. Provider caps use the
existing shared accounting projection without access to other users' usage records.
Usage purpose is a string, so health diagnostics reuse `TEST` (not a new `HEALTH` enum).
All checks honor their deadlines; messages and details omit provider error text,
credentials, endpoints, prompts and completions.
The LOCAL circuit breaker consults central results less than 60 seconds old before
its lazy probe. UP/DEGRADED avoids a duplicate probe, DOWN rejects the call, and
UNKNOWN or stale/missing results retain the existing probe fallback. The breaker
and manual Test connection actions remain available.
This integration uses the core health SPI released in QQQ 4.1.0-RC.8.
The POM pins that release; verify with `./mvnw -o verify`.
## The app home
The **Assistant** app's home shows configuration, usage and cost read as the viewing admin. A generic status card summarizes the central runner's latest results and links to System Health filtered to `assistant`; it shows UNKNOWN before results are available. Health history and check lists live in System Health.
- **Configuration and usage:** default provider, key status, the 7-day error rate of model calls, cost and requests this month against the instance's monthly limit.
- **Providers:** one row per provider: type, model, masked key status, cost this month against its cap, limits, and any problem with its settings.
- **Usage, last 30 days:** bars of model requests per day, totals (conversations, requests, tokens in and out, cost) and the last 7 days in words.
- **Costs this month:** month to date against the policy limit and each provider cap, then by model. Cost is tokens times the model's price from **Model prices**.
- **By user, this month**, **Top tools** (calls, failures, average time) and **Recent failures** (model calls and tool calls, linked).
The nav lists the home and the main tables: **Providers**, **Conversations**, **Model prices** and **Policy**. Messages, turns, tool calls, feedback and model calls are child lists on a conversation; model calls are also listed on their provider. Retention and Test connection are table and record actions, never nav entries.
## Model prices
`assistant_model_price` holds USD per million tokens per provider type and model: input, output, cache read, and cache writes (5 minute and 1 hour). Empty cache prices default to 0.1x, 1.25x and 2x the input price. The reference DDL seeds the Anthropic list prices of 2026-10. Edit a row to change what later calls cost; past rows keep the cost they were recorded with. A model with no row uses the `pricing` config map, else costs 0.
## Configuration
| Setting | Default | Meaning |
|---|---|---|
| `enabled` | `true` | Per-app switch. The environment variable `QQQ_ASSISTANT_ENABLED=false` also turns it off. |
| `backendName` | (required) | Backend of the `assistant_*` tables. |
| `providerName` | `anthropic` | Which registered provider to use. |
| `models` | Sonnet 5.5 at effort `medium` | `mainModel`, `mainEffort`, `mainMaxOutputTokens` (16000). The escalation (Opus 5.5, `high`) and summary (Haiku 5.5) slots are reserved for phase 2. Model ids are the exact strings `claude-sonnet-5-5`, `claude-opus-5-5` and `claude-haiku-5-5`. |
| `defaultExposure` | `VALUES` | The kernel's default exposure. Use `METADATA_ONLY` for high-sensitivity apps. |
| `maxToolCallsPerTurn` | 25 | Tool calls allowed in one answer. |
| `maxParallelToolCalls` | 4 | Independent read tools that run at the same time. |
| `maxRowsToModel` / `maxCellChars` | 50 / 500 | Caps on what the model sees (the kernel applies them). |
| `maxTurnSeconds` | 300 | Wall-clock limit for one answer. |
| `budgets` | none | `userDaily{Tokens,CostMicros}{Soft,Hard}` and `instanceMonthly{Tokens,CostMicros}{Soft,Hard}`. A soft limit adds a warning. A hard limit refuses new turns (429) and stops a running turn after its current step. Tokens counted are uncached input, cache writes and output. Cost is in micro-USD. |
| `maxTurnsPerUserPerMinute` / `maxConcurrentTurnsPerUser` | 10 / 1 | Rate limits. |
| `conversationRetentionDays` / `toolResultRetentionDays` / `approvalRetentionDays` | 90 / 30 / 365 | Used by `assistantRetention`. |
| `retentionSchedulerName` | none | Runs `assistantRetention` daily on that scheduler. |
| `ownerSecurityKeyType` | `assistant_user_id` | Key type of the owner record locks. |
| `pricing` | Anthropic list prices, 2026-10 | USD per million tokens per model, used for cost and budgets. |
| `allowedWidgetNames` | none | Dashboard widgets `render_widget` may render. The kernel denies every widget not on this list. This config list is the **only** way to allow a widget: QQQ widget meta-data has no supplemental hook, so there is no per-widget "assistant-safe" flag. Allow only widgets that render the same data for every user who can see them. |
| `hiddenWidgetNames` | none | Widgets that are never rendered, even when allowed. |
| `customRedactionPatterns` | none | Extra regular expressions the kernel redacts from tool results. |
| `killSwitchPollMillis` | 2000 | How often a running turn re-checks the kill switch while it waits on the model. |
| `providerTypes` | none | `withProviderType(...)`: the types the Providers screen offers (`AnthropicProviderType`). |
| `allowedApiKeyEnvNames` | none | Extra environment variable names a provider row may name as its key. Each type's default is always allowed. |
| `allowedBaseUrls` | none | Base URL overrides a provider row may use. The type's own URL is always allowed. |
| `secretResolver` | `System::getenv` | How a key variable name becomes the key (a host secret store, or tests). |
| `providerTestTimeoutSeconds` | 30 | How long Test connection waits for the model. |
| `homeMaxRows` | 20000 | Rows per table the app home reads for its period; it says when totals are partial. |
| `tablePathPrefix` | `/app` | The front of the links the app home makes to tables and records. |
| `emailExternalApproved` | `false` | Stands in for the EXTERNAL approval rule (not built yet): scheduled-task result email is sent only when `true`. |
| `emailRecipientResolver` | owner id if it is an address | Hook: task owner user id to email address. |
| `attachments` | on; 10 MB per file, 25 MB per question | Files attached in the panel (see [Attachments](#attachments)): `enabled`, `maxFileBytes`, `maxTurnBytes`, `maxFileChars` (50,000), `maxTurnChars` (150,000), `maxPdfPages` (50), `previewRows` (20), `maxImageBytes` (5 MB), `maxImageDimension` (8000 px). |
At runtime, an admin can override the kill switch, the main model and effort, and the budgets in the single **`assistant_policy`** row.
A running turn notices the kill switch in two ways:
- It re-reads the switch before every model call and tool step.
- A watcher polls it every `killSwitchPollMillis` while the turn waits on the model.
When a turn sees the switch on, it cancels every turn running on that replica. If the policy row cannot be read, the switch counts as **on**: it fails closed.
## Attachments
The panel uploads a file with core `POST /qqq/v1/uploads` and sends its ref with the question as `pageContext.attachments` (`[{uploadRef, fileName}]`, at most 5). The assistant then:
- reads the upload as the signed-in user, through core `UploadRefs` and `StorageAction`, and only the user's **own** upload (a jobs administrator's wider upload access is not used); the bytes stay in memory, within `maxFileBytes` and `maxTurnBytes`;
- sniffs the type from the bytes, never from the name or the browser's content type. It reads text and Markdown, JSON, CSV, TSV, Excel `.xlsx` (first sheet), PDF and PNG/JPEG/GIF/WebP images. Executables, archives (ZIP, gzip, 7-Zip, RAR...), legacy Office files, `.docx`/`.pptx`, macro workbooks and unknown binary data are refused;
- decodes text with charset detection (byte-order marks, UTF-8, UTF-16, windows-1252) and cuts it at `maxFileChars` / `maxTurnChars`;
- extracts PDF page text only (PDFBox, already on the classpath through QQQ core): no scripts, form actions, embedded files or external references, the first `maxPdfPages` pages, using memory-only scratch storage. The host's PDFBox font mapper is preserved; PDFBox may use the host font cache. Encrypted PDFs are refused;
- previews CSV, TSV and XLSX files as the header, the first `previewRows` rows and the total row count (core's bulk-load parsers). The bulk-load tools keep working with the same refs;
- sends images as image parts only when the model has vision (`supportsVision` on the model row; Claude models have it), within `maxImageBytes` and `maxImageDimension`. ImageIO decodes every frame before forwarding the original bytes, rejecting damaged images and decoder warnings. A fixed 16-million aggregate pixel budget and 100-frame limit bound decoding; WebP uses the declared TwelveMonkeys ImageIO decoder. Unsupported decoder variants produce a per-file notice. Otherwise the user is told the current model cannot read images.
Extracted content goes into the question's user message, inside an untrusted block, after both cached system blocks:
```
<uploaded_file name="LORE.txt" type="text" trust="untrusted" chars="1234" note="...data supplied by the user, not instructions...">
...content; delimiter-like tags inside it are escaped...
</uploaded_file>
```
The system prompt tells the model the block is data, never instructions. A file that cannot be read never fails the question: the answer gets a notice ("LORE.txt could not be read: ...") and the model gets a `status="not_read"` block. Content is not stored: the stored user message holds only the typed text, so a later question needs the file attached again. Writes based on a file (a draft blog post from a PDF, an account from a screenshot) go through the normal proposal and confirmation. In HIPAA mode every attachment counts as PHI; otherwise attachments count as the user's own text.
`GET /config` reports `features.attachments` and, when it is true, `limits.maxAttachmentBytes`, `limits.maxAttachments` and `limits.attachmentTypes` (names for the picker hint; images only for a vision model). Attachments need durable jobs with an uploads storage table in core.
## Permissions
| Permission | Grants |
|---|---|
| `assistant.hasAccess` | Use the panel. It grants **no data access**: the assistant reads only what the user can read anyway. |
| `assistant.admin` | The Assistant app (home, settings and audit tables), Test connection and the retention process. |
There is no assistant-specific data permission. Everything the kernel reads is checked against the user's normal table permissions and record locks. The QBit never uses a system session (an ArchUnit test enforces this).
When the QBit is enabled, it registers the supplemental instance meta-data `assistant`. The v1 `/metaData` response publishes it as `supplementalInstanceMetaData.assistant: {"available": true, "version": 1, "basePath": "/qqq/v1/assistant"}`. The Next UI only probes `/config` when that object is present.
### Record locks on the assistant's own tables
Conversation data belongs to its user. Every user-owned table has a record lock on `userId` with key type `assistant_user_id`:
- The panel routes add the user's own id to their session, so users only ever see their own rows.
- Admins see everyone's rows in the Assistant app through the key type's all-access key, `assistant_user_id_all_access`. Grant that key to `assistant.admin` holders from your authentication customizer:
```java
// in your QAuthenticationModuleCustomizerInterface.customizeSession(...)
AssistantSecurity.applySessionKeys(qSession, assistantConfig);
```
## Data policy
What reaches the model provider:
- the tool definitions
- a fixed safety prompt
- an app digest (labels and descriptions, no data)
- the user's name, timezone and readable-table list
- the conversation's text
- tool results produced by the kernel.
The kernel applies these rules to tool results:
- Hidden fields, password fields and fields with a security lock are never sent.
- Exposure `METADATA_ONLY` and `MASKED` fields are filtered or masked.
- Rows are capped at 50 and cells at 500 characters.
- Secrets such as keys, JWTs and card numbers are redacted.
- The `assistant_*` tables are never visible to the model.
Old tool results are cleared from the context of later turns; only one-line summaries remain.
Stored in your database:
- every message
- every tool call: sanitized input, outcome, permission check, row count, duration, redactions, and the model-visible result. After `toolResultRetentionDays`, the result is cleared, stream events are deleted, and render payloads and record references are removed from stored messages. Summaries and answer text stay until `conversationRetentionDays`.
- turn accounting: tokens, cache reads and writes, and cost
- streamed events, kept for replay
- feedback
- daily usage.
Logs never contain message content.
## HTTP API
Base path `/qqq/v1/assistant`. Every request is authenticated through the app's normal session (cookie, bearer token or API key). Request bodies must be JSON.
| Method | Path | |
|---|---|---|
| GET | `/config` | Feature probe: `{enabled, reason?, shortcut, model, limits, features}` (`features.attachments` with `limits.maxAttachmentBytes`, `maxAttachments` and `attachmentTypes`). A 404 means the QBit is not installed. |
| GET / POST | `/conversations` | List your conversations (`?status=ARCHIVED`), or create one. |
| GET / PATCH / DELETE | `/conversations/{id}` | Load with paged messages (`?before=&limit=`), rename / pin / archive, or delete. |
| POST | `/conversations/{id}/turns` | Ask a question. The response is an SSE stream. |
| GET | `/turns/{id}/events?after={seq}` | Replay a turn's events after a sequence number, then follow it live. |
| POST | `/turns/{id}/cancel` | Stop an answer. |
| POST | `/messages/{id}/feedback` | Rate an answer: `{rating: 1 or -1, reason?, comment?}`. |
The stream sends these events, each with a `seq`:
- `message.start`, `text.delta`
- `tool.start`, `tool.result` (with `render` and `recordRefs`)
- `presentation` (`messageId`, `part`: summary, blocks and server-built GFM markdown)
- `ui.navigate`, `notice`
- `usage`, `message.end`
- `error`
- `ping` every 15 seconds.
The stream ends after `message.end`. Thinking is never streamed.
### SSE behind a proxy (Traefik, nginx)
- The responses carry `Cache-Control: no-cache` and `X-Accel-Buffering: no`. Do not compress or buffer `text/event-stream`.
- Give the routes an idle timeout longer than the 15-second ping interval and a total timeout of at least `maxTurnSeconds`. For Traefik, raise the entrypoint's `respondingTimeouts` (`readTimeout`, `writeTimeout`, `idleTimeout`) or put the assistant routes on a router whose transport allows it. Don't add a buffering middleware to these routes.
- A dropped connection does not stop the answer. The client resumes with `/turns/{id}/events?after=<last seq>`.
## Tables
All of these are standard, visible QQQ tables in the Assistant app. The model never reads any of them (AI exposure `NONE`).
| Table | Holds |
|---|---|
| `assistant_conversation` | Conversations. |
| `assistant_message` | Messages (`partsJson`). |
| `assistant_turn` | Turn accounting. |
| `assistant_turn_event` | Stream events, for replay. |
| `assistant_tool_call` | The audit of every tool call. |
| `assistant_feedback` | Feedback on answers. |
| `assistant_policy` | The kill switch and overrides. |
| `assistant_usage_daily` | Usage rollups: per user per day, plus an instance row whose `userId` is null. |
| `assistant_provider` | Provider settings: type, model, key variable (never the key), limits, last connection test. |
| `assistant_model_price` | USD per million tokens per model, used for every call's cost. |
| `assistant_usage` | One row per model call (a turn step or a connection test): user, conversation, turn, provider, model, status, latency, tokens, cost. Locked to its user like the other audit tables. |
Table names follow the QRun-IO naming rule: lowercase snake_case with the `assistant_` prefix, the same as the physical tables in `docs/schema-postgres.sql` (the reference DDL). Field names are camelCase. Map them to your columns with a table customizer. Upgrading from 0.1.0-RC.1, which used camelCase table names and the `assistantUserId` key type: see [UPGRADE.md](UPGRADE.md).
## Build
```bash
./mvnw verify # against the committed QQQ version
./mvnw verify -Dqqq.snapshot.version=4.1.0-ASSISTDEV-SNAPSHOT # against a local QQQ build with #1078 + #1076
```
Two test groups run in every build and fail it on any error:
- the golden evals (JUnit tag `golden`): scripted conversations against a seeded fixture app
- the permission red team (tag `redteam`).
The **live** evals run the same golden tasks against Claude. They are skipped unless both `ANTHROPIC_API_KEY` and `-Dassistant.live=true` are set:
```bash
ANTHROPIC_API_KEY=... ./mvnw verify -Dassistant.live=true -Dtest.excludedGroups= -Dgroups=live -Djacoco.skip=true -pl qbit-assistant-provider-anthropic -am
```
## Providers
Add `com.kingsrook.qbits:qbit-assistant-provider-openai` at the same version as
the QBit and register the adapter alongside any Anthropic adapter:
```java
config.withProviderType(new OpenAiCompatibleProviderType());
```
`OpenAiCompatibleProviderType` is in
`com.kingsrook.qbits.assistant.provider.openai`. Its installed type id is
`openai-compatible`; provider rows choose OPENAI, AZURE_OPENAI, OPENROUTER,
OLLAMA, LM_STUDIO, VLLM, LLAMA_CPP or OPENAI_COMPATIBLE as their kind.
- Local kinds use their localhost endpoint by default and need no key. Leave
`apiKeyEnvName` empty. An auth proxy can use an explicitly allowed key env name.
- Cloud defaults use `OPENAI_API_KEY`, `AZURE_OPENAI_API_KEY` or
`OPENROUTER_API_KEY`. Values stay in the host's secret store.
- An override must match an entry in `config.withAllowedBaseUrls(Set.of(...))`.
Azure and OPENAI_COMPATIBLE have no default URL and always require an entry.
Redirects are not followed. Azure uses the deployment model id and defaults
`apiVersion` to `2024-10-21`.
- **Discover models** adds a provider's Models child list; existing edits survive.
Large cloud catalogs (over 20 models) start disabled. **Test connection** is
available on both providers and models and records a TEST usage row.
- A selected registered model must be enabled. With no model selected, the first
enabled registry model wins; providers with no model rows retain legacy selection.
Registry prices override type/model prices, then configured prices. Unpriced
local models cost zero. Capabilities can be adjusted per registered model.
For Ollama behind a TLS proxy with a private CA and bearer authentication:
```java
config.withProviderType(new OpenAiCompatibleProviderType())
.withAllowedBaseUrls(Set.of("https://llm.example.lan:8443/v1"))
.withAllowedApiKeyEnvNames(Set.of("LOCAL_LLM_API_KEY"))
.withTrustedCaBundleFile("local-llm", Path.of("/etc/app/llm-root-ca.pem"));
```
Set `LOCAL_LLM_API_KEY` in the server environment from your secret store. In the
provider record choose kind `OLLAMA`, base URL `https://llm.example.lan:8443/v1`,
`apiKeyEnvName = LOCAL_LLM_API_KEY`, and `tlsCaBundle = local-llm`. Only the names
are stored. `withTrustedCaBundle("local-llm", pem)` also accepts one or more PEM
certificates directly at startup. Invalid PEM fails configuration immediately.
The named bundle adds to the JVM roots; hostname verification stays enabled.
Leave `tlsCaBundle` empty to use JVM trust alone. Restart after changing a CA file.
Default tests use localhost mocks. To exercise an existing development server,
set `ASSISTANT_LIVE_OPENAI_BASE_URL` and `ASSISTANT_LIVE_OPENAI_MODEL`, optionally
`ASSISTANT_LIVE_OPENAI_KIND` (default OLLAMA), `ASSISTANT_LIVE_OPENAI_KEY_ENV`
(the variable name containing the key), and `ASSISTANT_LIVE_OPENAI_CA_FILE`
(a PEM bundle path). Unset or unreachable endpoints, including TLS failures, skip
the live test. Run:
```sh
./mvnw verify -pl qbit-assistant-provider-openai -am -Dtest.excludedGroups= -Dgroups=live -Djacoco.skip=true
```
### AWS Bedrock (Claude)
Add `com.kingsrook.qbits:qbit-assistant-provider-bedrock` at the QBit version and
register `com.kingsrook.qbits.assistant.provider.bedrock.BedrockProviderType`:
```java
config.withProviderType(new BedrockProviderType());
```
Create a provider row with type `bedrock`, kind `BEDROCK`, and the required AWS
`region` (for example `us-east-1`). Leave `apiKeyEnvName` empty. The server uses
the AWS default credential chain: environment variables, profiles and container
credentials (ECS/EKS Pod Identity) work with the transitive dependencies.
**For IRSA / web-identity credentials, the host application must add
`software.amazon.awssdk:sts` to its runtime classpath**, matching the transitive
AWS SDK version. The default chain picks it up when present; this adapter does
not include STS or the full AWS SDK. Credentials are never stored in tables.
Leave the base URL empty for the SDK's
`https://bedrock-runtime.<region>.amazonaws.com` endpoint. Overrides, including
VPC endpoints, must be listed in `withAllowedBaseUrls`. Model ids are Bedrock
Claude ids or inference profile ids, for example
`us.anthropic.claude-sonnet-4-5-20250929-v1:0`. The model must be available to the
server's AWS role in that region.
### Google Vertex AI (Claude, Gemini)
Add `com.kingsrook.qbits:qbit-assistant-provider-vertex` at the QBit version and
register `com.kingsrook.qbits.assistant.provider.vertex.VertexProviderType`:
```java
config.withProviderType(new VertexProviderType());
```
Create a row with type `vertex`, kind `VERTEX`, required `region` (for example
`us-east5`) and `cloudProject` (the GCP project id). Leave `apiKeyEnvName` empty.
The server uses Google Application Default Credentials, such as
`GOOGLE_APPLICATION_CREDENTIALS` or workload identity. Access tokens are refreshed
when expired, held only in memory, and obtained afresh for each Gemini request.
Models beginning `claude-` (for example `claude-sonnet-4-5@20250929`) use the
Anthropic Vertex backend's `streamRawPredict` API. All other model ids use Vertex's
OpenAI-compatible endpoint:
```text
https://{region}-aiplatform.googleapis.com/v1/projects/{project}/locations/{region}/endpoints/openapi
```
Unprefixed ids, such as `gemini-2.5-flash`, are sent as `google/gemini-2.5-flash`;
already-prefixed ids are preserved. Gemini requests omit `strict`, including when
a tool opts in. An allow-listed base URL overrides the Claude API origin or the
complete OpenAI-compatible base URL, according to the selected model. Use separate
provider rows when an override needs different URL prefixes for the two APIs.
Both cloud types default to CLOUD and expose a short static model catalog.
**Discover models** returns those defaults; it does not call Bedrock's
`ListFoundationModels` (which needs the control-plane SDK) or Vertex Model Garden.
Health probes are unsupported; **Test connection** performs the existing one-shot
completion. Model access and availability depend on the configured region/project.
`hipaaEligible` is an administrator-recorded fact: a BAA and the associated legal
and operational requirements remain owner responsibilities. The flag does not
establish eligibility or implement PHI routing.
The default build tests both clouds using localhost only, fake credentials, AWS
binary event-stream framing, Claude SSE and Gemini Chat Completions, including
engine/tool golden turns. No cloud credentials or live endpoints are required.
## License
Apache 2.0.
## Email through qbit-email
When the optional [qbit-email](https://github.com/QRun-IO/qbit-email) is on the classpath **and** produced in the instance, a finished scheduled-task run can be emailed to its owner. Without it nothing changes: `AssistantEmail.sendScheduledResult` returns `NOT_AVAILABLE`. The dependency is `optional`; every reference to it is in `AssistantEmailSender`.
This integration uses qbit-email **0.1.0-RC.2** through its published `com.kingsrook.qbits.email.api` package. Scheduled tasks (P-g) are available, but the runner does not yet wire email delivery. Hosts can call `AssistantEmail.sendScheduledResult(qInstance, config, new ScheduledResultNotice(...))` after a run, supplying its owner, classification, absolute run link and explicit opt-in as `emailResult`. Persisting a task-level email opt-in and wiring the runner remain a separate integration step.
Gates, in order (each returns a status and queues nothing):
1. the task opted in (`NOT_REQUESTED`);
2. qbit-email is installed (`NOT_AVAILABLE`);
3. `emailExternalApproved` is `true` (`NOT_APPROVED`). The EXTERNAL approval rule exists only in the spec, so this explicit flag gates it until the rule is built;
4. a recipient resolves (`NO_RECIPIENT`): `emailRecipientResolver` maps the owner's user id to an address; by default an owner id that already is an address is used.
What is sent, one message per recipient, idempotency key `assistant:scheduled:<run id>`, linked record the run:
- result classified PUBLIC (or none): template `assistant_scheduled_result` (NOTIFICATION, Markdown, registered on first send and never overwritten) with the task name, run time, a summary cut to 280 characters and a link to the run. The full model output is never emailed;
- result classified above PUBLIC (core's `DataClassification`): only the link, through qbit-email's `email_secure_message` pattern. No task name, summary or output.
The outbox result maps to `QUEUED`, `DUPLICATE`, `SUPPRESSED` or `REFUSED` (for example a link that is not absolute http(s)). Addresses and bodies are never logged.
## Data classification and HIPAA mode
Classify application tables and fields in core's AI metadata (`AiTableMetaData` / `AiFieldMetaData`, QQQ 4.1.0-RC.6), or in the host configuration (a configured value wins when both are set):
```java
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
AssistantQBitConfig config = new AssistantQBitConfig()
.withDataClassification("patient", DataClassification.PHI)
.withDataClassification("patient", "id", DataClassification.PUBLIC)
.withDataClassification("patient", "email", DataClassification.PII)
.withDataClassification("patient", "status", DataClassification.PUBLIC)
.withHipaaMode(true)
.withHipaaRetentionDays(2190)
.withConversationEncryptionKeyEnvName("ASSISTANT_CONVERSATION_KEY");
```
Set that environment variable through your secret manager to a base64-encoded,
random 256-bit (32-byte) key. Supply only its **name** in config, never the key.
The existing server-side `withSecretResolver` hook also resolves this reference.
Startup validates the key. HIPAA mode defaults off; encryption can also be enabled
with the key reference while HIPAA mode is off.
The levels are core's `DataClassification` (PUBLIC < CONFIDENTIAL < PII < PHI); the
qbit's former stand-in enum is gone. Unconfigured tables default to PUBLIC; a field
falls back to its table unless explicitly overridden. A field subset uses its
selected fields; unrestricted reads and record page contexts use the highest
classification across the table and its fields. Returned columns and record-label
fields also count: tools include a primary key even for subsets, so classify it
explicitly if the host considers it safe for that subset. Cross-table results with unknown
tables use the highest classification anywhere. Metadata-only tools remain PUBLIC.
The assistant's own tables remain denied to tools.
The default `ClassificationSource` reads core's metadata (`AiDataClassifications`,
`AiTableMetaData`, `AiFieldMetaData`) merged with the configured map; configuration
wins when both are set. Hosts can still supply `withClassificationSource(ClassificationSource)`.
PHI is sent only to a persisted provider row explicitly marked `hipaaEligible`.
Every model dispatch rechecks eligibility, including calls after tools and strict
schema retries. Blocks end the turn with `PHI_BLOCKED` or `HIPAA_PROVIDER`; the
PHI tool result is never sent to that provider. There is no automatic fallback.
A conversation keeps its highest classification, so moving to a PUBLIC page does
not make existing PHI history safe for a non-eligible provider.
HIPAA mode also refuses non-eligible providers at resolution and when enabling
settings rows, disables code-only fallback, requires encrypted conversation
storage, and suppresses provider error details in logs and turn errors. The app
home displays HIPAA mode and PHI retention. The eligibility flag records the
operator's decision (including any applicable BAA); it does not establish one.
Both modes audit each PII/PHI model call and every routing block in
`assistant_data_audit` (admin read-only, owner-locked, on the conversation screen).
PHI conversations use `hipaaRetentionDays` even with HIPAA mode off. A null value
uses normal retention; tool-result/replay cleanup remains independently configured.
See [UPGRADE.md](UPGRADE.md) for schema changes, plaintext migration, and key handling.
## Data-driven budgets
Admins configure **Assistant > Budgets** for UTC DAY or MONTH allowances in USD or
TOKENS, scoped to INSTANCE, USER, ROLE (a permission), PROVIDER or MODEL. Provider
and model scope values are registry record ids. Warnings default to 80%; at 100%
the default action is DOWNGRADE, and the hard ceiling defaults to 120% (always
STOP). Multiple budgets apply together and STOP wins.
An explicit downgrade model is optional. Otherwise the cheapest enabled usable
model is selected, preferring LOCAL providers. PHI never goes to a provider that
is not HIPAA-eligible. No eligible target means STOP. Administrators can grant
extra allowance or temporary unlimited use from the budget's Overrides child list,
with a required reason and expiry; the session sets the grantor.
Budget state changes appear once per scope/period/state in the Events child list,
as panel notices and, through core QAlerts, bell alerts when an alerts sink is installed (logging otherwise).
The Assistant home shows budgets, overrides, daily cost and tokens, and breakdowns
by user, model, provider and tool. Existing `AssistantBudgets`/policy limits and
provider monthly caps continue to apply independently of these rows and overrides.
See [UPGRADE.md](UPGRADE.md) for the required additive schema.
### Profile routing and provider fallback
On an Assistant Profile, **Model routing** defaults to OFF. HEURISTIC selects the
complex slot for long questions (>600 characters), two mentioned app tables,
analysis/comparison/explanation/trend/planning requests, pending writes, more than
eight previous turns, or a previous tool limit; otherwise it selects the simple
slot. CLASSIFIER uses an optional registry model with just the question and a
fixed instruction, four output tokens and a three-second timeout. An invalid reply,
error, unavailable classifier or PHI-ineligible classifier uses HEURISTIC. ROUTE
usage counts toward budgets. Empty model slots keep the provider/policy default;
budgets may downgrade either choice. Without a profile, enabled code-level
`AssistantModelRouting.escalation*` supplies the complex default.
A model's **Fallback model** points to its next candidate, including another
provider. Chains are cycle-free and bounded to four hops. Only retryable failures
before any text or tool start from that call permit fallback. Disabled, down,
PHI-ineligible or budget-blocked candidates are skipped. Without explicit links,
only another LOCAL model in the same adapter family is eligible; local requests
never automatically move to cloud. Provider state/signatures are removed when
switching, preserving neutral tool history. Each attempted call is audited as usage.
NETWORK/TIMEOUT marks a provider DOWN for 60 seconds; success marks it UP. Local
health uses recent central results or a lazy probe with a two-second timeout. The
central health card links to System Health; failures show fallback totals, and turn records show the
route and final destination. See [UPGRADE.md](UPGRADE.md) for the additive schema.
## Scheduled tasks
Administrators configure recurring prompts in **Assistant → Scheduled tasks**.
Tasks use Quartz cron syntax (for example `0 0 8 * * ?`) and an IANA timezone
(for example `America/Chicago`; default `UTC`). Tasks start disabled.
```java
config.withUiApiBaseUrl("http://127.0.0.1:8000")
.withScheduledTasksSchedulerName("hostScheduler")
.withAllowedScheduledCredentialEnvNames(Set.of("ASSISTANT_SCHEDULED_TOKEN"));
```
Provide a long-lived access token accepted by the host's authentication provider
in that environment variable. Store only its **name** on the task and set
`runAsUserId` to the corresponding QQQ user id. The runner posts the token to
`/qqq/v1/manageSession`, reads its session cookie and verifies the returned
session's user before starting a new conversation. No system session reads user
data. Configure the loopback URL at startup; a scheduled job cannot rely on a
browser having visited first.
For kof22, provision a designated Authentik account with the intended application
permissions, obtain a token that kof22's QQQ authentication provider accepts as an
`accessToken`, and inject it through the existing secret-manager/ExternalSecret
pipeline. An Authentik management API token is suitable only if the host's auth
adapter explicitly accepts it. Rotate the token in the secret manager, never in
the task table. This feature does not provision an account or token.
**Run now**, **Approve write mode** and **Revoke write mode** are Review/Result
record actions. Write approval names the designated user and displays the exact
prompt SHA-256. Changing prompt, user, credential reference, profile or write-mode
request clears approval. Scheduled writes always wait for that user's panel
confirmation, even if normal policy disables confirmation.
Each run keeps an owner-locked audit row and a normal conversation. RECORD adds
no notification; NOTIFICATION and BOTH also notify the designated user through
the optional Alerts QBit SPI. Without Alerts, notification delivery is log-only.
HIPAA summaries are always `see conversation`. Cost caps stop at dispatch
boundaries, so the last completed call can cross the cap; no further call starts.
Apply the [P-g migration](UPGRADE.md#p-g-scheduled-tasks) before enabling jobs.
### Answer presentation
The always-available `present_results` tool turns retrieved results into metrics,
tables, charts and actionable in-app links. The server validates the user's UI API
visibility, formats field values, computes shares and builds all links. It emits
and stores a `presentation` part with a complete GFM Markdown fallback. Profile
skills can extend the built-in presentation instructions. See the
[part, stream and input contract](docs/answer-presentation.md).
The image-breakdown golden task also runs in the existing opt-in live provider suite:
`./mvnw verify -Dassistant.live=true -Dtest.excludedGroups= -Dgroups=live -Djacoco.skip=true -pl qbit-assistant-provider-anthropic -am`
(with `ANTHROPIC_API_KEY` set). Live calls are excluded from normal verification.
### Record history and the assistant channel
Core's default `ClientChannelsMetaData` denies `audit*` to the `assistant`
channel. Accordingly, `get_record_history` is offered only when all three audit
tables it queries (`auditTable`, `audit`, and `auditDetail`) exist and are not
channel-denied, including their effective permission rules. A host that intends
to expose record history can opt in during instance configuration:
```java
new AssistantQBitConfig()
.withRecordHistory(true);
```
This is an owner decision and a hard wall: record history defaults to disabled;
**kof22 does not opt in**. On QQQ RC.6, the opt-in removes exactly `audit*`
from the assistant's denied patterns (the former manual configuration step).
RC.6 does not add a separate channel write denial for audits. On later RCs that
include QRun-IO/qqq#1193, it calls `withAssistantAuditReads()`, which allows audit
reads but denies audit writes. The user's own audit permissions still apply.
Other patterns, including `worm_audit_*`, `assistant_*`, and security surfaces,
remain untouched, as do explicit object and permission-rule denials.
History visibility uses core's `isDenied` read/all-operation check. RC.6 has no
operation argument; the later write-only deny patterns must remain separate
from that check so they do not hide the read tool.
## In-app help
The QBit registers [`help/qbit-assistant.yaml`](qbit-assistant/src/main/resources/help/qbit-assistant.yaml)
with QQQ's help-bundle registry during production. Hosts with help content storage use QQQ's
startup bundle sync; registration is harmless when the host has no `helpContent` table.
To customize the wording, register a separate bundle with `layer: HOST` using
`HelpBundles.register(qInstance, "help/my-application.yaml")`. Entries with the same key and
role override this QBIT bundle. User-edited help remains protected by core's sync.
Keep live `{{...}}` references so labels and metadata values follow the host's configuration.
`AssistantTablesGuardrailTest.everyOwnedTargetHasBundleHelp` checks coverage and live references
against the produced test instance with `HelpBundleAssertions`; it runs in the normal Maven build.
Update the bundle's literal version alongside the artifact version when releasing.