Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ceeb134c90
|
||
|
|
a15d66ecc7 | ||
|
|
886ee06cc4
|
||
|
|
1020c3b324
|
||
|
|
73b58d7391 | ||
|
|
64268e63d0
|
||
|
|
7081debe5f
|
||
|
|
b0b80ead3d
|
||
|
|
c40eebd3cd
|
||
|
|
ff84e35fb3 | ||
|
|
a7a4648dd1 | ||
|
|
467c2199dc
|
||
|
|
61e6f809f1
|
||
|
|
45593d2e79
|
||
|
|
de215f8ff9
|
No files matched your search
@@ -29,6 +29,8 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
|
||||
|
||||
## Modules
|
||||
|
||||
- `qbit-assistant-provider-api`: standalone provider SPI/value types and `engine.ModelPricing` (original package names); only Jackson annotations in production. Provider validation takes unresolved `ProviderSettings`, not an entity.
|
||||
|
||||
- `qbit-assistant` (`com.kingsrook.qbits.assistant`):
|
||||
- `AssistantQBitProducer`, `AssistantQBitConfig` (with `AssistantModelRouting` and `AssistantBudgets`), `AssistantRuntime`, `AssistantSecurity`.
|
||||
- `metadata/`: tables, owner key type, the Assistant app (home + main tables in the nav), the retention process, the settings customizer (key-reference checks, masked key status), and the `assistant.admin` custom permission checker.
|
||||
@@ -37,7 +39,7 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
|
||||
- `persistence/AssistantStore`: the **only** code that calls QQQ data actions, and only on the QBit's own tables, under the user's session.
|
||||
- `engine/`: `TurnEngine` (the loop), `ContextBuilder` (cache-prefix order), `SystemPrompt`, `PolicyService` (policy row, budgets), `RateLimiter`, the `TurnEvent` / `TurnEventLog` stream, and `MessageParts`.
|
||||
- `api/`: `AssistantApi` (transport-free) and `AssistantRouteProvider` (Javalin, SSE).
|
||||
- `provider/`: the provider-neutral SPI, plus `fake/ScriptedModelProvider` for tests, evals and demos.
|
||||
- `provider/`: runtime-backed `RegistryModelProvider`, plus `fake/ScriptedModelProvider` for tests, evals and demos. The SPI lives in `qbit-assistant-provider-api`.
|
||||
- `developer/`: the record Developer-screen panel, on the core developer-panel contract.
|
||||
- `processes/`: `AssistantRetentionStep` and `TestConnectionProcess` (record action on `assistant_provider`).
|
||||
- `engine/ProviderService` (active provider from `assistant_provider` rows, key references, prices), `UsageRecorder` (one `assistant_usage` row per model call), `ConnectionTest`.
|
||||
@@ -46,6 +48,8 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
|
||||
- `qbit-assistant-provider-bedrock`: Claude through the Anthropic Bedrock backend, AWS default credentials and SigV4; IRSA/web-identity hosts supply STS.
|
||||
- `qbit-assistant-provider-vertex`: Claude through the Anthropic Vertex backend and Gemini through OpenAI-compatible Chat Completions, with refreshed Google ADC tokens.
|
||||
|
||||
- `qbit-assistant-rag-pgvector`: JDBC vector storage; depends on the runtime RAG interfaces (`VectorStore`, `ChunkVector`).
|
||||
|
||||
## Invariants (do not break)
|
||||
|
||||
- **Sessions:**
|
||||
@@ -93,4 +97,4 @@ QQQ style:
|
||||
- `develop` publishes SNAPSHOTs.
|
||||
- `release/*` publishes RCs; the orb bumps `<revision>` in the root POM.
|
||||
- `v*` tags publish releases.
|
||||
- All five modules and the parent POM publish. The flatten plugin resolves `${revision}`.
|
||||
- All seven modules and the parent POM publish. The flatten plugin resolves `${revision}`.
|
||||
@@ -1,5 +1,33 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased - X1/Y1
|
||||
|
||||
- Add default-off `withRecordHistory(true)` host opt-in: remove only `audit*` on RC.6, or invoke core #1193 audit-read/write-deny support when available. Preserve user permissions and all other channel denials; kof22 does not opt in.
|
||||
|
||||
- Register explicit assistant-channel denials on every assistant table, process,
|
||||
report and widget, preserving core security patterns; fail runtime startup
|
||||
when an assistant object is exposed.
|
||||
- Bump the assistant UI API extension to 1.1.0 for optional owned-turn attribution
|
||||
on explain-access, and regenerate its OpenAPI contract.
|
||||
- Preserve turn attribution in knowledge discovery, RAG reads and explain-access
|
||||
calls; confirm actions retain the original turn and scheduled runs retain the task.
|
||||
- Build audit links with the presentation filter-link builder, filtering by
|
||||
record, table and channel, including deletes; widen audit link storage to TEXT.
|
||||
Document the host audit migration and the QBit column widening.
|
||||
- Verify the admin denial matrix through the real invoker and Next UI, startup
|
||||
failures, and confirmed-write human/channel attribution with decoded audit links.
|
||||
## Unreleased - Email
|
||||
|
||||
### Changed
|
||||
|
||||
- Switch scheduled-result email delivery to qbit-email 0.1.0-RC.2's published `com.kingsrook.qbits.email.api` package, preserving templates, categories, idempotency keys, linked records and status mapping.
|
||||
- Add optional qbit-email 0.1.0-RC.1 delivery of a scheduled task's result (`AssistantEmail.sendScheduledResult`): template `assistant_scheduled_result`, link-only secure message for results above PUBLIC using core's `DataClassification`, owner-recipient hook, opt-in and `emailExternalApproved` gates. Scheduled-task wiring remains a separate integration step.
|
||||
|
||||
## Unreleased - Provider API extraction (#30)
|
||||
|
||||
- Extract provider SPI/value types and `engine.ModelPricing` into `qbit-assistant-provider-api`, preserving package names. Its only production dependency is Jackson annotations.
|
||||
- Move Anthropic, OpenAI, Bedrock and Vertex main dependencies to the API; retain runtime fixtures in test scope. Pgvector still needs runtime RAG interfaces.
|
||||
- Provider-specific validation now takes unresolved `ProviderSettings` instead of `AssistantProvider`; add an ArchUnit boundary and standalone coverage gates.
|
||||
## Unreleased - P-g
|
||||
|
||||
- Add admin Scheduled tasks, owner-locked run history, Quartz cron/timezones,
|
||||
|
||||
@@ -29,6 +29,8 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
|
||||
|
||||
## Modules
|
||||
|
||||
- `qbit-assistant-provider-api`: standalone provider SPI/value types and `engine.ModelPricing` (original package names); only Jackson annotations in production. Provider validation takes unresolved `ProviderSettings`, not an entity.
|
||||
|
||||
- `qbit-assistant` (`com.kingsrook.qbits.assistant`):
|
||||
- `AssistantQBitProducer`, `AssistantQBitConfig` (with `AssistantModelRouting` and `AssistantBudgets`), `AssistantRuntime`, `AssistantSecurity`.
|
||||
- `metadata/`: tables, owner key type, the Assistant app (home + main tables in the nav), the retention process, the settings customizer (key-reference checks, masked key status), and the `assistant.admin` custom permission checker.
|
||||
@@ -37,7 +39,7 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
|
||||
- `persistence/AssistantStore`: the **only** code that calls QQQ data actions, and only on the QBit's own tables, under the user's session.
|
||||
- `engine/`: `TurnEngine` (the loop), `ContextBuilder` (cache-prefix order), `SystemPrompt`, `PolicyService` (policy row, budgets), `RateLimiter`, the `TurnEvent` / `TurnEventLog` stream, and `MessageParts`.
|
||||
- `api/`: `AssistantApi` (transport-free) and `AssistantRouteProvider` (Javalin, SSE).
|
||||
- `provider/`: the provider-neutral SPI, plus `fake/ScriptedModelProvider` for tests, evals and demos.
|
||||
- `provider/`: runtime-backed `RegistryModelProvider`, plus `fake/ScriptedModelProvider` for tests, evals and demos. The SPI lives in `qbit-assistant-provider-api`.
|
||||
- `developer/`: the record Developer-screen panel, on the core developer-panel contract.
|
||||
- `processes/`: `AssistantRetentionStep` and `TestConnectionProcess` (record action on `assistant_provider`).
|
||||
- `engine/ProviderService` (active provider from `assistant_provider` rows, key references, prices), `UsageRecorder` (one `assistant_usage` row per model call), `ConnectionTest`.
|
||||
@@ -46,7 +48,7 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
|
||||
- `qbit-assistant-provider-bedrock`: Claude through the Anthropic Bedrock backend, AWS default credentials and SigV4; IRSA/web-identity hosts supply STS.
|
||||
- `qbit-assistant-provider-vertex`: Claude through the Anthropic Vertex backend and Gemini through OpenAI-compatible Chat Completions, with refreshed Google ADC tokens.
|
||||
|
||||
- `qbit-assistant-rag-pgvector`: optional host-supplied JDBC vector store, isolated by instance schema; no QQQ RDBMS internals.
|
||||
- `qbit-assistant-rag-pgvector`: optional host-supplied JDBC vector store, isolated by instance schema; depends on runtime RAG interfaces (`VectorStore`, `ChunkVector`), with no QQQ RDBMS internals.
|
||||
|
||||
## Invariants (do not break)
|
||||
|
||||
@@ -55,6 +57,7 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
|
||||
- Never use `QSystemUserSession` or construct a `QSession` in main code (ArchUnit).
|
||||
- Every route uses `ExecutorSessionUtils.setupSession` (the user's own session).
|
||||
- `AssistantSecurity` only ever adds the user's OWN id key, or the all-access key for `assistant.admin` holders.
|
||||
- **Channel wall:** every `assistant_*` object (table, process, report and widget) is denied to the `assistant` channel; startup fails otherwise. Requests carry `assistant/<turnId>` or `assistant/scheduled/<taskId>`; administration uses `next-ui`.
|
||||
- **Data access:**
|
||||
- The vector index stores no record content: only source/table/key/field/chunk, content hash and embedding. Retrieval re-reads every hit through the UI API as the asking user, then applies the existing get_record exposure/masking path.
|
||||
- Only `persistence/` calls QueryAction, InsertAction and the other data actions (ArchUnit).
|
||||
@@ -105,4 +108,4 @@ QQQ style:
|
||||
- `develop` publishes SNAPSHOTs.
|
||||
- `release/*` publishes RCs; the orb bumps `<revision>` in the root POM.
|
||||
- `v*` tags publish releases.
|
||||
- All six modules and the parent POM publish. The flatten plugin resolves `${revision}`.
|
||||
- All seven modules and the parent POM publish. The flatten plugin resolves `${revision}`.
|
||||
@@ -4,13 +4,62 @@ An in-app AI assistant for any QQQ application. The user asks a question in a ch
|
||||
|
||||
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` | The QBit: conversation tables, turn engine, `/qqq/v1/assistant` routes (JSON + Server-Sent Events), budgets, kill switch, the provider SPI, and a scripted provider for tests and demos. |
|
||||
| `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. Existing
|
||||
`com.kingsrook.qbits.assistant.provider.*` imports and
|
||||
`com.kingsrook.qbits.assistant.engine.ModelPricing` stay the same.
|
||||
`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.
|
||||
|
||||
## 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**:
|
||||
@@ -188,6 +237,8 @@ The nav lists the home and the main tables: **Providers**, **Conversations**, **
|
||||
| `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. |
|
||||
|
||||
At runtime, an admin can override the kill switch, the main model and effort, and the budgets in the single **`assistant_policy`** row.
|
||||
|
||||
@@ -446,6 +497,24 @@ engine/tool golden turns. No cloud credentials or live endpoints are required.
|
||||
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):
|
||||
@@ -611,10 +680,19 @@ channel-denied, including their effective permission rules. A host that intends
|
||||
to expose record history can opt in during instance configuration:
|
||||
|
||||
```java
|
||||
ClientChannelsMetaData.ofOrWithNew(qInstance)
|
||||
.getDeniedPatterns().get("assistant").remove("audit*");
|
||||
new AssistantQBitConfig()
|
||||
.withRecordHistory(true);
|
||||
```
|
||||
|
||||
This removes only the default audit pattern; other channel denials and the
|
||||
signed-in user's audit permissions still apply. Keep explicit denials for audit
|
||||
surfaces the host does not intend to expose. The default remains unchanged.
|
||||
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.
|
||||
+40
-2
@@ -1,5 +1,43 @@
|
||||
# Upgrading qbit-assistant
|
||||
|
||||
## X1/Y1: channel wall and audit attribution
|
||||
|
||||
Requires QQQ 4.1.0-RC.6. Every assistant table, process, report and widget is
|
||||
explicitly denied to channel `assistant`; startup rejects an exposed object.
|
||||
Host customizations must preserve these denials. Administration uses `next-ui`.
|
||||
|
||||
Before enabling `AuditsMetaDataProvider.withClientChannelField(true)`, add the
|
||||
nullable `audit.client_channel VARCHAR(100)` column using the host migration
|
||||
conventions. kof22 needs a feature changeset under `changesets/features/` (for
|
||||
example `2026-10-14-audit-client-channel.sql`); no host migration is applied here.
|
||||
Existing audit rows remain null. The test fixture enables the field.
|
||||
|
||||
The existing `assistant_tool_call.auditHref` column now points to audit rows
|
||||
filtered by record, table and client channel, including for deletes. It is empty
|
||||
when the host has not enabled the audit channel field. Apply the X1/Y1 DDL in
|
||||
`docs/schema-postgres.sql` to widen the existing `assistant_tool_call.audit_href`
|
||||
column to TEXT: encoded filters with long table names or string record ids can
|
||||
exceed the old 500-character limit. No new QBit column is required. `GET /assistant/access` accepts an optional owned `turnId` for attribution.
|
||||
|
||||
## Provider API extraction (#30)
|
||||
|
||||
Hosts that depend on `qbit-assistant` keep working: it now brings in
|
||||
`qbit-assistant-provider-api` transitively. Package names are unchanged, including
|
||||
`com.kingsrook.qbits.assistant.engine.ModelPricing`. No schema migration is needed.
|
||||
|
||||
External providers and applications using only provider/pricing types should
|
||||
replace their `qbit-assistant` dependency with
|
||||
`com.kingsrook.qbits:qbit-assistant-provider-api` at the same version. This removes
|
||||
the assistant runtime and QQQ from that dependency path; only Jackson annotations
|
||||
are required by the API. Anthropic, OpenAI, Bedrock and Vertex adapters use this
|
||||
split; pgvector retains `qbit-assistant` for its runtime RAG interfaces.
|
||||
|
||||
Recompile custom provider types that override `validate(AssistantProvider)` and
|
||||
change the signature to `validate(ProviderSettings)`. The settings carry the
|
||||
configured kind, URL, API version, region, project, CA bundle name, timeouts and
|
||||
`apiKeyEnvName`; the key and bearer-token supplier are not resolved for validation.
|
||||
Providers using the default validator need no source change. Keep the runtime and
|
||||
provider artifacts on the same version; old validator overrides must be rebuilt.
|
||||
## P-g: scheduled tasks
|
||||
|
||||
Apply the **P-g** DDL in [docs/schema-postgres.sql](docs/schema-postgres.sql)
|
||||
@@ -23,8 +61,8 @@ See [scheduled-task setup](README.md#scheduled-tasks) for kof22 provisioning.
|
||||
QQQ RC.5 has no conditional UpdateInput. The runner uses a database-unique
|
||||
schedule-slot insertion as its optimistic claim, then advances `nextRunAt`
|
||||
before starting work. Crashes can skip a slot; they never retry it. No ArchUnit
|
||||
exception or fabricated session is introduced. Core client-channel enforcement
|
||||
arrives with the client-channel RC; the header and tool audit are already sent.
|
||||
exception or fabricated session is introduced. Core RC.6 enforces client-channel denials; scheduled requests carry
|
||||
`assistant/scheduled/<taskId>`.
|
||||
|
||||
Notifications use the optional Alerts QBit notification SPI with a single-user
|
||||
audience. Without it, delivery logs run id/status only. All output modes retain
|
||||
|
||||
@@ -287,6 +287,14 @@ AI exposure NONE is not enough, because the assistant acts as the user and an ad
|
||||
- **Tests:** an assistant write produces audit rows with the human actor and `via=assistant/<turnId>`; the same write from the UI produces `via=next-ui`.
|
||||
- kof22 needs a feature-named changeset (`changesets/features/...`) for the audit column.
|
||||
|
||||
### X1/Y1 as built
|
||||
- Record history is an explicit, default-off host opt-in (`withRecordHistory(true)`); kof22 does not opt in. RC.6 removes only `audit*`; later core #1193 support allows audit reads with a write-only denial. Other walls and user audit permissions remain intact.
|
||||
- RC.6's invoker fixes the `assistant` channel server-side and ignores spoofed client headers. Turn context survives knowledge discovery, RAG reads, confirm actions and owned-turn explain-access; scheduled calls retain `scheduled/<taskId>`.
|
||||
- The producer enumerates assistant tables, processes, reports and widgets (including source-QBit metadata), adds explicit denied-client rules without replacing user permissions, and registers `assistant_*` idempotently. Runtime construction independently validates the wall with core's `isDenied` and fails on exposed objects.
|
||||
- The fixture enables core's audit channel field. Hosts must migrate the nullable audit column before enabling it; kof22 needs its own feature changeset.
|
||||
- Successful insert/update/delete tool calls link to `/app/audit` with `recordId`, `auditTable.name` and `clientChannel` criteria, using the same encoder as `present_results`. Hosts without the channel field get no audit link. Existing audit link storage widens to TEXT for long encoded filters.
|
||||
- HTTP tests enumerate the configuration denial matrix for an admin, contrast Next UI access, reject broken startup metadata, and verify confirmed-write actor/channel plus the decoded audit filter.
|
||||
|
||||
### P. Providers, local models, HIPAA routing, budgets
|
||||
1. **Providers and models as data, any number, each with an enabled toggle.**
|
||||
- Kinds:
|
||||
|
||||
@@ -103,7 +103,7 @@ CREATE TABLE assistant_tool_call
|
||||
redactions_json TEXT,
|
||||
ui_api_operation VARCHAR(255),
|
||||
http_status INTEGER,
|
||||
audit_href VARCHAR(500),
|
||||
audit_href TEXT,
|
||||
pending_action_id INTEGER,
|
||||
create_date TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
@@ -574,3 +574,6 @@ CREATE TABLE assistant_scheduled_run (
|
||||
UNIQUE (task_id, scheduled_for)
|
||||
);
|
||||
CREATE INDEX assistant_scheduled_run_owner ON assistant_scheduled_run(user_id, started_at);
|
||||
|
||||
-- X1/Y1: encoded audit filters can exceed the old 500-character record links.
|
||||
ALTER TABLE assistant_tool_call ALTER COLUMN audit_href TYPE TEXT;
|
||||
@@ -28,6 +28,8 @@
|
||||
<anthropic.version>2.71.0</anthropic.version>
|
||||
<archunit.version>1.5.1</archunit.version>
|
||||
<jackson.version>2.21.7</jackson.version>
|
||||
<!-- optional email delivery; qbit-email from Maven Central -->
|
||||
<qbit-email.version>0.1.0-RC.2</qbit-email.version>
|
||||
<!-- live evals call the real API; run them with -Dtest.excludedGroups= -Dgroups=live -->
|
||||
<test.excludedGroups>live</test.excludedGroups>
|
||||
</properties>
|
||||
@@ -35,7 +37,7 @@
|
||||
<parent>
|
||||
<groupId>com.kingsrook</groupId>
|
||||
<artifactId>qbit-build-parent</artifactId>
|
||||
<version>2.0.0</version>
|
||||
<version>2.1.1</version>
|
||||
</parent>
|
||||
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
@@ -50,6 +52,7 @@
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<modules>
|
||||
<module>qbit-assistant-provider-api</module>
|
||||
<module>qbit-assistant</module>
|
||||
<module>qbit-assistant-provider-anthropic</module>
|
||||
<module>qbit-assistant-provider-openai</module>
|
||||
@@ -85,6 +88,11 @@
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-assistant-provider-api</artifactId>
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-assistant-provider-anthropic</artifactId>
|
||||
|
||||
@@ -32,9 +32,14 @@
|
||||
<url>https://github.com/QRun-IO/qbit-assistant</url>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-assistant-provider-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-assistant</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
~ Copyright 2026 Kingsrook, LLC
|
||||
~
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~ You may obtain a copy of the License at
|
||||
~
|
||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||
~
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
-->
|
||||
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-assistant-parent</artifactId>
|
||||
<version>${revision}</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>qbit-assistant-provider-api</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>QBit Assistant - Provider API</name>
|
||||
<description>Provider SPI and pricing value types, independent of the assistant runtime</description>
|
||||
<url>https://github.com/QRun-IO/qbit-assistant</url>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-annotations</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.assertj</groupId>
|
||||
<artifactId>assertj-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.tngtech.archunit</groupId>
|
||||
<artifactId>archunit-junit5</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<redirectTestOutputToFile>true</redirectTestOutputToFile>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>prepare-agent</id>
|
||||
<goals>
|
||||
<goal>prepare-agent</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>report</id>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>report</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>check</id>
|
||||
<goals>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<rules>
|
||||
<rule>
|
||||
<element>BUNDLE</element>
|
||||
<limits>
|
||||
<limit>
|
||||
<counter>INSTRUCTION</counter>
|
||||
<value>COVEREDRATIO</value>
|
||||
<minimum>0.70</minimum>
|
||||
</limit>
|
||||
<limit>
|
||||
<counter>CLASS</counter>
|
||||
<value>COVEREDRATIO</value>
|
||||
<minimum>0.90</minimum>
|
||||
</limit>
|
||||
</limits>
|
||||
</rule>
|
||||
</rules>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
+3
-4
@@ -20,7 +20,6 @@ package com.kingsrook.qbits.assistant.engine;
|
||||
import java.io.Serializable;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.assistant.AssistantModelRouting;
|
||||
import com.kingsrook.qbits.assistant.provider.ModelUsage;
|
||||
|
||||
|
||||
@@ -63,9 +62,9 @@ public class ModelPricing implements Serializable
|
||||
public static Map<String, ModelPricing> defaults()
|
||||
{
|
||||
Map<String, ModelPricing> map = new LinkedHashMap<>();
|
||||
map.put(AssistantModelRouting.SONNET_5_5, of(2.0, 10.0, 0.1));
|
||||
map.put(AssistantModelRouting.OPUS_5_5, of(4.0, 20.0, 0.05));
|
||||
map.put(AssistantModelRouting.HAIKU_5_5, of(0.10, 0.50, 0.1));
|
||||
map.put("claude-sonnet-5-5", of(2.0, 10.0, 0.1));
|
||||
map.put("claude-opus-5-5", of(4.0, 20.0, 0.05));
|
||||
map.put("claude-haiku-5-5", of(0.10, 0.50, 0.1));
|
||||
map.put("claude-fable-5-1", of(10.0, 50.0, 0.025));
|
||||
map.put("claude-fable-5", of(10.0, 50.0, 0.1));
|
||||
map.put("claude-opus-5", of(5.0, 25.0, 0.1));
|
||||
File renamed without changes.
+3
-2
@@ -18,7 +18,6 @@ package com.kingsrook.qbits.assistant.provider;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProvider;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -140,8 +139,10 @@ public interface AssistantProviderType
|
||||
|
||||
/*******************************************************************************
|
||||
** Additional settings errors, without resolving credentials or doing I/O.
|
||||
** Settings contain configured values and apiKeyEnvName; apiKey, the bearer
|
||||
** token supplier and SSLContext are not resolved for validation.
|
||||
*******************************************************************************/
|
||||
default List<String> validate(AssistantProvider row)
|
||||
default List<String> validate(ProviderSettings settings)
|
||||
{
|
||||
return (List.of());
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+32
@@ -36,6 +36,7 @@ public class ProviderSettings
|
||||
private String tlsCaBundle;
|
||||
@JsonIgnore
|
||||
private transient SSLContext sslContext;
|
||||
private String apiKeyEnvName;
|
||||
private String kind;
|
||||
private String apiKey;
|
||||
private String baseUrl;
|
||||
@@ -397,4 +398,35 @@ public class ProviderSettings
|
||||
this.bearerTokenSupplier = bearerTokenSupplier;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The configured environment variable name, never its secret value.
|
||||
*******************************************************************************/
|
||||
public String getApiKeyEnvName()
|
||||
{
|
||||
return (this.apiKeyEnvName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for apiKeyEnvName
|
||||
*******************************************************************************/
|
||||
public void setApiKeyEnvName(String apiKeyEnvName)
|
||||
{
|
||||
this.apiKeyEnvName = apiKeyEnvName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for apiKeyEnvName
|
||||
*******************************************************************************/
|
||||
public ProviderSettings withApiKeyEnvName(String apiKeyEnvName)
|
||||
{
|
||||
this.apiKeyEnvName = apiKeyEnvName;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+75
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.provider;
|
||||
|
||||
|
||||
import java.util.Set;
|
||||
import com.tngtech.archunit.base.DescribedPredicate;
|
||||
import com.tngtech.archunit.core.domain.JavaClass;
|
||||
import com.tngtech.archunit.core.domain.JavaClasses;
|
||||
import com.tngtech.archunit.core.importer.ClassFileImporter;
|
||||
import com.tngtech.archunit.core.importer.ImportOption;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
|
||||
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Keep external provider consumers independent of the assistant runtime and QQQ.
|
||||
*******************************************************************************/
|
||||
class ProviderApiArchitectureTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Allow only the extracted types, including their nested enums, under the
|
||||
** assistant namespace. A future dependency on any runtime package must fail.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void dependsOnlyOnApiTypes()
|
||||
{
|
||||
Set<String> apiTypes = Set.of(
|
||||
"com.kingsrook.qbits.assistant.provider.AssistantModelProvider",
|
||||
"com.kingsrook.qbits.assistant.provider.AssistantProviderType",
|
||||
"com.kingsrook.qbits.assistant.provider.CacheHint",
|
||||
"com.kingsrook.qbits.assistant.provider.CompletableStreamHandle",
|
||||
"com.kingsrook.qbits.assistant.provider.DiscoveredModel",
|
||||
"com.kingsrook.qbits.assistant.provider.EmbeddingRequest",
|
||||
"com.kingsrook.qbits.assistant.provider.EmbeddingResponse",
|
||||
"com.kingsrook.qbits.assistant.provider.HealthResult",
|
||||
"com.kingsrook.qbits.assistant.provider.ModelCapabilities",
|
||||
"com.kingsrook.qbits.assistant.provider.ModelEventListener",
|
||||
"com.kingsrook.qbits.assistant.provider.ModelMessage",
|
||||
"com.kingsrook.qbits.assistant.provider.ModelPart",
|
||||
"com.kingsrook.qbits.assistant.provider.ModelProviderException",
|
||||
"com.kingsrook.qbits.assistant.provider.ModelRequest",
|
||||
"com.kingsrook.qbits.assistant.provider.ModelResponse",
|
||||
"com.kingsrook.qbits.assistant.provider.ModelToolDefinition",
|
||||
"com.kingsrook.qbits.assistant.provider.ModelUsage",
|
||||
"com.kingsrook.qbits.assistant.provider.ProviderSettings",
|
||||
"com.kingsrook.qbits.assistant.provider.StreamHandle",
|
||||
"com.kingsrook.qbits.assistant.provider.SystemBlock",
|
||||
"com.kingsrook.qbits.assistant.provider.TokenCount",
|
||||
"com.kingsrook.qbits.assistant.provider.TrustedCaBundle",
|
||||
"com.kingsrook.qbits.assistant.engine.ModelPricing");
|
||||
JavaClasses imported = new ClassFileImporter()
|
||||
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
|
||||
.importPackages("com.kingsrook.qbits.assistant");
|
||||
DescribedPredicate<JavaClass> outsideRuntime = DescribedPredicate.describe("outside assistant or an extracted API type", type ->
|
||||
!type.getName().startsWith("com.kingsrook.qbits.assistant.") || apiTypes.contains(type.getName().split("\\$", 2)[0]));
|
||||
classes().should().onlyDependOnClassesThat(outsideRuntime).check(imported);
|
||||
noClasses().should().dependOnClassesThat().resideInAnyPackage("com.kingsrook.qqq..").check(imported);
|
||||
}
|
||||
}
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.provider;
|
||||
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import javax.net.ssl.SSLContext;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qbits.assistant.engine.ModelPricing;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Provider contracts work on a classpath with no assistant runtime or QQQ.
|
||||
*******************************************************************************/
|
||||
class ProviderApiTest
|
||||
{
|
||||
private final ObjectMapper mapper = new ObjectMapper().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve provider-neutral requests and tool order through JSON; signed
|
||||
** provider state remains in memory only.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void requestRoundTripAndCopy() throws Exception
|
||||
{
|
||||
ModelCapabilities capabilities = new ModelCapabilities().withTools(true).withParallelToolCalls(true)
|
||||
.withPromptCaching(true).withThinking(false).withMaxContextTokens(32000)
|
||||
.withVision(false).withStreaming(true).withReasoningEffort(true);
|
||||
ModelToolDefinition tool = new ModelToolDefinition().withName("lookup").withDescription("Look up a record")
|
||||
.withInputSchema(Map.of("type", "object")).withStrict(true);
|
||||
ModelMessage user = ModelMessage.user("Find it").withCacheHint(CacheHint.SHORT);
|
||||
ModelMessage assistant = ModelMessage.assistant("Looking").withPart(ModelPart.toolUse("call-1", "lookup", null))
|
||||
.withProviderState("private-signature");
|
||||
ModelMessage result = new ModelMessage().withRole(ModelMessage.Role.USER)
|
||||
.withParts(List.of(ModelPart.toolResult("call-1", "denied", true)));
|
||||
ModelRequest request = new ModelRequest().withModel("example").withEffort("low").withMaxTokens(100)
|
||||
.withParallelToolCalls(true).withStreaming(false).withCapabilities(capabilities)
|
||||
.withSystem(List.of(new SystemBlock("rules", CacheHint.LONG), new SystemBlock().withText("user context").withCacheHint(CacheHint.SHORT)))
|
||||
.withTools(List.of(tool)).withMessages(new ArrayList<>(List.of(user, assistant, result)));
|
||||
String json = mapper.writeValueAsString(request);
|
||||
assertThat(json).doesNotContain("private-signature", "providerState");
|
||||
ModelRequest restored = mapper.readValue(json, ModelRequest.class);
|
||||
assertThat(mapper.<JsonNode>valueToTree(restored)).isEqualTo(mapper.valueToTree(request));
|
||||
assertThat(restored.getMessages().get(1).getProviderState()).isNull();
|
||||
assertThat(restored.getMessages().get(2).getParts().get(0).getIsError()).isTrue();
|
||||
ModelRequest copy = request.copy();
|
||||
assertThat(mapper.<JsonNode>valueToTree(copy)).isEqualTo(mapper.valueToTree(request));
|
||||
copy.getMessages().clear();
|
||||
assertThat(request.getMessages()).hasSize(3);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Response helpers retain tool ordering, text, usage and safe metadata.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void responseAndPricingRoundTrip() throws Exception
|
||||
{
|
||||
ModelUsage usage = new ModelUsage().withInputTokens(2L).withOutputTokens(3L)
|
||||
.withCacheReadTokens(4L).withCacheWriteTokens(5L).withCacheWrite1hTokens(1L);
|
||||
ModelPart first = ModelPart.toolUse("first", "lookup", Map.of("id", 1));
|
||||
ModelPart second = ModelPart.toolUse("second", "lookup", Map.of("id", 2));
|
||||
ModelResponse response = new ModelResponse().withParts(List.of(ModelPart.text("a"), first, ModelPart.text("b"), second))
|
||||
.withModel("example").withStopReason(ModelResponse.STOP_TOOL_USE).withRequestId("request-1")
|
||||
.withUsage(usage).withProviderState("signature");
|
||||
assertThat(response.getText()).isEqualTo("ab");
|
||||
assertThat(response.getToolUses()).containsExactly(first, second);
|
||||
String json = mapper.writeValueAsString(response);
|
||||
assertThat(json).doesNotContain("signature", "providerState");
|
||||
ModelResponse restored = mapper.readValue(json, ModelResponse.class);
|
||||
assertThat(mapper.<JsonNode>valueToTree(restored)).isEqualTo(mapper.valueToTree(response));
|
||||
assertThat(restored.getProviderState()).isNull();
|
||||
assertThat(response.getProviderState()).isEqualTo("signature");
|
||||
ModelPricing pricing = ModelPricing.of(0.1, 0.5, 0.1);
|
||||
ModelPricing restoredPricing = mapper.readValue(mapper.writeValueAsString(pricing), ModelPricing.class);
|
||||
assertThat(restoredPricing.costMicros(usage)).isEqualTo(3);
|
||||
assertThat(new ModelUsage().withInputTokens(null).add(usage).add(null).billableTokens()).isEqualTo(10);
|
||||
TokenCount tokens = mapper.readValue(mapper.writeValueAsString(new TokenCount().withInputTokens(12L)), TokenCount.class);
|
||||
assertThat(tokens.getInputTokens()).isEqualTo(12L);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Plain validation settings require no credentials or entity classes, and
|
||||
** live token suppliers/TLS contexts cannot be serialized into diagnostics.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void settingsRoundTrip() throws Exception
|
||||
{
|
||||
ProviderSettings settings = new ProviderSettings().withKind("EXAMPLE").withApiKeyEnvName("EXAMPLE_KEY")
|
||||
.withApiKey("test-only-key").withBaseUrl("https://example.invalid").withApiVersion("v1")
|
||||
.withRegion("region").withCloudProject("project").withTlsCaBundle("corporate")
|
||||
.withConnectTimeoutSeconds(5).withRequestTimeoutSeconds(30)
|
||||
.withBearerTokenSupplier(() -> "ephemeral-token").withSslContext(SSLContext.getDefault());
|
||||
String json = mapper.writeValueAsString(settings);
|
||||
assertThat(json).doesNotContain("ephemeral-token", "bearerTokenSupplier", "sslContext");
|
||||
ProviderSettings restored = mapper.readValue(json, ProviderSettings.class);
|
||||
assertThat(mapper.<JsonNode>valueToTree(restored)).isEqualTo(mapper.valueToTree(settings));
|
||||
assertThat(settings.getBearerTokenSupplier().get()).isEqualTo("ephemeral-token");
|
||||
assertThat(settings.getSslContext()).isNotNull();
|
||||
assertThat(restored.getBearerTokenSupplier()).isNull();
|
||||
assertThat(restored.getSslContext()).isNull();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Embedding batches snapshot input and enforce the transport size contract.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void embeddingContracts()
|
||||
{
|
||||
List<String> input = new ArrayList<>(List.of("first", "second"));
|
||||
EmbeddingRequest request = new EmbeddingRequest("embed", input);
|
||||
input.clear();
|
||||
assertThat(request.input()).containsExactly("first", "second");
|
||||
assertThatThrownBy(() -> request.input().add("third")).isInstanceOf(UnsupportedOperationException.class);
|
||||
assertThatThrownBy(() -> new EmbeddingRequest(" ", List.of("text"))).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> new EmbeddingRequest(null, List.of("text"))).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> new EmbeddingRequest("embed", List.of())).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> new EmbeddingRequest("embed", Collections.nCopies(65, "text"))).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThat(new EmbeddingRequest("embed", Collections.nCopies(64, "text")).input()).hasSize(64);
|
||||
float[] vector = new float[] { 0.25F, 0.75F };
|
||||
EmbeddingResponse response = new EmbeddingResponse(List.of(vector), new ModelUsage().withInputTokens(2L), "request");
|
||||
assertThat(response.vectors().getFirst()).containsExactly(0.25F, 0.75F);
|
||||
assertThat(response.usage().getInputTokens()).isEqualTo(2L);
|
||||
assertThat(response.requestId()).isEqualTo("request");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cancellation is idempotent, preserves accrued usage, and wins over late
|
||||
** completion. Timeout also cancels the underlying transport.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void cancellationAndTimeout() throws Exception
|
||||
{
|
||||
AtomicInteger cancellations = new AtomicInteger();
|
||||
ModelUsage usage = new ModelUsage().withInputTokens(10L);
|
||||
CompletableStreamHandle handle = new CompletableStreamHandle();
|
||||
handle.setPartialUsage(() -> usage);
|
||||
handle.setOnCancel(cancellations::incrementAndGet);
|
||||
handle.cancel();
|
||||
handle.cancel();
|
||||
handle.complete(new ModelResponse().withStopReason(ModelResponse.STOP_END_TURN));
|
||||
handle.fail(new ModelProviderException(ModelProviderException.Kind.SERVER, 500, "late failure"));
|
||||
assertThat(handle.isCancelled()).isTrue();
|
||||
assertThat(cancellations.get()).isEqualTo(1);
|
||||
assertThat(handle.await(Duration.ofSeconds(1)).getUsage()).isSameAs(usage);
|
||||
CompletableStreamHandle timeout = new CompletableStreamHandle();
|
||||
timeout.setPartialUsage(() -> usage);
|
||||
assertThatThrownBy(() -> timeout.await(Duration.ofMillis(1))).isInstanceOfSatisfying(ModelProviderException.class, error ->
|
||||
{
|
||||
assertThat(error.getKind()).isEqualTo(ModelProviderException.Kind.TIMEOUT);
|
||||
assertThat(error.getPartialUsage()).isSameAs(usage);
|
||||
});
|
||||
assertThat(timeout.isCancelled()).isTrue();
|
||||
handle.setPartialUsage(() ->
|
||||
{
|
||||
throw (new IllegalStateException("closed"));
|
||||
});
|
||||
assertThat(handle.partialUsage()).isNull();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Error metadata is bounded and redacted independently of error classification.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void errorDetails()
|
||||
{
|
||||
ModelProviderException error = new ModelProviderException(ModelProviderException.Kind.INVALID_REQUEST, 400, "Invalid schema")
|
||||
.withStrictSchemaRejected(true).withProviderDetails("invalid_request", "bad key sk-abcdefghijk\ntry again", "req-123");
|
||||
assertThat(error.isStrictSchemaRejected()).isTrue();
|
||||
assertThat(error.getProviderErrorType()).isEqualTo("invalid_request");
|
||||
assertThat(error.getProviderMessage()).isEqualTo("bad key [redacted] try again");
|
||||
assertThat(error.getRequestId()).isEqualTo("req-123");
|
||||
assertThat(error.isRetryable()).isFalse();
|
||||
assertThat(ModelProviderException.sanitizeProviderMessage(" ", 10)).isNull();
|
||||
assertThat(ModelProviderException.sanitizeProviderMessage(null, 10)).isNull();
|
||||
assertThat(ModelProviderException.sanitizeProviderMessage("word ".repeat(100), 20)).hasSize(20);
|
||||
assertThat(ModelProviderException.sanitizeProviderMessage("a".repeat(50), 100)).isEqualTo("[redacted]");
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.provider;
|
||||
|
||||
|
||||
import java.time.Duration;
|
||||
import com.kingsrook.qbits.assistant.engine.ModelPricing;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Standalone contracts moved from the runtime's component tests.
|
||||
*******************************************************************************/
|
||||
class ProviderComponentsTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testPricing()
|
||||
{
|
||||
ModelPricing sonnet = ModelPricing.defaults().get("claude-sonnet-5-5");
|
||||
ModelUsage usage = new ModelUsage().withInputTokens(6000L).withOutputTokens(800L).withCacheReadTokens(12_000L).withCacheWriteTokens(1000L).withCacheWrite1hTokens(400L);
|
||||
//////////////////////////////////////////////////////////////////////////////////
|
||||
// 6000*2 + 800*10 + 12000*0.2 + 600*2.5 + 400*4 = 12000+8000+2400+1500+1600 //
|
||||
//////////////////////////////////////////////////////////////////////////////////
|
||||
assertThat(sonnet.costMicros(usage)).isEqualTo(25_500);
|
||||
assertThat(sonnet.costMicros(null)).isZero();
|
||||
assertThat(new ModelPricing().costMicros(usage)).isZero();
|
||||
assertThat(ModelPricing.defaults().get("claude-opus-5-5").getCacheReadUsdPerMTok()).isEqualTo(0.2);
|
||||
assertThat(usage.billableTokens()).isEqualTo(7800);
|
||||
assertThat(new ModelUsage().add(usage).add(null).getCacheReadTokens()).isEqualTo(12_000);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testStreamHandle() throws Exception
|
||||
{
|
||||
CompletableStreamHandle handle = new CompletableStreamHandle();
|
||||
handle.complete(new ModelResponse().withStopReason("end_turn"));
|
||||
assertThat(handle.await(Duration.ofSeconds(1)).getStopReason()).isEqualTo("end_turn");
|
||||
|
||||
CompletableStreamHandle failing = new CompletableStreamHandle();
|
||||
failing.fail(new ModelProviderException(ModelProviderException.Kind.RATE_LIMITED, 429, "slow down"));
|
||||
assertThatThrownBy(() -> failing.await(Duration.ofSeconds(1))).isInstanceOfSatisfying(ModelProviderException.class, e ->
|
||||
{
|
||||
assertThat(e.isRetryable()).isTrue();
|
||||
assertThat(e.getHttpStatus()).isEqualTo(429);
|
||||
});
|
||||
|
||||
CompletableStreamHandle cancelled = new CompletableStreamHandle();
|
||||
cancelled.setOnCancel(() ->
|
||||
{
|
||||
throw (new IllegalStateException("closing"));
|
||||
});
|
||||
cancelled.cancel();
|
||||
cancelled.cancel();
|
||||
assertThat(cancelled.await(Duration.ofSeconds(1)).getStopReason()).isEqualTo(ModelResponse.STOP_CANCELLED);
|
||||
assertThat(new ModelProviderException(ModelProviderException.Kind.INVALID_REQUEST, 400, "x").isRetryable()).isFalse();
|
||||
}
|
||||
}
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.provider;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A minimal external provider compiles and uses SPI defaults without the runtime.
|
||||
*******************************************************************************/
|
||||
class ProviderDefaultsTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Legacy two-argument factories and static model lists remain supported.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void minimalProviderDefaults() throws Exception
|
||||
{
|
||||
ExampleProvider provider = new ExampleProvider();
|
||||
ProviderSettings settings = new ProviderSettings().withApiKey("test-key").withBaseUrl("https://example.invalid");
|
||||
assertThat(provider.create(settings)).isSameAs(provider);
|
||||
assertThat(provider.settings.getApiKey()).isEqualTo("test-key");
|
||||
assertThat(provider.settings.getBaseUrl()).isEqualTo("https://example.invalid");
|
||||
assertThat(provider.getKinds()).containsExactly("EXAMPLE_PROVIDER");
|
||||
assertThat(provider.getDefaultBaseUrl("EXAMPLE_PROVIDER")).isNull();
|
||||
assertThat(provider.getDefaultApiKeyEnvName("EXAMPLE_PROVIDER")).isEqualTo("EXAMPLE_KEY");
|
||||
assertThat(provider.getDefaultLocation("EXAMPLE_PROVIDER")).isEqualTo("CLOUD");
|
||||
assertThat(provider.requiresBaseUrl("EXAMPLE_PROVIDER")).isTrue();
|
||||
assertThat(provider.validate(settings)).isEmpty();
|
||||
assertThat(provider.discoverModels(settings)).containsExactly(new DiscoveredModel("example", "example", null, null));
|
||||
assertThat(provider.checkHealth(settings).healthy()).isNull();
|
||||
assertThat(provider.checkHealth(settings).message()).isEqualTo("Health check not supported");
|
||||
assertThat(new HealthResult(false, 20L, "bad sk-abcdefghijk").message()).isEqualTo("bad [redacted]");
|
||||
assertThat(provider.countTokens(new ModelRequest())).isNull();
|
||||
assertThatThrownBy(() -> provider.embed(new EmbeddingRequest("example", List.of("text"))))
|
||||
.isInstanceOfSatisfying(ModelProviderException.class, error -> assertThat(error.getKind()).isEqualTo(ModelProviderException.Kind.INVALID_REQUEST));
|
||||
StringBuilder deltas = new StringBuilder();
|
||||
ModelEventListener listener = deltas::append;
|
||||
listener.onToolUseStart("call", "lookup");
|
||||
assertThat(deltas).isEmpty();
|
||||
listener.onTextDelta("visible answer");
|
||||
assertThat(deltas).hasToString("visible answer");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Only the original required SPI methods are implemented.
|
||||
*******************************************************************************/
|
||||
private static class ExampleProvider implements AssistantProviderType, AssistantModelProvider
|
||||
{
|
||||
private ProviderSettings settings;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Minimal provider contract.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getType()
|
||||
{
|
||||
return ("example-provider");
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Minimal provider contract.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getLabel()
|
||||
{
|
||||
return ("Example");
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Minimal provider contract.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<String> getModels()
|
||||
{
|
||||
return (List.of("example"));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Minimal provider contract.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getDefaultApiKeyEnvName()
|
||||
{
|
||||
return ("EXAMPLE_KEY");
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Minimal provider contract.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getDefaultBaseUrl()
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Minimal provider contract.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public AssistantModelProvider create(String apiKey, String baseUrl)
|
||||
{
|
||||
settings = new ProviderSettings().withApiKey(apiKey).withBaseUrl(baseUrl);
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Minimal provider contract.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getName()
|
||||
{
|
||||
return ("example");
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Minimal provider contract.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ModelCapabilities getCapabilities(String modelId)
|
||||
{
|
||||
return (new ModelCapabilities());
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Minimal provider contract.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public StreamHandle stream(ModelRequest request, ModelEventListener listener)
|
||||
{
|
||||
throw (new UnsupportedOperationException());
|
||||
}
|
||||
}
|
||||
}
|
||||
File renamed without changes.
@@ -41,9 +41,14 @@
|
||||
<artifactId>qbit-assistant-provider-anthropic</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-assistant-provider-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-assistant</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
|
||||
+3
-4
@@ -24,7 +24,6 @@ import com.anthropic.backends.Backend;
|
||||
import com.anthropic.bedrock.backends.BedrockBackend;
|
||||
import com.anthropic.client.okhttp.AnthropicOkHttpClient;
|
||||
import com.anthropic.core.Timeout;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProvider;
|
||||
import com.kingsrook.qbits.assistant.provider.AssistantModelProvider;
|
||||
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
|
||||
import com.kingsrook.qbits.assistant.provider.DiscoveredModel;
|
||||
@@ -117,14 +116,14 @@ public class BedrockProviderType implements AssistantProviderType
|
||||
** Validate cloud settings without loading credentials.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<String> validate(AssistantProvider row)
|
||||
public List<String> validate(ProviderSettings settings)
|
||||
{
|
||||
List<String> errors = new ArrayList<>();
|
||||
if(row.getRegion() == null || row.getRegion().isBlank())
|
||||
if(settings.getRegion() == null || settings.getRegion().isBlank())
|
||||
{
|
||||
errors.add("Region is required for BEDROCK.");
|
||||
}
|
||||
if(row.getApiKeyEnvName() != null && !row.getApiKeyEnvName().isBlank())
|
||||
if(settings.getApiKeyEnvName() != null && !settings.getApiKeyEnvName().isBlank())
|
||||
{
|
||||
errors.add("Leave API key variable empty: AWS Bedrock (Claude) uses the AWS default credential chain (environment, profile or server role).");
|
||||
}
|
||||
|
||||
@@ -32,9 +32,14 @@
|
||||
<url>https://github.com/QRun-IO/qbit-assistant</url>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-assistant-provider-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-assistant</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
|
||||
@@ -45,9 +45,14 @@
|
||||
<artifactId>qbit-assistant-provider-openai</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-assistant-provider-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-assistant</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
|
||||
+4
-5
@@ -19,7 +19,6 @@ package com.kingsrook.qbits.assistant.provider.vertex;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProvider;
|
||||
import com.kingsrook.qbits.assistant.provider.AssistantModelProvider;
|
||||
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
|
||||
import com.kingsrook.qbits.assistant.provider.DiscoveredModel;
|
||||
@@ -104,18 +103,18 @@ public class VertexProviderType implements AssistantProviderType
|
||||
** Validate cloud settings without loading credentials.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<String> validate(AssistantProvider row)
|
||||
public List<String> validate(ProviderSettings settings)
|
||||
{
|
||||
List<String> errors = new ArrayList<>();
|
||||
if(row.getRegion() == null || row.getRegion().isBlank())
|
||||
if(settings.getRegion() == null || settings.getRegion().isBlank())
|
||||
{
|
||||
errors.add("Region is required for VERTEX.");
|
||||
}
|
||||
if(row.getApiKeyEnvName() != null && !row.getApiKeyEnvName().isBlank())
|
||||
if(settings.getApiKeyEnvName() != null && !settings.getApiKeyEnvName().isBlank())
|
||||
{
|
||||
errors.add("Leave API key variable empty: Google Vertex AI (Claude, Gemini) uses Google Application Default Credentials (GOOGLE_APPLICATION_CREDENTIALS or workload identity).");
|
||||
}
|
||||
if(row.getCloudProject() == null || row.getCloudProject().isBlank())
|
||||
if(settings.getCloudProject() == null || settings.getCloudProject().isBlank())
|
||||
{
|
||||
errors.add("GCP project id (cloudProject) is required for VERTEX.");
|
||||
}
|
||||
|
||||
+14
-1
@@ -28,10 +28,14 @@
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>QBit Assistant</name>
|
||||
<description>In-app AI assistant QBit for QQQ: conversations, turn engine, SSE routes, budgets, provider SPI</description>
|
||||
<description>In-app AI assistant QBit for QQQ: conversations, turn engine, SSE routes, budgets and provider registry</description>
|
||||
<url>https://github.com/QRun-IO/qbit-assistant</url>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-assistant-provider-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-core</artifactId>
|
||||
@@ -45,6 +49,15 @@
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<!-- Optional email delivery of scheduled-task results. Every reference is in -->
|
||||
<!-- the email package; without the jar nothing changes. -->
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-email</artifactId>
|
||||
<version>${qbit-email.version}</version>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
|
||||
@@ -90,6 +90,7 @@ public class AssistantQBitConfig implements QBitConfig
|
||||
private String conversationEncryptionKeyEnvName;
|
||||
|
||||
private Boolean enabled = true;
|
||||
private Boolean recordHistory = false;
|
||||
private String backendName;
|
||||
private String providerName = DEFAULT_PROVIDER_NAME;
|
||||
private AssistantModelRouting models = new AssistantModelRouting();
|
||||
@@ -145,11 +146,13 @@ public class AssistantQBitConfig implements QBitConfig
|
||||
private Integer providerTestTimeoutSeconds = 30;
|
||||
private Integer homeMaxRows = 20000;
|
||||
private String tablePathPrefix = "/app";
|
||||
private Boolean emailExternalApproved = false;
|
||||
|
||||
private transient MetaDataCustomizerInterface<QTableMetaData> tableMetaDataCustomizer;
|
||||
private transient Map<String, AssistantModelProvider> modelProviders = new LinkedHashMap<>();
|
||||
private transient Map<String, AssistantProviderType> providerTypes = new LinkedHashMap<>();
|
||||
private transient Function<String, String> secretResolver = System::getenv;
|
||||
private transient Function<String, String> emailRecipientResolver;
|
||||
private transient AssistantRuntime runtime;
|
||||
|
||||
|
||||
@@ -1502,6 +1505,62 @@ public class AssistantQBitConfig implements QBitConfig
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the host's EXTERNAL approval rule allows a scheduled task's result
|
||||
** to leave the app by email. There is no approval rule in the qbit yet (the
|
||||
** scheduled runner is P-g), so this explicit flag stands in; default false.
|
||||
*******************************************************************************/
|
||||
public Boolean getEmailExternalApproved()
|
||||
{
|
||||
return (this.emailExternalApproved);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for emailExternalApproved
|
||||
*******************************************************************************/
|
||||
public void setEmailExternalApproved(Boolean emailExternalApproved)
|
||||
{
|
||||
this.emailExternalApproved = emailExternalApproved;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for emailExternalApproved
|
||||
*******************************************************************************/
|
||||
public AssistantQBitConfig withEmailExternalApproved(Boolean emailExternalApproved)
|
||||
{
|
||||
this.emailExternalApproved = emailExternalApproved;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The hook that turns a task owner's user id into an email address; null
|
||||
** means the default (an owner id that already is an address).
|
||||
*******************************************************************************/
|
||||
@JsonIgnore
|
||||
public Function<String, String> getEmailRecipientResolver()
|
||||
{
|
||||
return (emailRecipientResolver);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for emailRecipientResolver
|
||||
*******************************************************************************/
|
||||
public AssistantQBitConfig withEmailRecipientResolver(Function<String, String> emailRecipientResolver)
|
||||
{
|
||||
this.emailRecipientResolver = emailRecipientResolver;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for allowedApiKeyEnvNames (extra API key environment variable names a provider row may reference)
|
||||
*******************************************************************************/
|
||||
@@ -1700,6 +1759,28 @@ public class AssistantQBitConfig implements QBitConfig
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the host opts into record history; false by default.
|
||||
*******************************************************************************/
|
||||
public Boolean getRecordHistory()
|
||||
{
|
||||
return (recordHistory);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Owner decision — hard wall: record history requires explicit host opt-in.
|
||||
** kof22 does not opt in. The user's own audit permissions still apply.
|
||||
*******************************************************************************/
|
||||
public AssistantQBitConfig withRecordHistory(boolean recordHistory)
|
||||
{
|
||||
this.recordHistory = recordHistory;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for hipaaMode
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -25,6 +25,7 @@ import com.kingsrook.qbits.assistant.api.AssistantRoutes;
|
||||
import com.kingsrook.qbits.assistant.developer.AssistantDeveloperPanels;
|
||||
import com.kingsrook.qbits.assistant.home.AssistantAccessWidget;
|
||||
import com.kingsrook.qbits.assistant.home.AssistantHome;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantChannelWall;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantInstanceMetaData;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantRecordScreens;
|
||||
@@ -41,6 +42,7 @@ import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.ClientChannelsMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer;
|
||||
@@ -201,12 +203,42 @@ public class AssistantQBitProducer implements QBitMetaDataProducer<AssistantQBit
|
||||
QAppMetaData app = builder.buildAdminApp(tables);
|
||||
qInstance.addApp(app);
|
||||
|
||||
AssistantChannelWall.register(qInstance);
|
||||
if(Boolean.TRUE.equals(qBitConfig.getRecordHistory()))
|
||||
{
|
||||
enableRecordHistory(ClientChannelsMetaData.ofOrWithNew(qInstance));
|
||||
}
|
||||
postProduceActions(new MetaDataProducerMultiOutput(), qInstance);
|
||||
return (new MetaDataProducerMultiOutput());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** STAND-IN(core=QRun-IO/qqq#1193, removeBy=RC.8): call withAssistantAuditReads() directly once the qbit builds on a QQQ RC that has it
|
||||
*******************************************************************************/
|
||||
private void enableRecordHistory(ClientChannelsMetaData channels)
|
||||
{
|
||||
try
|
||||
{
|
||||
channels.getClass().getMethod("withAssistantAuditReads").invoke(channels);
|
||||
}
|
||||
catch(NoSuchMethodException e)
|
||||
{
|
||||
List<String> denied = channels.getDeniedPatterns().get("assistant");
|
||||
if(denied != null)
|
||||
{
|
||||
denied.removeIf("audit*"::equals);
|
||||
}
|
||||
}
|
||||
catch(ReflectiveOperationException e)
|
||||
{
|
||||
throw (new IllegalStateException("Could not enable assistant audit reads.", e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Declare budget alerts as top-level core metadata for admin visibility.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -32,6 +32,7 @@ import com.kingsrook.qbits.assistant.engine.ProfileService;
|
||||
import com.kingsrook.qbits.assistant.engine.ProviderService;
|
||||
import com.kingsrook.qbits.assistant.engine.RateLimiter;
|
||||
import com.kingsrook.qbits.assistant.engine.RunningTurn;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantChannelWall;
|
||||
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
|
||||
import com.kingsrook.qbits.assistant.rag.InMemoryVectorStore;
|
||||
import com.kingsrook.qbits.assistant.rag.RagService;
|
||||
@@ -103,6 +104,7 @@ public class AssistantRuntime
|
||||
*******************************************************************************/
|
||||
public AssistantRuntime(QInstance qInstance, AssistantQBitConfig config, Clock clock)
|
||||
{
|
||||
AssistantChannelWall.validate(qInstance);
|
||||
this.qInstance = qInstance;
|
||||
this.config = config;
|
||||
this.clock = clock;
|
||||
|
||||
+11
-1
@@ -103,7 +103,17 @@ public class AccessExplainer
|
||||
*******************************************************************************/
|
||||
public List<TableAccess> explain(UiApiGateway gateway, String sessionUUID, UiApiCredentials credentials, PolicyService.ToolPolicy policy, String tableName, String prompt) throws QException
|
||||
{
|
||||
UiApiResult response = gateway.invoke(new UiApiCall("GET", "/metaData").withSessionUUID(sessionUUID).withCredentials(credentials).withInvokedBy("assistant-explain"));
|
||||
return (explain(gateway, sessionUUID, credentials, policy, tableName, prompt, null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve a known turn's attribution on the metadata request.
|
||||
*******************************************************************************/
|
||||
public List<TableAccess> explain(UiApiGateway gateway, String sessionUUID, UiApiCredentials credentials, PolicyService.ToolPolicy policy, String tableName, String prompt, String turnId) throws QException
|
||||
{
|
||||
UiApiResult response = gateway.invoke(new UiApiCall("GET", "/metaData").withSessionUUID(sessionUUID).withCredentials(credentials).withInvokedBy("assistant-explain").withClientDetail(turnId));
|
||||
if(!response.isSuccess())
|
||||
{
|
||||
throw (new QException("GET /metaData answered HTTP " + response.status() + (response.errorMessage() == null ? "" : ": " + response.errorMessage())));
|
||||
|
||||
@@ -627,6 +627,7 @@ public class AssistantApi
|
||||
|
||||
store.insert(new AssistantToolCall()
|
||||
.withTurnId(action.getTurnId())
|
||||
.withClientChannel("assistant" + (action.getTurnId() == null ? "" : "/" + action.getTurnId()))
|
||||
.withConversationId(action.getConversationId())
|
||||
.withUserId(userId)
|
||||
.withToolCallId(action.getToolCallId())
|
||||
@@ -733,10 +734,24 @@ public class AssistantApi
|
||||
** reason.
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> access(String table, String prompt) throws QException, AssistantApiException
|
||||
{
|
||||
return (access(table, prompt, null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A supplied turn must belong to this user before using it for attribution.
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> access(String table, String prompt, Integer turnId) throws QException, AssistantApiException
|
||||
{
|
||||
requireAccess();
|
||||
if(turnId != null)
|
||||
{
|
||||
requireTurn(turnId);
|
||||
}
|
||||
List<Map<String, Object>> tables = new ArrayList<>();
|
||||
for(AccessExplainer.TableAccess access : runtime.getAccessExplainer().explain(runtime.getUiApiGateway(), session.getUuid(), credentials, runtime.getPolicyService().current(false).tools(), table, prompt))
|
||||
for(AccessExplainer.TableAccess access : runtime.getAccessExplainer().explain(runtime.getUiApiGateway(), session.getUuid(), credentials, runtime.getPolicyService().current(false).tools(), table, prompt, turnId == null ? null : String.valueOf(turnId)))
|
||||
{
|
||||
if(!access.uiVisible() && access.mode() == DataAccessMode.NONE)
|
||||
{
|
||||
|
||||
+3
-3
@@ -61,7 +61,7 @@ import io.javalin.http.HttpStatus;
|
||||
public final class AssistantSpecs
|
||||
{
|
||||
public static final String EXTENSION_NAME = "assistant";
|
||||
public static final String EXTENSION_VERSION = "1.0.0";
|
||||
public static final String EXTENSION_VERSION = "1.1.0";
|
||||
public static final String PREFIX = "/assistant";
|
||||
|
||||
|
||||
@@ -1312,7 +1312,7 @@ public final class AssistantSpecs
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (List.of(queryParam("table", Type.STRING, "Only this table."), queryParam("prompt", Type.STRING, "Only the tables this text mentions.")));
|
||||
return (List.of(queryParam("table", Type.STRING, "Only this table."), queryParam("prompt", Type.STRING, "Only the tables this text mentions."), queryParam("turnId", Type.INTEGER, "Attribute this request to an owned turn.")));
|
||||
}
|
||||
|
||||
|
||||
@@ -1329,7 +1329,7 @@ public final class AssistantSpecs
|
||||
protected Object handle(AssistantApi api, JsonInput input) throws Exception
|
||||
{
|
||||
String table = input.getQueryParam("table");
|
||||
return (api.access(StringUtils.hasContent(table) ? table : null, input.getQueryParam("prompt")));
|
||||
return (api.access(StringUtils.hasContent(table) ? table : null, input.getQueryParam("prompt"), input.getQueryParamInteger("turnId")));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.email;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Email delivery of a scheduled task's result, when the optional qbit-email
|
||||
** is present. This class names no qbit-email type, so it loads without the
|
||||
** jar; the adapter (AssistantEmailSender) is only loaded after the guard passes.
|
||||
**
|
||||
** Hosts can call sendScheduledResult after a run; the scheduled-task runner
|
||||
** does not yet wire email delivery. The gates below apply to every call:
|
||||
** the task opted in, the host approved EXTERNAL delivery
|
||||
** (AssistantQBitConfig.getEmailExternalApproved, until the approval rule
|
||||
** exists), and there is a recipient address.
|
||||
*******************************************************************************/
|
||||
public final class AssistantEmail
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private AssistantEmail()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether qbit-email is on the classpath and produced in this instance.
|
||||
*******************************************************************************/
|
||||
public static boolean isAvailable(QInstance qInstance)
|
||||
{
|
||||
try
|
||||
{
|
||||
Class.forName("com.kingsrook.qbits.email.api.EmailApi");
|
||||
}
|
||||
catch(ClassNotFoundException | LinkageError e)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
return (AssistantEmailSender.isInstalled(qInstance));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Email a finished scheduled run to its owner, if the task opted in and
|
||||
** every gate allows it. A missing email qbit is not an error; the status
|
||||
** says what happened.
|
||||
*******************************************************************************/
|
||||
public static ScheduledEmailStatus sendScheduledResult(QInstance qInstance, AssistantQBitConfig config, ScheduledResultNotice notice) throws QException
|
||||
{
|
||||
if(notice == null || !notice.emailResult())
|
||||
{
|
||||
return (ScheduledEmailStatus.NOT_REQUESTED);
|
||||
}
|
||||
if(!isAvailable(qInstance))
|
||||
{
|
||||
return (ScheduledEmailStatus.NOT_AVAILABLE);
|
||||
}
|
||||
if(!Boolean.TRUE.equals(config.getEmailExternalApproved()))
|
||||
{
|
||||
return (ScheduledEmailStatus.NOT_APPROVED);
|
||||
}
|
||||
String recipient = recipient(config, notice.ownerUserId());
|
||||
if(recipient == null)
|
||||
{
|
||||
return (ScheduledEmailStatus.NO_RECIPIENT);
|
||||
}
|
||||
return (AssistantEmailSender.send(notice, recipient));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The owner's address: the host's resolver, else the owner id when it is
|
||||
** itself an address. Null when there is none.
|
||||
*******************************************************************************/
|
||||
static String recipient(AssistantQBitConfig config, String ownerUserId)
|
||||
{
|
||||
if(ownerUserId == null || ownerUserId.isBlank())
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
String address = config.getEmailRecipientResolver() != null ? config.getEmailRecipientResolver().apply(ownerUserId) : (ownerUserId.contains("@") ? ownerUserId : null);
|
||||
return (address == null || address.isBlank() ? null : address.trim());
|
||||
}
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.email;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.email.api.EmailApi;
|
||||
import com.kingsrook.qbits.email.api.EmailApiCategory;
|
||||
import com.kingsrook.qbits.email.api.EmailApiRequest;
|
||||
import com.kingsrook.qbits.email.api.EmailApiResult;
|
||||
import com.kingsrook.qbits.email.api.EmailApiTemplate;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The only class that touches qbit-email. Registers the scheduled-result
|
||||
** template on first use (never overwriting edits) and queues one message per
|
||||
** recipient. Addresses, summaries and bodies are never logged.
|
||||
*******************************************************************************/
|
||||
final class AssistantEmailSender
|
||||
{
|
||||
static final String TEMPLATE_NAME = "assistant_scheduled_result";
|
||||
static final String SECURE_TEMPLATE = "email_secure_message";
|
||||
static final String SENDER_QBIT = "assistant";
|
||||
static final String SENDER_PROCESS = "assistantScheduledTask";
|
||||
|
||||
private static final int MAX_SUMMARY = 280;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private AssistantEmailSender()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the email qbit is produced in this instance.
|
||||
*******************************************************************************/
|
||||
static boolean isInstalled(QInstance qInstance)
|
||||
{
|
||||
return (EmailApi.isInstalled(qInstance));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Queue one message to one recipient. A result classified above PUBLIC
|
||||
** sends only the link, with the secure-message pattern.
|
||||
*******************************************************************************/
|
||||
static ScheduledEmailStatus send(ScheduledResultNotice notice, String recipient) throws QException
|
||||
{
|
||||
boolean linkOnly = notice.classification() != null && notice.classification().isAtLeast(DataClassification.CONFIDENTIAL);
|
||||
|
||||
EmailApiRequest request = new EmailApiRequest().withTo(recipient)
|
||||
.withIdempotencyKey("assistant:scheduled:" + notice.runId())
|
||||
.withLinkedRecord(notice.runTable(), notice.runId())
|
||||
.withSender(SENDER_QBIT, SENDER_PROCESS);
|
||||
|
||||
Map<String, Serializable> variables = new LinkedHashMap<>();
|
||||
variables.put("link", notice.link());
|
||||
if(linkOnly)
|
||||
{
|
||||
request.withTemplateName(SECURE_TEMPLATE);
|
||||
}
|
||||
else
|
||||
{
|
||||
ensureTemplate();
|
||||
request.withTemplateName(TEMPLATE_NAME);
|
||||
variables.put("taskName", notice.taskName());
|
||||
variables.put("runTime", notice.runTime() == null ? null : notice.runTime().toString());
|
||||
variables.put("summary", summary(notice.summary()));
|
||||
}
|
||||
request.withVariables(variables);
|
||||
|
||||
return (map(EmailApi.send(request)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One line, cut short; the full model output never goes in an email.
|
||||
*******************************************************************************/
|
||||
static String summary(String value)
|
||||
{
|
||||
if(value == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
String oneLine = value.replaceAll("\\s+", " ").trim();
|
||||
return (oneLine.length() <= MAX_SUMMARY ? oneLine : oneLine.substring(0, MAX_SUMMARY - 3) + "...");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Outbox result to the qbit's own vocabulary.
|
||||
*******************************************************************************/
|
||||
private static ScheduledEmailStatus map(EmailApiResult result)
|
||||
{
|
||||
return (switch(result.status())
|
||||
{
|
||||
case QUEUED -> ScheduledEmailStatus.QUEUED;
|
||||
case DUPLICATE -> ScheduledEmailStatus.DUPLICATE;
|
||||
case SUPPRESSED -> ScheduledEmailStatus.SUPPRESSED;
|
||||
case REFUSED -> ScheduledEmailStatus.REFUSED;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Register the NOTIFICATION template once; an existing template is left alone.
|
||||
*******************************************************************************/
|
||||
static void ensureTemplate() throws QException
|
||||
{
|
||||
EmailApi.ensureTemplate(new EmailApiTemplate()
|
||||
.withName(TEMPLATE_NAME)
|
||||
.withLabel("Assistant scheduled task result")
|
||||
.withCategory(EmailApiCategory.NOTIFICATION)
|
||||
.withKind("MARKDOWN")
|
||||
.withOwnerQbit(SENDER_QBIT)
|
||||
.withSubject("Assistant task result: {{taskName}}")
|
||||
.withBody("Your scheduled assistant task **{{taskName}}** finished at {{runTime}}.\n\n{{summary}}\n\n[Open the run]({{link}}){.button}")
|
||||
.withVariable("taskName", "Task name", "STRING", true, "CONFIDENTIAL")
|
||||
.withVariable("runTime", "Run time", "DATE_TIME", true, "PUBLIC")
|
||||
.withVariable("summary", "Short summary", "STRING", false, "CONFIDENTIAL")
|
||||
.withVariable("link", "Link to the run", "URL", true, "PUBLIC")
|
||||
.withSampleVariables("{\"taskName\":\"Weekly review\",\"runTime\":\"2026-10-12T09:00:00Z\",\"summary\":\"Three items need attention.\",\"link\":\"https://app.example.com/app/assistant_run/1\"}"));
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.email;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Outcome of trying to email a scheduled task's result. The first four mean
|
||||
** nothing was queued and why; the rest mirror the outbox result.
|
||||
*******************************************************************************/
|
||||
public enum ScheduledEmailStatus
|
||||
{
|
||||
NOT_REQUESTED,
|
||||
NOT_AVAILABLE,
|
||||
NOT_APPROVED,
|
||||
NO_RECIPIENT,
|
||||
QUEUED,
|
||||
DUPLICATE,
|
||||
SUPPRESSED,
|
||||
REFUSED
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.email;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What the scheduled-task runner knows about one finished run, for the
|
||||
** result email. The full model output is deliberately not part of it: only a
|
||||
** short summary, and only when the result is classified PUBLIC.
|
||||
**
|
||||
** @param runTable the table holding the run record (the email's linked record)
|
||||
** @param runId the run's id (also the idempotency key)
|
||||
** @param taskName the scheduled task's name
|
||||
** @param runTime when the run finished
|
||||
** @param summary a short summary (cut to 280 characters; never the model output)
|
||||
** @param classification the result's classification; null means PUBLIC
|
||||
** @param ownerUserId the task owner's user id (the way the qbit identifies users)
|
||||
** @param emailResult whether the task opted in to email delivery
|
||||
** @param link absolute link to the run record in the app
|
||||
*******************************************************************************/
|
||||
public record ScheduledResultNotice(String runTable, Object runId, String taskName, Instant runTime, String summary, DataClassification classification,
|
||||
String ownerUserId, boolean emailResult, String link)
|
||||
{
|
||||
}
|
||||
+23
-2
@@ -106,6 +106,16 @@ public class ContextBuilder
|
||||
** kernel's navigate and search_help.
|
||||
*******************************************************************************/
|
||||
public List<ModelToolDefinition> tools(PolicyService.ToolPolicy policy)
|
||||
{
|
||||
return (tools(policy, null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Attribute tool-discovery requests to the turn that needs them.
|
||||
*******************************************************************************/
|
||||
private List<ModelToolDefinition> tools(PolicyService.ToolPolicy policy, String turnId)
|
||||
{
|
||||
Map<String, ModelToolDefinition> byName = new TreeMap<>();
|
||||
for(ModelToolDefinition definition : toolbox.definitions(policy, toolbox.anyWritableTable()))
|
||||
@@ -123,7 +133,7 @@ public class ContextBuilder
|
||||
.withStrict(isStrict(definition.getName(), !Boolean.FALSE.equals(definition.getStrict()))));
|
||||
}
|
||||
}
|
||||
if(knowledgeTool != null && knowledgeTool.offered(policy, QContext.getQSession()))
|
||||
if(knowledgeTool != null && knowledgeTool.offered(policy, QContext.getQSession(), turnId))
|
||||
{
|
||||
byName.put(KnowledgeTool.NAME, knowledgeTool.definition());
|
||||
}
|
||||
@@ -216,12 +226,23 @@ public class ContextBuilder
|
||||
*******************************************************************************/
|
||||
public ModelRequest build(QSession session, String model, String effort, List<AssistantMessage> history, String userText, Map<String, Object> pageContext, PolicyService.ToolPolicy toolPolicy,
|
||||
String profileInstructions)
|
||||
{
|
||||
return (build(session, model, effort, history, userText, pageContext, toolPolicy, profileInstructions, null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Include the turn in any UI API calls needed to discover available tools.
|
||||
*******************************************************************************/
|
||||
public ModelRequest build(QSession session, String model, String effort, List<AssistantMessage> history, String userText, Map<String, Object> pageContext, PolicyService.ToolPolicy toolPolicy,
|
||||
String profileInstructions, String turnId)
|
||||
{
|
||||
ModelRequest request = new ModelRequest()
|
||||
.withModel(model)
|
||||
.withEffort(effort)
|
||||
.withMaxTokens(config.getModels().getMainMaxOutputTokens())
|
||||
.withTools(tools(toolPolicy));
|
||||
.withTools(tools(toolPolicy, turnId));
|
||||
|
||||
request.getSystem().add(new SystemBlock(SystemPrompt.staticBlock(appDigest()), CacheHint.LONG));
|
||||
request.getSystem().add(new SystemBlock(userBlock(session) + (StringUtils.hasContent(profileInstructions) ? "\n" + profileInstructions : ""), CacheHint.SHORT));
|
||||
|
||||
+6
-2
@@ -258,7 +258,11 @@ public class ProviderService
|
||||
return (errors);
|
||||
}
|
||||
|
||||
errors.addAll(type.validate(row));
|
||||
errors.addAll(type.validate(new ProviderSettings().withKind(kind(row))
|
||||
.withApiKeyEnvName(row.getApiKeyEnvName()).withBaseUrl(row.getBaseUrl())
|
||||
.withApiVersion(row.getApiVersion()).withRegion(row.getRegion()).withCloudProject(row.getCloudProject())
|
||||
.withTlsCaBundle(row.getTlsCaBundle()).withConnectTimeoutSeconds(row.getConnectTimeoutSeconds())
|
||||
.withRequestTimeoutSeconds(row.getRequestTimeoutSeconds())));
|
||||
String envName = keyEnvName(row);
|
||||
if(envName != null && !AssistantQBitConfig.ENV_NAME.matcher(envName).matches())
|
||||
{
|
||||
@@ -678,7 +682,7 @@ public class ProviderService
|
||||
{
|
||||
throw (new ProviderSetupException("No API key: the environment variable " + keyEnvName(row) + " is not set on this server."));
|
||||
}
|
||||
return (new ProviderSettings().withKind(kind(row)).withApiKey(key)
|
||||
return (new ProviderSettings().withKind(kind(row)).withApiKeyEnvName(row.getApiKeyEnvName()).withApiKey(key)
|
||||
.withBaseUrl(StringUtils.hasContent(row.getBaseUrl()) ? row.getBaseUrl().trim() : null)
|
||||
.withApiVersion(row.getApiVersion()).withRegion(row.getRegion()).withCloudProject(row.getCloudProject())
|
||||
.withTlsCaBundle(row.getTlsCaBundle()).withSslContext(StringUtils.hasContent(row.getTlsCaBundle()) ? config.getTrustedCaBundles().get(row.getTlsCaBundle()) : null)
|
||||
|
||||
@@ -262,7 +262,7 @@ public class TurnEngine implements Runnable
|
||||
String appName = runtime.appNameFor(pageContext);
|
||||
ProfileService.Persona persona = runtime.getProfileService().persona(session, appName, scheduledTask == null ? null : scheduledTask.getProfileId());
|
||||
String profileText = ProfileService.instructions(persona, runtime.getProfileService().relevantSkills(persona.profileId(), userText, appName));
|
||||
ModelRequest request = runtime.getContextBuilder().build(session, model, route.effort(), history, userText, pageContext, toolPolicy, profileText);
|
||||
ModelRequest request = runtime.getContextBuilder().build(session, model, route.effort(), history, userText, pageContext, toolPolicy, profileText, turn.getId() == null ? null : String.valueOf(turn.getId()));
|
||||
Integer maxPerRequest = active.row() == null ? null : active.row().getMaxOutputTokensPerRequest();
|
||||
if(maxPerRequest != null && (request.getMaxTokens() == null || request.getMaxTokens() > maxPerRequest))
|
||||
{
|
||||
@@ -885,7 +885,7 @@ public class TurnEngine implements Runnable
|
||||
.withUserId(userId)
|
||||
.withToolCallId(use.getToolUseId())
|
||||
.withToolName(use.getToolName())
|
||||
.withClientChannel(scheduledTask == null ? null : "assistant/scheduled/" + scheduledTask.getId())
|
||||
.withClientChannel(scheduledTask == null ? "assistant/" + turn.getId() : "assistant/scheduled/" + scheduledTask.getId())
|
||||
.withInputJson(truncate(redactInput(PresentResultsTool.NAME.equals(use.getToolName()) ? (presentation ? result.getRender() : Map.of()) : use.getInput()), MAX_INPUT_JSON_CHARS))
|
||||
.withOutcome(outcome)
|
||||
.withPermissionCheck(result.getPermissionCheck())
|
||||
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.metadata;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitProducer;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.ClientChannelsMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.SourceQBitAware;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Register and verify the assistant's configuration wall beneath user permissions.
|
||||
*******************************************************************************/
|
||||
public final class AssistantChannelWall
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private AssistantChannelWall()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Enumerate the instance so new assistant metadata cannot miss registration.
|
||||
** Preserve host permissions and other denied channels.
|
||||
*******************************************************************************/
|
||||
public static void register(QInstance qInstance)
|
||||
{
|
||||
ClientChannelsMetaData.ofOrWithNew(qInstance).withDeniedPattern("assistant", "assistant_*");
|
||||
for(MetaDataWithPermissionRules object : objects(qInstance))
|
||||
{
|
||||
QPermissionRules rules = object.getPermissionRules();
|
||||
rules = rules == null ? qInstance.getDefaultPermissionRules().clone() : rules.clone();
|
||||
Set<String> denied = new HashSet<>(rules.getDeniedClients() == null ? Set.of() : rules.getDeniedClients());
|
||||
denied.add("assistant");
|
||||
object.setPermissionRules(rules.withDeniedClients(denied));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fail before runtime initialization if a host customization opens the wall.
|
||||
*******************************************************************************/
|
||||
public static void validate(QInstance qInstance)
|
||||
{
|
||||
ClientChannelsMetaData channels = ClientChannelsMetaData.of(qInstance);
|
||||
for(MetaDataWithPermissionRules object : objects(qInstance))
|
||||
{
|
||||
QPermissionRules rules = object.getPermissionRules() == null ? qInstance.getDefaultPermissionRules() : object.getPermissionRules();
|
||||
if(!channels.isDenied("assistant", object.getName(), rules))
|
||||
{
|
||||
throw (new IllegalStateException("Assistant channel wall: " + object.getName() + " must be denied to client channel assistant before startup."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Tables, processes, reports and widgets, including host-added assistant objects.
|
||||
*******************************************************************************/
|
||||
private static List<MetaDataWithPermissionRules> objects(QInstance qInstance)
|
||||
{
|
||||
List<MetaDataWithPermissionRules> objects = new ArrayList<>();
|
||||
objects.addAll(qInstance.getTables().values());
|
||||
objects.addAll(qInstance.getProcesses().values());
|
||||
objects.addAll(qInstance.getReports().values());
|
||||
objects.addAll(qInstance.getWidgets().values());
|
||||
return (objects.stream().filter(object -> (object.getName() != null && object.getName().startsWith("assistant"))
|
||||
|| (object instanceof SourceQBitAware source && (AssistantQBitProducer.GROUP_ID + ":" + AssistantQBitProducer.ARTIFACT_ID).equals(source.getSourceQBitName()))).toList());
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -100,7 +100,7 @@ public class AssistantToolCall extends QRecordEntity
|
||||
@QField(isEditable = false)
|
||||
private Integer httpStatus;
|
||||
|
||||
@QField(isEditable = false, maxLength = 500)
|
||||
@QField(isEditable = false)
|
||||
private String auditHref;
|
||||
|
||||
@QField(isEditable = false, possibleValueSourceName = AssistantPendingAction.TABLE_NAME)
|
||||
|
||||
@@ -145,7 +145,7 @@ public class RagService
|
||||
{
|
||||
PolicyService.ToolPolicy policy = context.policy();
|
||||
UiApiToolbox.Context internal = new UiApiToolbox.Context(context.sessionUUID(), context.userTimezone(), context.gateway(),
|
||||
new PolicyService.ToolPolicy(true, Set.of(), policy == null ? 50 : policy.maxRowsPerCall(), policy == null || policy.redactPii()), null);
|
||||
new PolicyService.ToolPolicy(true, Set.of(), policy == null ? 50 : policy.maxRowsPerCall(), policy == null || policy.redactPii()), null, context.credentials(), context.turnId());
|
||||
return (reader.execute(internal, "knowledge-read", UiApiToolbox.GET_RECORD, Map.of("table", table, "id", pk)));
|
||||
}
|
||||
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.tools;
|
||||
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.output.AiLinkBuilder;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Encode server-assembled filters in the format consumed by the Next UI.
|
||||
*******************************************************************************/
|
||||
public final class FilterLinkBuilder
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private FilterLinkBuilder()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Callers validate model-supplied fields before constructing their criteria.
|
||||
*******************************************************************************/
|
||||
public static String href(String tableName, List<Map<String, Object>> criteria)
|
||||
{
|
||||
return (AiLinkBuilder.objectHref(tableName) + "?filter=" + URLEncoder.encode(JsonUtils.toJson(Map.of("criteria", criteria, "booleanOperator", "AND")), StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -71,6 +71,16 @@ public class KnowledgeTool
|
||||
** A source must be enabled and readable in the UI before offering the tool.
|
||||
*******************************************************************************/
|
||||
public Boolean offered(PolicyService.ToolPolicy policy, QSession session)
|
||||
{
|
||||
return (offered(policy, session, null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Tool discovery is a UI API call too: retain the turn when known.
|
||||
*******************************************************************************/
|
||||
public Boolean offered(PolicyService.ToolPolicy policy, QSession session, String turnId)
|
||||
{
|
||||
if(session == null || (policy != null && !policy.allows(NAME)))
|
||||
{
|
||||
@@ -90,7 +100,7 @@ public class KnowledgeTool
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
return (runtime.getAccessExplainer().explain(runtime.getUiApiGateway(), session.getUuid(), policy, null, null).stream()
|
||||
return (runtime.getAccessExplainer().explain(runtime.getUiApiGateway(), session.getUuid(), null, policy, null, null, turnId).stream()
|
||||
.anyMatch(t -> tables.contains(t.table()) && t.uiRead() && t.mode().allowsRead()));
|
||||
}
|
||||
catch(QException e)
|
||||
|
||||
+1
-2
@@ -20,7 +20,6 @@ package com.kingsrook.qbits.assistant.tools;
|
||||
import java.io.InputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.MathContext;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
@@ -388,7 +387,7 @@ public class PresentResultsTool
|
||||
values.forEach(PresentResultsTool::scalar);
|
||||
output.add(map("fieldName", fieldName, "operator", operator, "values", values));
|
||||
}
|
||||
return (safeHref(AiLinkBuilder.objectHref(tableName) + "?filter=" + URLEncoder.encode(JsonUtils.toJson(map("criteria", output, "booleanOperator", "AND")), StandardCharsets.UTF_8)));
|
||||
return (safeHref(FilterLinkBuilder.href(tableName, output)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -372,6 +372,9 @@ public class UiApiToolbox
|
||||
/*******************************************************************************
|
||||
** Offer history only when every audit table it queries is available to the
|
||||
** assistant channel, including inherited permission rules.
|
||||
** RC.6 isDenied checks the all-operation wall, with no operation argument.
|
||||
** Core #1193 must keep write-only denials separate from this read check;
|
||||
** do not consult write-deny patterns here when offering this read tool.
|
||||
*******************************************************************************/
|
||||
private boolean historyAllowed()
|
||||
{
|
||||
@@ -1285,10 +1288,19 @@ public class UiApiToolbox
|
||||
default -> "Deleted";
|
||||
};
|
||||
String label = table == null ? tableName : label(table);
|
||||
QTableMetaData audit = qInstance.getTable(AuditsMetaDataProvider.TABLE_NAME_AUDIT);
|
||||
if(id != null && audit != null && audit.getField(AuditsMetaDataProvider.FIELD_CLIENT_CHANNEL) != null)
|
||||
{
|
||||
String channel = "assistant" + (context.turnId() == null ? "" : "/" + context.turnId());
|
||||
outcome.auditHref = FilterLinkBuilder.href(AuditsMetaDataProvider.TABLE_NAME_AUDIT, List.of(
|
||||
Map.of("fieldName", "recordId", "operator", "EQUALS", "values", List.of(id)),
|
||||
Map.of("fieldName", "auditTable.name", "operator", "EQUALS", "values", List.of(tableName)),
|
||||
Map.of("fieldName", AuditsMetaDataProvider.FIELD_CLIENT_CHANNEL, "operator", "EQUALS", "values", List.of(channel))));
|
||||
}
|
||||
if(id != null && !DELETE_RECORD.equals(toolName))
|
||||
{
|
||||
String href = AiLinkBuilder.recordHref(tableName, id);
|
||||
outcome.auditHref = href;
|
||||
|
||||
outcome.result.getRecordRefs().add(new AiRecordRef().withTableName(tableName).withTableLabel(label).withId(id).withLabel(label + " " + id).withHref(href));
|
||||
}
|
||||
outcome.result.withOutcome(AiToolOutcome.OK).withTableName(tableName).withRowCount(1).withSummary(verb + " " + label + (id == null ? "" : " " + id))
|
||||
|
||||
@@ -212,7 +212,7 @@ info:
|
||||
description: "QQQ Assistant: conversations, streamed turns, feedback, pending actions\
|
||||
\ and data access explanations"
|
||||
title: "QQQ Middleware API extension: assistant"
|
||||
version: "1.0.0"
|
||||
version: "1.1.0"
|
||||
openapi: "3.0.3"
|
||||
paths:
|
||||
/qqq/v1/assistant/config:
|
||||
@@ -1038,6 +1038,12 @@ paths:
|
||||
required: false
|
||||
schema:
|
||||
type: "string"
|
||||
- description: "Attribute this request to an owned turn."
|
||||
in: "query"
|
||||
name: "turnId"
|
||||
required: false
|
||||
schema:
|
||||
type: "integer"
|
||||
responses:
|
||||
400:
|
||||
content:
|
||||
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant;
|
||||
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URLDecoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.assistant.access.DataAccessMode;
|
||||
import com.kingsrook.qbits.assistant.api.AssistantApi;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantDataAccess;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantTurn;
|
||||
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
|
||||
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
|
||||
import com.kingsrook.qbits.assistant.testing.UiApiTestServer;
|
||||
import com.kingsrook.qbits.assistant.tools.FilterLinkBuilder;
|
||||
import com.kingsrook.qbits.assistant.uiapi.UiApiResult;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Confirmed writes retain the human actor and link only to their channel's audits.
|
||||
*******************************************************************************/
|
||||
class AssistantAuditAttributionTest extends BaseAssistantTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Insert, update and delete all create usable audit links, including deletions.
|
||||
*******************************************************************************/
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"create_record", "update_record", "delete_record"})
|
||||
void confirmedWriteAttributesActorAndChannel(String tool) throws Exception
|
||||
{
|
||||
String base = UiApiTestServer.serve(qInstance, config);
|
||||
QContext.init(qInstance, AssistantTestFixture.ownerSession(AssistantTestFixture.ADMIN, config));
|
||||
var store = config.getRuntime().getStore();
|
||||
store.insert(new AssistantDataAccess().withObjectType(AssistantDataAccess.TYPE_TABLE).withObjectName("page").withMode(DataAccessMode.READ_WRITE.name()));
|
||||
config.getRuntime().getDataAccessService().invalidate();
|
||||
AssistantTurn turn = store.insert(new AssistantTurn().withUserId(AssistantTestFixture.ADMIN));
|
||||
AssistantPendingAction action = store.insert(new AssistantPendingAction().withUserId(AssistantTestFixture.ADMIN).withTurnId(turn.getId())
|
||||
.withToolCallId("write-1").withToolName(tool).withTableName("page").withRecordId("12")
|
||||
.withParamsJson(JsonUtils.toJson(tool.equals("delete_record") ? Map.of() : Map.of("title", "Assistant changed this page")))
|
||||
.withStatus(AssistantPendingAction.STATUS_PENDING).withExpiresAt(Instant.now().plusSeconds(600)));
|
||||
Map<String, Object> result = new AssistantApi(config.getRuntime(), QContext.getQSession()).confirmAction(action.getId());
|
||||
assertThat(result.get("status")).isEqualTo(AssistantPendingAction.STATUS_EXECUTED);
|
||||
String channel = "assistant/" + turn.getId();
|
||||
List<QRecord> audits = records("audit", new QQueryFilter().withCriteria("clientChannel", QCriteriaOperator.EQUALS, channel));
|
||||
assertThat(audits).hasSize(1);
|
||||
QRecord audit = audits.getFirst();
|
||||
assertHumanActor(audit);
|
||||
assertThat(records("auditTable", new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, audit.getValue("auditTableId"))).getFirst().getValueString("name")).isEqualTo("page");
|
||||
List<AssistantToolCall> calls = store.query(AssistantToolCall.TABLE_NAME, new QQueryFilter().withCriteria("pendingActionId", QCriteriaOperator.EQUALS, action.getId()), AssistantToolCall.class);
|
||||
assertThat(calls).hasSize(1);
|
||||
assertThat(calls.getFirst().getClientChannel()).isEqualTo(channel);
|
||||
String href = calls.getFirst().getAuditHref();
|
||||
assertThat(href).startsWith("/app/audit?filter=");
|
||||
QQueryFilter filter = JsonUtils.toObject(URLDecoder.decode(URI.create(href).getRawQuery().substring("filter=".length()), StandardCharsets.UTF_8), QQueryFilter.class);
|
||||
assertThat(filter.getCriteria()).hasSize(3);
|
||||
assertThat(filter.getCriteria()).anySatisfy(criterion ->
|
||||
{
|
||||
assertThat(criterion.getFieldName()).isEqualTo("clientChannel");
|
||||
assertThat(criterion.getValues()).containsExactly(channel);
|
||||
});
|
||||
assertThat(filter.getCriteria()).anySatisfy(criterion ->
|
||||
{
|
||||
assertThat(criterion.getFieldName()).isEqualTo("auditTable.name");
|
||||
assertThat(criterion.getValues()).containsExactly("page");
|
||||
});
|
||||
assertThat(filter.getCriteria()).anySatisfy(criterion ->
|
||||
{
|
||||
assertThat(criterion.getFieldName()).isEqualTo("recordId");
|
||||
assertThat(criterion.getValues()).extracting(String::valueOf).containsExactly(audit.getValueString("recordId"));
|
||||
});
|
||||
UiApiResult linked = ui(base, "POST", "/table/audit/query", JsonUtils.toJson(Map.of("filter", filter)));
|
||||
assertThat(linked.status()).isEqualTo(200);
|
||||
assertThat((List<?>) linked.json().get("records")).hasSize(1);
|
||||
|
||||
String directMethod = tool.equals("create_record") ? "POST" : tool.equals("update_record") ? "PATCH" : "DELETE";
|
||||
String directPath = "/table/page" + (tool.equals("create_record") ? "" : "/11");
|
||||
UiApiResult direct = ui(base, directMethod, directPath, tool.equals("delete_record") ? null : "{\"title\":\"UI changed this page\"}");
|
||||
assertThat(direct.status()).isBetween(200, 299);
|
||||
List<QRecord> directAudits = records("audit", new QQueryFilter().withCriteria("clientChannel", QCriteriaOperator.EQUALS, "next-ui"));
|
||||
assertThat(directAudits).hasSize(1).allSatisfy(this::assertHumanActor);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Encoded filters for string keys must survive persistence without truncation.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void storesLongAuditLinks() throws Exception
|
||||
{
|
||||
QContext.init(qInstance, AssistantTestFixture.ownerSession(AssistantTestFixture.ADMIN, config));
|
||||
String href = FilterLinkBuilder.href("audit", List.of(
|
||||
Map.of("fieldName", "recordId", "operator", "EQUALS", "values", List.of("opaque/record key ".repeat(12))),
|
||||
Map.of("fieldName", "auditTable.name", "operator", "EQUALS", "values", List.of("customer_external_document")),
|
||||
Map.of("fieldName", "clientChannel", "operator", "EQUALS", "values", List.of("assistant/42"))));
|
||||
assertThat(href.length()).isGreaterThan(500);
|
||||
assertThat(qInstance.getTable(AssistantToolCall.TABLE_NAME).getField("auditHref").getMaxLength()).isNull();
|
||||
AssistantToolCall call = config.getRuntime().getStore().insert(new AssistantToolCall().withUserId(AssistantTestFixture.ADMIN).withAuditHref(href));
|
||||
assertThat(config.getRuntime().getStore().query(AssistantToolCall.TABLE_NAME,
|
||||
new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, call.getId()), AssistantToolCall.class).getFirst().getAuditHref()).isEqualTo(href);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Explain access carries an owned turn, or the bare channel outside a turn.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void explainAccessUsesOwnedTurnOrBareChannel() throws Exception
|
||||
{
|
||||
UiApiTestServer.serve(qInstance, config);
|
||||
QContext.init(qInstance, AssistantTestFixture.ownerSession(AssistantTestFixture.ADMIN, config));
|
||||
AssistantTurn turn = config.getRuntime().getStore().insert(new AssistantTurn().withUserId(AssistantTestFixture.ADMIN));
|
||||
AssistantApi api = new AssistantApi(config.getRuntime(), QContext.getQSession());
|
||||
api.access("page", null, turn.getId());
|
||||
assertThat(UiApiTestServer.SCHEDULED_REQUESTS.getLast().get("client")).isEqualTo("assistant/" + turn.getId());
|
||||
api.access("page", null);
|
||||
assertThat(UiApiTestServer.SCHEDULED_REQUESTS.getLast().get("client")).isEqualTo("assistant");
|
||||
AssistantTurn other = config.getRuntime().getStore().insert(new AssistantTurn().withUserId(AssistantTestFixture.EDITOR));
|
||||
assertThatThrownBy(() -> api.access("page", null, other.getId())).hasMessageContaining("not found");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read the fixture's audit evidence independently of the assistant's gateway.
|
||||
*******************************************************************************/
|
||||
private List<QRecord> records(String table, QQueryFilter filter) throws Exception
|
||||
{
|
||||
return (new QueryAction().execute(new QueryInput(table).withFilter(filter)).getRecords());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Core's audit user is the authenticated human's display name.
|
||||
*******************************************************************************/
|
||||
private void assertHumanActor(QRecord audit) throws Exception
|
||||
{
|
||||
assertThat(records("auditUser", new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, audit.getValue("auditUserId"))).getFirst().getValueString("name"))
|
||||
.isEqualTo("Name of " + AssistantTestFixture.ADMIN);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A direct Next UI request from the same administrator.
|
||||
*******************************************************************************/
|
||||
private UiApiResult ui(String base, String method, String path, String body) throws Exception
|
||||
{
|
||||
HttpRequest request = HttpRequest.newBuilder(URI.create(base + "/qqq/v1" + path)).header("Cookie", "sessionId=" + AssistantTestFixture.ADMIN)
|
||||
.header("X-QQQ-Client", "next-ui").header("Content-Type", "application/json")
|
||||
.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(body)).build();
|
||||
HttpResponse<String> response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
|
||||
return (new UiApiResult(response.statusCode(), response.body()));
|
||||
}
|
||||
}
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant;
|
||||
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantChannelWall;
|
||||
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
|
||||
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
|
||||
import com.kingsrook.qbits.assistant.testing.UiApiTestServer;
|
||||
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
|
||||
import com.kingsrook.qbits.assistant.uiapi.UiApiCall;
|
||||
import com.kingsrook.qbits.assistant.uiapi.UiApiResult;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.ClientChannelsMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.reporting.QReportMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The real UI API enforces the channel wall even for assistant administrators.
|
||||
*******************************************************************************/
|
||||
class AssistantChannelWallTest extends BaseAssistantTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Every produced object is explicitly denied, with core security defaults intact.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void registersAllObjectsIdempotently()
|
||||
{
|
||||
QTableMetaData renamed = new QTableMetaData().withName("custom_settings");
|
||||
renamed.setSourceQBitName(AssistantQBitProducer.GROUP_ID + ":" + AssistantQBitProducer.ARTIFACT_ID);
|
||||
qInstance.addTable(renamed);
|
||||
qInstance.addReport(new QReportMetaData().withName("assistant_future_report").withPermissionRules(new QPermissionRules().withDeniedClients(Set.of("cli"))));
|
||||
AssistantChannelWall.register(qInstance);
|
||||
AssistantChannelWall.register(qInstance);
|
||||
List<MetaDataWithPermissionRules> objects = objects();
|
||||
assertThat(objects).isNotEmpty().allSatisfy(object -> assertThat(object.getPermissionRules().getDeniedClients()).contains("assistant"));
|
||||
assertThat(qInstance.getReport("assistant_future_report").getPermissionRules().getDeniedClients()).contains("cli");
|
||||
assertThat(ClientChannelsMetaData.of(qInstance).getDeniedPatterns().get("assistant"))
|
||||
.contains("assistant_*", "authentik_*", "audit*", "*api_key*", "*credential*", "*secret*")
|
||||
.doesNotHaveDuplicates();
|
||||
assertThat(renamed.getPermissionRules().getDeniedClients()).contains("assistant");
|
||||
AssistantChannelWall.validate(qInstance);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Removing both layers from one object fails runtime startup, for every kind.
|
||||
*******************************************************************************/
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"table", "process", "report", "widget"})
|
||||
void startupRejectsAnUndeniedObject(String kind)
|
||||
{
|
||||
qInstance.addReport(new QReportMetaData().withName("assistant_report"));
|
||||
AssistantChannelWall.register(qInstance);
|
||||
MetaDataWithPermissionRules object = switch(kind)
|
||||
{
|
||||
case "table" -> qInstance.getTables().values().stream().filter(value -> value.getName().startsWith("assistant")).findFirst().orElseThrow();
|
||||
case "process" -> qInstance.getProcesses().values().stream().filter(value -> value.getName().startsWith("assistant")).findFirst().orElseThrow();
|
||||
case "report" -> qInstance.getReport("assistant_report");
|
||||
default -> qInstance.getWidgets().values().stream().filter(value -> value.getName().startsWith("assistant")).findFirst().orElseThrow();
|
||||
};
|
||||
ClientChannelsMetaData.of(qInstance).getDeniedPatterns().get("assistant").remove("assistant_*");
|
||||
object.getPermissionRules().withDeniedClients(Set.of());
|
||||
assertThatThrownBy(() -> new AssistantRuntime(qInstance, config)).isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("Assistant channel wall").hasMessageContaining(object.getName());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Direct invocations bypass tool exposure checks, proving the wall lives in core.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void adminDeniedAcrossEveryTableAndProcess() throws Exception
|
||||
{
|
||||
String base = UiApiTestServer.serve(qInstance, config);
|
||||
UiApiResult metadata = invoke(new UiApiCall("GET", "/metaData"));
|
||||
assertThat(metadata.status()).isEqualTo(200);
|
||||
UiApiResult uiMetadata = ui(base, "GET", "/metaData", null);
|
||||
assertThat(uiMetadata.status()).isEqualTo(200);
|
||||
QContext.init(qInstance, AssistantTestFixture.ownerSession(AssistantTestFixture.ADMIN, config));
|
||||
UiApiToolbox.Outcome listed = config.getRuntime().getToolbox().execute(new UiApiToolbox.Context(AssistantTestFixture.ADMIN, null,
|
||||
config.getRuntime().getUiApiGateway(), null, null, null, "42"), "list", UiApiToolbox.LIST_TABLES, Map.of());
|
||||
for(String table : qInstance.getTables().keySet().stream().filter(name -> name.startsWith("assistant")).toList())
|
||||
{
|
||||
assertThat(((Map<?, ?>) metadata.json().get("tables")).containsKey(table)).isFalse();
|
||||
assertThat(((Map<?, ?>) uiMetadata.json().get("tables")).containsKey(table)).isTrue();
|
||||
assertThat(listed.getResult().getModelContent()).doesNotContain("\"" + table + "\"");
|
||||
for(UiApiCall call : List.of(
|
||||
new UiApiCall("POST", "/table/{tableName}/query").withJsonBody(Map.of()),
|
||||
new UiApiCall("GET", "/table/{tableName}/{primaryKey}").withPathParam("primaryKey", "1"),
|
||||
new UiApiCall("POST", "/table/{tableName}").withJsonBody(Map.of()),
|
||||
new UiApiCall("PATCH", "/table/{tableName}/{primaryKey}").withPathParam("primaryKey", "1").withJsonBody(Map.of()),
|
||||
new UiApiCall("DELETE", "/table/{tableName}/{primaryKey}").withPathParam("primaryKey", "1")))
|
||||
{
|
||||
assertThat(invoke(call.withPathParam("tableName", table)).status()).as("%s %s", table, call.operation()).isEqualTo(403);
|
||||
}
|
||||
assertThat(ui(base, "POST", "/table/" + table + "/query", "{}").status()).as(table).isEqualTo(200);
|
||||
}
|
||||
for(String process : qInstance.getProcesses().keySet().stream().filter(name -> name.startsWith("assistant")).toList())
|
||||
{
|
||||
assertThat(((Map<?, ?>) metadata.json().get("processes")).containsKey(process)).isFalse();
|
||||
assertThat(((Map<?, ?>) uiMetadata.json().get("processes")).containsKey(process)).isTrue();
|
||||
assertThat(invoke(new UiApiCall("POST", "/processes/{processName}/init").withPathParam("processName", process).withJsonBody(Map.of())).status()).as(process).isEqualTo(403);
|
||||
assertThat(ui(base, "POST", "/processes/" + process + "/init", "{}").status()).as(process).isEqualTo(200);
|
||||
}
|
||||
assertThat(UiApiTestServer.SCHEDULED_REQUESTS).anySatisfy(request -> assertThat(request.get("client")).isEqualTo("assistant/42"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Spoofed headers cannot turn an assistant call into a Next UI request.
|
||||
*******************************************************************************/
|
||||
private UiApiResult invoke(UiApiCall call) throws Exception
|
||||
{
|
||||
return (config.getRuntime().getUiApiGateway().invoke(call.withSessionUUID(AssistantTestFixture.ADMIN).withClientDetail("42").withHeader("X-QQQ-Client", "next-ui")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The same administrator's direct browser request.
|
||||
*******************************************************************************/
|
||||
private UiApiResult ui(String base, String method, String path, String body) throws Exception
|
||||
{
|
||||
HttpRequest request = HttpRequest.newBuilder(URI.create(base + "/qqq/v1" + path)).header("Cookie", "sessionId=" + AssistantTestFixture.ADMIN)
|
||||
.header("X-QQQ-Client", "next-ui").header("Content-Type", "application/json")
|
||||
.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(body)).build();
|
||||
HttpResponse<String> response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
|
||||
return (new UiApiResult(response.statusCode(), response.body()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Enumerate rather than maintaining an allowlist that could miss new objects.
|
||||
*******************************************************************************/
|
||||
private List<MetaDataWithPermissionRules> objects()
|
||||
{
|
||||
List<MetaDataWithPermissionRules> objects = new ArrayList<>();
|
||||
objects.addAll(qInstance.getTables().values());
|
||||
objects.addAll(qInstance.getProcesses().values());
|
||||
objects.addAll(qInstance.getReports().values());
|
||||
objects.addAll(qInstance.getWidgets().values());
|
||||
return (objects.stream().filter(object -> object.getName().startsWith("assistant")).toList());
|
||||
}
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant;
|
||||
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.ClientChannelsMetaData;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Compatibility seam for the future core audit-read opt-in.
|
||||
*******************************************************************************/
|
||||
public class RecordHistoryConfigTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** RC.6 changes exactly one pattern, preserving every other channel rule.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void fallbackRemovesOnlyAuditPattern() throws Exception
|
||||
{
|
||||
ClientChannelsMetaData channels = ClientChannelsMetaData.defaults();
|
||||
channels.withDeniedPattern("assistant", "audit_private*").withDeniedPattern("next-ui", "audit*");
|
||||
channels.withDeniedObject("assistant", "auditDetail");
|
||||
List<String> expected = new ArrayList<>(channels.getDeniedPatterns().get("assistant"));
|
||||
expected.remove("audit*");
|
||||
enable(channels);
|
||||
enable(channels);
|
||||
assertThat(channels.getDeniedPatterns().get("assistant")).containsExactlyElementsOf(expected)
|
||||
.contains("worm_audit_*", "assistant_*", "audit_private*");
|
||||
assertThat(channels.getDeniedPatterns().get("next-ui")).contains("audit*");
|
||||
assertThat(channels.getDeniedObjects().get("assistant")).contains("auditDetail");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Lookup uses the future method name and does not run the fallback afterward.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void invokesFutureCoreMethod() throws Exception
|
||||
{
|
||||
FutureChannels channels = new FutureChannels();
|
||||
channels.withDeniedPattern("assistant", "audit*");
|
||||
enable(channels);
|
||||
assertThat(channels.called).isTrue();
|
||||
assertThat(channels.getDeniedPatterns().get("assistant")).contains("audit*");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A broken future core method must fail startup rather than weaken the wall.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void invocationFailureDoesNotFallBack()
|
||||
{
|
||||
FutureChannels channels = new FutureChannels();
|
||||
channels.fail = true;
|
||||
channels.withDeniedPattern("assistant", "audit*");
|
||||
assertThatThrownBy(() -> enable(channels)).cause().isInstanceOf(IllegalStateException.class);
|
||||
assertThat(channels.getDeniedPatterns().get("assistant")).contains("audit*");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Exercise the one private compatibility helper without widening production API.
|
||||
*******************************************************************************/
|
||||
private void enable(ClientChannelsMetaData channels) throws Exception
|
||||
{
|
||||
Method method = AssistantQBitProducer.class.getDeclaredMethod("enableRecordHistory", ClientChannelsMetaData.class);
|
||||
method.setAccessible(true);
|
||||
method.invoke(new AssistantQBitProducer(), channels);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A future core API stand-in; RC.6 metadata is not final.
|
||||
*******************************************************************************/
|
||||
public static class FutureChannels extends ClientChannelsMetaData
|
||||
{
|
||||
private Boolean called = false;
|
||||
private Boolean fail = false;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Record reflective dispatch; deliberately leave the pattern to detect fallback.
|
||||
*******************************************************************************/
|
||||
public ClientChannelsMetaData withAssistantAuditReads()
|
||||
{
|
||||
called = true;
|
||||
if(fail)
|
||||
{
|
||||
throw (new IllegalStateException("test failure"));
|
||||
}
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
}
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.email;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
import com.kingsrook.qbits.email.EmailQBitConfig;
|
||||
import com.kingsrook.qbits.email.EmailQBitProducer;
|
||||
import com.kingsrook.qbits.email.api.EmailApi;
|
||||
import com.kingsrook.qbits.email.api.EmailApiCategory;
|
||||
import com.kingsrook.qbits.email.api.EmailApiMessage;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Scheduled-result email through qbit-email, with the email qbit produced on a
|
||||
** memory-backend instance, and the behavior when it is not.
|
||||
*******************************************************************************/
|
||||
class AssistantEmailTest
|
||||
{
|
||||
private static final String RUN_TABLE = "assistant_scheduled_run";
|
||||
|
||||
private QInstance instance;
|
||||
private AssistantQBitConfig config;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setup() throws Exception
|
||||
{
|
||||
instance = newInstance(true);
|
||||
config = new AssistantQBitConfig().withEmailExternalApproved(true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void cleanup()
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A memory-backend instance, with or without the email qbit.
|
||||
*******************************************************************************/
|
||||
private static QInstance newInstance(boolean withEmail) throws Exception
|
||||
{
|
||||
MemoryRecordStore.fullReset();
|
||||
QContext.clear();
|
||||
QInstance qInstance = new QInstance();
|
||||
qInstance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
|
||||
qInstance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS));
|
||||
if(withEmail)
|
||||
{
|
||||
new EmailQBitProducer().withQBitConfig(new EmailQBitConfig().withBackendName("memory").withPublicBaseUrl("https://app.example.com")
|
||||
.withSecretResolver(name -> "QQQ_EMAIL_UNSUBSCRIBE_KEY".equals(name) ? "test-unsubscribe-key" : null)).produce(qInstance);
|
||||
}
|
||||
if(withEmail)
|
||||
{
|
||||
QContext.init(qInstance, new QSession().withPermissions(List.of("email.view", "email.send", "email.manage", "email.admin", "email.hasAccess")));
|
||||
new InsertAction().execute(new InsertInput("email_provider").withRecords(List.of(new QRecord().withValue("name", "relay").withValue("label", "Relay")
|
||||
.withValue("kind", "SMTP").withValue("host", "127.0.0.1").withValue("port", 25).withValue("security", "NONE")
|
||||
.withValue("fromAddress", "sender@example.com").withValue("enabled", true).withValue("isDefault", true))));
|
||||
}
|
||||
return (qInstance);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private ScheduledResultNotice notice(int runId, String summary, DataClassification classification, boolean optIn)
|
||||
{
|
||||
return (new ScheduledResultNotice(RUN_TABLE, runId, "Weekly review", Instant.parse("2026-10-12T09:00:00Z"), summary, classification,
|
||||
"owner@example.com", optIn, "https://app.example.com/app/" + RUN_TABLE + "/" + runId));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private List<EmailApiMessage> messages() throws Exception
|
||||
{
|
||||
return (EmailApi.findMessagesByIdempotencyKeyPrefix("assistant:scheduled:", 100));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Inspect the test outbox through core as the authorized email administrator.
|
||||
** The public email API deliberately excludes bodies and recipient addresses.
|
||||
*******************************************************************************/
|
||||
private QRecord storedMessage() throws Exception
|
||||
{
|
||||
return (new QueryAction().execute(new QueryInput("email_message")).getRecords().getFirst());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A PUBLIC result queues one NOTIFICATION message from the template, with
|
||||
** the idempotency key and the linked run; a repeat is a duplicate.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void publicResultQueuesTemplateMessage() throws Exception
|
||||
{
|
||||
assertThat(AssistantEmail.isAvailable(instance)).isTrue();
|
||||
assertThat(AssistantEmail.sendScheduledResult(instance, config, notice(7, "Three items need attention.", null, true))).isEqualTo(ScheduledEmailStatus.QUEUED);
|
||||
|
||||
List<EmailApiMessage> messages = messages();
|
||||
assertThat(messages).hasSize(1);
|
||||
EmailApiMessage metadata = messages.getFirst();
|
||||
assertThat(metadata.category()).isEqualTo(EmailApiCategory.NOTIFICATION);
|
||||
assertThat(metadata.idempotencyKey()).isEqualTo("assistant:scheduled:7");
|
||||
assertThat(metadata.linkedTableName()).isEqualTo(RUN_TABLE);
|
||||
assertThat(metadata.linkedRecordId()).isEqualTo("7");
|
||||
assertThat(metadata.templateName()).isEqualTo("assistant_scheduled_result");
|
||||
QRecord message = storedMessage();
|
||||
assertThat(message.getValueString("toAddresses")).isEqualTo("owner@example.com");
|
||||
assertThat(message.getValueString("senderQbit")).isEqualTo("assistant");
|
||||
assertThat(message.getValueString("subject")).contains("Weekly review");
|
||||
assertThat(message.getValueString("htmlBody")).contains("Three items need attention.").contains("https://app.example.com/app/assistant_scheduled_run/7");
|
||||
QRecord template = new QueryAction().execute(new QueryInput("email_template")).getRecords().stream().filter(t -> "assistant_scheduled_result".equals(t.getValueString("name"))).findFirst().orElseThrow();
|
||||
assertThat(template.getValueString("category")).isEqualTo("NOTIFICATION");
|
||||
assertThat(template.getValueString("ownerQbit")).isEqualTo("assistant");
|
||||
assertThat(message.getValueInteger("templateId")).isEqualTo(template.getValueInteger("id"));
|
||||
|
||||
assertThat(AssistantEmail.sendScheduledResult(instance, config, notice(7, "Three items need attention.", null, true))).isEqualTo(ScheduledEmailStatus.DUPLICATE);
|
||||
assertThat(messages()).hasSize(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Above PUBLIC, only the link goes out, with the secure-message pattern;
|
||||
** the summary and task name are not in the message.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void classifiedResultSendsLinkOnly() throws Exception
|
||||
{
|
||||
assertThat(AssistantEmail.sendScheduledResult(instance, config, notice(8, "Patient Jane Doe has diabetes", DataClassification.PHI, true))).isEqualTo(ScheduledEmailStatus.QUEUED);
|
||||
|
||||
EmailApiMessage metadata = messages().getFirst();
|
||||
assertThat(metadata.category()).isEqualTo(EmailApiCategory.SECURITY);
|
||||
assertThat(metadata.idempotencyKey()).isEqualTo("assistant:scheduled:8");
|
||||
assertThat(metadata.linkedRecordId()).isEqualTo("8");
|
||||
assertThat(metadata.templateName()).isEqualTo("email_secure_message");
|
||||
QRecord message = storedMessage();
|
||||
assertThat(message.getValueString("htmlBody")).contains("https://app.example.com/app/assistant_scheduled_run/8").doesNotContain("Jane Doe").doesNotContain("Weekly review");
|
||||
assertThat(message.getValueString("textBody")).doesNotContain("Jane Doe");
|
||||
assertThat(message.getValueString("subject")).doesNotContain("Weekly review");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Each gate on its own: opt-in, the external approval, a recipient.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void gates() throws Exception
|
||||
{
|
||||
assertThat(AssistantEmail.sendScheduledResult(instance, config, notice(1, "x", null, false))).isEqualTo(ScheduledEmailStatus.NOT_REQUESTED);
|
||||
assertThat(AssistantEmail.sendScheduledResult(instance, config, null)).isEqualTo(ScheduledEmailStatus.NOT_REQUESTED);
|
||||
assertThat(AssistantEmail.sendScheduledResult(instance, new AssistantQBitConfig(), notice(2, "x", null, true))).isEqualTo(ScheduledEmailStatus.NOT_APPROVED);
|
||||
|
||||
ScheduledResultNotice noAddress = new ScheduledResultNotice(RUN_TABLE, 3, "t", Instant.now(), "x", null, "user-17", true, "https://app.example.com/app/x/3");
|
||||
assertThat(AssistantEmail.sendScheduledResult(instance, config, noAddress)).isEqualTo(ScheduledEmailStatus.NO_RECIPIENT);
|
||||
assertThat(AssistantEmail.sendScheduledResult(instance, config, new ScheduledResultNotice(RUN_TABLE, 3, "t", Instant.now(), "x", null, null, true, "https://app.example.com/a"))).isEqualTo(ScheduledEmailStatus.NO_RECIPIENT);
|
||||
assertThat(messages()).isEmpty();
|
||||
|
||||
config.withEmailRecipientResolver(id -> "user-17".equals(id) ? " user17@example.com " : null);
|
||||
assertThat(AssistantEmail.sendScheduledResult(instance, config, noAddress)).isEqualTo(ScheduledEmailStatus.QUEUED);
|
||||
assertThat(storedMessage().getValueString("toAddresses")).isEqualTo("user17@example.com");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The outbox refusing the message (here a relative link) is reported, not thrown.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void refusedIsReported() throws Exception
|
||||
{
|
||||
ScheduledResultNotice relative = new ScheduledResultNotice(RUN_TABLE, 4, "t", Instant.now(), "x", null, "owner@example.com", true, "/app/x/4");
|
||||
assertThat(AssistantEmail.sendScheduledResult(instance, config, relative)).isEqualTo(ScheduledEmailStatus.REFUSED);
|
||||
assertThat(messages()).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The summary is one line and cut short.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void summaryIsShort() throws Exception
|
||||
{
|
||||
assertThat(AssistantEmailSender.summary(null)).isNull();
|
||||
assertThat(AssistantEmailSender.summary("a\n\n b")).isEqualTo("a b");
|
||||
assertThat(AssistantEmailSender.summary("x".repeat(1000))).hasSize(280).endsWith("...");
|
||||
|
||||
AssistantEmail.sendScheduledResult(instance, config, notice(5, "y".repeat(2000), null, true));
|
||||
assertThat(storedMessage().getValueString("htmlBody")).doesNotContain("y".repeat(281));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Without the email qbit in the instance nothing is queued and nothing breaks.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void withoutEmailQbit() throws Exception
|
||||
{
|
||||
QInstance bare = newInstance(false);
|
||||
assertThat(AssistantEmail.isAvailable(bare)).isFalse();
|
||||
assertThat(AssistantEmail.isAvailable(null)).isFalse();
|
||||
assertThat(AssistantEmail.sendScheduledResult(bare, config, notice(6, "x", null, true))).isEqualTo(ScheduledEmailStatus.NOT_AVAILABLE);
|
||||
}
|
||||
}
|
||||
-57
@@ -18,7 +18,6 @@ package com.kingsrook.qbits.assistant.engine;
|
||||
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
@@ -27,16 +26,12 @@ import java.util.concurrent.atomic.AtomicReference;
|
||||
import com.kingsrook.qbits.assistant.AssistantModelRouting;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantPolicy;
|
||||
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
|
||||
import com.kingsrook.qbits.assistant.provider.CompletableStreamHandle;
|
||||
import com.kingsrook.qbits.assistant.provider.ModelProviderException;
|
||||
import com.kingsrook.qbits.assistant.provider.ModelResponse;
|
||||
import com.kingsrook.qbits.assistant.provider.ModelUsage;
|
||||
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
|
||||
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -203,27 +198,6 @@ class EngineComponentsTest extends BaseAssistantTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testPricing()
|
||||
{
|
||||
ModelPricing sonnet = ModelPricing.defaults().get(AssistantModelRouting.SONNET_5_5);
|
||||
ModelUsage usage = new ModelUsage().withInputTokens(6000L).withOutputTokens(800L).withCacheReadTokens(12_000L).withCacheWriteTokens(1000L).withCacheWrite1hTokens(400L);
|
||||
//////////////////////////////////////////////////////////////////////////////////
|
||||
// 6000*2 + 800*10 + 12000*0.2 + 600*2.5 + 400*4 = 12000+8000+2400+1500+1600 //
|
||||
//////////////////////////////////////////////////////////////////////////////////
|
||||
assertThat(sonnet.costMicros(usage)).isEqualTo(25_500);
|
||||
assertThat(sonnet.costMicros(null)).isZero();
|
||||
assertThat(new ModelPricing().costMicros(usage)).isZero();
|
||||
assertThat(ModelPricing.defaults().get(AssistantModelRouting.OPUS_5_5).getCacheReadUsdPerMTok()).isEqualTo(0.2);
|
||||
assertThat(usage.billableTokens()).isEqualTo(7800);
|
||||
assertThat(new ModelUsage().add(usage).add(null).getCacheReadTokens()).isEqualTo(12_000);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@@ -268,37 +242,6 @@ class EngineComponentsTest extends BaseAssistantTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testStreamHandle() throws Exception
|
||||
{
|
||||
CompletableStreamHandle handle = new CompletableStreamHandle();
|
||||
handle.complete(new ModelResponse().withStopReason("end_turn"));
|
||||
assertThat(handle.await(Duration.ofSeconds(1)).getStopReason()).isEqualTo("end_turn");
|
||||
|
||||
CompletableStreamHandle failing = new CompletableStreamHandle();
|
||||
failing.fail(new ModelProviderException(ModelProviderException.Kind.RATE_LIMITED, 429, "slow down"));
|
||||
assertThatThrownBy(() -> failing.await(Duration.ofSeconds(1))).isInstanceOfSatisfying(ModelProviderException.class, e ->
|
||||
{
|
||||
assertThat(e.isRetryable()).isTrue();
|
||||
assertThat(e.getHttpStatus()).isEqualTo(429);
|
||||
});
|
||||
|
||||
CompletableStreamHandle cancelled = new CompletableStreamHandle();
|
||||
cancelled.setOnCancel(() ->
|
||||
{
|
||||
throw (new IllegalStateException("closing"));
|
||||
});
|
||||
cancelled.cancel();
|
||||
cancelled.cancel();
|
||||
assertThat(cancelled.await(Duration.ofSeconds(1)).getStopReason()).isEqualTo(ModelResponse.STOP_CANCELLED);
|
||||
assertThat(new ModelProviderException(ModelProviderException.Kind.INVALID_REQUEST, 400, "x").isRetryable()).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -41,8 +41,10 @@ import com.kingsrook.qbits.assistant.provider.ModelUsage;
|
||||
import com.kingsrook.qbits.assistant.provider.fake.ScriptedModelProvider;
|
||||
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
|
||||
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
|
||||
import com.kingsrook.qbits.assistant.testing.UiApiTestServer;
|
||||
import com.kingsrook.qbits.assistant.tools.KnowledgeTool;
|
||||
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
|
||||
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
|
||||
import com.kingsrook.qbits.assistant.uiapi.UiApiCall;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiExposure;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
|
||||
@@ -54,6 +56,7 @@ import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInpu
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -278,6 +281,25 @@ class RagTest extends BaseAssistantTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Discovery and RAG record reads preserve turn details and request credentials.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void attributesKnowledgeRequestsToTurn()
|
||||
{
|
||||
QContext.init(qInstance, AssistantTestFixture.ownerSession(AssistantTestFixture.EDITOR, config));
|
||||
UiApiTestServer.serve(qInstance, config);
|
||||
UiApiTestServer.SCHEDULED_REQUESTS.clear();
|
||||
config.getRuntime().getContextBuilder().build(QContext.getQSession(), "model", null, List.of(), "question", Map.of(), PolicyService.ToolPolicy.defaults(), null, "42");
|
||||
UiApiToolbox.Context context = new UiApiToolbox.Context("invalid-session", null, config.getRuntime().getUiApiGateway(),
|
||||
PolicyService.ToolPolicy.defaults(), null, UiApiCredentials.ofSessionUUID(AssistantTestFixture.EDITOR), "42");
|
||||
assertThat(config.getRuntime().getRagService().read(context, "blog_post", "1").getResult().getOutcome()).isEqualTo(AiToolOutcome.OK);
|
||||
assertThat(UiApiTestServer.SCHEDULED_REQUESTS).hasSizeGreaterThanOrEqualTo(2).allSatisfy(request ->
|
||||
assertThat(request.get("client")).isEqualTo("assistant/42"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** UI read permission is checked again for every vector hit, with no identifiers
|
||||
** or snippets leaking from denied records. Exposure is reused from get_record.
|
||||
|
||||
+1
-1
@@ -145,7 +145,7 @@ public class AssistantTestFixture
|
||||
.withField(new QFieldMetaData("diagnosis", QFieldType.STRING)));
|
||||
}
|
||||
|
||||
new AuditsMetaDataProvider().defineAll(qInstance, BACKEND, null);
|
||||
new AuditsMetaDataProvider().withClientChannelField(true).defineAll(qInstance, BACKEND, null);
|
||||
for(String auditTable : List.of(AuditsMetaDataProvider.TABLE_NAME_AUDIT, AuditsMetaDataProvider.TABLE_NAME_AUDIT_DETAIL, AuditsMetaDataProvider.TABLE_NAME_AUDIT_TABLE, AuditsMetaDataProvider.TABLE_NAME_AUDIT_USER))
|
||||
{
|
||||
qInstance.getTable(auditTable).setPermissionRules(new QPermissionRules().withLevel(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS));
|
||||
|
||||
+16
-6
@@ -66,8 +66,16 @@ class UiApiToolsSecurityTest extends BaseAssistantTest
|
||||
** The default channel wall hides history, even when the user can read audits.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void historyHiddenByDefault()
|
||||
void historyHiddenByDefault() throws Exception
|
||||
{
|
||||
assertThat(config.getRecordHistory()).isFalse();
|
||||
UiApiTestServer.serve(qInstance, config);
|
||||
for(String table : List.of("auditTable", "audit", "auditDetail"))
|
||||
{
|
||||
UiApiResult result = config.getRuntime().getUiApiGateway().invoke(new UiApiCall("POST", "/table/{tableName}/query")
|
||||
.withPathParam("tableName", table).withJsonBody(Map.of()).withSessionUUID(AssistantTestFixture.EDITOR));
|
||||
assertThat(result.status()).isEqualTo(403);
|
||||
}
|
||||
assertThat(config.getRuntime().getToolbox().definitions()).extracting(definition -> definition.getName()).doesNotContain(UiApiToolbox.RECORD_HISTORY);
|
||||
assertThat(tool(AssistantTestFixture.EDITOR, null, UiApiToolbox.RECORD_HISTORY, input("table", "page", "id", "12")).getHttpStatus()).isEqualTo(403);
|
||||
}
|
||||
@@ -78,9 +86,10 @@ class UiApiToolsSecurityTest extends BaseAssistantTest
|
||||
** A host opt-in offers history and reads it through the real UI API.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void historyOfferedAndReadableAfterHostOptIn()
|
||||
void historyOfferedAndReadableAfterHostOptIn() throws Exception
|
||||
{
|
||||
ClientChannelsMetaData.ofOrWithNew(qInstance).getDeniedPatterns().get("assistant").remove("audit*");
|
||||
config.getRuntime().close();
|
||||
qInstance = AssistantTestFixture.build(config.withRecordHistory(true));
|
||||
assertThat(config.getRuntime().getToolbox().definitions()).extracting(definition -> definition.getName()).contains(UiApiToolbox.RECORD_HISTORY);
|
||||
UiApiToolbox.Outcome outcome = tool(AssistantTestFixture.EDITOR, null, UiApiToolbox.RECORD_HISTORY, input("table", "page", "id", "12", "since", "THIS_WEEK"));
|
||||
assertThat(outcome.getResult().getOutcome()).isEqualTo(AiToolOutcome.OK);
|
||||
@@ -95,10 +104,11 @@ class UiApiToolsSecurityTest extends BaseAssistantTest
|
||||
*******************************************************************************/
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = { AuditsMetaDataProvider.TABLE_NAME_AUDIT_TABLE, AuditsMetaDataProvider.TABLE_NAME_AUDIT, AuditsMetaDataProvider.TABLE_NAME_AUDIT_DETAIL })
|
||||
void historyRespectsEachAuditTableDenial(String name)
|
||||
void historyRespectsEachAuditTableDenial(String name) throws Exception
|
||||
{
|
||||
config.getRuntime().close();
|
||||
qInstance = AssistantTestFixture.build(config.withRecordHistory(true));
|
||||
ClientChannelsMetaData channels = ClientChannelsMetaData.ofOrWithNew(qInstance);
|
||||
channels.getDeniedPatterns().get("assistant").remove("audit*");
|
||||
channels.withDeniedObject("assistant", name);
|
||||
assertThat(config.getRuntime().getToolbox().definitions()).extracting(definition -> definition.getName()).doesNotContain(UiApiToolbox.RECORD_HISTORY);
|
||||
channels.getDeniedObjects().get("assistant").remove(name);
|
||||
@@ -321,7 +331,7 @@ class UiApiToolsSecurityTest extends BaseAssistantTest
|
||||
assertThat(log).hasSize(1);
|
||||
assertThat(log.get(0).getUiApiOperation()).isEqualTo("POST /table/{tableName}");
|
||||
assertThat(log.get(0).getHttpStatus()).isBetween(200, 299);
|
||||
assertThat(log.get(0).getAuditHref()).startsWith("/app/blog_post/");
|
||||
assertThat(log.get(0).getAuditHref()).startsWith("/app/audit?filter=");
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user