Compare commits

..
Author SHA1 Message Date
james.maes 467c2199dc Merge remote-tracking branch 'origin/develop' into feature/provider-api-module
# Conflicts:
#	CHANGELOG.md
#	UPGRADE.md
2026-10-14 07:31:36 -05:00
james.maes 01309ddfe9 Merge pull request #36 from QRun-IO/feature/p7-scheduled-tasks
feat: scheduled assistant tasks as a designated user through the UI API (P-g)
2026-10-14 07:18:36 -05:00
james.maes de04965cd7 Merge remote-tracking branch 'origin/develop' into feature/p7-scheduled-tasks
# Conflicts:
#	qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/AssistantRuntime.java
2026-10-14 07:13:09 -05:00
james.maes 61e6f809f1 Merge remote-tracking branch 'origin/develop' into feature/provider-api-module 2026-10-14 07:07:52 -05:00
james.maes 8e8a5c59cd Merge pull request #38 from QRun-IO/feature/bulk-load-tools
feat(bulk-load): assistant tools to use and create saved bulk load profiles
2026-10-14 07:05:51 -05:00
james.maesandClaude Opus 5.5 87835961f4 chore(bulk-load): tag the jobs-launch stand-in with the kernel PR (QRun-IO/qqq#1178)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:11:32 -05:00
james.maesandClaude Opus 5.5 c3a828c1c3 feat(bulk-load): assistant tools to use and create saved bulk load profiles
The owner asked (2026-10-13) that the assistant use and create bulk load
profiles. This adds the spec #1171 section 3.4 tools, all through the
/qqq/v1 UI API with the user's own session:

- bulk_profiles_list / bulk_profile_describe: the user's and shared
  profiles, read through the savedBulkLoadProfile table (record locks).
- bulk_file_attach: an upload the panel already made (pageContext
  attachments, refs only), with its masked column profiles.
- bulk_mapping_suggest: core's suggester. The model then refines the
  profile from the headers and the permitted samples.
- bulk_load_dry_run: the table's real bulk-insert validation (SAMPLE or
  FULL), with nothing stored. Error groups come with row numbers, so the
  model can fix the profile and run again.
- bulk_profile_save (write): storeSavedBulkLoadProfile, plus
  insertSharedRecord for each share. It runs only after the user confirms
  the pending action, and only as far as the user may share.
- bulk_load_run (write): goes through a BulkLoadRunner seam after
  confirmation. The default runner is the durable jobs launch, where core
  creates assistant jobs AWAITING_CONFIRMATION. Until that ships, it
  answers that the launch is not available yet (STAND-IN).

PHI: the X-QQQ-AI-Route header (hipaa|standard) comes from the turn's
provider HIPAA eligibility, never the model. The server masks samples and
values. Tool-call logs drop sampleValues, example bullets and value
mappings. Data access READ_WRITE is required to propose and to confirm
writes.

Supporting changes, all additive:
- UiApiCall: form params and headers.
- LoopbackUiApiGateway: multipart bodies.
- UiApiToolbox.Outcome.of.
- One dispatch branch each in ContextBuilder, TurnEngine and
  AssistantApi.confirmAction.
- ToolLabels entries.
- The config seam.

The core operations are QRun-IO/qqq#1179 (STAND-IN until an RC). Until
then the tools say the server does not offer them.

Tests: BulkLoadToolsTest (6), including the golden eval. It uploads a CSV
with messy headers. The assistant proposes a profile, the dry run finds
the missing required field, the assistant defaults it, the dry run
passes, it saves and shares the profile after confirmation, and in a
second turn runs the load after confirmation. Full ./mvnw verify is green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:47:00 -05:00
james.maesandClaude Opus 5.5 de215f8ff9 refactor: qbit-assistant-provider-api module for the provider SPI and pricing (#30)
Architecture review QRun-IO/qqq#1150 F5: qbit-marketing compiled against
assistant internals.

- New artifact qbit-assistant-provider-api with the provider SPI and
  value types (same package names) and engine.ModelPricing; production
  dependency jackson-annotations only. An ArchUnit test keeps it free of
  assistant internals.
- AssistantProviderType's settings validation takes ProviderSettings
  instead of the assistant_provider entity (documented in UPGRADE.md).
- qbit-assistant and the provider modules depend on the API artifact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:49:45 -05:00
james.maes 9e1bda8dae Merge remote-tracking branch 'origin/develop' into feature/p7-scheduled-tasks
# Conflicts:
#	README.md
#	qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnEngine.java
#	qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/metadata/AssistantRecordScreens.java
2026-10-13 20:37:33 -05:00
james.maesandClaude Opus 5.5 907d943f8d feat: scheduled assistant tasks as a designated user through the UI API (P-g)
- assistant_scheduled_task (Scheduled tasks): prompt, run-as user,
  credential env-var reference (allow-listed, never stored), profile,
  Quartz cron + time zone, output mode, write-mode request and per-task
  admin approval (cleared when the prompt, user, credential, profile or
  request changes), per-run cost cap; assistant_scheduled_run child list.
- Runner (60 s schedule): claims a slot via a unique (task, scheduledFor)
  run key, logs in exactly like a UI client (POST /qqq/v1/manageSession
  with accessToken), checks the session's user is the run-as user, then
  runs a normal turn on a new conversation (channel assistant/scheduled,
  X-QQQ-Client assistant/scheduled/<taskId>). No system or constructed
  sessions.
- PHI routing, budgets, routing/fallback apply unchanged; output as the
  conversation and run record, a notification through core alerts, or
  both. Record actions: Approve write mode, Revoke write mode, Run now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:34:46 -05:00
james.maes 5e1c213fd6 Merge pull request #34 from QRun-IO/feature/presentation-demo-shots
test(demo): answer presentation demo turn and panel screenshots
2026-10-13 20:29:45 -05:00
james.maes 1a65aa2257 Merge pull request #33 from QRun-IO/feature/p6-rag
feat: RAG over instance content, permission-filtered through the UI API (P-f)
2026-10-13 20:29:40 -05:00
james.maesandClaude Opus 5.5 6a26a7d4fa test(demo): answer presentation demo turn; panel screenshots
The demo's mock host answers "How many images do I have by type?" with
count_records through the UI API and then present_results, so the
panel shows the owner's example (summary, metric, table with shares and
row links, pie chart, filter links, action). Screenshots light/dark at
390/1440 px with the Next UI build of qqq-frontend-next#111.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:03:20 -05:00
88 changed files with 6434 additions and 146 deletions

No files matched your search

+6 -2
View File
@@ -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}`.
+19
View File
@@ -1,5 +1,24 @@
# Changelog
## 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,
and a 60-second QQQ scheduled process using the host's scheduler context.
- Log in through the loopback UI API with a separately allow-listed credential
environment reference; verify the designated user before starting a normal turn.
- Add Review/Result Approve write mode, Revoke write mode and Run now actions.
Approval binds the task definition; scheduled writes always await confirmation.
- Attribute scheduled UI API requests and tool logs, apply per-run STOP caps,
and send optional Alerts notifications to the designated user. HIPAA summaries
contain only `see conversation`.
- Add unique schedule-slot claims, fixed-clock fixture integration tests and
additive Postgres DDL. No API routes or provider modules change.
## Unreleased - P-f
- Add OpenAI-compatible embeddings (including Azure), explicit CHAT/EMBEDDING
+8 -3
View File
@@ -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,11 +48,12 @@ 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)
- **Sessions:**
- Scheduled runs log in as the designated user through `POST /qqq/v1/manageSession`; resolve only its returned UUID, verify the user id, and never construct or elevate a session. The QQQ-supplied scheduler context touches only assistant bookkeeping.
- 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.
@@ -67,6 +70,8 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
- Errors (DENIED, NOT_FOUND, INVALID_INPUT, ERROR) are `is_error` results.
- Unknown tools and over-limit calls also get error results.
- **Keys:** never stored in a table. `assistant_provider.apiKeyEnvName` holds only an allowed environment variable NAME; `apiKeyStatus` is computed on read (last 4 at most) and stripped on save.
- Scheduled task `credentialEnvName` is a separately allow-listed environment variable NAME. Tokens never enter rows, model context or logs. `credentialStatus` is computed and stripped on save.
- Scheduled write approval is invalidated by changes to prompt, run-as user, credential reference, profile or write request. Scheduled writes always require the designated user's panel confirmation.
- **Logging:** never log message content, prompts, tool results or keys. Structured end-of-turn log only.
- **Thinking:** thinking is never streamed or stored. The Anthropic adapter keeps the raw assistant message (with signatures) only in memory, as `providerState`, for the tool loop.
- **Links:** only `/app` or `/app/...` hrefs are emitted (`TurnEngine.isAppHref`).
@@ -102,4 +107,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}`.
+58 -1
View File
@@ -4,10 +4,28 @@ 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.
@@ -551,6 +569,45 @@ health is probed when unknown/stale with a two-second timeout. Provider cards sh
health and down-until, failures show fallback totals, and turn records show the
route and final destination. See [UPGRADE.md](UPGRADE.md) for the additive schema.
## Scheduled tasks
Administrators configure recurring prompts in **Assistant → Scheduled tasks**.
Tasks use Quartz cron syntax (for example `0 0 8 * * ?`) and an IANA timezone
(for example `America/Chicago`; default `UTC`). Tasks start disabled.
```java
config.withUiApiBaseUrl("http://127.0.0.1:8000")
.withScheduledTasksSchedulerName("hostScheduler")
.withAllowedScheduledCredentialEnvNames(Set.of("ASSISTANT_SCHEDULED_TOKEN"));
```
Provide a long-lived access token accepted by the host's authentication provider
in that environment variable. Store only its **name** on the task and set
`runAsUserId` to the corresponding QQQ user id. The runner posts the token to
`/qqq/v1/manageSession`, reads its session cookie and verifies the returned
session's user before starting a new conversation. No system session reads user
data. Configure the loopback URL at startup; a scheduled job cannot rely on a
browser having visited first.
For kof22, provision a designated Authentik account with the intended application
permissions, obtain a token that kof22's QQQ authentication provider accepts as an
`accessToken`, and inject it through the existing secret-manager/ExternalSecret
pipeline. An Authentik management API token is suitable only if the host's auth
adapter explicitly accepts it. Rotate the token in the secret manager, never in
the task table. This feature does not provision an account or token.
**Run now**, **Approve write mode** and **Revoke write mode** are Review/Result
record actions. Write approval names the designated user and displays the exact
prompt SHA-256. Changing prompt, user, credential reference, profile or write-mode
request clears approval. Scheduled writes always wait for that user's panel
confirmation, even if normal policy disables confirmation.
Each run keeps an owner-locked audit row and a normal conversation. RECORD adds
no notification; NOTIFICATION and BOTH also notify the designated user through
the optional Alerts QBit SPI. Without Alerts, notification delivery is log-only.
HIPAA summaries are always `see conversation`. Cost caps stop at dispatch
boundaries, so the last completed call can cross the cap; no further call starts.
Apply the [P-g migration](UPGRADE.md#p-g-scheduled-tasks) before enabling jobs.
### Answer presentation
The always-available `present_results` tool turns retrieved results into metrics,
+50
View File
@@ -1,5 +1,55 @@
# Upgrading qbit-assistant
## 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)
before deploying. The unique `(task_id, scheduled_for)` constraint is required
for multi-replica safety; retain run rows as schedule-slot claim history.
Existing conversations default to channel `panel`. Tasks start disabled.
Set `withScheduledTasksSchedulerName(...)` to an existing QQQ scheduler and
configure `withUiApiBaseUrl(...)` with this replica's own listener URL. The runner
polls every 60 seconds. QQQ must supply a scheduler session able to manage the
assistant tables; this QBit never creates that session. The configured backend
must enforce unique keys atomically (Postgres does).
Allow only designated credential references with
`withAllowedScheduledCredentialEnvNames(Set.of("ASSISTANT_SCHEDULED_TOKEN"))`.
Provide that variable through the host's secret manager. It must hold a token
accepted by the host's `manageSession` authentication provider for `runAsUserId`.
The runner fails closed on missing/disallowed credentials or an identity mismatch.
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.
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
the owner-locked audit row and conversation. HIPAA run summaries never copy the
answer; they contain only `see conversation`.
## P-f: knowledge retrieval
Apply the **P-f** assistant-table DDL in
+86
View File
@@ -469,6 +469,92 @@ AI exposure NONE is not enough, because the assistant acts as the user and an ad
per-record pgvector replacement is transactional, while scan reconciliation is
not a snapshot of concurrently changing application data.
### P-g as built
- `assistant_scheduled_task` is admin-only, AI exposure NONE, and appears as
Scheduled tasks. The owner-locked, read-only `assistant_scheduled_run` child
list retains run status, accounting and a bounded summary. Conversations gain
read-only `scheduledTaskId` and `channel` (`panel` or `assistant/scheduled`).
Task and run timestamps use the runtime clock; cron uses Quartz and a validated
ZoneId, including DST. Enablement and schedule edits recompute `nextRunAt`.
- Credentials are references, never stored tokens. `credentialEnvName` must
match `[A-Z_][A-Z0-9_]*` and the dedicated
`allowedScheduledCredentialEnvNames` allowlist. The existing secret resolver
supplies its value. Read-only `credentialStatus` reports presence without
exposing token characters and is stripped on every save.
- A run sends JSON `{accessToken: ...}` to the same loopback base URL's
`POST /qqq/v1/manageSession`. RC.5 with HTTP-only cookies returns the UUID in
`Set-Cookie: sessionUUID`; legacy JSON `uuid` is also accepted. Only then does
the runner resolve that UUID through the host authentication module, exactly
the UUID lookup used by the UI request pipeline. No direct token authentication,
automated-user session or constructed QSession exists in assistant code.
The resolved user id must equal `runAsUserId` before any model call. MOCK in
the integration fixture maps test tokens to the existing user-prefix sessions;
that token adapter is test code only.
- kof22 provisions an Authentik designated account with normal QQQ permissions
and injects an accepted long-lived token through its secret manager and
ExternalSecret into an allow-listed environment variable. The task holds the
variable name and QQQ user id. Host authentication must accept that token type
as `accessToken`; an Authentik management API token is not automatically an
application access token. Neither account nor credential is provisioned here.
- `assistantScheduledTasksRunner` registers a 60-second QScheduleMetaData when
`scheduledTasksSchedulerName` names a host scheduler. QQQ supplies its normal
scheduler context for assistant-table bookkeeping; no ArchUnit exception is
necessary. Configure `uiApiBaseUrl` at startup. No browser visit is required.
- **Claim compatibility:** RC.5 UpdateInput cannot express a conditional
`WHERE next_run_at = expected`. AssistantStore therefore claims the expected
schedule slot using the database-enforced unique `(taskId, scheduledFor)` run
key and advances `nextRunAt` before login/dispatch. This is a unique-insert
optimistic claim rather than a conditional update. The memory fixture also
serializes claim operations on its instance. The database constraint, not
that monitor, prevents cross-replica duplicate execution. Do not remove claim
history or deploy to a backend without atomic unique keys. A crash can lose a
run; it never retries a claimed slot. A later tick advances an already-claimed
slot left behind by a crash between claim and schedule update.
- The unchanged AssistantApi starts a new normal turn under the logged-in
designated user, preserving start gates, rate limiting, P-c classification,
P-d budgets and P-e routing/fallback. An internal context supplies the task's
selected profile and additional cost cap. Cost includes chat, classifier,
failed calls and knowledge-query embeddings. STOP is checked at dispatch
boundaries; an in-flight call can exceed the cap, and no next call is sent.
- Every scheduled UI API request, including metadata and knowledge retrieval,
carries `X-QQQ-Client: assistant/scheduled/<taskId>`. The only uiapi change is
additive `LoopbackUiApiGateway.invokeWithClient`; custom gateways currently
fail closed for scheduled requests because their channel contract is unknown.
Tool-call rows also record `clientChannel`. Core channel deny enforcement
remains pending the client-channel RC; existing AI exposure/data-access
restrictions continue to apply on RC.5.
- Approve write mode, Revoke write mode and Run now are admin record actions
using standard streamed ETL Review/Result. Review shows the exact prompt's
SHA-256 and run-as user. Result rereads and compares the complete reviewed
definition before recording the actual administrator's user id and time.
A persisted approval hash additionally binds prompt, run-as user, credential
reference, profile and requested mode. Editing any of these clears approval;
ordinary saves cannot set approval or computed bookkeeping fields.
- This base has create/update/delete tools, but no W1 conversation mode/policy
switch. Scheduled runs explicitly narrow the existing tool policy: write tools
require both request and matching approval, a READ_WRITE table and the policy's
allowed-tools list. All scheduled writes require panel confirmation, including
when normal policy allows immediate writes. A pending proposal adds a waiting
notice to the run summary; the scheduler never confirms it.
- RECORD keeps the run and conversation; NOTIFICATION and BOTH also submit an
Alerts notification with `NotificationAudience.user(runAsUserId)` and an
`/app/...` conversation link. Reflection targets the installed Alerts QBit's
RC.5 notification SPI/sink, following WebhooksAlerts' optional-dependency
pattern. Without it, a log contains run id and status only. Notification text
contains no answer data. In HIPAA mode run summaries contain exactly
`see conversation`; conversation payloads retain the existing encryption.
The existing record URL is an admin-screen link. Non-admin owners can read
their scheduled conversations in the panel's conversation list; opening a
particular existing conversation from a notification still needs a frontend
deep-link contract (the current panel only supports open/new-prompt links).
This phase does not broaden existing conversation record-screen permissions.
- Failures close the run as FAILED or BLOCKED with sanitized errors and wait
until the next schedule. All output modes retain their audit record. Tests
exercise real manageSession and UI API requests with a fixed clock, designated
permissions and owner locks, headers, competing runners, approval invalidation,
notification modes, PHI refusal, encrypted HIPAA storage and cost STOP.
### Phases (each a green PR)
| | Scope | Depends on |
|---|---|---|
+50
View File
@@ -524,3 +524,53 @@ CREATE TABLE assistant_rag_source (
-- );
-- CREATE INDEX IF NOT EXISTS assistant_rag_chunk_cosine
-- ON assistant_rag.assistant_rag_chunk USING hnsw (embedding vector_cosine_ops);
-- P-g: designated-user scheduled assistant tasks. Install before enabling jobs.
CREATE TABLE assistant_scheduled_task (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT FALSE,
prompt TEXT NOT NULL CHECK (char_length(prompt) BETWEEN 1 AND 4000),
run_as_user_id VARCHAR(200) NOT NULL,
credential_env_name VARCHAR(200) NOT NULL,
credential_status VARCHAR(200), -- computed; always NULL in stored rows
profile_id INTEGER REFERENCES assistant_profile(id),
cron_expression VARCHAR(200) NOT NULL,
timezone VARCHAR(100) NOT NULL DEFAULT 'UTC',
next_run_at TIMESTAMPTZ,
output_mode VARCHAR(20) NOT NULL DEFAULT 'RECORD' CHECK (output_mode IN ('RECORD', 'NOTIFICATION', 'BOTH')),
write_mode_requested BOOLEAN NOT NULL DEFAULT FALSE,
write_mode_approved_by VARCHAR(200),
write_mode_approved_at TIMESTAMPTZ,
write_mode_approval_hash VARCHAR(64),
max_cost_usd_per_run NUMERIC(18,6) CHECK (max_cost_usd_per_run >= 0),
last_run_at TIMESTAMPTZ,
last_run_status VARCHAR(20),
last_run_error VARCHAR(500),
create_date TIMESTAMPTZ,
modify_date TIMESTAMPTZ
);
CREATE INDEX assistant_scheduled_task_due ON assistant_scheduled_task(next_run_at) WHERE enabled;
ALTER TABLE assistant_conversation ADD COLUMN scheduled_task_id INTEGER REFERENCES assistant_scheduled_task(id);
ALTER TABLE assistant_conversation ADD COLUMN channel VARCHAR(64) NOT NULL DEFAULT 'panel';
ALTER TABLE assistant_tool_call ADD COLUMN client_channel VARCHAR(64);
CREATE TABLE assistant_scheduled_run (
id SERIAL PRIMARY KEY,
task_id INTEGER NOT NULL REFERENCES assistant_scheduled_task(id),
user_id VARCHAR(200) NOT NULL,
scheduled_for TIMESTAMPTZ NOT NULL,
started_at TIMESTAMPTZ,
ended_at TIMESTAMPTZ,
status VARCHAR(20) NOT NULL CHECK (status IN ('RUNNING', 'OK', 'FAILED', 'SKIPPED', 'BLOCKED')),
conversation_id INTEGER REFERENCES assistant_conversation(id) ON DELETE SET NULL,
turn_id INTEGER REFERENCES assistant_turn(id) ON DELETE SET NULL,
summary VARCHAR(2000),
cost_micros BIGINT,
error VARCHAR(500),
create_date TIMESTAMPTZ,
modify_date TIMESTAMPTZ,
UNIQUE (task_id, scheduled_for)
);
CREATE INDEX assistant_scheduled_run_owner ON assistant_scheduled_run(user_id, started_at);
+5
View File
@@ -35,3 +35,8 @@ Light and dark, 390 and 1440 px:
- `p-provider-local-*`: Local GPU (Ollama), keyless LOCAL settings, discovery/test results and discovered models.
- `p-model-*`: a discovered model's capabilities, compute prices and connection-test result.
- `p-home-providers-*`: the home's provider summary and usage from the executed demo calls, including disabled OpenRouter with its key Not set.
Answer presentation (owner priority, 2026-10-13), taken against `OpenAiDemoServer` with the Next UI build of QRun-IO/qqq-frontend-next#111 (`next dev` proxied to the demo). The demo's OpenAI-compatible mock answers "How many images do I have by type?" the way a model would through the real adapter: five `count_records` calls through the UI API, then `present_results`.
Light and dark, 390 and 1440 px:
- `panel-presentation-*`: the summary line, an Images metric, the "Media by type" table (counts and the server-computed 80.2% and 19.8%, each row a filtered-list link), the pie chart, two filter links (Open JPEG media, Open PNG media) and a follow-up action.
Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

+6
View File
@@ -50,6 +50,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 +86,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>
+116
View File
@@ -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>
@@ -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));
@@ -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());
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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]");
}
}
@@ -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();
}
}
@@ -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());
}
}
}
+5
View File
@@ -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>
@@ -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).");
}
+5
View File
@@ -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>
@@ -21,8 +21,13 @@ import java.io.IOException;
import java.math.BigDecimal;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
import com.kingsrook.qbits.assistant.model.AssistantModel;
@@ -35,6 +40,9 @@ import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
import com.kingsrook.qbits.assistant.provider.fake.ScriptedModelProvider;
import com.kingsrook.qbits.assistant.provider.openai.OpenAiCompatibleProviderType;
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.testing.EvalSupport;
import com.kingsrook.qbits.assistant.testing.GoldenTasks;
import com.kingsrook.qbits.assistant.testing.PresentationGoldenTask;
import com.kingsrook.qbits.assistant.testing.TurnRunner;
import com.kingsrook.qqq.backend.core.actions.processes.QProcessCallbackFactory;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
@@ -61,6 +69,8 @@ public class OpenAiDemoServer extends AbstractQQQApplication
{
private static final String ADMIN = "admin-demo";
private static final String MODEL = "qwen3:30b-a3b";
private static final String IMAGE_QUESTION = "How many images do I have by type?";
private static final ObjectMapper JSON = new ObjectMapper();
private static QInstance instance;
@@ -160,6 +170,8 @@ public class OpenAiDemoServer extends AbstractQQQApplication
{
throw (new QException("Demo assistant turn failed: " + outcome.errorCode()));
}
TurnRunner.Outcome presentation = new TurnRunner(instance, config).run(ADMIN, IMAGE_QUESTION);
PresentationGoldenTask.check(presentation);
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK)
.withCustomizer(new QCodeReference(DemoSessionCustomizer.class)));
instance.setHomeAppName(AssistantMetaDataBuilder.APP_NAME);
@@ -167,6 +179,7 @@ public class OpenAiDemoServer extends AbstractQQQApplication
System.out.println("Assistant home: http://localhost:" + port + "/app/" + AssistantMetaDataBuilder.APP_NAME);
System.out.println("Local provider: http://localhost:" + port + "/app/assistant_provider/" + providerId);
System.out.println("Local model: http://localhost:" + port + "/app/assistant_model/" + modelId);
System.out.println("Image presentation conversation id: " + presentation.conversationId());
}
@@ -211,7 +224,7 @@ public class OpenAiDemoServer extends AbstractQQQApplication
{
try(exchange)
{
exchange.getRequestBody().readAllBytes();
byte[] request = exchange.getRequestBody().readAllBytes();
String path = exchange.getRequestURI().getPath();
String body;
if("GET".equals(exchange.getRequestMethod()) && "/v1/models".equals(path))
@@ -227,16 +240,7 @@ public class OpenAiDemoServer extends AbstractQQQApplication
else if("POST".equals(exchange.getRequestMethod()) && "/v1/chat/completions".equals(path))
{
exchange.getResponseHeaders().set("Content-Type", "text/event-stream");
body = """
data: {"id":"demo-completion","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello from the local demo provider."}}]}
data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
data: {"choices":[],"usage":{"prompt_tokens":24,"completion_tokens":8,"total_tokens":32}}
data: [DONE]
""";
body = completion(JSON.readTree(request));
}
else
{
@@ -248,4 +252,84 @@ public class OpenAiDemoServer extends AbstractQQQApplication
exchange.getResponseBody().flush();
}
}
/*******************************************************************************
** Follow the latest user turn, so connection tests and repeated chats are
** independent. Counts are read from the adapter's actual tool messages.
*******************************************************************************/
private static String completion(JsonNode request) throws IOException
{
String question = "";
Map<String, Integer> counts = new LinkedHashMap<>();
boolean presented = false;
for(JsonNode message : request.path("messages"))
{
if("user".equals(message.path("role").asText()))
{
question = message.path("content").asText();
counts.clear();
presented = false;
}
else if("tool".equals(message.path("role").asText()) && IMAGE_QUESTION.equals(question))
{
String id = message.path("tool_call_id").asText();
if(id.startsWith("count-image/"))
{
counts.put(id.substring("count-".length()), ((Number) EvalSupport.unwrap(message.path("content").asText()).get("count")).intValue());
}
else if("present-images".equals(id))
{
presented = true;
}
}
}
Map<String, Object> delta = Map.of("role", "assistant", "content", "Hello from the local demo provider.");
String finish = "stop";
if(IMAGE_QUESTION.equals(question) && !presented)
{
List<Map<String, Object>> calls = new ArrayList<>();
if(counts.isEmpty())
{
for(String mime : List.of("image/jpeg", "image/png", "image/webp", "image/gif", "image/svg+xml"))
{
calls.add(toolCall(calls.size(), "count-" + mime, "count_records", Map.of("table", "media", "filter",
GoldenTasks.filter("AND", GoldenTasks.criterion("mimeType", "EQUALS", mime)))));
}
}
else
{
if(counts.size() != 5 || counts.get("image/webp") != 0 || counts.get("image/gif") != 0 || counts.get("image/svg+xml") != 0)
{
throw (new IOException("Unexpected demo image counts."));
}
Map<String, Object> input = PresentationGoldenTask.input(counts.get("image/jpeg"), counts.get("image/png"));
input.put("actions", List.of(Map.of("label", "Show the PNG files", "prompt", "Show the PNG files")));
calls.add(toolCall(0, "present-images", "present_results", input));
}
delta = Map.of("role", "assistant", "tool_calls", calls);
finish = "tool_calls";
}
else if(presented)
{
delta = Map.of("role", "assistant", "content", "You can open either image type above.");
}
return ("data: " + JSON.writeValueAsString(Map.of("id", "demo-completion", "object", "chat.completion.chunk",
"choices", List.of(Map.of("index", 0, "delta", delta)))) + "\n\n"
+ "data: " + JSON.writeValueAsString(Map.of("choices", List.of(Map.of("index", 0, "delta", Map.of(), "finish_reason", finish)))) + "\n\n"
+ "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":24,\"completion_tokens\":8,\"total_tokens\":32}}\n\n"
+ "data: [DONE]\n\n");
}
/*******************************************************************************
** Encode tool arguments as a JSON string, as OpenAI streaming requires.
*******************************************************************************/
private static Map<String, Object> toolCall(Integer index, String id, String name, Map<String, Object> arguments) throws IOException
{
return (Map.of("index", index, "id", id, "type", "function", "function", Map.of("name", name, "arguments", JSON.writeValueAsString(arguments))));
}
}
@@ -29,6 +29,11 @@ Startup prints these URLs (with the selected port):
- Local provider: `http://localhost:8099/app/assistant_provider/1`
- Priced and tested model: `http://localhost:8099/app/assistant_model/1`
Startup also prints `Image presentation conversation id: <id>`. Open that
conversation in the assistant panel history as `admin-demo` to capture the
answer to "How many images do I have by type?". The id is assigned on each run;
the conversation and its presentation are memory-backed and reset on restart.
Every browser session is `admin-demo`. Startup inserts an enabled, default,
keyless Local GPU (Ollama) provider, marked LOCAL and HIPAA eligible for the
synthetic demo, and a disabled CLOUD OpenRouter provider whose key is Not set.
@@ -40,8 +45,14 @@ mock: `qwen3:30b-a3b`, `gpt-oss:20b`, `qwen3-coder:30b`, and `bge-m3`.
A normal update sets qwen3's input/output compute prices to $0.05/$0.10 per
million tokens. The provider and model Test connection processes then each
make a real HTTP completion call, recording two TEST usage rows and the
last-test fields. One assistant turn records a third usage row with purpose
TURN. Discovery timestamps/status also come from the actual process.
last-test fields. The greeting turn records a third usage row with purpose
TURN. An image breakdown turn then makes three more model calls through the
real OpenAI adapter and engine: five MIME-filtered `count_records` calls,
`present_results` using the returned counts, and a short closing message.
The fixture seeds 81 JPEG and 20 PNG rows (no WebP, GIF or SVG). The presentation
includes a table with computed shares, filtered row links, a pie chart, JPEG
and PNG filter links, and a "Show the PNG files" prompt action.
Discovery timestamps/status also come from the actual process.
Replies and token counts are synthetic mock API responses (24 input / 8 output
tokens per completion), not live GPU measurements or billed cloud usage.
+5
View File
@@ -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>
@@ -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.");
}
+5 -1
View File
@@ -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>
@@ -17,6 +17,7 @@
package com.kingsrook.qbits.assistant;
import javax.net.ssl.SSLContext;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -28,7 +29,6 @@ import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.regex.Pattern;
import javax.net.ssl.SSLContext;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.kingsrook.qbits.assistant.classification.ClassificationSource;
import com.kingsrook.qbits.assistant.classification.ConfiguredClassificationSource;
@@ -110,6 +110,9 @@ public class AssistantQBitConfig implements QBitConfig
private Integer policyCacheSeconds = 30;
private String ownerSecurityKeyType = DEFAULT_OWNER_KEY_TYPE;
private String shortcut = DEFAULT_SHORTCUT;
private Set<String> allowedScheduledCredentialEnvNames = new LinkedHashSet<>();
private String scheduledTasksSchedulerName;
private String retentionSchedulerName;
private Boolean registerRoutes = true;
private Map<String, ModelPricing> pricing = ModelPricing.defaults();
@@ -133,6 +136,7 @@ public class AssistantQBitConfig implements QBitConfig
//////////////////////////////////////////////////////////////////////////
private String uiApiBaseUrl;
private UiApiGateway uiApiGateway;
private com.kingsrook.qbits.assistant.tools.bulkload.BulkLoadRunner bulkLoadRunner;
private Integer pendingActionTtlSeconds = 900;
private List<String> customRedactionPatterns = new ArrayList<>();
private Integer killSwitchPollMillis = 2000;
@@ -213,6 +217,13 @@ public class AssistantQBitConfig implements QBitConfig
requirePositive(errors, "killSwitchPollMillis", killSwitchPollMillis);
requirePositive(errors, "providerTestTimeoutSeconds", providerTestTimeoutSeconds);
requirePositive(errors, "homeMaxRows", homeMaxRows);
for(String name : allowedScheduledCredentialEnvNames == null ? Set.<String>of() : allowedScheduledCredentialEnvNames)
{
if(name == null || !ENV_NAME.matcher(name).matches())
{
errors.add("allowedScheduledCredentialEnvNames contains an invalid environment variable name.");
}
}
for(String name : allowedApiKeyEnvNames == null ? Set.<String>of() : allowedApiKeyEnvNames)
{
if(name == null || !ENV_NAME.matcher(name).matches())
@@ -1202,6 +1213,38 @@ public class AssistantQBitConfig implements QBitConfig
/*******************************************************************************
** Getter for bulkLoadRunner (null: the durable jobs launch,
** JobsLaunchBulkLoadRunner) - how bulk_load_run starts a load.
*******************************************************************************/
public com.kingsrook.qbits.assistant.tools.bulkload.BulkLoadRunner getBulkLoadRunner()
{
return (this.bulkLoadRunner);
}
/*******************************************************************************
** Setter for bulkLoadRunner
*******************************************************************************/
public void setBulkLoadRunner(com.kingsrook.qbits.assistant.tools.bulkload.BulkLoadRunner bulkLoadRunner)
{
this.bulkLoadRunner = bulkLoadRunner;
}
/*******************************************************************************
** Fluent setter for bulkLoadRunner
*******************************************************************************/
public AssistantQBitConfig withBulkLoadRunner(com.kingsrook.qbits.assistant.tools.bulkload.BulkLoadRunner bulkLoadRunner)
{
this.bulkLoadRunner = bulkLoadRunner;
return (this);
}
/*******************************************************************************
** Getter for uiApiGateway (null: the loopback stand-in)
*******************************************************************************/
@@ -1822,4 +1865,66 @@ public class AssistantQBitConfig implements QBitConfig
this.vectorStore = vectorStore;
return (this);
}
/*******************************************************************************
** Getter for allowedScheduledCredentialEnvNames
*******************************************************************************/
public Set<String> getAllowedScheduledCredentialEnvNames()
{
return (allowedScheduledCredentialEnvNames);
}
/*******************************************************************************
** Setter for allowedScheduledCredentialEnvNames
*******************************************************************************/
public void setAllowedScheduledCredentialEnvNames(Set<String> allowedScheduledCredentialEnvNames)
{
this.allowedScheduledCredentialEnvNames = allowedScheduledCredentialEnvNames;
}
/*******************************************************************************
** Fluent setter for allowedScheduledCredentialEnvNames
*******************************************************************************/
public AssistantQBitConfig withAllowedScheduledCredentialEnvNames(Set<String> allowedScheduledCredentialEnvNames)
{
this.allowedScheduledCredentialEnvNames = allowedScheduledCredentialEnvNames;
return (this);
}
/*******************************************************************************
** Getter for scheduledTasksSchedulerName
*******************************************************************************/
public String getScheduledTasksSchedulerName()
{
return (scheduledTasksSchedulerName);
}
/*******************************************************************************
** Setter for scheduledTasksSchedulerName
*******************************************************************************/
public void setScheduledTasksSchedulerName(String scheduledTasksSchedulerName)
{
this.scheduledTasksSchedulerName = scheduledTasksSchedulerName;
}
/*******************************************************************************
** Fluent setter for scheduledTasksSchedulerName
*******************************************************************************/
public AssistantQBitConfig withScheduledTasksSchedulerName(String scheduledTasksSchedulerName)
{
this.scheduledTasksSchedulerName = scheduledTasksSchedulerName;
return (this);
}
}
@@ -21,17 +21,21 @@ import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Properties;
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.AssistantInstanceMetaData;
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
import com.kingsrook.qbits.assistant.metadata.AssistantRecordScreens;
import com.kingsrook.qbits.assistant.model.AssistantBudget;
import com.kingsrook.qbits.assistant.processes.DiscoverModelsProcess;
import com.kingsrook.qbits.assistant.processes.KnowledgeIndexProcess;
import com.kingsrook.qbits.assistant.processes.ScheduledTaskProcess;
import com.kingsrook.qbits.assistant.processes.TestConnectionProcess;
import com.kingsrook.qbits.assistant.processes.TestModelConnectionProcess;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
import com.kingsrook.qbits.assistant.model.AssistantBudget;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
@@ -174,7 +178,7 @@ public class AssistantQBitProducer implements QBitMetaDataProducer<AssistantQBit
QProcessMetaData testConnection = TestConnectionProcess.process();
testConnection.setSourceQBitName(qBitMetaData.getName());
qInstance.addProcess(testConnection);
for(QProcessMetaData process : java.util.List.of(DiscoverModelsProcess.process(), TestModelConnectionProcess.process()))
for(QProcessMetaData process : List.of(DiscoverModelsProcess.process(), TestModelConnectionProcess.process()))
{
process.setSourceQBitName(qBitMetaData.getName());
qInstance.addProcess(process);
@@ -186,6 +190,11 @@ public class AssistantQBitProducer implements QBitMetaDataProducer<AssistantQBit
qInstance.addProcess(process);
}
for(QProcessMetaData process : ScheduledTaskProcess.processes(qBitConfig))
{
process.setSourceQBitName(qBitMetaData.getName());
qInstance.addProcess(process);
}
qInstance.addWidget(AssistantHome.widget());
qInstance.addWidget(AssistantAccessWidget.widget());
@@ -226,19 +235,19 @@ public class AssistantQBitProducer implements QBitMetaDataProducer<AssistantQBit
boolean routes = false;
if(Boolean.TRUE.equals(qBitConfig.getRegisterRoutes()) && isJavalinAvailable())
{
com.kingsrook.qbits.assistant.api.AssistantRoutes.register(qInstance);
AssistantRoutes.register(qInstance);
routes = true;
}
if(qBitConfig.isEffectivelyEnabled())
{
com.kingsrook.qbits.assistant.metadata.AssistantInstanceMetaData.register(qInstance);
AssistantInstanceMetaData.register(qInstance);
}
boolean developerPanel = false;
if(com.kingsrook.qbits.assistant.developer.AssistantDeveloperPanels.isContractAvailable())
if(AssistantDeveloperPanels.isContractAvailable())
{
com.kingsrook.qbits.assistant.developer.AssistantDeveloperPanels.register(qInstance);
AssistantDeveloperPanels.register(qInstance);
developerPanel = true;
}
@@ -36,14 +36,19 @@ import com.kingsrook.qbits.assistant.persistence.AssistantStore;
import com.kingsrook.qbits.assistant.rag.InMemoryVectorStore;
import com.kingsrook.qbits.assistant.rag.RagService;
import com.kingsrook.qbits.assistant.rag.VectorStore;
import com.kingsrook.qbits.assistant.scheduled.ScheduledTurn;
import com.kingsrook.qbits.assistant.tools.KnowledgeTool;
import com.kingsrook.qbits.assistant.tools.bulkload.BulkLoadTools;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qbits.assistant.uiapi.LoopbackUiApiGateway;
import com.kingsrook.qbits.assistant.uiapi.UiApiCall;
import com.kingsrook.qbits.assistant.uiapi.UiApiGateway;
import com.kingsrook.qbits.assistant.uiapi.UiApiResult;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolKernel;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolKernelSettings;
import com.kingsrook.qqq.backend.core.actions.ai.exposure.AiExposurePolicy;
import com.kingsrook.qqq.backend.core.actions.ai.output.AiRedactor;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
@@ -56,8 +61,11 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
*******************************************************************************/
public class AssistantRuntime
{
private final ScheduledTurn scheduledTurn = new ScheduledTurn();
private final RagService ragService;
private final KnowledgeTool knowledgeTool;
private final BulkLoadTools bulkLoadTools;
private final QInstance qInstance;
private final AssistantQBitConfig config;
@@ -112,11 +120,41 @@ public class AssistantRuntime
this.accessExplainer = new AccessExplainer(qInstance, dataAccessService);
this.toolbox = new UiApiToolbox(qInstance, dataAccessService, exposurePolicy, new AiRedactor(settings.getCustomRedactionPatterns()),
config.getMaxCellChars() == null ? AiToolKernelSettings.DEFAULT_MAX_CELL_CHARS : config.getMaxCellChars());
this.uiApiGateway = config.getUiApiGateway() != null ? config.getUiApiGateway() : new LoopbackUiApiGateway(this::uiApiBaseUrl, qInstance);
UiApiGateway delegate = config.getUiApiGateway() != null ? config.getUiApiGateway() : new LoopbackUiApiGateway(this::uiApiBaseUrl, qInstance);
this.uiApiGateway = new UiApiGateway()
{
/*******************************************************************************
** Delegate using this instance's configured collaborator.
*******************************************************************************/
@Override
public boolean isInvocable(String method, String pathTemplate)
{
return (delegate.isInvocable(method, pathTemplate));
}
/*******************************************************************************
** Delegate using this instance's configured collaborator.
*******************************************************************************/
@Override
public UiApiResult invoke(UiApiCall call) throws QException
{
String channel = scheduledTurn.channel(call.getSessionUUID());
if(channel != null)
{
if(delegate instanceof LoopbackUiApiGateway loopback)
{
return (loopback.invokeWithClient(call, channel));
}
throw (new QException("Scheduled tasks require the loopback gateway for client attribution."));
}
return (delegate.invoke(call));
}
};
VectorStore vectors = config.getVectorStore() == null ? new InMemoryVectorStore() : config.getVectorStore();
this.ragService = new RagService(this, vectors);
this.knowledgeTool = new KnowledgeTool(this, ragService, vectors);
this.contextBuilder = new ContextBuilder(config, kernel, toolbox).withKnowledgeTool(knowledgeTool);
this.bulkLoadTools = new BulkLoadTools(qInstance, dataAccessService, config.getBulkLoadRunner());
this.contextBuilder = new ContextBuilder(config, kernel, toolbox).withKnowledgeTool(knowledgeTool).withBulkLoadTools(bulkLoadTools);
}
@@ -447,4 +485,21 @@ public class AssistantRuntime
return (knowledgeTool);
}
/*******************************************************************************
** Server-owned scheduled execution context.
*******************************************************************************/
public ScheduledTurn getScheduledTurn()
{
return (scheduledTurn);
}
/*******************************************************************************
** The bulk-load tools (saved profiles, attached files, dry runs, loads).
*******************************************************************************/
public BulkLoadTools getBulkLoadTools()
{
return (bulkLoadTools);
}
}
@@ -46,6 +46,7 @@ import com.kingsrook.qbits.assistant.model.AssistantTurn;
import com.kingsrook.qbits.assistant.model.AssistantTurnEvent;
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qbits.assistant.tools.bulkload.BulkLoadTools;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolOutcome;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolResult;
import com.kingsrook.qqq.backend.core.actions.ai.output.AiRedactor;
@@ -598,7 +599,9 @@ public class AssistantApi
}
PolicyService.EffectivePolicy policy = runtime.getPolicyService().current(true);
UiApiToolbox.Context context = new UiApiToolbox.Context(session.getUuid(), session.getValue(QSession.VALUE_KEY_USER_TIMEZONE), runtime.getUiApiGateway(), policy.tools(), null);
UiApiToolbox.Outcome outcome = runtime.getToolbox().executeConfirmedWrite(context, action.getToolCallId(), action.getToolName(), action.getTableName(), action.getRecordId(), values);
UiApiToolbox.Outcome outcome = BulkLoadTools.WRITE_TOOLS.contains(action.getToolName())
? runtime.getBulkLoadTools().executeConfirmed(context, action.getToolCallId(), action.getToolName(), action.getTableName(), values)
: runtime.getToolbox().executeConfirmedWrite(context, action.getToolCallId(), action.getToolName(), action.getTableName(), action.getRecordId(), values);
AiToolResult result = outcome.getResult();
boolean ok = result.getOutcome() == AiToolOutcome.OK;
@@ -36,6 +36,7 @@ import com.kingsrook.qbits.assistant.provider.ModelToolDefinition;
import com.kingsrook.qbits.assistant.provider.SystemBlock;
import com.kingsrook.qbits.assistant.tools.KnowledgeTool;
import com.kingsrook.qbits.assistant.tools.PresentResultsTool;
import com.kingsrook.qbits.assistant.tools.bulkload.BulkLoadTools;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolDefinition;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolKernel;
@@ -66,12 +67,14 @@ public class ContextBuilder
public static final int MAX_CONTEXT_CHARS = 512;
public static final int MAX_SELECTED_IDS = 100;
public static final int MAX_ATTACHMENTS = 5;
private final AssistantQBitConfig config;
private final AiToolKernel kernel;
private final UiApiToolbox toolbox;
private volatile String appDigest;
private KnowledgeTool knowledgeTool;
private BulkLoadTools bulkLoadTools;
@@ -125,6 +128,13 @@ public class ContextBuilder
byName.put(KnowledgeTool.NAME, knowledgeTool.definition());
}
byName.put(PresentResultsTool.NAME, PresentResultsTool.definition(isStrict(PresentResultsTool.NAME, true)));
if(bulkLoadTools != null && bulkLoadTools.offered())
{
for(ModelToolDefinition definition : bulkLoadTools.definitions(policy, toolbox.anyWritableTable()))
{
byName.put(definition.getName(), definition);
}
}
return (new ArrayList<>(byName.values()));
}
@@ -352,6 +362,32 @@ public class ContextBuilder
}
}
////////////////////////////////////////////////////////////////////
// files the user attached in the panel (already uploaded through //
// the UI API as the user): only their opaque refs and names //
////////////////////////////////////////////////////////////////////
if(raw.get("attachments") instanceof List<?> attachments)
{
List<Map<String, Object>> kept = new ArrayList<>();
for(Object attachment : attachments)
{
if(attachment instanceof Map<?, ?> a && scalar(a.get("uploadRef")) != null && kept.size() < MAX_ATTACHMENTS)
{
Map<String, Object> one = new LinkedHashMap<>();
one.put("uploadRef", scalar(a.get("uploadRef")));
if(scalar(a.get("fileName")) != null)
{
one.put("fileName", scalar(a.get("fileName")));
}
kept.add(one);
}
}
if(!kept.isEmpty())
{
clean.put("attachments", kept);
}
}
if(JsonUtils.toJson(clean).length() > 4000)
{
clean.remove("filter");
@@ -390,4 +426,15 @@ public class ContextBuilder
return (this);
}
/*******************************************************************************
** Fluent setter for the bulk-load tools.
*******************************************************************************/
public ContextBuilder withBulkLoadTools(BulkLoadTools bulkLoadTools)
{
this.bulkLoadTools = bulkLoadTools;
return (this);
}
}
@@ -17,23 +17,23 @@
package com.kingsrook.qbits.assistant.engine;
import java.util.stream.Collectors;
import java.util.Objects;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.Semaphore;
import java.util.function.Consumer;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.classification.DataClassification;
import com.kingsrook.qbits.assistant.model.AssistantConversation;
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
import com.kingsrook.qbits.assistant.model.AssistantProfile;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qbits.assistant.model.AssistantTurn;
import com.kingsrook.qbits.assistant.provider.CacheHint;
import com.kingsrook.qbits.assistant.provider.ModelMessage;
@@ -43,6 +43,7 @@ import com.kingsrook.qbits.assistant.provider.ModelResponse;
import com.kingsrook.qbits.assistant.provider.ModelUsage;
import com.kingsrook.qbits.assistant.provider.StreamHandle;
import com.kingsrook.qbits.assistant.provider.SystemBlock;
import com.kingsrook.qbits.assistant.scheduled.ScheduledTurn;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
@@ -116,7 +117,9 @@ public class ModelRouter
public Route route(QSession session, AssistantConversation conversation, AssistantTurn turn, String question, Map<String, Object> pageContext,
PolicyService.EffectivePolicy policy, TurnClassification classification, RunningTurn running, Instant deadline, Consumer<String> notice) throws QException, InterruptedException
{
AssistantProfile profile = runtime.getProfileService().pick(session, runtime.appNameFor(pageContext));
var scheduled = runtime.getScheduledTurn().task(conversation.getId());
ScheduledTurn.checkCost(scheduled, 0L);
AssistantProfile profile = runtime.getProfileService().pick(session, runtime.appNameFor(pageContext), scheduled == null ? null : scheduled.getProfileId());
String mode = profile == null || profile.getRoutingMode() == null ? "OFF" : profile.getRoutingMode();
Boolean escalation = profile == null && Boolean.TRUE.equals(runtime.getConfig().getModels().getEscalationEnabled());
String decision = "OFF";
@@ -107,7 +107,17 @@ public class ProfileService
*******************************************************************************/
public Persona persona(QSession session, String appName)
{
AssistantProfile profile = pick(session, appName);
return (persona(session, appName, null));
}
/*******************************************************************************
** An administrator-selected scheduled profile still respects role scope.
*******************************************************************************/
public Persona persona(QSession session, String appName, Integer profileId)
{
AssistantProfile profile = pick(session, appName, profileId);
if(profile == null)
{
return (new Persona(null, DEFAULT_DISPLAY_NAME, DEFAULT_ICON, null, null, List.of()));
@@ -217,6 +227,22 @@ public class ProfileService
*******************************************************************************/
public AssistantProfile pick(QSession session, String appName)
{
return (pick(session, appName, null));
}
/*******************************************************************************
** Explicit profiles supply instructions and routing, never permissions.
*******************************************************************************/
public AssistantProfile pick(QSession session, String appName, Integer profileId)
{
if(profileId != null)
{
return (load().profiles().stream().filter(p -> profileId.equals(p.getId()) && !Boolean.FALSE.equals(p.getEnabled())
&& (!StringUtils.hasContent(p.getPermissionName()) || session.hasPermission(p.getPermissionName()))).findFirst().orElse(null));
}
List<AssistantProfile> enabled = load().profiles().stream().filter(p -> !Boolean.FALSE.equals(p.getEnabled())).toList();
return (enabled.stream()
.filter(p -> appliesTo(p, session, appName))
@@ -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)
@@ -37,7 +37,14 @@ public class ToolLabels
Map.entry("get_record_history", new String[] { "Reading the change history", "Read the change history" }),
Map.entry("render_widget", new String[] { "Rendering a widget", "Rendered a widget" }),
Map.entry("search_help", new String[] { "Searching help", "Searched help" }),
Map.entry("navigate", new String[] { "Opening a screen", "Opened a screen" })
Map.entry("navigate", new String[] { "Opening a screen", "Opened a screen" }),
Map.entry("bulk_profiles_list", new String[] { "Listing saved bulk load profiles", "Listed saved bulk load profiles" }),
Map.entry("bulk_profile_describe", new String[] { "Reading a bulk load profile", "Read a bulk load profile" }),
Map.entry("bulk_file_attach", new String[] { "Reading the attached file", "Read the attached file" }),
Map.entry("bulk_mapping_suggest", new String[] { "Suggesting a column mapping", "Suggested a column mapping" }),
Map.entry("bulk_load_dry_run", new String[] { "Checking the mapping (dry run)", "Checked the mapping (dry run)" }),
Map.entry("bulk_profile_save", new String[] { "Preparing to save the profile", "Prepared to save the profile" }),
Map.entry("bulk_load_run", new String[] { "Preparing the load", "Prepared the load" })
);
@@ -27,16 +27,20 @@ import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.model.AssistantConversation;
import com.kingsrook.qbits.assistant.model.AssistantMessage;
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
import com.kingsrook.qbits.assistant.model.AssistantScheduledTask;
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
import com.kingsrook.qbits.assistant.model.AssistantTurn;
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
@@ -51,8 +55,10 @@ import com.kingsrook.qbits.assistant.provider.ModelRequest;
import com.kingsrook.qbits.assistant.provider.ModelResponse;
import com.kingsrook.qbits.assistant.provider.ModelUsage;
import com.kingsrook.qbits.assistant.provider.StreamHandle;
import com.kingsrook.qbits.assistant.scheduled.ScheduledTurn;
import com.kingsrook.qbits.assistant.tools.KnowledgeTool;
import com.kingsrook.qbits.assistant.tools.PresentResultsTool;
import com.kingsrook.qbits.assistant.tools.bulkload.BulkLoadTools;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qqq.backend.core.actions.ai.AiRecordRef;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolCall;
@@ -126,6 +132,9 @@ public class TurnEngine implements Runnable
private final StringBuilder textBuffer = new StringBuilder();
private long lastTextFlush = System.currentTimeMillis();
private AssistantScheduledTask scheduledTask;
private PolicyService.ToolPolicy toolPolicy;
private Instant startedAt;
private Instant deadline;
private int toolCallCount = 0;
@@ -184,6 +193,13 @@ public class TurnEngine implements Runnable
try
{
QContext.init(runtime.getQInstance(), session);
scheduledTask = runtime.getScheduledTurn().task(conversation.getId());
if(conversation.getScheduledTaskId() != null && scheduledTask == null)
{
throw (new QException("Scheduled execution context is no longer available; start a new conversation."));
}
toolPolicy = scheduledTask == null ? policy.tools() : ScheduledTurn.tools(runtime, scheduledTask, policy.tools());
ScheduledTurn.checkCost(runtime, conversation.getId(), turn.getId());
classification = new TurnClassification(config.getClassificationSource(), conversation, pageContext);
store.saveClassification(userId, conversation.getId(), turn.getId(), classification);
startKillSwitchWatcher();
@@ -229,9 +245,9 @@ public class TurnEngine implements Runnable
}
String appName = runtime.appNameFor(pageContext);
ProfileService.Persona persona = runtime.getProfileService().persona(session, appName);
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, policy.tools(), profileText);
ModelRequest request = runtime.getContextBuilder().build(session, model, route.effort(), history, userText, pageContext, toolPolicy, profileText);
Integer maxPerRequest = active.row() == null ? null : active.row().getMaxOutputTokensPerRequest();
if(maxPerRequest != null && (request.getMaxTokens() == null || request.getMaxTokens() > maxPerRequest))
{
@@ -241,7 +257,7 @@ public class TurnEngine implements Runnable
{
request.setParallelToolCalls(false);
}
Set<String> allowedTools = runtime.getContextBuilder().toolNames(policy.tools());
Set<String> allowedTools = runtime.getContextBuilder().toolNames(toolPolicy);
boolean toolLimitReached = false;
while(true)
@@ -258,6 +274,7 @@ public class TurnEngine implements Runnable
ModelResponse response = fallback.call(request, () -> active, selected -> callModelWithStrictFallback(selected, request));
stopReason = response.getStopReason();
ScheduledTurn.checkCost(runtime, conversation.getId(), turn.getId());
if(ModelResponse.STOP_CANCELLED.equals(stopReason))
{
@@ -542,6 +559,7 @@ public class TurnEngine implements Runnable
{
return (new ModelResponse().withStopReason(ModelResponse.STOP_CANCELLED));
}
ScheduledTurn.checkCost(runtime, conversation.getId(), turn.getId());
PolicyService.BudgetStatus configuredBudget = runtime.getPolicyService().checkConfiguredBudget(policy, userId, usage.billableTokens(), costMicros);
if(configuredBudget.hardExceeded())
{
@@ -555,7 +573,7 @@ public class TurnEngine implements Runnable
throw (new BudgetService.Blocked(budgetDecision.reason()));
}
ProviderService.Active selected = budgetDecision.active();
if(!java.util.Objects.equals(active.providerId(), selected.providerId()) || !java.util.Objects.equals(active.model(), selected.model()))
if(!Objects.equals(active.providerId(), selected.providerId()) || !Objects.equals(active.model(), selected.model()))
{
active = selected;
model = active.model();
@@ -716,6 +734,11 @@ public class TurnEngine implements Runnable
{
results[index] = runtime.getKnowledgeTool().execute(toolContext(), call.getId(), call.getInput(), classification.getClassification(), conversation.getId(), turn.getId());
}
else if(BulkLoadTools.NAMES.contains(call.getName()))
{
outcomes[index] = runtime.getBulkLoadTools().execute(toolContext(), call.getId(), call.getName(), call.getInput(), hipaaRoute());
results[index] = outcomes[index].getResult();
}
else
{
outcomes[index] = runtime.getToolbox().execute(toolContext(), call.getId(), call.getName(), call.getInput());
@@ -755,11 +778,11 @@ public class TurnEngine implements Runnable
{
future.get(waitMillis, TimeUnit.MILLISECONDS);
}
catch(java.util.concurrent.TimeoutException e)
catch(TimeoutException e)
{
future.cancel(true);
}
catch(java.util.concurrent.ExecutionException e)
catch(ExecutionException e)
{
LOG.debug("tool future failed", hipaaSafe(e));
}
@@ -847,6 +870,7 @@ public class TurnEngine implements Runnable
.withUserId(userId)
.withToolCallId(use.getToolUseId())
.withToolName(use.getToolName())
.withClientChannel(scheduledTask == null ? null : "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())
@@ -856,7 +880,7 @@ public class TurnEngine implements Runnable
.withRowCount(result.getRowCount())
.withDurationMs(result.getDurationMs())
.withResultSummary(summary)
.withResultJson(truncate(result.getModelContent(), MAX_RESULT_JSON_CHARS))
.withResultJson(truncate(BulkLoadTools.NAMES.contains(use.getToolName()) ? BulkLoadTools.forLog(result.getModelContent()) : result.getModelContent(), MAX_RESULT_JSON_CHARS))
.withRedactionsJson(result.getRedactions() == null || result.getRedactions().isEmpty() ? null : JsonUtils.toJson(result.getRedactions()))
.withUiApiOperation(extras == null ? ("search_knowledge".equals(use.getToolName()) ? "GET /table/{tableName}/{primaryKey}" : null) : extras.getOperation())
.withHttpStatus(extras == null ? null : extras.getHttpStatus())
@@ -876,9 +900,24 @@ public class TurnEngine implements Runnable
** API), the gateway, the policy's tool settings, and the pending-action
** store for writes that need confirmation.
*******************************************************************************/
private boolean hipaaRoute()
{
///////////////////////////////////////////////////////////////////////
// the X-QQQ-AI-Route the bulk-load tools send: the turn's provider's //
// HIPAA eligibility, never anything the model says //
///////////////////////////////////////////////////////////////////////
return (active != null && active.row() != null && Boolean.TRUE.equals(active.row().getHipaaEligible()));
}
/*******************************************************************************
** The UI API tool context: the user's session, the gateway, the policy's
** tools and the pending-action hook.
*******************************************************************************/
private UiApiToolbox.Context toolContext()
{
return (new UiApiToolbox.Context(session.getUuid(), userTimezone(), runtime.getUiApiGateway(), policy.tools(), this::proposeWrite));
return (new UiApiToolbox.Context(session.getUuid(), userTimezone(), runtime.getUiApiGateway(), toolPolicy, this::proposeWrite));
}
@@ -1052,6 +1091,10 @@ public class TurnEngine implements Runnable
finally
{
running.getLog().close();
if(scheduledTask != null)
{
runtime.getScheduledTurn().detach(conversation.getId(), session.getUuid());
}
runtime.unregister(turn.getId());
runtime.getRateLimiter().release(userId);
}
@@ -17,6 +17,8 @@
package com.kingsrook.qbits.assistant.metadata;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.home.AssistantAccessWidget;
import com.kingsrook.qbits.assistant.home.AssistantHome;
@@ -30,13 +32,15 @@ import com.kingsrook.qbits.assistant.model.AssistantDataAudit;
import com.kingsrook.qbits.assistant.model.AssistantFeedback;
import com.kingsrook.qbits.assistant.model.AssistantMessage;
import com.kingsrook.qbits.assistant.model.AssistantModel;
import com.kingsrook.qbits.assistant.model.AssistantRagSource;
import com.kingsrook.qbits.assistant.model.AssistantModelPrice;
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
import com.kingsrook.qbits.assistant.model.AssistantPolicy;
import com.kingsrook.qbits.assistant.model.AssistantProfile;
import com.kingsrook.qbits.assistant.model.AssistantProfileSkill;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qbits.assistant.model.AssistantRagSource;
import com.kingsrook.qbits.assistant.model.AssistantScheduledRun;
import com.kingsrook.qbits.assistant.model.AssistantScheduledTask;
import com.kingsrook.qbits.assistant.model.AssistantSkill;
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
import com.kingsrook.qbits.assistant.model.AssistantTurn;
@@ -68,8 +72,6 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import java.util.ArrayList;
import java.util.List;
/*******************************************************************************
@@ -111,7 +113,7 @@ public class AssistantMetaDataBuilder
** calls on a conversation; model calls on a provider) or reached from the
** home. Processes are table and record actions, never nav entries.
*******************************************************************************/
public static final List<String> NAV_TABLES = List.of(AssistantRagSource.TABLE_NAME, AssistantBudget.TABLE_NAME, AssistantProvider.TABLE_NAME, AssistantProfile.TABLE_NAME, AssistantSkill.TABLE_NAME, AssistantDataAccess.TABLE_NAME, AssistantConversation.TABLE_NAME, AssistantModelPrice.TABLE_NAME, AssistantPolicy.TABLE_NAME);
public static final List<String> NAV_TABLES = List.of(AssistantScheduledTask.TABLE_NAME, AssistantRagSource.TABLE_NAME, AssistantBudget.TABLE_NAME, AssistantProvider.TABLE_NAME, AssistantProfile.TABLE_NAME, AssistantSkill.TABLE_NAME, AssistantDataAccess.TABLE_NAME, AssistantConversation.TABLE_NAME, AssistantModelPrice.TABLE_NAME, AssistantPolicy.TABLE_NAME);
private final AssistantQBitConfig config;
@@ -187,6 +189,17 @@ public class AssistantMetaDataBuilder
.withUniqueKey(new UniqueKey("name"))
.withRecordLabelFormat("%s").withRecordLabelFields("name"));
QCodeReference scheduledCustomizer = new QCodeReference(AssistantScheduledTaskCustomizer.class);
tables.add(text(base(AssistantScheduledTask.TABLE_NAME, "Scheduled tasks", "schedule", AssistantScheduledTask.class), "prompt")
.withRecordLabelFormat("%s").withRecordLabelFields("name")
.withCustomizer(TableCustomizers.PRE_INSERT_RECORD, scheduledCustomizer).withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, scheduledCustomizer)
.withCustomizer(TableCustomizers.POST_QUERY_RECORD, scheduledCustomizer));
tables.add(readOnly(owned(base(AssistantScheduledRun.TABLE_NAME, "Scheduled runs", "history", AssistantScheduledRun.class), false))
.withoutCapabilities(Capability.TABLE_DELETE)
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED))
.withUniqueKey(new UniqueKey("taskId", "scheduledFor"))
.withRecordLabelFormat("Run #%s").withRecordLabelFields("id"));
QCodeReference ragCustomizer = new QCodeReference(AssistantRagSourceCustomizer.class);
tables.add(text(base(AssistantRagSource.TABLE_NAME, "Knowledge sources", "manage_search", AssistantRagSource.class), "filterJson")
.withRecordLabelFormat("%s").withRecordLabelFields("name")
@@ -43,6 +43,8 @@ import com.kingsrook.qbits.assistant.model.AssistantProfile;
import com.kingsrook.qbits.assistant.model.AssistantProfileSkill;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qbits.assistant.model.AssistantRagSource;
import com.kingsrook.qbits.assistant.model.AssistantScheduledRun;
import com.kingsrook.qbits.assistant.model.AssistantScheduledTask;
import com.kingsrook.qbits.assistant.model.AssistantSkill;
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
import com.kingsrook.qbits.assistant.model.AssistantTurn;
@@ -52,9 +54,9 @@ import com.kingsrook.qbits.assistant.model.AssistantUsageDaily;
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
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.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReferenceWithProperties;
import com.kingsrook.qqq.backend.core.model.metadata.fields.AdornmentType;
import com.kingsrook.qqq.backend.core.model.metadata.fields.DisplayFormat;
@@ -132,6 +134,8 @@ public final class AssistantRecordScreens
}
return (List.of(
QPossibleValueSource.newForTable(AssistantScheduledTask.TABLE_NAME),
enumSource("assistant_scheduled_output", List.of(new QPossibleValue<>("RECORD", "Record"), new QPossibleValue<>("NOTIFICATION", "Notification"), new QPossibleValue<>("BOTH", "Both"))),
QPossibleValueSource.newForTable(AssistantBudget.TABLE_NAME),
QPossibleValueSource.newForTable(AssistantModel.TABLE_NAME).withName("assistant_registry_model"),
QPossibleValueSource.newForTable(AssistantModel.TABLE_NAME).withName("assistant_budget_model"),
@@ -173,6 +177,32 @@ public final class AssistantRecordScreens
{
switch(table.getName())
{
case AssistantScheduledTask.TABLE_NAME ->
{
link(table, "profileId", AssistantProfile.TABLE_NAME, "Profile");
pvs(table, "outputMode", "assistant_scheduled_output", "Output mode");
help(table, "credentialEnvName", "Name of an allowed environment variable holding this user's access token. Never enter the token here.");
help(table, "cronExpression", "Quartz cron expression, including seconds; for example 0 0 8 * * ? for 08:00 daily.");
help(table, "timezone", "IANA time zone, for example America/Chicago. Defaults to UTC; daylight saving time follows this zone.");
help(table, "writeModeRequested", "Requires administrator approval of this exact prompt and identity. Each proposed write still requires the user's confirmation.");
help(table, "maxCostUsdPerRun", "Optional USD STOP cap. Checked before each model or embedding call; a completed call may cross the cap.");
sections(table,
section("task", "Task", "schedule", Tier.T1, "id", "name", "enabled", "prompt", "profileId"),
section("identity", "Run as", "person", Tier.T2, "runAsUserId", "credentialEnvName", "credentialStatus"),
section("schedule", "Schedule and output", "event", Tier.T2, "cronExpression", "timezone", "nextRunAt", "outputMode", "maxCostUsdPerRun"),
section("approval", "Write approval", "verified_user", Tier.T2, "writeModeRequested", "writeModeApprovedBy", "writeModeApprovedAt", "writeModeApprovalHash"),
section("lastRun", "Last run", "history", Tier.T2, "lastRunAt", "lastRunStatus", "lastRunError"));
}
case AssistantScheduledRun.TABLE_NAME ->
{
link(table, "taskId", AssistantScheduledTask.TABLE_NAME, "Task");
link(table, "conversationId", AssistantConversation.TABLE_NAME, "Conversation");
link(table, "turnId", AssistantTurn.TABLE_NAME, "Turn");
help(table, "costMicros", MICROS_HELP);
sections(table,
section("run", "Run", "history", Tier.T1, "id", "taskId", "userId", "status", "scheduledFor", "startedAt", "endedAt"),
section("result", "Result", "chat", Tier.T2, "conversationId", "turnId", "summary", "costMicros", "error"));
}
case AssistantBudget.TABLE_NAME -> budget(table);
case AssistantBudgetOverride.TABLE_NAME, AssistantBudgetEvent.TABLE_NAME ->
{
@@ -213,6 +243,11 @@ public final class AssistantRecordScreens
*******************************************************************************/
public static void addConversationChildLists(QInstance qInstance)
{
QTableMetaData scheduledTask = qInstance.getTable(AssistantScheduledTask.TABLE_NAME);
if(scheduledTask != null)
{
childList(qInstance, scheduledTask, AssistantScheduledRun.TABLE_NAME, "Runs", "history", List.of("startedAt", "status", "conversationId", "costMicros", "summary"), "taskId", "scheduled");
}
QTableMetaData conversation = qInstance.getTable(AssistantConversation.TABLE_NAME);
if(conversation == null)
{
@@ -0,0 +1,132 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-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.metadata;
import java.io.Serializable;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.model.AssistantScheduledTask;
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
import com.kingsrook.qbits.assistant.scheduled.ScheduledTasks;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.QueryOrGetInputInterface;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage;
/*******************************************************************************
** Validate task edits and invalidate approvals before changed text is saved.
*******************************************************************************/
public class AssistantScheduledTaskCustomizer implements TableCustomizerInterface
{
/*******************************************************************************
** Internal status writes bypass this customizer only inside AssistantStore.
*******************************************************************************/
@Override
public List<QRecord> preInsertOrUpdate(AbstractActionInput input, List<QRecord> records, boolean isPreview, Optional<List<QRecord>> oldRecordList) throws QException
{
Optional<Map<Serializable, QRecord>> old = oldRecordListToMap("id", oldRecordList);
AssistantRuntime runtime = AssistantRuntime.of(QContext.getQInstance());
for(QRecord record : records)
{
record.getValues().remove("credentialStatus");
if(input instanceof UpdateInput update
&& update.hasFlag(AssistantStore.ScheduledWrite.INTERNAL))
{
continue;
}
QRecord previous = old.map(rows -> rows.get(record.getValue("id"))).orElse(null);
for(String field : List.of("writeModeApprovedBy", "writeModeApprovedAt", "writeModeApprovalHash", "nextRunAt", "lastRunAt", "lastRunStatus", "lastRunError"))
{
record.getValues().remove(field);
}
QRecord merged = new QRecord();
merged.setValues(previous == null ? new LinkedHashMap<>() : new LinkedHashMap<>(previous.getValues()));
record.getValues().forEach(merged::setValue);
for(var entry : Map.<String, Serializable>of("enabled", false, "timezone", "UTC", "outputMode", "RECORD", "writeModeRequested", false).entrySet())
{
if(merged.getValue(entry.getKey()) == null)
{
merged.setValue(entry.getKey(), entry.getValue());
record.setValue(entry.getKey(), entry.getValue());
}
}
if(previous == null || ScheduledTasks.APPROVAL_FIELDS.stream().anyMatch(field -> !Objects.equals(previous.getValue(field), merged.getValue(field))))
{
record.setValue("writeModeApprovedBy", null);
record.setValue("writeModeApprovedAt", null);
record.setValue("writeModeApprovalHash", null);
}
try
{
AssistantScheduledTask task = new AssistantScheduledTask(merged);
ScheduledTasks.checkReference(runtime.getConfig(), task.getCredentialEnvName());
if(task.getPrompt() == null || task.getPrompt().isBlank() || task.getPrompt().length() > 4000)
{
throw (new QException("The prompt must contain 1 to 4000 characters."));
}
if(!List.of("RECORD", "NOTIFICATION", "BOTH").contains(task.getOutputMode()))
{
throw (new QException("Output mode must be RECORD, NOTIFICATION or BOTH."));
}
if(task.getMaxCostUsdPerRun() != null && task.getMaxCostUsdPerRun().signum() < 0)
{
throw (new QException("The per-run cost cap cannot be negative."));
}
var next = ScheduledTasks.next(task, runtime.getClock().instant());
if(previous == null || List.of("enabled", "cronExpression", "timezone").stream().anyMatch(field -> !Objects.equals(previous.getValue(field), merged.getValue(field))))
{
record.setValue("nextRunAt", Boolean.TRUE.equals(task.getEnabled()) ? next : null);
}
}
catch(QException e)
{
record.addError(new BadInputStatusMessage(e.getMessage()));
}
}
return (records);
}
/*******************************************************************************
** Computed status is never persisted.
*******************************************************************************/
@Override
public List<QRecord> postQuery(QueryOrGetInputInterface input, List<QRecord> records) throws QException
{
AssistantRuntime runtime = AssistantRuntime.of(QContext.getQInstance());
for(QRecord record : records)
{
record.setValue("credentialStatus", ScheduledTasks.credentialStatus(runtime.getConfig(), new AssistantScheduledTask(record)));
}
return (records);
}
}
@@ -38,6 +38,12 @@ public class AssistantConversation extends QRecordEntity
@Deprecated(since = "0.1.0-RC.3")
public static final String LEGACY_TABLE_NAME = "assistantConversation";
@QField(isEditable = false)
private Integer scheduledTaskId;
@QField(isEditable = false, maxLength = 64)
private String channel = "panel";
@QField(isEditable = false, maxLength = 16)
private String maxClassification;
@@ -535,4 +541,66 @@ public class AssistantConversation extends QRecordEntity
/*******************************************************************************
** Getter for scheduledTaskId
*******************************************************************************/
public Integer getScheduledTaskId()
{
return (scheduledTaskId);
}
/*******************************************************************************
** Setter for scheduledTaskId
*******************************************************************************/
public void setScheduledTaskId(Integer scheduledTaskId)
{
this.scheduledTaskId = scheduledTaskId;
}
/*******************************************************************************
** Fluent setter for scheduledTaskId
*******************************************************************************/
public AssistantConversation withScheduledTaskId(Integer scheduledTaskId)
{
this.scheduledTaskId = scheduledTaskId;
return (this);
}
/*******************************************************************************
** Getter for channel
*******************************************************************************/
public String getChannel()
{
return (channel);
}
/*******************************************************************************
** Setter for channel
*******************************************************************************/
public void setChannel(String channel)
{
this.channel = channel;
}
/*******************************************************************************
** Fluent setter for channel
*******************************************************************************/
public AssistantConversation withChannel(String channel)
{
this.channel = channel;
return (this);
}
}
@@ -0,0 +1,532 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-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.model;
import java.time.Instant;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QField;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
/*******************************************************************************
** Scheduled assistant bookkeeping; credentials are references only.
*******************************************************************************/
public class AssistantScheduledRun extends QRecordEntity
{
public static final String TABLE_NAME = "assistant_scheduled_run";
@QField(isEditable = false, isPrimaryKey = true)
private Integer id;
@QField(isRequired = true)
private Integer taskId;
@QField(isRequired = true, maxLength = 200)
private String userId;
@QField()
private Instant scheduledFor;
@QField()
private Instant startedAt;
@QField()
private Instant endedAt;
@QField(maxLength = 20)
private String status;
@QField()
private Integer conversationId;
@QField()
private Integer turnId;
@QField(maxLength = 2000)
private String summary;
@QField()
private Long costMicros;
@QField(maxLength = 500)
private String error;
@QField(isEditable = false)
private Instant createDate;
@QField(isEditable = false)
private Instant modifyDate;
/*******************************************************************************
** Empty entity for framework mapping.
*******************************************************************************/
public AssistantScheduledRun()
{
}
/*******************************************************************************
** Map a bookkeeping record.
*******************************************************************************/
public AssistantScheduledRun(QRecord record) throws QException
{
populateFromQRecord(record);
}
/*******************************************************************************
** Getter for id
*******************************************************************************/
public Integer getId()
{
return (id);
}
/*******************************************************************************
** Setter for id
*******************************************************************************/
public void setId(Integer id)
{
this.id = id;
}
/*******************************************************************************
** Fluent setter for id
*******************************************************************************/
public AssistantScheduledRun withId(Integer id)
{
this.id = id;
return (this);
}
/*******************************************************************************
** Getter for taskId
*******************************************************************************/
public Integer getTaskId()
{
return (taskId);
}
/*******************************************************************************
** Setter for taskId
*******************************************************************************/
public void setTaskId(Integer taskId)
{
this.taskId = taskId;
}
/*******************************************************************************
** Fluent setter for taskId
*******************************************************************************/
public AssistantScheduledRun withTaskId(Integer taskId)
{
this.taskId = taskId;
return (this);
}
/*******************************************************************************
** Getter for userId
*******************************************************************************/
public String getUserId()
{
return (userId);
}
/*******************************************************************************
** Setter for userId
*******************************************************************************/
public void setUserId(String userId)
{
this.userId = userId;
}
/*******************************************************************************
** Fluent setter for userId
*******************************************************************************/
public AssistantScheduledRun withUserId(String userId)
{
this.userId = userId;
return (this);
}
/*******************************************************************************
** Getter for scheduledFor
*******************************************************************************/
public Instant getScheduledFor()
{
return (scheduledFor);
}
/*******************************************************************************
** Setter for scheduledFor
*******************************************************************************/
public void setScheduledFor(Instant scheduledFor)
{
this.scheduledFor = scheduledFor;
}
/*******************************************************************************
** Fluent setter for scheduledFor
*******************************************************************************/
public AssistantScheduledRun withScheduledFor(Instant scheduledFor)
{
this.scheduledFor = scheduledFor;
return (this);
}
/*******************************************************************************
** Getter for startedAt
*******************************************************************************/
public Instant getStartedAt()
{
return (startedAt);
}
/*******************************************************************************
** Setter for startedAt
*******************************************************************************/
public void setStartedAt(Instant startedAt)
{
this.startedAt = startedAt;
}
/*******************************************************************************
** Fluent setter for startedAt
*******************************************************************************/
public AssistantScheduledRun withStartedAt(Instant startedAt)
{
this.startedAt = startedAt;
return (this);
}
/*******************************************************************************
** Getter for endedAt
*******************************************************************************/
public Instant getEndedAt()
{
return (endedAt);
}
/*******************************************************************************
** Setter for endedAt
*******************************************************************************/
public void setEndedAt(Instant endedAt)
{
this.endedAt = endedAt;
}
/*******************************************************************************
** Fluent setter for endedAt
*******************************************************************************/
public AssistantScheduledRun withEndedAt(Instant endedAt)
{
this.endedAt = endedAt;
return (this);
}
/*******************************************************************************
** Getter for status
*******************************************************************************/
public String getStatus()
{
return (status);
}
/*******************************************************************************
** Setter for status
*******************************************************************************/
public void setStatus(String status)
{
this.status = status;
}
/*******************************************************************************
** Fluent setter for status
*******************************************************************************/
public AssistantScheduledRun withStatus(String status)
{
this.status = status;
return (this);
}
/*******************************************************************************
** Getter for conversationId
*******************************************************************************/
public Integer getConversationId()
{
return (conversationId);
}
/*******************************************************************************
** Setter for conversationId
*******************************************************************************/
public void setConversationId(Integer conversationId)
{
this.conversationId = conversationId;
}
/*******************************************************************************
** Fluent setter for conversationId
*******************************************************************************/
public AssistantScheduledRun withConversationId(Integer conversationId)
{
this.conversationId = conversationId;
return (this);
}
/*******************************************************************************
** Getter for turnId
*******************************************************************************/
public Integer getTurnId()
{
return (turnId);
}
/*******************************************************************************
** Setter for turnId
*******************************************************************************/
public void setTurnId(Integer turnId)
{
this.turnId = turnId;
}
/*******************************************************************************
** Fluent setter for turnId
*******************************************************************************/
public AssistantScheduledRun withTurnId(Integer turnId)
{
this.turnId = turnId;
return (this);
}
/*******************************************************************************
** Getter for summary
*******************************************************************************/
public String getSummary()
{
return (summary);
}
/*******************************************************************************
** Setter for summary
*******************************************************************************/
public void setSummary(String summary)
{
this.summary = summary;
}
/*******************************************************************************
** Fluent setter for summary
*******************************************************************************/
public AssistantScheduledRun withSummary(String summary)
{
this.summary = summary;
return (this);
}
/*******************************************************************************
** Getter for costMicros
*******************************************************************************/
public Long getCostMicros()
{
return (costMicros);
}
/*******************************************************************************
** Setter for costMicros
*******************************************************************************/
public void setCostMicros(Long costMicros)
{
this.costMicros = costMicros;
}
/*******************************************************************************
** Fluent setter for costMicros
*******************************************************************************/
public AssistantScheduledRun withCostMicros(Long costMicros)
{
this.costMicros = costMicros;
return (this);
}
/*******************************************************************************
** Getter for error
*******************************************************************************/
public String getError()
{
return (error);
}
/*******************************************************************************
** Setter for error
*******************************************************************************/
public void setError(String error)
{
this.error = error;
}
/*******************************************************************************
** Fluent setter for error
*******************************************************************************/
public AssistantScheduledRun withError(String error)
{
this.error = error;
return (this);
}
/*******************************************************************************
** Getter for createDate
*******************************************************************************/
public Instant getCreateDate()
{
return (createDate);
}
/*******************************************************************************
** Setter for createDate
*******************************************************************************/
public void setCreateDate(Instant createDate)
{
this.createDate = createDate;
}
/*******************************************************************************
** Fluent setter for createDate
*******************************************************************************/
public AssistantScheduledRun withCreateDate(Instant createDate)
{
this.createDate = createDate;
return (this);
}
/*******************************************************************************
** Getter for modifyDate
*******************************************************************************/
public Instant getModifyDate()
{
return (modifyDate);
}
/*******************************************************************************
** Setter for modifyDate
*******************************************************************************/
public void setModifyDate(Instant modifyDate)
{
this.modifyDate = modifyDate;
}
/*******************************************************************************
** Fluent setter for modifyDate
*******************************************************************************/
public AssistantScheduledRun withModifyDate(Instant modifyDate)
{
this.modifyDate = modifyDate;
return (this);
}
}
@@ -0,0 +1,805 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-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.model;
import java.math.BigDecimal;
import java.time.Instant;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QField;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
/*******************************************************************************
** Scheduled assistant bookkeeping; credentials are references only.
*******************************************************************************/
public class AssistantScheduledTask extends QRecordEntity
{
public static final String TABLE_NAME = "assistant_scheduled_task";
@QField(isEditable = false, isPrimaryKey = true)
private Integer id;
@QField(isRequired = true, maxLength = 200)
private String name;
@QField()
private Boolean enabled;
@QField(isRequired = true, maxLength = 4000)
private String prompt;
@QField(isRequired = true, maxLength = 200)
private String runAsUserId;
@QField(isRequired = true, maxLength = 200)
private String credentialEnvName;
@QField(isEditable = false, maxLength = 200)
private String credentialStatus;
@QField()
private Integer profileId;
@QField(isRequired = true, maxLength = 200)
private String cronExpression;
@QField(maxLength = 100)
private String timezone;
@QField(isEditable = false)
private Instant nextRunAt;
@QField(maxLength = 20)
private String outputMode;
@QField()
private Boolean writeModeRequested;
@QField(isEditable = false, maxLength = 200)
private String writeModeApprovedBy;
@QField(isEditable = false)
private Instant writeModeApprovedAt;
@QField(isEditable = false, maxLength = 64)
private String writeModeApprovalHash;
@QField()
private BigDecimal maxCostUsdPerRun;
@QField(isEditable = false)
private Instant lastRunAt;
@QField(isEditable = false, maxLength = 20)
private String lastRunStatus;
@QField(isEditable = false, maxLength = 500)
private String lastRunError;
@QField(isEditable = false)
private Instant createDate;
@QField(isEditable = false)
private Instant modifyDate;
/*******************************************************************************
** Empty entity for framework mapping.
*******************************************************************************/
public AssistantScheduledTask()
{
}
/*******************************************************************************
** Map a bookkeeping record.
*******************************************************************************/
public AssistantScheduledTask(QRecord record) throws QException
{
populateFromQRecord(record);
}
/*******************************************************************************
** Getter for id
*******************************************************************************/
public Integer getId()
{
return (id);
}
/*******************************************************************************
** Setter for id
*******************************************************************************/
public void setId(Integer id)
{
this.id = id;
}
/*******************************************************************************
** Fluent setter for id
*******************************************************************************/
public AssistantScheduledTask withId(Integer id)
{
this.id = id;
return (this);
}
/*******************************************************************************
** Getter for name
*******************************************************************************/
public String getName()
{
return (name);
}
/*******************************************************************************
** Setter for name
*******************************************************************************/
public void setName(String name)
{
this.name = name;
}
/*******************************************************************************
** Fluent setter for name
*******************************************************************************/
public AssistantScheduledTask withName(String name)
{
this.name = name;
return (this);
}
/*******************************************************************************
** Getter for enabled
*******************************************************************************/
public Boolean getEnabled()
{
return (enabled);
}
/*******************************************************************************
** Setter for enabled
*******************************************************************************/
public void setEnabled(Boolean enabled)
{
this.enabled = enabled;
}
/*******************************************************************************
** Fluent setter for enabled
*******************************************************************************/
public AssistantScheduledTask withEnabled(Boolean enabled)
{
this.enabled = enabled;
return (this);
}
/*******************************************************************************
** Getter for prompt
*******************************************************************************/
public String getPrompt()
{
return (prompt);
}
/*******************************************************************************
** Setter for prompt
*******************************************************************************/
public void setPrompt(String prompt)
{
this.prompt = prompt;
}
/*******************************************************************************
** Fluent setter for prompt
*******************************************************************************/
public AssistantScheduledTask withPrompt(String prompt)
{
this.prompt = prompt;
return (this);
}
/*******************************************************************************
** Getter for runAsUserId
*******************************************************************************/
public String getRunAsUserId()
{
return (runAsUserId);
}
/*******************************************************************************
** Setter for runAsUserId
*******************************************************************************/
public void setRunAsUserId(String runAsUserId)
{
this.runAsUserId = runAsUserId;
}
/*******************************************************************************
** Fluent setter for runAsUserId
*******************************************************************************/
public AssistantScheduledTask withRunAsUserId(String runAsUserId)
{
this.runAsUserId = runAsUserId;
return (this);
}
/*******************************************************************************
** Getter for credentialEnvName
*******************************************************************************/
public String getCredentialEnvName()
{
return (credentialEnvName);
}
/*******************************************************************************
** Setter for credentialEnvName
*******************************************************************************/
public void setCredentialEnvName(String credentialEnvName)
{
this.credentialEnvName = credentialEnvName;
}
/*******************************************************************************
** Fluent setter for credentialEnvName
*******************************************************************************/
public AssistantScheduledTask withCredentialEnvName(String credentialEnvName)
{
this.credentialEnvName = credentialEnvName;
return (this);
}
/*******************************************************************************
** Getter for credentialStatus
*******************************************************************************/
public String getCredentialStatus()
{
return (credentialStatus);
}
/*******************************************************************************
** Setter for credentialStatus
*******************************************************************************/
public void setCredentialStatus(String credentialStatus)
{
this.credentialStatus = credentialStatus;
}
/*******************************************************************************
** Fluent setter for credentialStatus
*******************************************************************************/
public AssistantScheduledTask withCredentialStatus(String credentialStatus)
{
this.credentialStatus = credentialStatus;
return (this);
}
/*******************************************************************************
** Getter for profileId
*******************************************************************************/
public Integer getProfileId()
{
return (profileId);
}
/*******************************************************************************
** Setter for profileId
*******************************************************************************/
public void setProfileId(Integer profileId)
{
this.profileId = profileId;
}
/*******************************************************************************
** Fluent setter for profileId
*******************************************************************************/
public AssistantScheduledTask withProfileId(Integer profileId)
{
this.profileId = profileId;
return (this);
}
/*******************************************************************************
** Getter for cronExpression
*******************************************************************************/
public String getCronExpression()
{
return (cronExpression);
}
/*******************************************************************************
** Setter for cronExpression
*******************************************************************************/
public void setCronExpression(String cronExpression)
{
this.cronExpression = cronExpression;
}
/*******************************************************************************
** Fluent setter for cronExpression
*******************************************************************************/
public AssistantScheduledTask withCronExpression(String cronExpression)
{
this.cronExpression = cronExpression;
return (this);
}
/*******************************************************************************
** Getter for timezone
*******************************************************************************/
public String getTimezone()
{
return (timezone);
}
/*******************************************************************************
** Setter for timezone
*******************************************************************************/
public void setTimezone(String timezone)
{
this.timezone = timezone;
}
/*******************************************************************************
** Fluent setter for timezone
*******************************************************************************/
public AssistantScheduledTask withTimezone(String timezone)
{
this.timezone = timezone;
return (this);
}
/*******************************************************************************
** Getter for nextRunAt
*******************************************************************************/
public Instant getNextRunAt()
{
return (nextRunAt);
}
/*******************************************************************************
** Setter for nextRunAt
*******************************************************************************/
public void setNextRunAt(Instant nextRunAt)
{
this.nextRunAt = nextRunAt;
}
/*******************************************************************************
** Fluent setter for nextRunAt
*******************************************************************************/
public AssistantScheduledTask withNextRunAt(Instant nextRunAt)
{
this.nextRunAt = nextRunAt;
return (this);
}
/*******************************************************************************
** Getter for outputMode
*******************************************************************************/
public String getOutputMode()
{
return (outputMode);
}
/*******************************************************************************
** Setter for outputMode
*******************************************************************************/
public void setOutputMode(String outputMode)
{
this.outputMode = outputMode;
}
/*******************************************************************************
** Fluent setter for outputMode
*******************************************************************************/
public AssistantScheduledTask withOutputMode(String outputMode)
{
this.outputMode = outputMode;
return (this);
}
/*******************************************************************************
** Getter for writeModeRequested
*******************************************************************************/
public Boolean getWriteModeRequested()
{
return (writeModeRequested);
}
/*******************************************************************************
** Setter for writeModeRequested
*******************************************************************************/
public void setWriteModeRequested(Boolean writeModeRequested)
{
this.writeModeRequested = writeModeRequested;
}
/*******************************************************************************
** Fluent setter for writeModeRequested
*******************************************************************************/
public AssistantScheduledTask withWriteModeRequested(Boolean writeModeRequested)
{
this.writeModeRequested = writeModeRequested;
return (this);
}
/*******************************************************************************
** Getter for writeModeApprovedBy
*******************************************************************************/
public String getWriteModeApprovedBy()
{
return (writeModeApprovedBy);
}
/*******************************************************************************
** Setter for writeModeApprovedBy
*******************************************************************************/
public void setWriteModeApprovedBy(String writeModeApprovedBy)
{
this.writeModeApprovedBy = writeModeApprovedBy;
}
/*******************************************************************************
** Fluent setter for writeModeApprovedBy
*******************************************************************************/
public AssistantScheduledTask withWriteModeApprovedBy(String writeModeApprovedBy)
{
this.writeModeApprovedBy = writeModeApprovedBy;
return (this);
}
/*******************************************************************************
** Getter for writeModeApprovedAt
*******************************************************************************/
public Instant getWriteModeApprovedAt()
{
return (writeModeApprovedAt);
}
/*******************************************************************************
** Setter for writeModeApprovedAt
*******************************************************************************/
public void setWriteModeApprovedAt(Instant writeModeApprovedAt)
{
this.writeModeApprovedAt = writeModeApprovedAt;
}
/*******************************************************************************
** Fluent setter for writeModeApprovedAt
*******************************************************************************/
public AssistantScheduledTask withWriteModeApprovedAt(Instant writeModeApprovedAt)
{
this.writeModeApprovedAt = writeModeApprovedAt;
return (this);
}
/*******************************************************************************
** Getter for writeModeApprovalHash
*******************************************************************************/
public String getWriteModeApprovalHash()
{
return (writeModeApprovalHash);
}
/*******************************************************************************
** Setter for writeModeApprovalHash
*******************************************************************************/
public void setWriteModeApprovalHash(String writeModeApprovalHash)
{
this.writeModeApprovalHash = writeModeApprovalHash;
}
/*******************************************************************************
** Fluent setter for writeModeApprovalHash
*******************************************************************************/
public AssistantScheduledTask withWriteModeApprovalHash(String writeModeApprovalHash)
{
this.writeModeApprovalHash = writeModeApprovalHash;
return (this);
}
/*******************************************************************************
** Getter for maxCostUsdPerRun
*******************************************************************************/
public BigDecimal getMaxCostUsdPerRun()
{
return (maxCostUsdPerRun);
}
/*******************************************************************************
** Setter for maxCostUsdPerRun
*******************************************************************************/
public void setMaxCostUsdPerRun(BigDecimal maxCostUsdPerRun)
{
this.maxCostUsdPerRun = maxCostUsdPerRun;
}
/*******************************************************************************
** Fluent setter for maxCostUsdPerRun
*******************************************************************************/
public AssistantScheduledTask withMaxCostUsdPerRun(BigDecimal maxCostUsdPerRun)
{
this.maxCostUsdPerRun = maxCostUsdPerRun;
return (this);
}
/*******************************************************************************
** Getter for lastRunAt
*******************************************************************************/
public Instant getLastRunAt()
{
return (lastRunAt);
}
/*******************************************************************************
** Setter for lastRunAt
*******************************************************************************/
public void setLastRunAt(Instant lastRunAt)
{
this.lastRunAt = lastRunAt;
}
/*******************************************************************************
** Fluent setter for lastRunAt
*******************************************************************************/
public AssistantScheduledTask withLastRunAt(Instant lastRunAt)
{
this.lastRunAt = lastRunAt;
return (this);
}
/*******************************************************************************
** Getter for lastRunStatus
*******************************************************************************/
public String getLastRunStatus()
{
return (lastRunStatus);
}
/*******************************************************************************
** Setter for lastRunStatus
*******************************************************************************/
public void setLastRunStatus(String lastRunStatus)
{
this.lastRunStatus = lastRunStatus;
}
/*******************************************************************************
** Fluent setter for lastRunStatus
*******************************************************************************/
public AssistantScheduledTask withLastRunStatus(String lastRunStatus)
{
this.lastRunStatus = lastRunStatus;
return (this);
}
/*******************************************************************************
** Getter for lastRunError
*******************************************************************************/
public String getLastRunError()
{
return (lastRunError);
}
/*******************************************************************************
** Setter for lastRunError
*******************************************************************************/
public void setLastRunError(String lastRunError)
{
this.lastRunError = lastRunError;
}
/*******************************************************************************
** Fluent setter for lastRunError
*******************************************************************************/
public AssistantScheduledTask withLastRunError(String lastRunError)
{
this.lastRunError = lastRunError;
return (this);
}
/*******************************************************************************
** Getter for createDate
*******************************************************************************/
public Instant getCreateDate()
{
return (createDate);
}
/*******************************************************************************
** Setter for createDate
*******************************************************************************/
public void setCreateDate(Instant createDate)
{
this.createDate = createDate;
}
/*******************************************************************************
** Fluent setter for createDate
*******************************************************************************/
public AssistantScheduledTask withCreateDate(Instant createDate)
{
this.createDate = createDate;
return (this);
}
/*******************************************************************************
** Getter for modifyDate
*******************************************************************************/
public Instant getModifyDate()
{
return (modifyDate);
}
/*******************************************************************************
** Setter for modifyDate
*******************************************************************************/
public void setModifyDate(Instant modifyDate)
{
this.modifyDate = modifyDate;
}
/*******************************************************************************
** Fluent setter for modifyDate
*******************************************************************************/
public AssistantScheduledTask withModifyDate(Instant modifyDate)
{
this.modifyDate = modifyDate;
return (this);
}
}
@@ -40,6 +40,9 @@ public class AssistantToolCall extends QRecordEntity
@Deprecated(since = "0.1.0-RC.3")
public static final String LEGACY_TABLE_NAME = "assistantToolCall";
@QField(isEditable = false, maxLength = 64)
private String clientChannel;
@QField(isEditable = false, isPrimaryKey = true)
private Integer id;
@@ -803,4 +806,35 @@ public class AssistantToolCall extends QRecordEntity
return (this);
}
/*******************************************************************************
** Getter for clientChannel
*******************************************************************************/
public String getClientChannel()
{
return (clientChannel);
}
/*******************************************************************************
** Setter for clientChannel
*******************************************************************************/
public void setClientChannel(String clientChannel)
{
this.clientChannel = clientChannel;
}
/*******************************************************************************
** Fluent setter for clientChannel
*******************************************************************************/
public AssistantToolCall withClientChannel(String clientChannel)
{
this.clientChannel = clientChannel;
return (this);
}
}
@@ -17,8 +17,20 @@
package com.kingsrook.qbits.assistant.persistence;
import java.io.Serializable;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.classification.DataClassification;
import com.kingsrook.qbits.assistant.engine.TurnClassification;
import com.kingsrook.qbits.assistant.model.AssistantBudget;
import com.kingsrook.qbits.assistant.model.AssistantBudgetEvent;
import com.kingsrook.qbits.assistant.model.AssistantBudgetOverride;
@@ -32,18 +44,22 @@ import com.kingsrook.qbits.assistant.model.AssistantModelPrice;
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
import com.kingsrook.qbits.assistant.model.AssistantPolicy;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qbits.assistant.model.AssistantScheduledRun;
import com.kingsrook.qbits.assistant.model.AssistantScheduledTask;
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
import com.kingsrook.qbits.assistant.model.AssistantTurn;
import com.kingsrook.qbits.assistant.model.AssistantTurnEvent;
import com.kingsrook.qbits.assistant.model.AssistantUsage;
import com.kingsrook.qbits.assistant.model.AssistantUsageDaily;
import com.kingsrook.qbits.assistant.provider.ModelUsage;
import com.kingsrook.qbits.assistant.scheduled.ScheduledTasks;
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.ActionFlag;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput;
@@ -56,15 +72,6 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateOutput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import java.io.Serializable;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/*******************************************************************************
@@ -773,7 +780,7 @@ public class AssistantStore
/*******************************************************************************
** Save the turn level and never lower persisted conversation history.
*******************************************************************************/
public synchronized void saveClassification(String userId, Integer conversationId, Integer turnId, com.kingsrook.qbits.assistant.engine.TurnClassification classification) throws QException
public synchronized void saveClassification(String userId, Integer conversationId, Integer turnId, TurnClassification classification) throws QException
{
AssistantConversation current = getConversation(userId, conversationId);
classification.inherit(current);
@@ -781,8 +788,8 @@ public class AssistantStore
.withValue("classification", classification.getClassification().name()).withValue("classificationTables", classification.getTables()));
if(current != null)
{
var maximum = com.kingsrook.qbits.assistant.classification.DataClassification.max(
com.kingsrook.qbits.assistant.classification.DataClassification.stored(current.getMaxClassification()), classification.getClassification());
var maximum = DataClassification.max(
DataClassification.stored(current.getMaxClassification()), classification.getClassification());
if(!maximum.name().equals(current.getMaxClassification()))
{
updateFields(AssistantConversation.TABLE_NAME, new QRecord().withValue("id", conversationId).withValue("maxClassification", maximum.name()));
@@ -934,4 +941,98 @@ public class AssistantStore
updateFields(AssistantBudgetUsage.TABLE_NAME, projection.withValue("id", existing.get(0).getId()));
}
}
/*******************************************************************************
** Internal bookkeeping flag; never accepted from a UI request body.
*******************************************************************************/
public enum ScheduledWrite implements ActionFlag
{
INTERNAL
}
/*******************************************************************************
** Update computed task fields through QQQ under the caller's session.
*******************************************************************************/
public void scheduledUpdate(QRecord record) throws QException
{
UpdateOutput output = new UpdateAction().execute(new UpdateInput(AssistantScheduledTask.TABLE_NAME)
.withRecord(record).withFlag(ScheduledWrite.INTERNAL));
if(CollectionUtils.nullSafeHasContents(output.getRecords().get(0).getErrors()))
{
throw (new QException("Could not update scheduled task bookkeeping."));
}
}
/*******************************************************************************
** Read a task definition, never application data.
*******************************************************************************/
public AssistantScheduledTask scheduledTask(Integer id) throws QException
{
return (first(query(AssistantScheduledTask.TABLE_NAME,
new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, id), AssistantScheduledTask.class)));
}
/*******************************************************************************
** Optimistic slot claim. The database unique(taskId, scheduledFor) is the
** cross-replica arbiter; the monitor also covers the fixture memory backend.
** Advance before dispatch. A crash can skip a slot, never run it twice.
*******************************************************************************/
public ScheduledClaim claimScheduled(
AssistantScheduledTask candidate, Instant now, Boolean manual) throws QException
{
synchronized(QContext.getQInstance())
{
var task = scheduledTask(candidate.getId());
if(task == null || (!manual && (!Boolean.TRUE.equals(task.getEnabled()) || task.getNextRunAt() == null
|| task.getNextRunAt().isAfter(now) || !Objects.equals(task.getNextRunAt(), candidate.getNextRunAt()))))
{
return (null);
}
Instant slot = manual ? now : task.getNextRunAt();
Instant next = Boolean.TRUE.equals(task.getEnabled()) ? ScheduledTasks.next(task, now) : null;
var filter = new QQueryFilter().withCriteria("taskId", QCriteriaOperator.EQUALS, task.getId()).withCriteria("scheduledFor", QCriteriaOperator.EQUALS, slot);
if(!query(AssistantScheduledRun.TABLE_NAME, filter, AssistantScheduledRun.class).isEmpty())
{
if(!manual)
{
scheduledUpdate(new QRecord().withValue("id", task.getId()).withValue("nextRunAt", next));
}
return (null);
}
AssistantScheduledRun run;
try
{
run = insert(new AssistantScheduledRun().withTaskId(task.getId()).withUserId(task.getRunAsUserId())
.withScheduledFor(slot).withStartedAt(now).withStatus("RUNNING").withCostMicros(0L));
}
catch(QException e)
{
if(!query(AssistantScheduledRun.TABLE_NAME, filter, AssistantScheduledRun.class).isEmpty())
{
return (null);
}
throw (e);
}
scheduledUpdate(new QRecord().withValue("id", task.getId()).withValue("nextRunAt", next));
return (new ScheduledClaim(task, run));
}
}
/*******************************************************************************
** The claimed task snapshot and its run owner must remain consistent.
*******************************************************************************/
public record ScheduledClaim(AssistantScheduledTask task, AssistantScheduledRun run)
{
}
}
@@ -0,0 +1,241 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-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.processes;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.AssistantSecurity;
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
import com.kingsrook.qbits.assistant.model.AssistantScheduledTask;
import com.kingsrook.qbits.assistant.scheduled.ScheduledTaskRunner;
import com.kingsrook.qbits.assistant.scheduled.ScheduledTasks;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaData;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
/*******************************************************************************
** Admin record actions and the scheduler's bookkeeping step. No nav processes.
*******************************************************************************/
public final class ScheduledTaskProcess
{
public static final String RUNNER = "assistantScheduledTasksRunner";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private ScheduledTaskProcess()
{
}
/*******************************************************************************
** QQQ supplies the scheduler session. No session is created here.
*******************************************************************************/
public static List<QProcessMetaData> processes(AssistantQBitConfig config)
{
List<QProcessMetaData> result = new ArrayList<>();
QProcessMetaData runner = new QProcessMetaData().withName(RUNNER).withLabel("Run due assistant tasks")
.withPermissionRules(AssistantMetaDataBuilder.adminRules())
.withStep(new QBackendStepMetaData().withName("runDue").withCode(new QCodeReference(Runner.class)));
if(config.getScheduledTasksSchedulerName() != null && !config.getScheduledTasksSchedulerName().isBlank())
{
runner.withSchedule(new QScheduleMetaData().withSchedulerName(config.getScheduledTasksSchedulerName()).withRepeatSeconds(60));
}
result.add(runner);
for(String action : List.of("Approve", "Revoke", "RunNow"))
{
String label = "Approve".equals(action) ? "Approve write mode" : "Revoke".equals(action) ? "Revoke write mode" : "Run now";
Map<String, Serializable> values = new HashMap<>();
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, AssistantScheduledTask.TABLE_NAME);
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, AssistantScheduledTask.TABLE_NAME);
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true);
values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true);
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, label + " for the selected tasks.");
values.put("scheduledAction", action);
QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(ExtractViaQueryStep.class, Review.class, Result.class, values);
process.setName("assistantScheduledTask" + action);
process.setLabel(label);
process.setIcon(new QIcon("schedule"));
process.setTableName(AssistantScheduledTask.TABLE_NAME);
process.setPermissionRules(AssistantMetaDataBuilder.adminRules());
result.add(process);
}
return (result);
}
/*******************************************************************************
** Actions must enforce permission even when called outside the UI executor.
*******************************************************************************/
private static AssistantRuntime admin() throws QException
{
if(QContext.getQSession() == null || !QContext.getQSession().hasPermission(AssistantQBitConfig.ADMIN_PERMISSION))
{
throw (new QException("assistant.admin is required for scheduled task actions."));
}
return (AssistantRuntime.of(QContext.getQInstance()));
}
/*******************************************************************************
** Background scheduler step uses only the context supplied by QQQ.
*******************************************************************************/
public static class Runner implements BackendStep
{
/*******************************************************************************
** Dispatch the due page once.
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
output.addValue("runs", new ScheduledTaskRunner(AssistantRuntime.of(QContext.getQInstance())).runDue());
}
}
/*******************************************************************************
** Review binds the displayed prompt hash and user to the exact settings.
*******************************************************************************/
public static class Review extends AbstractTransformStep implements ProcessSummaryProviderInterface
{
private final ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
/*******************************************************************************
** No login or model call on Review.
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
admin();
for(QRecord record : input.getRecords())
{
AssistantScheduledTask task = new AssistantScheduledTask(record);
record.setValue("reviewedHash", ScheduledTasks.approvalHash(task));
lines.add(TestConnectionProcess.single(Status.INFO, "Run as " + task.getRunAsUserId() + "; prompt SHA-256 " + ScheduledTasks.hash(task.getPrompt())
+ ". Write mode permits policy-allowed create, update and delete proposals; writes wait for this user's panel confirmation.", task.getId()));
output.getRecords().add(record);
}
}
/*******************************************************************************
** Standard streamed ETL Review lines.
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
return (lines);
}
}
/*******************************************************************************
** Result rereads tasks; a change since Review requires a fresh review.
*******************************************************************************/
public static class Result extends AbstractLoadStep implements ProcessSummaryProviderInterface
{
private final ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
/*******************************************************************************
** Approval records the actual administrator's session user id.
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
AssistantRuntime runtime = admin();
String action = input.getValueString("scheduledAction");
for(QRecord record : input.getRecords())
{
AssistantScheduledTask task = runtime.getStore().scheduledTask(record.getValueInteger("id"));
if(task == null || !ScheduledTasks.approvalHash(task).equals(record.getValueString("reviewedHash")))
{
throw (new QException("The task changed after Review. Review it again."));
}
String summary;
if("RunNow".equals(action))
{
var run = new ScheduledTaskRunner(runtime).run(task, true);
summary = run == null ? "Task already claimed." : "Run " + run.getId() + ": " + run.getStatus();
}
else if("Approve".equals(action) || "Revoke".equals(action))
{
Boolean approve = "Approve".equals(action);
runtime.getStore().scheduledUpdate(new QRecord().withValue("id", task.getId())
.withValue("writeModeApprovedBy", approve ? AssistantSecurity.userId(QContext.getQSession()) : null)
.withValue("writeModeApprovedAt", approve ? runtime.getClock().instant() : null)
.withValue("writeModeApprovalHash", approve ? ScheduledTasks.approvalHash(task) : null));
summary = approve ? "Write mode approved." : "Write mode revoked.";
}
else
{
throw (new QException("Unknown scheduled task action."));
}
lines.add(TestConnectionProcess.single(Status.INFO, summary, task.getId()));
output.getRecords().add(record);
}
}
/*******************************************************************************
** Standard streamed ETL Result lines.
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
return (lines);
}
}
}
@@ -35,6 +35,7 @@ import com.kingsrook.qbits.assistant.provider.EmbeddingRequest;
import com.kingsrook.qbits.assistant.provider.EmbeddingResponse;
import com.kingsrook.qbits.assistant.provider.ModelProviderException;
import com.kingsrook.qbits.assistant.provider.ModelResponse;
import com.kingsrook.qbits.assistant.scheduled.ScheduledTurn;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -106,6 +107,7 @@ public class EmbeddingService
private List<float[]> batch(Integer modelId, List<String> texts, DataClassification classification, String tables, Integer conversationId, Integer turnId) throws QException
{
String userId = AssistantSecurity.userId(QContext.getQSession());
ScheduledTurn.checkCost(runtime, conversationId, turnId);
ProviderService.Active active = resolve(modelId, classification, tables, conversationId, turnId);
PolicyService.EffectivePolicy policy = runtime.getPolicyService().current(true);
BudgetService.Decision budget = runtime.getBudgetService().evaluate(userId, active, classification == DataClassification.PHI, false);
@@ -0,0 +1,72 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-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.scheduled;
import com.kingsrook.qbits.assistant.model.AssistantScheduledRun;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** Optional Alerts notification SPI bridge. The audience is exactly one user;
** notification bodies contain only status and a link, never answer text.
*******************************************************************************/
public class ScheduledAlerts
{
private static final QLogger LOG = QLogger.getLogger(ScheduledAlerts.class);
/*******************************************************************************
** Reflection keeps the optional notification implementation out of the ABI.
*******************************************************************************/
public void raise(AssistantScheduledRun run)
{
LOG.info("Scheduled assistant result", logPair("runId", run.getId()), logPair("status", run.getStatus()));
try
{
String prefix = "com.kingsrook.qbits.alerts.spi.";
Class<?> type = Class.forName(prefix + "NotificationSignal");
Object signal = type.getConstructor(String.class).newInstance("Scheduled assistant task finished");
type.getMethod("withSourceName", String.class).invoke(signal, "assistant");
type.getMethod("withSourceKey", String.class).invoke(signal, "assistant/scheduled/run/" + run.getId());
type.getMethod("withBody", String.class).invoke(signal, "Status: " + run.getStatus() + ". See conversation.");
Class<?> audienceType = Class.forName(prefix + "NotificationAudience");
Object audience = audienceType.getMethod("user", String.class).invoke(null, run.getUserId());
type.getMethod("withAudience", audienceType).invoke(signal, audience);
if(run.getConversationId() != null)
{
Class<?> linkType = Class.forName(prefix + "AlertLink");
Object link = linkType.getMethod("toPath", String.class, String.class).invoke(null,
"/app/assistantAdmin/assistant_conversation/" + run.getConversationId(), "See conversation");
type.getMethod("withLink", linkType).invoke(signal, link);
}
Object runtime = Class.forName("com.kingsrook.qbits.alerts.AlertsRuntime").getMethod("get").invoke(null);
Object sink = runtime.getClass().getMethod("sink").invoke(runtime);
sink.getClass().getMethod("notify", type).invoke(sink, signal);
}
catch(Exception | LinkageError e)
{
LOG.info("Scheduled assistant notification unavailable", logPair("runId", run.getId()));
}
}
}
@@ -0,0 +1,119 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-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.scheduled;
import java.net.HttpCookie;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Map;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.AssistantSecurity;
import com.kingsrook.qbits.assistant.model.AssistantScheduledTask;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleDispatcher;
/*******************************************************************************
** Log in exactly as a UI client, then resolve the returned session UUID with
** the host's authentication module. Never authenticate directly with a token
** in process, create an automated session, or manufacture session permissions.
*******************************************************************************/
public class ScheduledLogin
{
/*******************************************************************************
** No response body or transport exception can leak an access token.
*******************************************************************************/
public QSession login(AssistantRuntime runtime, AssistantScheduledTask task) throws QException
{
String token = ScheduledTasks.credential(runtime.getConfig(), task);
String base = runtime.uiApiBaseUrl();
if(base == null || base.isBlank())
{
throw (new QException("Configure uiApiBaseUrl before scheduling assistant tasks."));
}
String uuid;
try
{
HttpRequest request = HttpRequest.newBuilder(URI.create(base.replaceAll("/+$", "") + "/qqq/v1/manageSession"))
.timeout(Duration.ofSeconds(30)).header("Content-Type", "application/json")
.header("X-QQQ-Client", "assistant/scheduled/" + task.getId())
.POST(HttpRequest.BodyPublishers.ofString(new ObjectMapper().writeValueAsString(Map.of("accessToken", token)), StandardCharsets.UTF_8)).build();
HttpResponse<String> response = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build()
.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if(response.statusCode() != 200)
{
throw (new QException("Scheduled login manageSession answered HTTP " + response.statusCode() + "."));
}
uuid = response.headers().allValues("Set-Cookie").stream().flatMap(value -> HttpCookie.parse(value).stream())
.filter(cookie -> "sessionUUID".equals(cookie.getName())).map(HttpCookie::getValue).findFirst().orElse("");
if(uuid.isBlank())
{
uuid = new ObjectMapper().readTree(response.body()).path("uuid").asText();
}
if(uuid.isBlank())
{
throw (new QException("Scheduled login returned no session UUID."));
}
}
catch(QException e)
{
throw (e);
}
catch(InterruptedException e)
{
Thread.currentThread().interrupt();
throw (new QException("Scheduled login interrupted."));
}
catch(Exception e)
{
throw (new QException("Scheduled login failed."));
}
QSession session;
try
{
var instance = runtime.getQInstance();
String key = QAuthenticationType.MOCK.equals(instance.getAuthentication().getType()) ? "sessionId" : "sessionUUID";
session = new QAuthenticationModuleDispatcher().getQModule(instance.getAuthentication()).createSession(instance, Map.of(key, uuid));
}
catch(Exception e)
{
throw (new QException("Scheduled login session could not be resolved."));
}
if(!task.getRunAsUserId().equals(AssistantSecurity.userId(session)))
{
throw (new QException("The credential belongs to another user."));
}
if(!uuid.equals(session.getUuid()))
{
throw (new QException("The authentication module did not preserve the logged-in session UUID."));
}
AssistantSecurity.applySessionKeys(session, runtime.getConfig());
return (session);
}
}
@@ -0,0 +1,218 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-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.scheduled;
import java.time.Instant;
import java.util.Map;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.api.AssistantApi;
import com.kingsrook.qbits.assistant.api.AssistantApiException;
import com.kingsrook.qbits.assistant.engine.MessageParts;
import com.kingsrook.qbits.assistant.engine.ProviderService;
import com.kingsrook.qbits.assistant.engine.RunningTurn;
import com.kingsrook.qbits.assistant.model.AssistantConversation;
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
import com.kingsrook.qbits.assistant.model.AssistantScheduledRun;
import com.kingsrook.qbits.assistant.model.AssistantScheduledTask;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.session.QSession;
/*******************************************************************************
** Scheduler bookkeeping uses its supplied QContext. Each actual turn switches
** to the designated user's freshly authenticated UI session, restoring context
** afterwards. No user data is fetched by the scheduler or the store.
*******************************************************************************/
public class ScheduledTaskRunner
{
private final AssistantRuntime runtime;
private final ScheduledLogin login;
private final ScheduledAlerts alerts;
/*******************************************************************************
** Default production collaborators.
*******************************************************************************/
public ScheduledTaskRunner(AssistantRuntime runtime)
{
this(runtime, new ScheduledLogin(), new ScheduledAlerts());
}
/*******************************************************************************
** Notification seam for hosts and tests; authentication stays explicit.
*******************************************************************************/
public ScheduledTaskRunner(AssistantRuntime runtime, ScheduledLogin login, ScheduledAlerts alerts)
{
this.runtime = runtime;
this.login = login;
this.alerts = alerts;
}
/*******************************************************************************
** One bounded page; each slot is advanced before any authentication/model.
*******************************************************************************/
public Integer runDue() throws QException
{
Instant now = runtime.getClock().instant();
Integer count = 0;
for(AssistantScheduledTask task : runtime.getStore().query(AssistantScheduledTask.TABLE_NAME, new QQueryFilter()
.withCriteria("enabled", QCriteriaOperator.EQUALS, true).withCriteria("nextRunAt", QCriteriaOperator.LESS_THAN_OR_EQUALS, now)
.withOrderBy(new QFilterOrderBy("nextRunAt", true)).withLimit(100), AssistantScheduledTask.class))
{
if(run(task, false) != null)
{
count++;
}
}
return (count);
}
/*******************************************************************************
** Claim, authenticate, start the normal API turn, and record its result.
*******************************************************************************/
public AssistantScheduledRun run(AssistantScheduledTask candidate, Boolean manual) throws QException
{
var store = runtime.getStore();
var claim = store.claimScheduled(candidate, runtime.getClock().instant(), manual);
if(claim == null)
{
return (null);
}
AssistantScheduledRun run = claim.run();
AssistantScheduledTask task = claim.task();
var original = QContext.capture();
QSession session = null;
Boolean attached = false;
try
{
session = login.login(runtime, task);
QContext.init(runtime.getQInstance(), session);
AssistantApi api = new AssistantApi(runtime, session);
Integer conversationId = ((Number) api.createConversation(Map.of("title", task.getName())).get("id")).intValue();
run.setConversationId(conversationId);
store.updateFields(AssistantConversation.TABLE_NAME, new QRecord().withValue("id", conversationId)
.withValue("scheduledTaskId", task.getId()).withValue("channel", "assistant/scheduled"));
runtime.getScheduledTurn().attach(conversationId, session.getUuid(), task);
attached = true;
RunningTurn running = api.startTurn(conversationId, Map.of("text", task.getPrompt()));
run.setTurnId(running.getTurnId());
while(runtime.getRunningTurn(running.getTurnId()) != null)
{
Thread.sleep(20);
}
var turn = store.getTurn(task.getRunAsUserId(), run.getTurnId());
if(turn == null || "RUNNING".equals(turn.getStatus()))
{
throw (new QException("The scheduled turn did not persist a terminal result."));
}
run.setCostMicros(ScheduledTurn.cost(runtime, turn.getId()));
run.setStatus("COMPLETE".equals(turn.getStatus()) ? "OK" : blocked(turn.getErrorCode()) ? "BLOCKED" : "FAILED");
run.setError("OK".equals(run.getStatus()) ? null : "Turn stopped: " + (turn.getErrorCode() == null ? turn.getStatus() : turn.getErrorCode()));
String summary = MessageParts.fromJson(store.getMessage(task.getRunAsUserId(), turn.getAssistantMessageId()).getPartsJson()).text();
Boolean pending = !store.query(AssistantPendingAction.TABLE_NAME, new QQueryFilter().withCriteria("turnId", QCriteriaOperator.EQUALS, turn.getId())
.withCriteria("userId", QCriteriaOperator.EQUALS, task.getRunAsUserId()).withCriteria("status", QCriteriaOperator.EQUALS, "PENDING").withLimit(1), AssistantPendingAction.class).isEmpty();
run.setSummary(Boolean.TRUE.equals(runtime.getConfig().getHipaaMode()) ? "see conversation"
: truncate((pending ? "Waiting for your confirmation in the panel. " : "") + summary, 2000));
}
catch(AssistantApiException e)
{
run.withStatus(blocked(e.getCode()) ? "BLOCKED" : "FAILED").withError("Turn could not start: " + e.getCode());
}
catch(InterruptedException e)
{
if(run.getTurnId() != null && runtime.getRunningTurn(run.getTurnId()) != null)
{
runtime.getRunningTurn(run.getTurnId()).cancel("SCHEDULER_INTERRUPTED");
}
Thread.currentThread().interrupt();
run.withStatus("FAILED").withError("Scheduled runner interrupted.");
}
catch(QException e)
{
if(e instanceof ProviderService.ProviderSetupException && blocked(e.getMessage()))
{
run.withStatus("BLOCKED").withError("Provider blocked by PHI/HIPAA policy.");
}
else
{
run.withStatus("FAILED").withError(run.getConversationId() == null ? truncate(e.getMessage(), 500) : "Scheduled turn failed; see conversation.");
}
}
catch(RuntimeException e)
{
run.withStatus("FAILED").withError("Scheduled task failed.");
}
finally
{
if(attached && (run.getTurnId() == null || runtime.getRunningTurn(run.getTurnId()) == null))
{
runtime.getScheduledTurn().detach(run.getConversationId(), session.getUuid());
}
QContext.init(original);
}
run.setEndedAt(runtime.getClock().instant());
if(Boolean.TRUE.equals(runtime.getConfig().getHipaaMode()))
{
run.setSummary("see conversation");
}
store.update(run);
store.scheduledUpdate(new QRecord().withValue("id", task.getId()).withValue("lastRunAt", run.getStartedAt())
.withValue("lastRunStatus", run.getStatus()).withValue("lastRunError", run.getError()));
if(!"RECORD".equals(task.getOutputMode()))
{
alerts.raise(run);
}
return (run);
}
/*******************************************************************************
** Policy denials are distinct from operational failures.
*******************************************************************************/
private static Boolean blocked(String code)
{
return (code != null && (code.contains("BUDGET") || code.contains("PHI") || code.contains("HIPAA") || code.contains("KILL_SWITCH")));
}
/*******************************************************************************
** Bound stored summaries and sanitized errors.
*******************************************************************************/
private static String truncate(String value, Integer limit)
{
return (value == null || value.length() <= limit ? value : value.substring(0, limit));
}
}
@@ -0,0 +1,164 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-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.scheduled;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.time.ZoneId;
import java.util.Date;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
import java.util.TimeZone;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.model.AssistantScheduledTask;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import org.quartz.CronExpression;
/*******************************************************************************
** Schedule and credential validation shared by saves, reviews and execution.
*******************************************************************************/
public final class ScheduledTasks
{
public static final List<String> APPROVAL_FIELDS = List.of("prompt", "runAsUserId", "credentialEnvName", "profileId", "writeModeRequested");
/*******************************************************************************
** Utility class.
*******************************************************************************/
private ScheduledTasks()
{
}
/*******************************************************************************
** Quartz computes wall-clock schedules in a validated IANA timezone.
*******************************************************************************/
public static Instant next(AssistantScheduledTask task, Instant after) throws QException
{
try
{
CronExpression cron = new CronExpression(task.getCronExpression());
cron.setTimeZone(TimeZone.getTimeZone(ZoneId.of(task.getTimezone() == null ? "UTC" : task.getTimezone())));
Date next = cron.getNextValidTimeAfter(Date.from(after));
return (next == null ? null : next.toInstant());
}
catch(Exception e)
{
throw (new QException("Invalid Quartz cron expression or timezone."));
}
}
/*******************************************************************************
** Refuse a pasted token or an unrelated host secret without echoing it.
*******************************************************************************/
public static void checkReference(AssistantQBitConfig config, String name) throws QException
{
if(name == null || !name.matches("[A-Z_][A-Z0-9_]*"))
{
throw (new QException("The scheduled credential must be an environment variable name."));
}
if(config.getAllowedScheduledCredentialEnvNames() == null || !config.getAllowedScheduledCredentialEnvNames().contains(name))
{
throw (new QException("The scheduled credential environment variable is not allowed."));
}
}
/*******************************************************************************
** Resolve only after the reference passes the dedicated allowlist.
*******************************************************************************/
public static String credential(AssistantQBitConfig config, AssistantScheduledTask task) throws QException
{
checkReference(config, task.getCredentialEnvName());
String token = config.getSecretResolver().apply(task.getCredentialEnvName());
if(token == null || token.isBlank())
{
throw (new QException("The scheduled credential environment variable is not set."));
}
return (token);
}
/*******************************************************************************
** Presence only; no token characters are exposed in record screens.
*******************************************************************************/
public static String credentialStatus(AssistantQBitConfig config, AssistantScheduledTask task)
{
try
{
credential(config, task);
return ("Set (from " + task.getCredentialEnvName() + ")");
}
catch(QException e)
{
return (e.getMessage());
}
}
/*******************************************************************************
** Bind approval to all security-relevant settings, including null values.
*******************************************************************************/
public static String approvalHash(AssistantScheduledTask task) throws QException
{
return (hash(JsonUtils.toJson(APPROVAL_FIELDS.stream().map(name -> task.toQRecord().getValue(name)).toList())));
}
/*******************************************************************************
** SHA-256 displayed on Review and checked again on Result.
*******************************************************************************/
public static String hash(String text) throws QException
{
try
{
return (HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(text.getBytes(StandardCharsets.UTF_8))));
}
catch(Exception e)
{
throw (new QException("Could not hash the scheduled task."));
}
}
/*******************************************************************************
** An approval for an older task definition never authorizes writes.
*******************************************************************************/
public static Boolean writesApproved(AssistantScheduledTask task) throws QException
{
return (Boolean.TRUE.equals(task.getWriteModeRequested()) && task.getWriteModeApprovedAt() != null
&& task.getWriteModeApprovedBy() != null && Objects.equals(task.getWriteModeApprovalHash(), approvalHash(task)));
}
}
@@ -0,0 +1,173 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-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.scheduled;
import java.math.BigDecimal;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.engine.BudgetService;
import com.kingsrook.qbits.assistant.engine.PolicyService;
import com.kingsrook.qbits.assistant.model.AssistantScheduledTask;
import com.kingsrook.qbits.assistant.model.AssistantUsage;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
/*******************************************************************************
** Server-owned per-conversation execution settings. They never come from a
** panel request or prompt. Scoped to one runtime and removed after completion.
*******************************************************************************/
public class ScheduledTurn
{
private final Map<Integer, AssistantScheduledTask> tasks = new ConcurrentHashMap<>();
private final Map<String, String> channels = new ConcurrentHashMap<>();
/*******************************************************************************
** Attach a snapshot before the unchanged API starts its normal turn.
*******************************************************************************/
public void attach(Integer conversationId, String uuid, AssistantScheduledTask task) throws QException
{
if(channels.putIfAbsent(uuid, "assistant/scheduled/" + task.getId()) != null)
{
throw (new QException("This login session already has a scheduled task in progress."));
}
tasks.put(conversationId, task);
}
/*******************************************************************************
** Remove references to the completed run's context.
*******************************************************************************/
public void detach(Integer conversationId, String uuid)
{
if(conversationId != null)
{
AssistantScheduledTask task = tasks.remove(conversationId);
if(task != null)
{
channels.remove(uuid, "assistant/scheduled/" + task.getId());
}
}
}
/*******************************************************************************
** Snapshot of task settings for this conversation.
*******************************************************************************/
public AssistantScheduledTask task(Integer conversationId)
{
return (tasks.get(conversationId));
}
/*******************************************************************************
** Client attribution also covers metadata and knowledge retrieval calls.
*******************************************************************************/
public String channel(String uuid)
{
return (uuid == null ? null : channels.get(uuid));
}
/*******************************************************************************
** Narrow the normal policy. Scheduled writes always require confirmation.
*******************************************************************************/
public static PolicyService.ToolPolicy tools(AssistantRuntime runtime, AssistantScheduledTask task, PolicyService.ToolPolicy policy) throws QException
{
Set<String> allowed = runtime.getContextBuilder().toolNames(policy).stream()
.filter(name -> !Set.of("create_record", "update_record", "delete_record").contains(name) || approved(task))
.collect(Collectors.toSet());
if(allowed.isEmpty())
{
allowed = Set.of("__no_tools__");
}
return (new PolicyService.ToolPolicy(true, allowed, policy.maxRowsPerCall(), policy.redactPii()));
}
/*******************************************************************************
** Approval failure closes write mode.
*******************************************************************************/
private static Boolean approved(AssistantScheduledTask task)
{
try
{
return (ScheduledTasks.writesApproved(task));
}
catch(QException e)
{
return (false);
}
}
/*******************************************************************************
** Extra STOP cap at dispatch boundaries; a completed call can cross it.
*******************************************************************************/
public static void checkCost(AssistantScheduledTask task, Long costMicros) throws BudgetService.Blocked
{
if(task != null && task.getMaxCostUsdPerRun() != null
&& BigDecimal.valueOf(costMicros, 6).compareTo(task.getMaxCostUsdPerRun()) >= 0)
{
throw (new BudgetService.Blocked("The scheduled task reached its per-run cost cap."));
}
}
/*******************************************************************************
** Include chat, classifier, failed attempts and knowledge-query embeddings.
*******************************************************************************/
public static Long cost(AssistantRuntime runtime, Integer turnId) throws QException
{
return (runtime.getStore().query(AssistantUsage.TABLE_NAME,
new QQueryFilter().withCriteria("turnId",
QCriteriaOperator.EQUALS, turnId),
AssistantUsage.class).stream().mapToLong(row -> row.getCostMicros() == null ? 0 : row.getCostMicros()).sum());
}
/*******************************************************************************
** Check persisted accounting before each embedding and model dispatch.
*******************************************************************************/
public static void checkCost(AssistantRuntime runtime, Integer conversationId, Integer turnId) throws QException
{
AssistantScheduledTask task = conversationId == null ? null : runtime.getScheduledTurn().task(conversationId);
if(task != null)
{
checkCost(task, cost(runtime, turnId));
}
}
}
@@ -203,6 +203,21 @@ public class UiApiToolbox
/*******************************************************************************
** An outcome built by another tool family (e.g. the bulk-load tools), so
** TurnEngine and the confirm route log it the same way.
*******************************************************************************/
public static Outcome of(AiToolResult result, String operation, Integer httpStatus, Integer pendingActionId)
{
Outcome outcome = new Outcome(result);
outcome.operation = operation;
outcome.httpStatus = httpStatus;
outcome.pendingActionId = pendingActionId;
return (outcome);
}
/*******************************************************************************
** Getter for result
*******************************************************************************/
@@ -0,0 +1,57 @@
/*
* 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.bulkload;
import com.kingsrook.qbits.assistant.uiapi.UiApiGateway;
import com.kingsrook.qqq.backend.core.exceptions.QException;
/*******************************************************************************
** How bulk_load_run starts a load (design spec #1171 section 3.4): one seam,
** so the run can move from today's path to durable jobs without the tool
** changing. Every implementation goes through the /qqq/v1 UI API as the user.
*******************************************************************************/
public interface BulkLoadRunner
{
/*******************************************************************************
** A load to start: the target table, the upload, and the saved profile.
*******************************************************************************/
record Request(String sessionUUID, String userTimezone, UiApiGateway gateway, String tableName, String uploadRef, Integer savedBulkLoadProfileId)
{
}
/*******************************************************************************
** What started: status uses the durable job status names (jobs_job.status:
** AWAITING_CONFIRMATION, QUEUED, RUNNING, COMPLETED,
** COMPLETED_WITH_ERRORS, FAILED, ...); jobId when it runs as a job,
** processUUID when it ran synchronously; a short summary; the HTTP status.
*******************************************************************************/
record RunHandle(String status, Long jobId, String processUUID, String summary, String operation, Integer httpStatus)
{
}
/*******************************************************************************
** Start the load. Throws when it cannot start at all (the handle's status
** reports a load that started and failed).
*******************************************************************************/
RunHandle start(Request request) throws QException;
}
@@ -0,0 +1,883 @@
/*
* 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.bulkload;
import java.time.Duration;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.assistant.access.DataAccessService;
import com.kingsrook.qbits.assistant.engine.PolicyService;
import com.kingsrook.qbits.assistant.provider.ModelToolDefinition;
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.actions.ai.AiToolOutcome;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolResult;
import com.kingsrook.qqq.backend.core.actions.ai.output.AiDataEnvelope;
import com.kingsrook.qqq.backend.core.actions.ai.tools.AiJsonSchema;
import com.kingsrook.qqq.backend.core.actions.ai.tools.AiToolException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** The assistant's bulk-load tools (design spec #1171 section 3.4): use and
** create saved bulk load profiles from a file the user attached in the chat.
** Every call goes through the /qqq/v1 UI API with the user's own session, so
** table and process permissions, record locks and the PHI rule are enforced
** by the server, never by the model:
** - bulk_profiles_list / bulk_profile_describe: the user's (and shared)
** saved profiles, through the savedBulkLoadProfile table (record locks);
** - bulk_file_attach: an upload the panel already made (by its ref), with
** its column profiles (samples only where the server allows);
** - bulk_mapping_suggest: core's suggester, then the model refines it;
** - bulk_load_dry_run: the table's real bulk-insert validation, no store;
** - bulk_profile_save (write): storeSavedBulkLoadProfile, and shares through
** insertSharedRecord, after the user confirms;
** - bulk_load_run (write): through {@link BulkLoadRunner}, after the user
** confirms (and, as a durable job, again on its job record).
**
** The AI route header (X-QQQ-AI-Route: hipaa|standard) is set here from the
** turn's provider, never from the model's input. Tool-call logs keep
** profiles and counts, never sample values (see {@link #forLog}).
**
** STAND-IN(core=#1179, removeBy=RC.8): the profile, suggestMapping and
** validate operations ship in a QQQ RC after 4.1.0-RC.5; until then the
** tools report that the server does not offer them.
*******************************************************************************/
public class BulkLoadTools
{
public static final String LIST = "bulk_profiles_list";
public static final String DESCRIBE = "bulk_profile_describe";
public static final String ATTACH = "bulk_file_attach";
public static final String SUGGEST = "bulk_mapping_suggest";
public static final String DRY_RUN = "bulk_load_dry_run";
public static final String SAVE = "bulk_profile_save";
public static final String RUN = "bulk_load_run";
public static final Set<String> NAMES = Set.of(LIST, DESCRIBE, ATTACH, SUGGEST, DRY_RUN, SAVE, RUN);
public static final Set<String> WRITE_TOOLS = Set.of(SAVE, RUN);
public static final String PROFILE_TABLE = "savedBulkLoadProfile";
public static final String ROUTE_HEADER = "X-QQQ-AI-Route";
private static final Set<String> LOG_STRIPPED_KEYS = Set.of("sampleValues", "bulletsOfText", "valueMappings");
private final QInstance qInstance;
private final DataAccessService dataAccess;
private final BulkLoadRunner runner;
/*******************************************************************************
**
*******************************************************************************/
public BulkLoadTools(QInstance qInstance, DataAccessService dataAccess, BulkLoadRunner runner)
{
this.qInstance = qInstance;
this.dataAccess = dataAccess;
this.runner = runner == null ? new JobsLaunchBulkLoadRunner() : runner;
}
/*******************************************************************************
** Offered when the instance has saved bulk load profiles.
*******************************************************************************/
public boolean offered()
{
return (qInstance != null && qInstance.getTable(PROFILE_TABLE) != null);
}
/*******************************************************************************
** The definitions the policy allows (writes only when anyWritable).
*******************************************************************************/
public List<ModelToolDefinition> definitions(PolicyService.ToolPolicy policy, boolean anyWritable)
{
List<ModelToolDefinition> definitions = new ArrayList<>();
if(!offered())
{
return (definitions);
}
Map<String, Object> table = AiJsonSchema.string("The table to load into (from list_tables).");
Map<String, Object> upload = AiJsonSchema.string("The upload ref of the file the user attached (from the conversation's attachments).");
Map<String, Object> profile = new LinkedHashMap<>(Map.of("type", "object", "description", """
A v1 bulk load profile: {"version":"v1","layout":"FLAT","hasHeaderRow":true,"fieldList":[{"fieldName":"...","columnIndex":0, \
"headerName":"...","defaultValue":null,"doValueMapping":false,"valueMappings":{"file value":"field value"}}]}."""));
add(definitions, policy, LIST, "List the saved bulk load profiles (the user's own and those shared with them) for a table.",
AiJsonSchema.object(AiJsonSchema.properties("tableName", table), List.of("tableName")));
add(definitions, policy, DESCRIBE, "Read one saved bulk load profile: its table, owner and column mapping.",
AiJsonSchema.object(AiJsonSchema.properties("id", AiJsonSchema.integer("The profile id (from bulk_profiles_list).")), List.of("id")));
add(definitions, policy, ATTACH, """
Look at a file the user attached for a bulk load: whether row 1 is a header, and each column's type, blanks, distinct count, \
pattern and (only where the server allows) a few sample values.""",
AiJsonSchema.object(AiJsonSchema.properties("uploadRef", upload, "tableName", table), List.of("uploadRef", "tableName")));
add(definitions, policy, SUGGEST, """
Suggest a bulk load profile for an attached file: the server's header matching, with the columns it left unmapped, required \
fields it did not fill, and fields whose values need mapping. Refine the profile yourself from the headers and column \
profiles, then check it with bulk_load_dry_run.""",
AiJsonSchema.object(AiJsonSchema.properties("uploadRef", upload, "tableName", table), List.of("uploadRef", "tableName")));
add(definitions, policy, DRY_RUN, """
Dry-run a bulk load profile over an attached file through the table's real bulk insert validation. Nothing is stored. \
Returns counts, summary lines and error groups with row numbers. SAMPLE checks the first 100 rows; FULL the whole file. \
Fix the profile and run it again until there are no errors (or explain the rest to the user).""",
AiJsonSchema.object(AiJsonSchema.properties("uploadRef", upload, "tableName", table, "profile", profile,
"mode", AiJsonSchema.enumOf(List.of("SAMPLE", "FULL"), "SAMPLE (default) or FULL.")), List.of("uploadRef", "tableName", "profile")));
if(anyWritable)
{
Map<String, Object> share = AiJsonSchema.object(AiJsonSchema.properties(
"audienceType", AiJsonSchema.enumOf(List.of("user", "group"), "Share with a user (email) or a group."),
"audienceId", AiJsonSchema.string("The user's email or the group's name."),
"scope", AiJsonSchema.enumOf(List.of("READ_ONLY", "READ_WRITE"), "The share's scope (default READ_ONLY).")), List.of("audienceType", "audienceId"));
add(definitions, policy, SAVE, """
Save a bulk load profile (after a clean dry run) under a name, optionally sharing it. The user confirms before it is saved; \
it is saved as the user, and shares are limited to what the user may share.""",
AiJsonSchema.object(AiJsonSchema.properties("tableName", table, "label", AiJsonSchema.string("The profile's name."), "profile", profile,
"shares", AiJsonSchema.array(share, "Optional shares.")), List.of("tableName", "label", "profile")));
add(definitions, policy, RUN, """
Load an attached file into a table with a saved profile. The user confirms first; a large load runs as a background job that \
the user starts from its job record.""",
AiJsonSchema.object(AiJsonSchema.properties("tableName", table, "uploadRef", upload, "profileId", AiJsonSchema.integer("The saved profile id.")),
List.of("tableName", "uploadRef", "profileId")));
}
return (definitions);
}
/*******************************************************************************
**
*******************************************************************************/
private static void add(List<ModelToolDefinition> definitions, PolicyService.ToolPolicy policy, String name, String description, Map<String, Object> schema)
{
if(policy == null || policy.allows(name))
{
definitions.add(new ModelToolDefinition().withName(name).withDescription(description).withInputSchema(schema).withStrict(false));
}
}
/*******************************************************************************
** Run a tool. hipaaRoute is the turn's provider's HIPAA eligibility (sent
** as X-QQQ-AI-Route; the model cannot set it).
*******************************************************************************/
public UiApiToolbox.Outcome execute(UiApiToolbox.Context context, String toolCallId, String toolName, Map<String, Object> input, boolean hipaaRoute)
{
AiToolResult result = new AiToolResult().withToolCallId(toolCallId).withToolName(toolName);
Map<String, Object> in = input == null ? Map.of() : input;
Call call = new Call(context, hipaaRoute);
Integer actionId = null;
long started = System.nanoTime();
try
{
if(context.policy() != null && !context.policy().allows(toolName))
{
deny(result, "policy:tool", "The administrator has not allowed the " + toolName + " tool.");
return (UiApiToolbox.Outcome.of(result, null, null, null));
}
String tableName = in.get("tableName") instanceof String t ? t.strip() : null;
if(tableName != null)
{
result.setTableName(tableName);
if(!dataAccess.forTable(tableName).mode().allowsRead())
{
deny(result, "dataAccess:" + dataAccess.forTable(tableName).mode(), "The assistant may not use the " + tableName + " table.");
return (UiApiToolbox.Outcome.of(result, null, null, null));
}
}
switch(toolName)
{
case LIST -> list(call, result, required(in, "tableName"));
case DESCRIBE -> describe(call, result, requiredInteger(in, "id"));
case ATTACH -> attach(call, result, required(in, "tableName"), required(in, "uploadRef"));
case SUGGEST -> suggest(call, result, required(in, "tableName"), required(in, "uploadRef"));
case DRY_RUN -> dryRun(call, result, required(in, "tableName"), required(in, "uploadRef"), requiredMap(in, "profile"), in.get("mode") instanceof String m ? m : "SAMPLE");
case SAVE, RUN -> actionId = propose(context, result, toolName, required(in, "tableName"), in);
default -> throw (AiToolException.invalidInput("Unknown tool \"" + toolName + "\"."));
}
}
catch(AiToolException e)
{
result.withOutcome(e.getOutcome() == null ? AiToolOutcome.INVALID_INPUT : e.getOutcome()).withSummary(shortText(e.getMessage())).withModelContent(e.getMessage());
}
catch(QException | RuntimeException e)
{
result.withOutcome(AiToolOutcome.ERROR).withSummary("Failed").withModelContent("The tool failed: " + shortText(e.getMessage()));
}
finally
{
result.setDurationMs((System.nanoTime() - started) / 1_000_000);
}
return (UiApiToolbox.Outcome.of(result, call.operation, call.httpStatus, actionId));
}
/*******************************************************************************
** Run a confirmed write (from a pending action) with the confirming user's
** session.
*******************************************************************************/
public UiApiToolbox.Outcome executeConfirmed(UiApiToolbox.Context context, String toolCallId, String toolName, String tableName, Map<String, Object> values)
{
AiToolResult result = new AiToolResult().withToolCallId(toolCallId).withToolName(toolName).withTableName(tableName);
Call call = new Call(context, false);
long started = System.nanoTime();
try
{
if(!WRITE_TOOLS.contains(toolName))
{
throw (AiToolException.invalidInput("Not a write tool: " + toolName));
}
DataAccessService.Decision decision = dataAccess.forTable(SAVE.equals(toolName) ? PROFILE_TABLE : tableName);
if(!decision.mode().allowsWrite())
{
deny(result, "dataAccess:" + decision.mode(), "The assistant may not write here (data access " + decision.mode() + ", " + decision.source() + ").");
return (UiApiToolbox.Outcome.of(result, null, null, null));
}
if(SAVE.equals(toolName))
{
save(call, result, tableName, values);
}
else
{
run(call, result, context, tableName, values);
}
}
catch(AiToolException e)
{
result.withOutcome(e.getOutcome() == null ? AiToolOutcome.INVALID_INPUT : e.getOutcome()).withSummary(shortText(e.getMessage())).withModelContent(e.getMessage());
}
catch(QException | RuntimeException e)
{
result.withOutcome(AiToolOutcome.ERROR).withSummary("Failed").withModelContent("The write failed: " + shortText(e.getMessage()));
}
finally
{
result.setDurationMs((System.nanoTime() - started) / 1_000_000);
}
return (UiApiToolbox.Outcome.of(result, call.operation, call.httpStatus, null));
}
////////////////////////////////////////////////////////////////////////////
// read tools //
////////////////////////////////////////////////////////////////////////////
/*******************************************************************************
** POST /table/savedBulkLoadProfile/query: the profiles the record locks let
** the user see, for one table.
*******************************************************************************/
private void list(Call call, AiToolResult result, String tableName) throws QException
{
Map<String, Object> filter = Map.of(
"criteria", List.of(Map.of("fieldName", "tableName", "operator", "EQUALS", "values", List.of(tableName))),
"orderBys", List.of(Map.of("fieldName", "label", "isAscending", true)));
UiApiResult response = call.invoke(new UiApiCall("POST", "/table/{tableName}/query").withPathParam("tableName", PROFILE_TABLE)
.withJsonBody(Map.of("filter", filter)));
if(response == null || !response.isSuccess())
{
fail(result, call, response);
return;
}
List<Map<String, Object>> rows = new ArrayList<>();
for(Object record : list(response.json().get("records")))
{
Map<String, Object> values = map(map(record).get("values"));
Map<String, Object> row = new LinkedHashMap<>();
row.put("id", values.get("id"));
row.put("label", values.get("label"));
row.put("owner", values.get("userId"));
row.put("fieldCount", fieldList(values.get("mappingJson")).size());
rows.add(row);
}
result.withOutcome(AiToolOutcome.OK).withRowCount(rows.size()).withSummary("Found " + rows.size() + " saved profile" + (rows.size() == 1 ? "" : "s"))
.withPermissionCheck(call.permissionCheck())
.withModelContent(AiDataEnvelope.wrap(LIST, PROFILE_TABLE, JsonUtils.toJson(Map.of("tableName", tableName, "profiles", rows))));
}
/*******************************************************************************
** GET /table/savedBulkLoadProfile/{id}: one profile (when the locks allow).
*******************************************************************************/
private void describe(Call call, AiToolResult result, Integer id) throws QException
{
UiApiResult response = call.invoke(new UiApiCall("GET", "/table/{tableName}/{primaryKey}").withPathParam("tableName", PROFILE_TABLE).withPathParam("primaryKey", id));
if(response == null || !response.isSuccess())
{
fail(result, call, response);
return;
}
Map<String, Object> values = map(map(response.json().get("record")).get("values"));
Map<String, Object> out = new LinkedHashMap<>();
out.put("id", values.get("id"));
out.put("label", values.get("label"));
out.put("tableName", values.get("tableName"));
out.put("owner", values.get("userId"));
out.put("profile", values.get("mappingJson") == null ? null : new JSONObject(String.valueOf(values.get("mappingJson"))).toMap());
result.setTableName(String.valueOf(values.get("tableName")));
result.withOutcome(AiToolOutcome.OK).withRowCount(1).withSummary("Read profile " + values.get("label"))
.withPermissionCheck(call.permissionCheck())
.withModelContent(AiDataEnvelope.wrap(DESCRIBE, PROFILE_TABLE, JsonUtils.toJson(out)));
}
/*******************************************************************************
** GET /bulkLoad/uploads/{uploadRef}/profile.
*******************************************************************************/
private void attach(Call call, AiToolResult result, String tableName, String uploadRef) throws QException
{
UiApiResult response = call.invoke(new UiApiCall("GET", "/bulkLoad/uploads/{uploadRef}/profile").withPathParam("uploadRef", uploadRef)
.withQueryParam("tableName", tableName).withQueryParam("sampleCount", 5));
if(response == null || !response.isSuccess())
{
fail(result, call, response);
return;
}
Map<String, Object> json = response.json();
int columns = list(json.get("columns")).size();
result.withOutcome(AiToolOutcome.OK).withRowCount(columns).withSummary("Read " + json.get("fileName") + ": " + columns + " columns")
.withPermissionCheck(call.permissionCheck())
.withModelContent(AiDataEnvelope.wrap(ATTACH, tableName, response.body()));
}
/*******************************************************************************
** POST /bulkLoad/{tableName}/suggestMapping.
*******************************************************************************/
private void suggest(Call call, AiToolResult result, String tableName, String uploadRef) throws QException
{
UiApiResult response = call.invoke(new UiApiCall("POST", "/bulkLoad/{tableName}/suggestMapping").withPathParam("tableName", tableName)
.withJsonBody(Map.of("uploadRef", uploadRef)));
if(response == null || !response.isSuccess())
{
fail(result, call, response);
return;
}
Map<String, Object> json = response.json();
int mapped = list(map(json.get("profile")).get("fieldList")).size();
result.withOutcome(AiToolOutcome.OK).withRowCount(mapped).withSummary("Suggested a mapping of " + mapped + " field" + (mapped == 1 ? "" : "s"))
.withPermissionCheck(call.permissionCheck())
.withModelContent(AiDataEnvelope.wrap(SUGGEST, tableName, response.body()));
}
/*******************************************************************************
** POST /bulkLoad/{tableName}/validate (nothing is stored).
*******************************************************************************/
private void dryRun(Call call, AiToolResult result, String tableName, String uploadRef, Map<String, Object> profile, String mode) throws QException
{
UiApiResult response = call.invoke(new UiApiCall("POST", "/bulkLoad/{tableName}/validate").withPathParam("tableName", tableName)
.withJsonBody(Map.of("uploadRef", uploadRef, "profile", profile, "mode", "FULL".equalsIgnoreCase(mode) ? "FULL" : "SAMPLE"))
.withTimeout(Duration.ofSeconds(120)));
if(response == null || !response.isSuccess())
{
fail(result, call, response);
return;
}
Map<String, Object> json = response.json();
Integer ok = ValueUtils.getValueAsInteger(json.get("okCount"));
Integer errors = ValueUtils.getValueAsInteger(json.get("errorCount"));
result.withOutcome(AiToolOutcome.OK).withRowCount(ok)
.withSummary("Dry run (" + json.get("mode") + "): " + ok + " ok, " + errors + " with errors")
.withPermissionCheck(call.permissionCheck())
.withRender(render("bulk_load_dry_run", tableName, json))
.withModelContent(AiDataEnvelope.wrap(DRY_RUN, tableName, response.body()));
}
////////////////////////////////////////////////////////////////////////////
// write tools //
////////////////////////////////////////////////////////////////////////////
/*******************************************************************************
** Propose a save or a run as a pending action (the user confirms in the
** panel; nothing happens until then).
*******************************************************************************/
private Integer propose(UiApiToolbox.Context context, AiToolResult result, String toolName, String tableName, Map<String, Object> in) throws QException
{
DataAccessService.Decision decision = dataAccess.forTable(SAVE.equals(toolName) ? PROFILE_TABLE : tableName);
if(!decision.mode().allowsWrite())
{
deny(result, "dataAccess:" + decision.mode(), "The assistant may only read here (data access " + decision.mode() + ", " + decision.source() + "). Writes need READ_WRITE.");
return (null);
}
Map<String, Object> values = new LinkedHashMap<>();
String summary;
if(SAVE.equals(toolName))
{
String label = required(in, "label");
values.put("label", label);
values.put("profile", requiredMap(in, "profile"));
if(in.get("shares") instanceof List<?> shares && !shares.isEmpty())
{
values.put("shares", shares);
}
summary = "Save the bulk load profile \"" + label + "\" for " + tableName + (values.containsKey("shares") ? " and share it" : "");
}
else
{
values.put("uploadRef", required(in, "uploadRef"));
values.put("profileId", requiredInteger(in, "profileId"));
summary = "Load the attached file into " + tableName + " with saved profile " + values.get("profileId");
}
if(context.pendingActions() == null)
{
throw (new QException("The " + toolName + " action could not be proposed."));
}
String operation = SAVE.equals(toolName) ? "POST /processes/{processName}/init" : "POST " + JobsLaunchBulkLoadRunner.LAUNCH_OPERATION_PATH;
Integer actionId = context.pendingActions().propose(new UiApiToolbox.ProposedWrite(result.getToolCallId(), toolName, operation, tableName, null, values, summary));
if(actionId == null)
{
throw (new QException("The " + toolName + " action could not be proposed."));
}
Map<String, Object> render = new LinkedHashMap<>();
render.put("type", "pending_action");
render.put("actionId", actionId);
render.put("toolName", toolName);
render.put("tableName", tableName);
render.put("summary", summary);
result.withOutcome(AiToolOutcome.OK).withSummary("Waiting for your confirmation").withRender(render)
.withModelContent("Proposed (pending action " + actionId + "): " + summary + ". Nothing happens until the user confirms it in the panel.");
return (actionId);
}
/*******************************************************************************
** Confirmed save: storeSavedBulkLoadProfile, then insertSharedRecord per
** share (core lets only the owner share).
*******************************************************************************/
private void save(Call call, AiToolResult result, String tableName, Map<String, Object> values) throws QException
{
Map<String, Object> processValues = new LinkedHashMap<>();
processValues.put("tableName", tableName);
processValues.put("label", values.get("label"));
processValues.put("mappingJson", JsonUtils.toJson(values.get("profile")));
processValues.put("isBulkEdit", false);
UiApiResult response = call.invoke(new UiApiCall("POST", "/processes/{processName}/init").withPathParam("processName", "storeSavedBulkLoadProfile")
.withFormParam("values", JsonUtils.toJson(processValues)));
String error = processError(response);
if(error != null)
{
result.withOutcome(response == null || response.isSuccess() ? AiToolOutcome.ERROR : outcomeFor(response.status())).withSummary(shortText(error)).withPermissionCheck(call.permissionCheck())
.withModelContent("The profile was not saved: " + error);
return;
}
Integer id = ValueUtils.getValueAsInteger(map(map(list(map(response.json().get("values")).get("savedBulkLoadProfileList")).stream().findFirst().orElse(null)).get("values")).get("id"));
List<String> shared = new ArrayList<>();
List<String> failed = new ArrayList<>();
for(Object share : list(values.get("shares")))
{
Map<String, Object> s = map(share);
Map<String, Object> shareValues = new LinkedHashMap<>();
shareValues.put("tableName", PROFILE_TABLE);
shareValues.put("recordId", id);
shareValues.put("audienceType", s.get("audienceType"));
shareValues.put("audienceId", s.get("audienceId"));
shareValues.put("scopeId", s.get("scope") == null ? "READ_ONLY" : s.get("scope"));
UiApiResult shareResponse = call.invoke(new UiApiCall("POST", "/processes/{processName}/init").withPathParam("processName", "insertSharedRecord")
.withFormParam("values", JsonUtils.toJson(shareValues)));
String shareError = processError(shareResponse);
(shareError == null ? shared : failed).add(s.get("audienceType") + " " + s.get("audienceId") + (shareError == null ? "" : " (" + shortText(shareError) + ")"));
}
String summary = "Saved profile \"" + values.get("label") + "\" (id " + id + ")" + (shared.isEmpty() ? "" : ", shared with " + String.join(", ", shared))
+ (failed.isEmpty() ? "" : "; not shared with " + String.join(", ", failed));
result.withOutcome(AiToolOutcome.OK).withRowCount(1).withSummary(shortText(summary)).withPermissionCheck(call.permissionCheck())
.withModelContent(JsonUtils.toJson(Map.of("id", id == null ? "" : id, "label", values.get("label"), "shared", shared, "notShared", failed)));
}
/*******************************************************************************
** Confirmed run: through the runner seam.
*******************************************************************************/
private void run(Call call, AiToolResult result, UiApiToolbox.Context context, String tableName, Map<String, Object> values) throws QException
{
BulkLoadRunner.RunHandle handle = runner.start(new BulkLoadRunner.Request(context.sessionUUID(), context.userTimezone(), context.gateway(), tableName,
String.valueOf(values.get("uploadRef")), ValueUtils.getValueAsInteger(values.get("profileId"))));
call.operation = handle.operation();
call.httpStatus = handle.httpStatus();
boolean ok = !"FAILED".equals(handle.status()) && !"NOT_AVAILABLE".equals(handle.status());
Map<String, Object> out = new LinkedHashMap<>();
out.put("status", handle.status());
out.put("jobId", handle.jobId());
out.put("processUUID", handle.processUUID());
out.put("summary", handle.summary());
result.withOutcome(ok ? AiToolOutcome.OK : AiToolOutcome.ERROR).withSummary(shortText(handle.summary())).withPermissionCheck(call.permissionCheck())
.withModelContent(JsonUtils.toJson(out));
}
////////////////////////////////////////////////////////////////////////////
// helpers //
////////////////////////////////////////////////////////////////////////////
/*******************************************************************************
** A tool's input or result as kept in the tool-call log: profiles and
** counts, never sample values, example bullets or value mappings.
*******************************************************************************/
public static String forLog(String json)
{
if(json == null)
{
return (null);
}
try
{
int start = json.indexOf('{');
int end = json.lastIndexOf('}');
if(start < 0 || end < start)
{
return (json);
}
JSONObject object = new JSONObject(json.substring(start, end + 1));
strip(object);
return (object.toString());
}
catch(RuntimeException e)
{
return ("{\"note\":\"not logged\"}");
}
}
/*******************************************************************************
**
*******************************************************************************/
private static void strip(Object node)
{
if(node instanceof JSONObject object)
{
for(String key : new ArrayList<>(object.keySet()))
{
if(LOG_STRIPPED_KEYS.contains(key))
{
object.remove(key);
}
else
{
strip(object.get(key));
}
}
}
else if(node instanceof JSONArray array)
{
for(int i = 0; i < array.length(); i++)
{
strip(array.get(i));
}
}
}
/*******************************************************************************
** A compact render for the panel (counts and error groups, no values).
*******************************************************************************/
private static Map<String, Object> render(String type, String tableName, Map<String, Object> json)
{
Map<String, Object> render = new LinkedHashMap<>();
render.put("type", type);
render.put("tableName", tableName);
render.put("mode", json.get("mode"));
render.put("okCount", json.get("okCount"));
render.put("errorCount", json.get("errorCount"));
List<Map<String, Object>> errors = new ArrayList<>();
for(Object error : list(json.get("errors")))
{
Map<String, Object> e = map(error);
errors.add(new LinkedHashMap<>(Map.of("rows", String.valueOf(e.get("rowNumbers")), "field", String.valueOf(e.get("fieldName")), "message", String.valueOf(e.get("message")))));
}
render.put("errors", errors);
return (render);
}
/*******************************************************************************
** The error of a process init call (HTTP or the process's ERROR type), or
** null when it completed.
*******************************************************************************/
private static String processError(UiApiResult response)
{
if(response == null)
{
return ("No response.");
}
if(!response.isSuccess())
{
return (response.errorMessage() == null ? "HTTP " + response.status() : response.errorMessage());
}
Map<String, Object> json = response.json();
if("ERROR".equals(json.get("type")))
{
return (String.valueOf(json.getOrDefault("userFacingError", json.getOrDefault("error", "The process failed."))));
}
return (null);
}
/*******************************************************************************
**
*******************************************************************************/
private static void fail(AiToolResult result, Call call, UiApiResult response)
{
if(response == null)
{
result.withOutcome(AiToolOutcome.ERROR).withSummary("Not available")
.withModelContent("This server does not offer " + call.operation + " yet (it needs a newer QQQ). Use the table's Bulk Load screen instead.");
return;
}
AiToolOutcome kind = outcomeFor(response.status());
String message = response.errorMessage() == null ? "HTTP " + response.status() : response.errorMessage();
result.withOutcome(kind).withSummary(kind == AiToolOutcome.DENIED ? "Not allowed" : shortText(message)).withPermissionCheck(call.permissionCheck())
.withModelContent(call.operation + " answered HTTP " + response.status() + ": " + shortText(message));
}
/*******************************************************************************
**
*******************************************************************************/
private static AiToolOutcome outcomeFor(int status)
{
return (switch(status)
{
case 401, 403 -> AiToolOutcome.DENIED;
case 404 -> AiToolOutcome.NOT_FOUND;
case 400, 409, 422 -> AiToolOutcome.INVALID_INPUT;
default -> AiToolOutcome.ERROR;
});
}
/*******************************************************************************
**
*******************************************************************************/
private static void deny(AiToolResult result, String check, String message)
{
result.withOutcome(AiToolOutcome.DENIED).withSummary("Not allowed").withPermissionCheck(check).withModelContent(message);
}
/*******************************************************************************
**
*******************************************************************************/
private static String required(Map<String, Object> in, String name) throws AiToolException
{
if(in.get(name) instanceof String s && StringUtils.hasContent(s))
{
return (s.strip());
}
throw (AiToolException.invalidInput(name + " is required."));
}
/*******************************************************************************
**
*******************************************************************************/
private static Integer requiredInteger(Map<String, Object> in, String name) throws AiToolException
{
try
{
Integer value = ValueUtils.getValueAsInteger(in.get(name));
if(value != null)
{
return (value);
}
}
catch(RuntimeException e)
{
// fall through
}
throw (AiToolException.invalidInput(name + " must be a number."));
}
/*******************************************************************************
**
*******************************************************************************/
@SuppressWarnings("unchecked")
private static Map<String, Object> requiredMap(Map<String, Object> in, String name) throws AiToolException
{
Object value = in.get(name);
if(value instanceof String s && s.strip().startsWith("{"))
{
return (new JSONObject(s).toMap());
}
if(value instanceof Map<?, ?> m && !m.isEmpty())
{
return ((Map<String, Object>) m);
}
throw (AiToolException.invalidInput(name + " is required (a JSON object)."));
}
/*******************************************************************************
**
*******************************************************************************/
private static List<Object> fieldList(Object mappingJson)
{
if(mappingJson == null)
{
return (List.of());
}
try
{
return (new JSONObject(String.valueOf(mappingJson)).optJSONArray("fieldList") == null ? List.of()
: new JSONObject(String.valueOf(mappingJson)).getJSONArray("fieldList").toList());
}
catch(RuntimeException e)
{
return (List.of());
}
}
/*******************************************************************************
**
*******************************************************************************/
@SuppressWarnings("unchecked")
private static Map<String, Object> map(Object value)
{
return (value instanceof Map<?, ?> m ? (Map<String, Object>) m : Map.of());
}
/*******************************************************************************
**
*******************************************************************************/
@SuppressWarnings("unchecked")
private static List<Object> list(Object value)
{
return (value instanceof List<?> l ? (List<Object>) l : List.of());
}
/*******************************************************************************
**
*******************************************************************************/
private static String shortText(String text)
{
if(text == null)
{
return ("");
}
return (text.length() > 300 ? text.substring(0, 297) + "..." : text);
}
/*******************************************************************************
** One tool call's UI API invocations: the user's session, the route header,
** and the last operation and status (for the log).
*******************************************************************************/
private static class Call
{
private final UiApiToolbox.Context context;
private final boolean hipaaRoute;
private String operation;
private Integer httpStatus;
/*******************************************************************************
**
*******************************************************************************/
Call(UiApiToolbox.Context context, boolean hipaaRoute)
{
this.context = context;
this.hipaaRoute = hipaaRoute;
}
/*******************************************************************************
** Invoke, or null when the server does not offer the operation (an
** older QQQ).
*******************************************************************************/
UiApiResult invoke(UiApiCall uiApiCall) throws QException
{
operation = uiApiCall.operation();
if(!context.gateway().isInvocable(uiApiCall.getMethod(), uiApiCall.getPathTemplate()))
{
return (null);
}
uiApiCall.withSessionUUID(context.sessionUUID()).withUserTimezone(context.userTimezone()).withInvokedBy("assistant")
.withHeader(ROUTE_HEADER, hipaaRoute ? "hipaa" : "standard");
UiApiResult response = context.gateway().invoke(uiApiCall);
httpStatus = response.status();
return (response);
}
/*******************************************************************************
**
*******************************************************************************/
String permissionCheck()
{
return ("uiApi:" + operation + ":" + httpStatus);
}
}
}
@@ -0,0 +1,90 @@
/*
* 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.bulkload;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.assistant.uiapi.UiApiCall;
import com.kingsrook.qbits.assistant.uiapi.UiApiResult;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
/*******************************************************************************
** The default {@link BulkLoadRunner}: the durable jobs launch,
** POST /qqq/v1/processes/{table}.bulkInsert/jobs with the upload ref and the
** saved profile (design spec #1171 sections 2.4 and 3.4). From the assistant
** channel core creates the job AWAITING_CONFIRMATION, so it starts only when
** the user confirms it outside the assistant.
**
** STAND-IN(core=#1178, removeBy=RC.8): until the jobs kernel ships in a QQQ
** RC, the launch is not a spec'd operation on the server; the runner then
** says so instead of guessing at another path (a load needs the upload ref,
** which the bulk-insert process endpoints cannot take).
*******************************************************************************/
public class JobsLaunchBulkLoadRunner implements BulkLoadRunner
{
public static final String LAUNCH_OPERATION_PATH = "/processes/{processName}/jobs";
public static final String NOT_AVAILABLE = "Running a bulk load from the assistant needs the durable jobs launch "
+ "(POST /qqq/v1/processes/{process}/jobs), which this server does not have yet. Open the table's Bulk Load "
+ "screen and choose the saved profile instead.";
/*******************************************************************************
**
*******************************************************************************/
@Override
public RunHandle start(Request request) throws QException
{
String processName = request.tableName() + ".bulkInsert";
if(!request.gateway().isInvocable("POST", LAUNCH_OPERATION_PATH))
{
return (new RunHandle("NOT_AVAILABLE", null, null, NOT_AVAILABLE, "POST " + LAUNCH_OPERATION_PATH, null));
}
Map<String, Object> body = new LinkedHashMap<>();
body.put("uploadRefs", List.of(request.uploadRef()));
body.put("savedBulkLoadProfileId", request.savedBulkLoadProfileId());
body.put("mode", "EXECUTE");
body.put("label", "Bulk load into " + request.tableName() + " (assistant)");
UiApiResult response = request.gateway().invoke(new UiApiCall("POST", LAUNCH_OPERATION_PATH)
.withPathParam("processName", processName)
.withJsonBody(body)
.withSessionUUID(request.sessionUUID())
.withUserTimezone(request.userTimezone())
.withInvokedBy("assistant"));
if(!response.isSuccess())
{
return (new RunHandle("FAILED", null, null, response.errorMessage() == null ? "HTTP " + response.status() : response.errorMessage(),
"POST " + LAUNCH_OPERATION_PATH, response.status()));
}
Map<String, Object> json = response.json();
Long jobId = ValueUtils.getValueAsLong(json.get("jobId"));
String status = json.get("status") == null ? "QUEUED" : String.valueOf(json.get("status"));
String summary = "AWAITING_CONFIRMATION".equals(status)
? "Job " + jobId + " is waiting for your confirmation on its job record before it starts."
: "Job " + jobId + " is " + status.toLowerCase() + ".";
return (new RunHandle(status, jobId, null, summary, "POST " + LAUNCH_OPERATION_PATH, response.status()));
}
}
@@ -30,6 +30,7 @@ import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import java.util.StringJoiner;
import java.util.UUID;
import java.util.function.Supplier;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
@@ -64,6 +65,9 @@ public class LoopbackUiApiGateway implements UiApiGateway
public static final String INVOKED_BY_HEADER = "X-QQQ-Invoked-By";
public static final String TIMEZONE_HEADER = "X-QQQ-UserTimezone";
/** Headers a call cannot set itself (lower case). */
private static final Set<String> RESERVED_HEADERS = Set.of("cookie", "authorization", "x-api-key", "content-type", "accept", INVOKED_BY_HEADER.toLowerCase(), TIMEZONE_HEADER.toLowerCase());
private static final Set<String> SPEC_OPERATIONS = specOperations();
private final Supplier<String> baseUrlSupplier;
@@ -161,6 +165,35 @@ public class LoopbackUiApiGateway implements UiApiGateway
/*******************************************************************************
** Add server-owned client attribution for a scheduled turn. All operation,
** session, encoding and transport rules remain those of this gateway.
*******************************************************************************/
public UiApiResult invokeWithClient(UiApiCall call, String clientChannel) throws QException
{
if(call == null || !isInvocable(call.getMethod(), call.getPathTemplate()) || !StringUtils.hasContent(call.getSessionUUID()))
{
throw (new QException("A scheduled UI API call requires a spec'd operation and a session."));
}
try
{
HttpRequest request = HttpRequest.newBuilder(buildRequest(call), (name, value) -> true).setHeader("X-QQQ-Client", clientChannel).build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
return (new UiApiResult(response.statusCode(), response.body()));
}
catch(InterruptedException e)
{
Thread.currentThread().interrupt();
throw (new QException("Scheduled UI API request interrupted."));
}
catch(IOException e)
{
throw (new QException("Scheduled UI API request failed."));
}
}
/*******************************************************************************
** The HTTP request: path parameters filled and encoded, query string, JSON
** body, the session cookie and the marker headers.
@@ -204,8 +237,31 @@ public class LoopbackUiApiGateway implements UiApiGateway
{
builder.header(TIMEZONE_HEADER, call.getUserTimezone());
}
call.getHeaders().forEach((name, value) ->
{
if(!RESERVED_HEADERS.contains(name.toLowerCase()))
{
builder.header(name, value);
}
});
if(call.getJsonBody() != null)
if(!call.getFormParams().isEmpty())
{
////////////////////////////////////////////////////////////////
// multipart/form-data text fields (the /processes endpoints) //
////////////////////////////////////////////////////////////////
String boundary = "qqq-assistant-" + UUID.randomUUID();
StringBuilder body = new StringBuilder();
String lineEnd = "\r\n";
call.getFormParams().forEach((name, value) -> body.append("--").append(boundary).append(lineEnd)
.append("Content-Disposition: form-data; name=\"").append(name.replace("\"", "")).append("\"").append(lineEnd)
.append("Content-Type: text/plain; charset=UTF-8").append(lineEnd).append(lineEnd)
.append(value).append(lineEnd));
body.append("--").append(boundary).append("--").append(lineEnd);
builder.header("Content-Type", "multipart/form-data; boundary=" + boundary);
builder.method(call.getMethod(), HttpRequest.BodyPublishers.ofString(body.toString(), StandardCharsets.UTF_8));
}
else if(call.getJsonBody() != null)
{
builder.header("Content-Type", "application/json");
builder.method(call.getMethod(), HttpRequest.BodyPublishers.ofString(call.getJsonBody() instanceof String s ? s : JsonUtils.toJson(call.getJsonBody()), StandardCharsets.UTF_8));
@@ -33,6 +33,8 @@ public class UiApiCall
private final String pathTemplate;
private final Map<String, String> pathParams = new LinkedHashMap<>();
private final Map<String, String> queryParams = new LinkedHashMap<>();
private final Map<String, String> formParams = new LinkedHashMap<>();
private final Map<String, String> headers = new LinkedHashMap<>();
private Object jsonBody;
private String sessionUUID;
private String userTimezone;
@@ -128,6 +130,58 @@ public class UiApiCall
/*******************************************************************************
** Add a multipart form field (ignored when null). A call with form fields
** is sent as multipart/form-data (the /processes endpoints), mirroring
** core's UiApiRequest.withFormParam.
*******************************************************************************/
public UiApiCall withFormParam(String name, Object value)
{
if(value != null)
{
formParams.put(name, String.valueOf(value));
}
return (this);
}
/*******************************************************************************
** Getter for formParams
*******************************************************************************/
public Map<String, String> getFormParams()
{
return (formParams);
}
/*******************************************************************************
** Add a request header the invoker sets (e.g. X-QQQ-AI-Route); ignored
** when null. The session cookie and the invoked-by marker cannot be
** overridden this way.
*******************************************************************************/
public UiApiCall withHeader(String name, Object value)
{
if(name != null && value != null)
{
headers.put(name, String.valueOf(value));
}
return (this);
}
/*******************************************************************************
** Getter for headers
*******************************************************************************/
public Map<String, String> getHeaders()
{
return (headers);
}
/*******************************************************************************
** Fluent setter for jsonBody
*******************************************************************************/
@@ -17,7 +17,10 @@
package com.kingsrook.qbits.assistant;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.assistant.metadata.AssistantAdminPermissionChecker;
import com.kingsrook.qbits.assistant.metadata.AssistantInstanceMetaData;
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
import com.kingsrook.qbits.assistant.model.AssistantConversation;
import com.kingsrook.qbits.assistant.model.AssistantPolicy;
@@ -26,8 +29,8 @@ import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionCheckResult;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -44,9 +47,8 @@ import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
import java.util.ArrayList;
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;
@@ -88,7 +90,7 @@ class AssistantQBitProducerTest extends BaseAssistantTest
// the nav: the home plus the main tables; processes are actions //
//////////////////////////////////////////////////////////////////////
assertThat(qInstance.getApp(AssistantMetaDataBuilder.APP_NAME).getChildren()).extracting(c -> c.getName())
.containsExactly("assistant_rag_source", "assistant_budget", "assistant_provider", "assistant_profile", "assistant_skill", "assistant_data_access", "assistant_conversation", "assistant_model_price", "assistant_policy");
.containsExactly("assistant_scheduled_task", "assistant_rag_source", "assistant_budget", "assistant_provider", "assistant_profile", "assistant_skill", "assistant_data_access", "assistant_conversation", "assistant_model_price", "assistant_policy");
assertThat(qInstance.getApp(AssistantMetaDataBuilder.APP_NAME).getWidgets()).containsExactly("assistantHome", "assistantAccess");
assertThat(qInstance.getProcess(AssistantMetaDataBuilder.RETENTION_PROCESS_NAME).getTableName()).isEqualTo("assistant_conversation");
assertThat(qInstance.getProcess(AssistantMetaDataBuilder.RETENTION_PROCESS_NAME)).isNotNull();
@@ -96,9 +98,9 @@ class AssistantQBitProducerTest extends BaseAssistantTest
assertThat(qInstance.getPossibleValueSource(AssistantConversation.TABLE_NAME)).isNotNull();
assertThat(qInstance.getQBits().values()).anyMatch(q -> "assistant".equals(q.getArtifactId()));
assertThat(com.kingsrook.qbits.assistant.metadata.AssistantInstanceMetaData.of(qInstance)).isNotNull();
assertThat(com.kingsrook.qbits.assistant.metadata.AssistantInstanceMetaData.of(qInstance).getVersion()).isEqualTo(1);
assertThat(com.kingsrook.qbits.assistant.metadata.AssistantInstanceMetaData.of(qInstance).getBasePath()).isEqualTo("/qqq/v1/assistant");
assertThat(AssistantInstanceMetaData.of(qInstance)).isNotNull();
assertThat(AssistantInstanceMetaData.of(qInstance).getVersion()).isEqualTo(1);
assertThat(AssistantInstanceMetaData.of(qInstance).getBasePath()).isEqualTo("/qqq/v1/assistant");
assertThat(config.getRuntime()).isNotNull();
assertThat(AssistantRuntime.of(qInstance)).isSameAs(config.getRuntime());
assertThat(AssistantQBitProducer.getVersion()).isNotBlank();
@@ -192,7 +194,7 @@ class AssistantQBitProducerTest extends BaseAssistantTest
assertThat(editor.getSecurityKeyValues(AssistantQBitConfig.DEFAULT_OWNER_KEY_TYPE)).containsExactly(AssistantTestFixture.EDITOR);
assertThat(editor.getSecurityKeyValues("assistant_user_id_all_access")).isNullOrEmpty();
QSession admin = new QSession().withUser(new com.kingsrook.qqq.backend.core.model.session.QUser().withIdReference("boss"));
QSession admin = new QSession().withUser(new QUser().withIdReference("boss"));
admin.withPermission(AssistantQBitConfig.ADMIN_PERMISSION);
AssistantSecurity.applySessionKeys(admin, config);
assertThat(admin.getSecurityKeyValues("assistant_user_id_all_access")).containsExactly(true);
@@ -277,7 +279,7 @@ class AssistantQBitProducerTest extends BaseAssistantTest
AssistantQBitConfig hostConfig = new AssistantQBitConfig().withBackendName("memory").withOwnerSecurityKeyType("userKey").withRetentionSchedulerName("quartz").withRegisterRoutes(false);
hostConfig.setEnabled(false);
new AssistantQBitProducer().withQBitConfig(hostConfig).produce(instance);
assertThat(com.kingsrook.qbits.assistant.metadata.AssistantInstanceMetaData.of(instance)).as("not advertised when disabled").isNull();
assertThat(AssistantInstanceMetaData.of(instance)).as("not advertised when disabled").isNull();
assertThat(instance.getSecurityKeyType("userKey").getAllAccessKeyName()).isEqualTo("userKeyAll");
assertThat(instance.getProcess(AssistantMetaDataBuilder.RETENTION_PROCESS_NAME).getSchedule().getSchedulerName()).isEqualTo("quartz");
hostConfig.getRuntime().close();
@@ -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();
}
/*******************************************************************************
**
*******************************************************************************/
@@ -0,0 +1,557 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-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.scheduled;
import java.math.BigDecimal;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.Executors;
import java.util.stream.Stream;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.AssistantSecurity;
import com.kingsrook.qbits.assistant.classification.DataClassification;
import com.kingsrook.qbits.assistant.model.AssistantDataAccess;
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
import com.kingsrook.qbits.assistant.model.AssistantPolicy;
import com.kingsrook.qbits.assistant.model.AssistantProfile;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qbits.assistant.model.AssistantScheduledRun;
import com.kingsrook.qbits.assistant.model.AssistantScheduledTask;
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
import com.kingsrook.qbits.assistant.processes.ScheduledTaskProcess;
import com.kingsrook.qbits.assistant.provider.AssistantModelProvider;
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
import com.kingsrook.qbits.assistant.provider.ModelProviderException;
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.uiapi.UiApiCall;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
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.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.modules.authentication.implementations.MockAuthenticationModule;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Scheduled runs use real manageSession and UI API requests on the fixture.
*******************************************************************************/
class ScheduledTasksTest extends BaseAssistantTest
{
private static final Instant NOW = Instant.parse("2026-03-08T06:55:00Z");
private final Map<String, String> secrets = new HashMap<>();
private final List<AssistantScheduledRun> notifications = new ArrayList<>();
private AssistantRuntime runtime;
/*******************************************************************************
** Dedicated environment references; the test resolver contains fake tokens.
*******************************************************************************/
@Override
protected void customizeConfig(AssistantQBitConfig config)
{
config.withAllowedScheduledCredentialEnvNames(Set.of("SCHEDULED_TOKEN")).withSecretResolver(secrets::get)
.withDataClassification("patient", DataClassification.PHI);
}
/*******************************************************************************
** Fixed time for due selection, cron, budgets and persisted timestamps.
*******************************************************************************/
@BeforeEach
void setupSchedule()
{
config.getRuntime().close();
runtime = new AssistantRuntime(qInstance, config, Clock.fixed(NOW, ZoneOffset.UTC));
config.setRuntime(runtime);
qInstance.getAuthentication().setModuleCodeReference(new QCodeReference(TokenMock.class));
secrets.put("SCHEDULED_TOKEN", AssistantTestFixture.EDITOR);
admin();
}
/*******************************************************************************
** RC.5 MOCK maps sessionId but ignores accessToken. This test-only module
** maps login tokens to that same fixture identity, preserving all grants.
*******************************************************************************/
public static class TokenMock extends MockAuthenticationModule
{
/*******************************************************************************
** Login and subsequent UUID resolution use identical fixture sessions.
*******************************************************************************/
@Override
public QSession createSession(QInstance instance, Map<String, String> context) throws QAuthenticationException
{
Map<String, String> mapped = new HashMap<>(context);
if(context.get("accessToken") != null)
{
mapped.put("sessionId", context.get("accessToken"));
}
QSession session = super.createSession(instance, mapped);
AssistantSecurity.applySessionKeys(session, AssistantRuntime.of(instance).getConfig());
return (session);
}
}
/*******************************************************************************
** Bookkeeping starts in the scheduler/admin context supplied by the host.
*******************************************************************************/
private void admin()
{
QContext.init(qInstance, AssistantTestFixture.ownerSession(AssistantTestFixture.ADMIN, config));
}
/*******************************************************************************
** A due daily task, crossing Chicago's spring daylight-saving transition.
*******************************************************************************/
private AssistantScheduledTask task() throws Exception
{
AssistantScheduledTask task = runtime.getStore().insert(new AssistantScheduledTask().withName("Morning report").withEnabled(true)
.withPrompt("Summarize today's posts").withRunAsUserId(AssistantTestFixture.EDITOR).withCredentialEnvName("SCHEDULED_TOKEN")
.withCronExpression("0 0 8 * * ?").withTimezone("America/Chicago").withOutputMode("RECORD").withWriteModeRequested(false));
runtime.getStore().scheduledUpdate(new QRecord().withValue("id", task.getId()).withValue("nextRunAt", NOW.minusSeconds(60)));
return (runtime.getStore().scheduledTask(task.getId()));
}
/*******************************************************************************
** Capture notification delivery without changing the runner.
*******************************************************************************/
private ScheduledTaskRunner scheduledRunner()
{
return (new ScheduledTaskRunner(runtime, new ScheduledLogin(), new ScheduledAlerts()
{
/*******************************************************************************
** Delegate using this instance's configured collaborator.
*******************************************************************************/
@Override
public void raise(AssistantScheduledRun run)
{
notifications.add(run);
}
}));
}
/*******************************************************************************
** One due slot, one owned conversation; next wall-clock occurrence uses DST.
*******************************************************************************/
@Test
void dueRunsOnceAndAdvancesAcrossDst() throws Exception
{
AssistantScheduledTask task = task();
provider.thenText("Today's report");
assertThat(scheduledRunner().runDue()).isEqualTo(1);
assertThat(scheduledRunner().runDue()).isZero();
var runs = runtime.getStore().query(AssistantScheduledRun.TABLE_NAME, new QQueryFilter(), AssistantScheduledRun.class);
assertThat(runs).hasSize(1);
AssistantScheduledRun run = runs.get(0);
assertThat(run.getStatus()).as(run.getError()).isEqualTo("OK");
assertThat(run.getSummary()).isEqualTo("Today's report");
assertThat(run.getStartedAt()).isEqualTo(NOW);
var conversation = runtime.getStore().getConversation(AssistantTestFixture.EDITOR, run.getConversationId());
assertThat(conversation.getUserId()).isEqualTo(AssistantTestFixture.EDITOR);
assertThat(conversation.getChannel()).isEqualTo("assistant/scheduled");
assertThat(conversation.getScheduledTaskId()).isEqualTo(task.getId());
assertThat(runtime.getStore().scheduledTask(task.getId()).getNextRunAt()).isEqualTo(Instant.parse("2026-03-08T13:00:00Z"));
assertThat(QContext.getQSession().getUser().getIdReference()).isEqualTo(AssistantTestFixture.ADMIN);
assertThat(notifications).isEmpty();
}
/*******************************************************************************
** Every tool HTTP call carries both the designated UUID and client channel.
*******************************************************************************/
@Test
void designatedUserDenialAndClientHeaders() throws Exception
{
AssistantScheduledTask task = task();
provider.thenToolUse("query_records", input("table", "analytics_daily_stat", "filter", filter("AND"))).thenText("Access denied");
AssistantScheduledRun run = scheduledRunner().run(task, false);
assertThat(run.getStatus()).as(run.getError()).isEqualTo("OK");
var calls = runtime.getStore().query(AssistantToolCall.TABLE_NAME, new QQueryFilter().withCriteria("turnId", QCriteriaOperator.EQUALS, run.getTurnId()), AssistantToolCall.class);
assertThat(calls).hasSize(1);
assertThat(calls.get(0).getHttpStatus()).isEqualTo(403);
assertThat(calls.get(0).getClientChannel()).isEqualTo("assistant/scheduled/" + task.getId());
assertThat(UiApiTestServer.SCHEDULED_REQUESTS).isNotEmpty().allSatisfy(request ->
{
assertThat(request.get("client")).isEqualTo("assistant/scheduled/" + task.getId());
assertThat(request.get("session")).isEqualTo(AssistantTestFixture.EDITOR);
});
QContext.init(qInstance, AssistantTestFixture.ownerSession("editor-other", config));
assertThat(runtime.getStore().query(AssistantScheduledRun.TABLE_NAME, new QQueryFilter(), AssistantScheduledRun.class)).isEmpty();
assertThat(runtime.getStore().getConversation("editor-other", run.getConversationId())).isNull();
}
/*******************************************************************************
** Credential failures occur before a model call and never expose a token.
*******************************************************************************/
@Test
void credentialFailures() throws Exception
{
AssistantScheduledTask task = task();
secrets.clear();
assertThat(scheduledRunner().run(task, false).getError()).contains("not set");
secrets.put("SCHEDULED_TOKEN", "analyst-other");
task = task();
assertThat(scheduledRunner().run(task, false).getError()).contains("another user");
secrets.put("SCHEDULED_TOKEN", "Deny");
task = task();
assertThat(scheduledRunner().run(task, false).getError()).contains("HTTP");
task = task();
config.setAllowedScheduledCredentialEnvNames(Set.of());
AssistantScheduledRun run = scheduledRunner().run(task, false);
assertThat(run.getStatus()).as(run.getError()).isEqualTo("FAILED");
assertThat(run.getError()).contains("not allowed");
assertThat(provider.getRequests()).isEmpty();
}
/*******************************************************************************
** Independent runner instances compete for the same durable schedule slot.
*******************************************************************************/
@Test
void racingRunnersClaimOnlyOnce() throws Exception
{
AssistantScheduledTask task = task();
provider.thenText("Once");
CyclicBarrier barrier = new CyclicBarrier(2);
try(var executor = Executors.newVirtualThreadPerTaskExecutor())
{
var work = (Callable<AssistantScheduledRun>) () ->
{
admin();
barrier.await();
return (scheduledRunner().run(task, false));
};
var one = executor.submit(work);
var two = executor.submit(work);
assertThat(Stream.of(one.get(), two.get()).filter(Objects::nonNull)).hasSize(1);
}
assertThat(provider.getRequests()).hasSize(1);
}
/*******************************************************************************
** Per-run caps stop before further dispatch, including a zero-cost allowance.
*******************************************************************************/
@Test
void costCapStopsRun() throws Exception
{
AssistantScheduledTask task = task();
runtime.getStore().updateFields(AssistantScheduledTask.TABLE_NAME, new QRecord().withValue("id", task.getId()).withValue("maxCostUsdPerRun", BigDecimal.ZERO));
AssistantScheduledRun run = scheduledRunner().run(task, false);
assertThat(run.getStatus()).as(run.getError()).isEqualTo("BLOCKED");
assertThat(run.getError()).contains("BUDGET");
assertThat(provider.getRequests()).isEmpty();
task = task();
runtime.getStore().updateFields(AssistantScheduledTask.TABLE_NAME, new QRecord().withValue("id", task.getId()).withValue("maxCostUsdPerRun", new BigDecimal("0.000001")));
provider.thenToolUse("count_records", input("table", "page", "filter", filter("AND"))).thenText("Must not run");
assertThat(scheduledRunner().run(task, false).getStatus()).isEqualTo("BLOCKED");
assertThat(provider.getRequests()).hasSize(1);
}
/*******************************************************************************
** Approved writes are pending proposals even when normal auto-confirm is on.
*******************************************************************************/
@Test
void approvalAndPromptInvalidation() throws Exception
{
AssistantScheduledTask task = task();
secrets.put("SCHEDULED_TOKEN", AssistantTestFixture.ADMIN);
runtime.getStore().updateFields(AssistantScheduledTask.TABLE_NAME, new QRecord().withValue("id", task.getId()).withValue("runAsUserId", AssistantTestFixture.ADMIN).withValue("writeModeRequested", true));
runtime.getStore().insert(new AssistantDataAccess().withObjectType("TABLE").withObjectName("page").withMode("READ_WRITE"));
runtime.getDataAccessService().invalidate();
provider.thenText("Read only");
assertThat(scheduledRunner().run(task, false).getStatus()).isEqualTo("OK");
assertThat(provider.getRequests().get(0).getTools()).noneMatch(tool -> tool.getName().equals("create_record"));
task = runtime.getStore().scheduledTask(task.getId());
action(task, "Approve");
assertThat(ScheduledTasks.writesApproved(runtime.getStore().scheduledTask(task.getId()))).isTrue();
provider.thenToolUse("create_record", input("table", "page", "values", Map.of("title", "Scheduled proposal"))).thenText("Please confirm");
AssistantScheduledRun run = scheduledRunner().run(task, true);
assertThat(run.getStatus()).as(run.getError()).isEqualTo("OK");
assertThat(run.getSummary()).contains("Waiting for your confirmation");
assertThat(provider.getRequests().get(1).getTools()).anyMatch(tool -> tool.getName().equals("create_record"));
assertThat(runtime.getStore().query(AssistantPendingAction.TABLE_NAME, new QQueryFilter(), AssistantPendingAction.class)).hasSize(1);
runtime.getStore().updateFields(AssistantScheduledTask.TABLE_NAME, new QRecord().withValue("id", task.getId()).withValue("prompt", "Changed prompt"));
assertThat(runtime.getStore().scheduledTask(task.getId()).getWriteModeApprovedAt()).isNull();
assertThat(runtime.getStore().scheduledTask(task.getId()).getWriteModeApprovalHash()).isNull();
action(runtime.getStore().scheduledTask(task.getId()), "Approve");
action(runtime.getStore().scheduledTask(task.getId()), "Revoke");
assertThat(runtime.getStore().scheduledTask(task.getId()).getWriteModeApprovedAt()).isNull();
QContext.init(qInstance, AssistantTestFixture.ownerSession(AssistantTestFixture.EDITOR, config));
AssistantScheduledTask selected = task;
assertThatThrownBy(() -> action(selected, "Approve")).hasMessageContaining("assistant.admin");
}
/*******************************************************************************
** Exercise the same Review/Result steps the record action uses.
*******************************************************************************/
private void action(AssistantScheduledTask task, String action) throws Exception
{
RunBackendStepInput input = new RunBackendStepInput();
input.setRecords(List.of(task.toQRecord()));
input.addValue("scheduledAction", action);
RunBackendStepOutput reviewOutput = new RunBackendStepOutput();
ScheduledTaskProcess.Review review = new ScheduledTaskProcess.Review();
review.runOnePage(input, reviewOutput);
assertThat(review.getProcessSummary(reviewOutput, false)).isNotEmpty();
input.setRecords(reviewOutput.getRecords());
RunBackendStepOutput resultOutput = new RunBackendStepOutput();
ScheduledTaskProcess.Result result = new ScheduledTaskProcess.Result();
result.runOnePage(input, resultOutput);
assertThat(result.getProcessSummary(resultOutput, true)).isNotEmpty();
}
/*******************************************************************************
** Output modes retain minimal run history and route notifications by owner.
*******************************************************************************/
@Test
void outputModesAndModelFailure() throws Exception
{
for(String mode : List.of("RECORD", "NOTIFICATION", "BOTH"))
{
AssistantScheduledTask task = task();
runtime.getStore().updateFields(AssistantScheduledTask.TABLE_NAME, new QRecord().withValue("id", task.getId()).withValue("outputMode", mode));
provider.thenText("Summary");
assertThat(scheduledRunner().run(task, false).getStatus()).isEqualTo("OK");
}
assertThat(notifications).hasSize(2).allSatisfy(run -> assertThat(run.getUserId()).isEqualTo(AssistantTestFixture.EDITOR));
provider.thenError(ModelProviderException.Kind.OTHER, "Private provider detail");
AssistantScheduledRun failed = scheduledRunner().run(task(), false);
assertThat(failed.getStatus()).isEqualTo("FAILED");
assertThat(failed.getError()).doesNotContain("Private provider detail");
new ScheduledAlerts().raise(failed);
}
/*******************************************************************************
** Validation rejects malformed schedules and secret-looking references.
*******************************************************************************/
@Test
void validationAndManualProcess() throws Exception
{
AssistantScheduledTask task = task();
assertThatThrownBy(() -> ScheduledTasks.next(task.withTimezone("No/SuchZone"), NOW)).hasMessageContaining("timezone");
assertThatThrownBy(() -> ScheduledTasks.next(task.withTimezone("UTC").withCronExpression("invalid"), NOW)).hasMessageContaining("cron");
assertThatThrownBy(() -> ScheduledTasks.checkReference(config, "token-SECRET")).hasMessageNotContaining("SECRET");
assertThatThrownBy(() -> runtime.getStore().updateFields(AssistantScheduledTask.TABLE_NAME, new QRecord().withValue("id", task.getId()).withValue("prompt", " "))).hasMessageContaining("Prompt");
AssistantScheduledTask saved = runtime.getStore().scheduledTask(task.getId());
assertThat(saved.getCredentialStatus()).contains("Set").doesNotContain(AssistantTestFixture.EDITOR);
provider.thenText("Manual");
action(saved, "RunNow");
config.setScheduledTasksSchedulerName("hostScheduler");
assertThat(ScheduledTaskProcess.processes(config).get(0).getSchedule().getRepeatSeconds()).isEqualTo(60);
new ScheduledTaskProcess.Runner().run(new RunBackendStepInput(), new RunBackendStepOutput());
}
/*******************************************************************************
** The scheduled profile supplies persona text and normal PHI refusal applies.
*******************************************************************************/
@Test
void selectedProfileAndPhiBlock() throws Exception
{
AssistantProfile profile = runtime.getStore().insert(new AssistantProfile().withName("Scheduled").withDisplayName("Daily helper").withEnabled(true)
.withPersonality("Use a concise daily briefing style."));
AssistantScheduledTask task = task();
runtime.getStore().updateFields(AssistantScheduledTask.TABLE_NAME, new QRecord().withValue("id", task.getId()).withValue("profileId", profile.getId()));
provider.thenToolUse("get_record", input("table", "patient", "id", 1)).thenText("Must not reach a noneligible provider");
AssistantScheduledRun run = scheduledRunner().run(task, false);
assertThat(run.getStatus()).as(run.getError()).isEqualTo("BLOCKED");
assertThat(provider.getRequests()).hasSize(1);
assertThat(provider.getRequests().get(0).getSystem().get(1).getText()).contains("Daily helper");
}
/*******************************************************************************
** HIPAA retains the answer only in encrypted conversation storage.
*******************************************************************************/
@Test
void hipaaSummaryAndEncryption() throws Exception
{
secrets.put("CONVERSATION_KEY", Base64.getEncoder().encodeToString(new byte[32]));
config.withHipaaMode(true).withConversationEncryptionKeyEnvName("CONVERSATION_KEY");
config.withProviderType(new AssistantProviderType()
{
/*******************************************************************************
** Delegate using this instance's configured collaborator.
*******************************************************************************/
@Override
public String getType()
{
return ("scheduled-test");
}
/*******************************************************************************
** Delegate using this instance's configured collaborator.
*******************************************************************************/
@Override
public String getLabel()
{
return ("Scheduled test");
}
/*******************************************************************************
** Delegate using this instance's configured collaborator.
*******************************************************************************/
@Override
public List<String> getModels()
{
return (List.of("test-model"));
}
/*******************************************************************************
** Delegate using this instance's configured collaborator.
*******************************************************************************/
@Override
public String getDefaultApiKeyEnvName()
{
return (null);
}
/*******************************************************************************
** Delegate using this instance's configured collaborator.
*******************************************************************************/
@Override
public String getDefaultBaseUrl()
{
return ("http://localhost");
}
/*******************************************************************************
** Delegate using this instance's configured collaborator.
*******************************************************************************/
@Override
public AssistantModelProvider create(String key, String base)
{
return (provider);
}
});
runtime.close();
runtime = new AssistantRuntime(qInstance, config, Clock.fixed(NOW, ZoneOffset.UTC));
config.setRuntime(runtime);
AssistantScheduledRun blockedRun = scheduledRunner().run(task(), false);
assertThat(blockedRun.getStatus()).isEqualTo("BLOCKED");
assertThat(blockedRun.getSummary()).isEqualTo("see conversation");
assertThat(provider.getRequests()).isEmpty();
runtime.getStore().insert(new AssistantProvider().withName("Eligible local")
.withProviderType("scheduled-test").withModel("test-model").withEnabled(true).withIsDefault(true).withLocation("LOCAL").withHipaaEligible(true));
provider.thenText("PHI answer sentinel");
AssistantScheduledRun run = scheduledRunner().run(task(), false);
assertThat(run.getStatus()).as(run.getError()).isEqualTo("OK");
assertThat(run.getSummary()).isEqualTo("see conversation");
var records = new QueryAction().execute(
new QueryInput("assistant_message")).getRecords();
assertThat(records).isNotEmpty().allSatisfy(record -> assertThat(record.getValueString("partsJson")).startsWith("enc:v1:").doesNotContain("sentinel"));
}
/*******************************************************************************
** Instance/user budgets and the kill switch also stop scheduled starts.
*******************************************************************************/
@Test
void normalPolicyStillApplies() throws Exception
{
AssistantScheduledTask task = task();
runtime.getStore().insert(new AssistantPolicy().withName("Stop").withKillSwitch(true));
assertThat(scheduledRunner().run(task, false).getStatus()).isEqualTo("BLOCKED");
assertThat(provider.getRequests()).isEmpty();
runtime.getStore().delete("assistant_policy", new QQueryFilter());
config.getBudgets().setUserDailyCostMicrosHard(0L);
AssistantScheduledRun budgetRun = scheduledRunner().run(task(), false);
assertThat(budgetRun.getStatus()).isEqualTo("BLOCKED");
assertThat(budgetRun.getError()).contains("BUDGET");
assertThat(provider.getRequests()).isEmpty();
}
/*******************************************************************************
** Application record locks stay effective with a designated-user login.
*******************************************************************************/
@Test
void applicationRowsRemainOwnerLocked() throws Exception
{
qInstance.getTable("blog_post").withRecordSecurityLock(new RecordSecurityLock()
.withFieldName("slug").withSecurityKeyType(config.getOwnerSecurityKeyType()));
assertThat(runtime.getUiApiGateway().invoke(new UiApiCall("PATCH", "/table/{tableName}/{primaryKey}")
.withPathParam("tableName", "blog_post").withPathParam("primaryKey", "1").withSessionUUID(AssistantTestFixture.ADMIN)
.withJsonBody(Map.of("slug", AssistantTestFixture.EDITOR))).status()).isEqualTo(200);
provider.thenToolUse("query_records", input("table", "blog_post", "filter", filter("AND"))).thenText("Your one post");
AssistantScheduledRun run = scheduledRunner().run(task(), false);
assertThat(run.getStatus()).as(run.getError()).isEqualTo("OK");
var calls = runtime.getStore().query(AssistantToolCall.TABLE_NAME, new QQueryFilter().withCriteria("turnId", QCriteriaOperator.EQUALS, run.getTurnId()), AssistantToolCall.class);
assertThat(calls).singleElement().satisfies(call -> assertThat(call.getRowCount()).isEqualTo(1));
}
}
@@ -19,6 +19,9 @@ package com.kingsrook.qbits.assistant.testing;
import java.io.IOException;
import java.net.ServerSocket;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication;
@@ -34,6 +37,8 @@ import com.kingsrook.qqq.middleware.javalin.QApplicationJavalinServer;
*******************************************************************************/
public final class UiApiTestServer
{
public static final List<Map<String, String>> SCHEDULED_REQUESTS = new CopyOnWriteArrayList<>();
private static QApplicationJavalinServer current;
private static QInstance currentInstance;
private static int currentPort;
@@ -89,6 +94,7 @@ public final class UiApiTestServer
if(current == null || currentInstance != qInstance)
{
stop();
SCHEDULED_REQUESTS.clear();
try
{
currentPort = freePort();
@@ -96,7 +102,15 @@ public final class UiApiTestServer
.withPort(currentPort)
.withServeFrontendNext(false)
.withServeFrontendMaterialDashboard(false)
.withServeLegacyUnversionedMiddlewareAPI(false);
.withServeLegacyUnversionedMiddlewareAPI(false)
.withJavalinConfigCustomizer(app -> app.routes.before(context ->
{
String client = context.header("X-QQQ-Client");
if(client != null && !context.path().endsWith("manageSession"))
{
SCHEDULED_REQUESTS.add(Map.of("client", client, "session", String.valueOf(context.cookie("sessionUUID")), "path", context.path()));
}
}));
current.start();
currentInstance = qInstance;
}
@@ -0,0 +1,350 @@
/*
* 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.bulkload;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.access.DataAccessMode;
import com.kingsrook.qbits.assistant.api.AssistantApi;
import com.kingsrook.qbits.assistant.engine.PolicyService;
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.provider.ModelPart;
import com.kingsrook.qbits.assistant.provider.ModelResponse;
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qbits.assistant.testing.TurnRunner;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qbits.assistant.uiapi.UiApiCall;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolOutcome;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.savedbulkloadprofiles.SavedBulkLoadProfileMetaDataProvider;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import org.json.JSONObject;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static com.kingsrook.qbits.assistant.testing.EvalSupport.firstResult;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** The bulk-load tools: definitions, the UI API calls they make (as the
** user, with the route header from the provider, never the model), pending
** actions for writes, the confirmed save and run, and log hygiene. Plus the
** golden eval: a messy CSV -> profile -> dry run with an error -> fixed ->
** saved -> loaded.
*******************************************************************************/
class BulkLoadToolsTest extends BaseAssistantTest
{
private final FakeBulkLoadServer server = new FakeBulkLoadServer(true);
private final List<BulkLoadRunner.Request> runs = new ArrayList<>();
/*******************************************************************************
** The fake server as the gateway, and a recording runner.
*******************************************************************************/
@Override
protected void customizeConfig(AssistantQBitConfig config)
{
config.withUiApiGateway(server).withBulkLoadRunner(request ->
{
runs.add(request);
return (new BulkLoadRunner.RunHandle("AWAITING_CONFIRMATION", 42L, null, "Job 42 is waiting for your confirmation on its job record.", "POST /processes/{processName}/jobs", 201));
});
}
/*******************************************************************************
** The saved-profile tables (so the tools are offered), and write access.
*******************************************************************************/
@BeforeEach
void setUp() throws QException
{
new SavedBulkLoadProfileMetaDataProvider().defineAll(qInstance, AssistantTestFixture.BACKEND, null);
setAccess(BulkLoadTools.PROFILE_TABLE, DataAccessMode.READ_WRITE);
setAccess("blog_post", DataAccessMode.READ_WRITE);
}
/*******************************************************************************
**
*******************************************************************************/
@AfterEach
void clear()
{
QContext.clear();
}
/*******************************************************************************
**
*******************************************************************************/
private void setAccess(String tableName, DataAccessMode mode) throws QException
{
QContext.init(qInstance, AssistantTestFixture.sessionFor(AssistantTestFixture.ADMIN));
try
{
config.getRuntime().getStore().insert(new AssistantDataAccess().withObjectType(AssistantDataAccess.TYPE_TABLE).withObjectName(tableName).withMode(mode.name()));
config.getRuntime().getDataAccessService().invalidate();
}
finally
{
QContext.clear();
}
}
/*******************************************************************************
** One tool call as the turn engine makes it.
*******************************************************************************/
private UiApiToolbox.Outcome tool(String toolName, Map<String, Object> in, boolean hipaa)
{
QContext.init(qInstance, AssistantTestFixture.ownerSession(AssistantTestFixture.ADMIN, config));
try
{
return (config.getRuntime().getBulkLoadTools().execute(new UiApiToolbox.Context("session-1", null, server, PolicyService.ToolPolicy.defaults(),
write -> config.getRuntime().getStore().insert(new AssistantPendingAction().withUserId(AssistantTestFixture.ADMIN).withToolName(write.toolName())
.withTableName(write.tableName()).withUiApiOperation(write.operation()).withParamsJson(JsonUtils.toJson(write.values()))
.withSummary(write.summary()).withStatus(AssistantPendingAction.STATUS_PENDING).withExpiresAt(java.time.Instant.now().plusSeconds(600))).getId()),
"call-1", toolName, in, hipaa));
}
finally
{
QContext.clear();
}
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testDefinitionsAreOffered()
{
List<String> names = config.getRuntime().getContextBuilder().tools().stream().map(d -> d.getName()).toList();
assertThat(names).contains(BulkLoadTools.LIST, BulkLoadTools.DESCRIBE, BulkLoadTools.ATTACH, BulkLoadTools.SUGGEST, BulkLoadTools.DRY_RUN);
assertThat(config.getRuntime().getBulkLoadTools().definitions(PolicyService.ToolPolicy.defaults(), true))
.extracting(d -> d.getName()).containsExactlyInAnyOrderElementsOf(BulkLoadTools.NAMES);
assertThat(config.getRuntime().getBulkLoadTools().definitions(PolicyService.ToolPolicy.defaults(), false))
.extracting(d -> d.getName()).doesNotContain(BulkLoadTools.SAVE, BulkLoadTools.RUN);
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testReadToolsCallTheUiApiWithTheProvidersRoute()
{
UiApiToolbox.Outcome list = tool(BulkLoadTools.LIST, Map.of("tableName", "blog_post"), false);
assertThat(list.getResult().getOutcome()).isEqualTo(AiToolOutcome.OK);
assertThat(list.getResult().getModelContent()).contains("Posts from the CMS export");
assertThat(list.getOperation()).isEqualTo("POST /table/{tableName}/query");
UiApiToolbox.Outcome describe = tool(BulkLoadTools.DESCRIBE, Map.of("id", 3), false);
assertThat(describe.getResult().getModelContent()).contains("fieldList");
////////////////////////////////////////////////////////////////////////
// the route header comes from the provider flag, whatever the input //
////////////////////////////////////////////////////////////////////////
tool(BulkLoadTools.ATTACH, Map.of("tableName", "blog_post", "uploadRef", "up_1", "X-QQQ-AI-Route", "hipaa", "route", "hipaa"), false);
tool(BulkLoadTools.ATTACH, Map.of("tableName", "blog_post", "uploadRef", "up_1"), true);
List<UiApiCall> attaches = server.calls("GET /bulkLoad/uploads/{uploadRef}/profile");
assertThat(attaches.get(0).getHeaders()).containsEntry(BulkLoadTools.ROUTE_HEADER, "standard");
assertThat(attaches.get(1).getHeaders()).containsEntry(BulkLoadTools.ROUTE_HEADER, "hipaa");
assertThat(attaches.get(0).getSessionUUID()).isEqualTo("session-1");
UiApiToolbox.Outcome missing = tool(BulkLoadTools.ATTACH, Map.of("tableName", "blog_post", "uploadRef", "nope"), false);
assertThat(missing.getResult().getOutcome()).isEqualTo(AiToolOutcome.NOT_FOUND);
UiApiToolbox.Outcome invalid = tool(BulkLoadTools.DRY_RUN, Map.of("tableName", "blog_post", "uploadRef", "up_1"), false);
assertThat(invalid.getResult().getOutcome()).isEqualTo(AiToolOutcome.INVALID_INPUT);
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testDataAccessAndAnOlderServer() throws QException
{
setAccess("page", DataAccessMode.NONE);
UiApiToolbox.Outcome denied = tool(BulkLoadTools.SUGGEST, Map.of("tableName", "page", "uploadRef", "up_1"), false);
assertThat(denied.getResult().getOutcome()).isEqualTo(AiToolOutcome.DENIED);
assertThat(server.getCalls()).isEmpty();
setAccess("media", DataAccessMode.READ);
UiApiToolbox.Outcome readOnly = tool(BulkLoadTools.RUN, Map.of("tableName", "media", "uploadRef", "up_1", "profileId", 7), false);
assertThat(readOnly.getResult().getOutcome()).isEqualTo(AiToolOutcome.DENIED);
BulkLoadTools older = new BulkLoadTools(qInstance, config.getRuntime().getDataAccessService(), null);
FakeBulkLoadServer oldApi = new FakeBulkLoadServer(false);
QContext.init(qInstance, AssistantTestFixture.ownerSession(AssistantTestFixture.ADMIN, config));
UiApiToolbox.Outcome outcome = older.execute(new UiApiToolbox.Context("s", null, oldApi, null, null), "c", BulkLoadTools.SUGGEST,
Map.of("tableName", "blog_post", "uploadRef", "up_1"), false);
assertThat(outcome.getResult().getOutcome()).isEqualTo(AiToolOutcome.ERROR);
assertThat(outcome.getResult().getModelContent()).contains("does not offer");
assertThat(oldApi.getCalls()).isEmpty();
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testJobsLaunchRunnerSaysWhenTheLaunchIsMissing() throws QException
{
BulkLoadRunner.RunHandle handle = new JobsLaunchBulkLoadRunner().start(new BulkLoadRunner.Request("s", null, server, "blog_post", "up_1", 7));
assertThat(handle.status()).isEqualTo("NOT_AVAILABLE");
assertThat(handle.summary()).contains("durable jobs launch");
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testForLogDropsSamplesAndValues()
{
String logged = BulkLoadTools.forLog("<qqq_data source=\"x\">{\"columns\":[{\"header\":\"A\",\"sampleValues\":[\"" + FakeBulkLoadServer.SAMPLE_SENTINEL
+ "\"]}],\"summaryLines\":[{\"bulletsOfText\":[\"Row 3 [x]\"]}],\"profile\":{\"fieldList\":[{\"valueMappings\":{\"secret\":1}}]}}</qqq_data>");
assertThat(logged).doesNotContain(FakeBulkLoadServer.SAMPLE_SENTINEL).doesNotContain("Row 3 [x]").doesNotContain("secret").contains("header");
assertThat(BulkLoadTools.forLog(null)).isNull();
assertThat(BulkLoadTools.forLog("plain")).isEqualTo("plain");
}
/*******************************************************************************
** Golden eval (owner request 2026-10-13): upload a CSV with messy headers;
** the assistant proposes a profile, validates it, fixes one error, saves
** it (shared with a group, after the user confirms) and runs the load
** (after the user confirms). The scripted model stands in for the LLM;
** its fix reads the dry run's error from the tool result.
*******************************************************************************/
@Test
@SuppressWarnings("unchecked")
void testGoldenMessyCsvToSavedProfileAndLoad() throws Exception
{
Map<String, Object> attachments = Map.of("attachments", List.of(Map.of("uploadRef", "up_1", "fileName", "posts.csv")));
AtomicReference<String> suggested = new AtomicReference<>();
provider.thenToolUse(BulkLoadTools.ATTACH, Map.of("tableName", "blog_post", "uploadRef", "up_1"))
.thenToolUse(BulkLoadTools.SUGGEST, Map.of("tableName", "blog_post", "uploadRef", "up_1"))
.thenRespond(request ->
{
Map<String, Object> suggestion = firstResult(request);
suggested.set(JsonUtils.toJson(suggestion.get("profile")));
return (toolUse("dry1", BulkLoadTools.DRY_RUN, Map.of("tableName", "blog_post", "uploadRef", "up_1", "profile", suggestion.get("profile"))));
})
.thenRespond(request ->
{
////////////////////////////////////////////////////////////////
// read the error, fix it: default the missing required field //
////////////////////////////////////////////////////////////////
Map<String, Object> dryRun = firstResult(request);
assertThat(dryRun.get("errorCount")).isEqualTo(2);
String missingField = String.valueOf(((Map<?, ?>) ((List<?>) dryRun.get("errors")).get(0)).get("fieldName"));
Map<String, Object> profile = new JSONObject(suggested.get()).toMap();
List<Map<String, Object>> fields = new ArrayList<>((List<Map<String, Object>>) profile.get("fieldList"));
fields.add(new LinkedHashMap<>(Map.of("fieldName", missingField, "defaultValue", "DRAFT")));
profile.put("fieldList", fields);
return (toolUse("dry2", BulkLoadTools.DRY_RUN, Map.of("tableName", "blog_post", "uploadRef", "up_1", "profile", profile, "mode", "FULL")));
})
.thenRespond(request ->
{
Map<String, Object> profile = new JSONObject(suggested.get()).toMap();
((List<Map<String, Object>>) profile.get("fieldList")).add(new LinkedHashMap<>(Map.of("fieldName", "status", "defaultValue", "DRAFT")));
return (toolUse("save", BulkLoadTools.SAVE, Map.of("tableName", "blog_post", "label", "CMS post export", "profile", profile,
"shares", List.of(Map.of("audienceType", "group", "audienceId", "kof22-admins")))));
})
.thenText("The file maps cleanly once Status defaults to DRAFT (2 rows, no errors). Confirm to save the profile \"CMS post export\".");
TurnRunner.Outcome first = runner.run(AssistantTestFixture.ADMIN, null, "Load the attached posts.csv into blog posts and save the mapping for next time.", attachments);
List<String> tools = first.toolCalls().stream().map(AssistantToolCall::getToolName).toList();
assertThat(tools).containsExactly(BulkLoadTools.ATTACH, BulkLoadTools.SUGGEST, BulkLoadTools.DRY_RUN, BulkLoadTools.DRY_RUN, BulkLoadTools.SAVE);
List<UiApiCall> validates = server.calls("POST /bulkLoad/{tableName}/validate");
assertThat(validates).hasSize(2);
assertThat(validates).allSatisfy(c -> assertThat(c.getHeaders()).containsEntry(BulkLoadTools.ROUTE_HEADER, "standard"));
assertThat(first.toolCalls().get(2).getResultSummary()).contains("2 with errors");
assertThat(first.toolCalls().get(3).getResultSummary()).contains("0 with errors");
assertThat(first.toolCalls()).allSatisfy(c -> assertThat(String.valueOf(c.getResultJson()) + c.getInputJson()).doesNotContain(FakeBulkLoadServer.SAMPLE_SENTINEL));
assertThat(server.calls("POST /processes/{processName}/init")).as("nothing is saved before the user confirms").isEmpty();
Integer saveActionId = first.toolCalls().get(4).getPendingActionId();
assertThat(saveActionId).isNotNull();
QContext.init(qInstance, AssistantTestFixture.sessionFor(AssistantTestFixture.ADMIN));
Map<String, Object> saved = new AssistantApi(config.getRuntime(), QContext.getQSession()).confirmAction(saveActionId);
QContext.clear();
assertThat(saved.get("status")).isEqualTo(AssistantPendingAction.STATUS_EXECUTED);
List<UiApiCall> processCalls = server.calls("POST /processes/{processName}/init");
assertThat(processCalls).extracting(c -> c.getPathParams().get("processName")).containsExactly("storeSavedBulkLoadProfile", "insertSharedRecord");
Map<String, Object> stored = FakeBulkLoadServer.formValues(processCalls.get(0));
assertThat(stored).containsEntry("label", "CMS post export").containsEntry("tableName", "blog_post");
assertThat(String.valueOf(stored.get("mappingJson"))).contains("DRAFT");
assertThat(FakeBulkLoadServer.formValues(processCalls.get(1))).containsEntry("audienceType", "group").containsEntry("audienceId", "kof22-admins")
.containsEntry("recordId", 7).containsEntry("scopeId", "READ_ONLY");
////////////////////////////////////////////////////////////////
// second turn: run the load with the saved profile; confirm //
////////////////////////////////////////////////////////////////
provider.thenToolUse(BulkLoadTools.RUN, Map.of("tableName", "blog_post", "uploadRef", "up_1", "profileId", 7))
.thenText("The load is queued as job 42; start it from its job record when you are ready.");
TurnRunner.Outcome second = runner.run(AssistantTestFixture.ADMIN, first.conversationId(), "Now load it.", attachments);
AssistantToolCall runCall = second.toolCalls().get(0);
assertThat(runCall.getToolName()).isEqualTo(BulkLoadTools.RUN);
assertThat(runs).as("nothing runs before the user confirms").isEmpty();
QContext.init(qInstance, AssistantTestFixture.sessionFor(AssistantTestFixture.ADMIN));
Map<String, Object> ran = new AssistantApi(config.getRuntime(), QContext.getQSession()).confirmAction(runCall.getPendingActionId());
QContext.clear();
assertThat(ran.get("outcome")).isEqualTo("OK");
assertThat(runs).hasSize(1);
assertThat(runs.get(0).savedBulkLoadProfileId()).isEqualTo(7);
assertThat(runs.get(0).uploadRef()).isEqualTo("up_1");
assertThat(runs.get(0).tableName()).isEqualTo("blog_post");
}
/*******************************************************************************
**
*******************************************************************************/
private static ModelResponse toolUse(String id, String name, Map<String, Object> input)
{
return (new ModelResponse().withParts(List.of(ModelPart.toolUse("toolu_" + id, name, input))).withStopReason(ModelResponse.STOP_TOOL_USE));
}
}
@@ -0,0 +1,176 @@
/*
* 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.bulkload;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.assistant.uiapi.UiApiCall;
import com.kingsrook.qbits.assistant.uiapi.UiApiGateway;
import com.kingsrook.qbits.assistant.uiapi.UiApiResult;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** A stand-in for the server's bulk-load UI API operations (core's
** /bulkLoad/... and the profile processes), recording every call. Its
** validate answers like core's dry run: the blog_post status field is
** required, so a profile that neither maps nor defaults it has errors.
*******************************************************************************/
public class FakeBulkLoadServer implements UiApiGateway
{
public static final String SAMPLE_SENTINEL = "SAMPLE_SENTINEL_5b1e";
private static final Set<String> OPERATIONS = Set.of(
"GET /bulkLoad/uploads/{uploadRef}/profile", "POST /bulkLoad/{tableName}/suggestMapping", "POST /bulkLoad/{tableName}/validate",
"POST /table/{tableName}/query", "GET /table/{tableName}/{primaryKey}", "POST /processes/{processName}/init");
private final List<UiApiCall> calls = new ArrayList<>();
private final boolean offersBulkLoad;
/*******************************************************************************
**
*******************************************************************************/
public FakeBulkLoadServer(boolean offersBulkLoad)
{
this.offersBulkLoad = offersBulkLoad;
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public boolean isInvocable(String method, String pathTemplate)
{
String operation = method + " " + pathTemplate;
return (OPERATIONS.contains(operation) && (offersBulkLoad || !pathTemplate.startsWith("/bulkLoad")));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public synchronized UiApiResult invoke(UiApiCall call)
{
calls.add(call);
String operation = call.operation();
return (switch(operation)
{
case "GET /bulkLoad/uploads/{uploadRef}/profile" -> "up_1".equals(call.getPathParams().get("uploadRef"))
? ok(new JSONObject().put("uploadRef", "up_1").put("fileName", "posts.csv").put("hasHeaderRow", true).put("rowsProfiled", 2)
.put("columns", new JSONArray()
.put(new JSONObject().put("columnIndex", 0).put("header", "Post Title").put("mappedFieldName", "title").put("samplesAllowed", true)
.put("sampleValues", new JSONArray().put(SAMPLE_SENTINEL)))
.put(new JSONObject().put("columnIndex", 1).put("header", "Teaser").put("samplesAllowed", false))))
: new UiApiResult(404, "{\"error\":\"Upload not found.\"}");
case "POST /bulkLoad/{tableName}/suggestMapping" -> ok(new JSONObject()
.put("profile", new JSONObject().put("version", "v1").put("layout", "FLAT").put("hasHeaderRow", true)
.put("fieldList", new JSONArray().put(new JSONObject().put("fieldName", "title").put("columnIndex", 0).put("headerName", "Post Title"))))
.put("unmappedColumns", new JSONArray().put(new JSONObject().put("columnIndex", 1).put("header", "Teaser")))
.put("unmappedRequiredFields", new JSONArray().put(new JSONObject().put("fieldName", "status").put("label", "Status"))));
case "POST /bulkLoad/{tableName}/validate" -> validate(call);
case "POST /table/{tableName}/query" -> ok(new JSONObject().put("records", new JSONArray()
.put(new JSONObject().put("values", new JSONObject().put("id", 3).put("label", "Posts from the CMS export").put("userId", "admin-1")
.put("mappingJson", "{\"version\":\"v1\",\"fieldList\":[{\"fieldName\":\"title\",\"columnIndex\":0}]}")))));
case "GET /table/{tableName}/{primaryKey}" -> ok(new JSONObject().put("record", new JSONObject().put("values", new JSONObject().put("id", 3)
.put("label", "Posts from the CMS export").put("tableName", "blog_post").put("userId", "admin-1")
.put("mappingJson", "{\"version\":\"v1\",\"fieldList\":[{\"fieldName\":\"title\",\"columnIndex\":0}]}"))));
case "POST /processes/{processName}/init" -> "storeSavedBulkLoadProfile".equals(call.getPathParams().get("processName"))
? ok(new JSONObject().put("type", "COMPLETE").put("values", new JSONObject().put("savedBulkLoadProfileList",
new JSONArray().put(new JSONObject().put("values", new JSONObject().put("id", 7).put("label", "x"))))))
: ok(new JSONObject().put("type", "COMPLETE").put("values", new JSONObject()));
default -> new UiApiResult(404, "{\"error\":\"no such operation\"}");
});
}
/*******************************************************************************
** Errors unless status is mapped or defaulted.
*******************************************************************************/
private UiApiResult validate(UiApiCall call)
{
JSONObject body = new JSONObject(JsonUtils.toJson(call.getJsonBody()));
JSONArray fieldList = body.getJSONObject("profile").getJSONArray("fieldList");
boolean hasStatus = false;
for(int i = 0; i < fieldList.length(); i++)
{
JSONObject field = fieldList.getJSONObject(i);
if("status".equals(field.optString("fieldName")) && (field.has("columnIndex") || !field.isNull("defaultValue") && field.has("defaultValue")))
{
hasStatus = true;
}
}
JSONObject result = new JSONObject().put("mode", body.optString("mode", "SAMPLE")).put("processName", "blog_post.bulkInsert").put("recordCount", 2);
if(hasStatus)
{
return (ok(result.put("okCount", 2).put("errorCount", 0).put("errors", new JSONArray())));
}
return (ok(result.put("okCount", 0).put("errorCount", 2).put("errors", new JSONArray()
.put(new JSONObject().put("rowNumbers", "Rows 2-3").put("fieldName", "status").put("errorCode", "REQUIRED").put("message", "Missing value in required field: Status")))));
}
/*******************************************************************************
**
*******************************************************************************/
private static UiApiResult ok(JSONObject json)
{
return (new UiApiResult(200, json.toString()));
}
/*******************************************************************************
** The calls, in order.
*******************************************************************************/
public synchronized List<UiApiCall> getCalls()
{
return (new ArrayList<>(calls));
}
/*******************************************************************************
** Calls of one operation.
*******************************************************************************/
public synchronized List<UiApiCall> calls(String operation)
{
return (calls.stream().filter(c -> c.operation().equals(operation)).toList());
}
/*******************************************************************************
** A form value of a process call, as JSON.
*******************************************************************************/
public static Map<String, Object> formValues(UiApiCall call)
{
return (new JSONObject(call.getFormParams().get("values")).toMap());
}
}