Compare commits

...
Author SHA1 Message Date
james.maesandClaude Opus 5.5 9c3af04a91 fix(defaults): empty rollbacks so a rollback never deletes host data
A seeded row cannot be told apart from a host's own row with the same name
('default' profile, policy, starter skill names), and Liquibase also rolls
back changesets it marked ran, so the name-based rollback lines could delete
a host's data. Every seed changeset now rolls back to nothing; the test runs
a real Liquibase rollback over host-owned rows and checks they survive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:08:13 -05:00
james.maes 6d6b4696a5 Merge pull request #50 from QRun-IO/fix/assistant-defaults-guards
fix(defaults): portable Liquibase preconditions; seeds never change an existing host
2026-10-14 16:58:37 -05:00
james.maesandClaude Opus 5.5 ba8e7a6562 fix(defaults): portable preconditions; never change an existing host on deploy
Review of #48 found:
- the formatted-SQL table-exists precondition does not parse in Liquibase 5
  (and is unsupported in 4.19), failing the host's whole migration: use
  sql-checks only;
- the budget and prices were seeded beside a host's own budgets and over its
  configured pricing: seed the policy, budget and prices only into empty
  tables, the profile only when no default profile exists;
- the Setup card ignored registry and configured prices: price the default
  model exactly as calls are costed;
- "Add rows for every table" could read a stale data-access cache.
The changelog test now runs through Liquibase 4.33 (kof22's version):
fresh schema, re-run, an already configured host, and a missing table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:54:36 -05:00
james.maes 3b56c93da7 Merge pull request #48 from QRun-IO/feature/tables-populated-defaults
feat: starter defaults, setup card and table help (no empty config tables)
2026-10-14 16:33:09 -05:00
james.maesandClaude Opus 5.5 a5c38d74a8 feat: starter defaults, setup card and table help so no config table starts empty
Owner requirement 2026-10-14: every assistant table should be populated and
visibly used, and an empty table should say how to set it up.

- db/liquibase/qbit-assistant/assistant-defaults.changelog.sql (host-included,
  insert-only, self-guarding): default policy, default profile, four starter
  skills linked to it, a $50/month instance budget (warn 80, downgrade 100,
  stop 120), current Anthropic prices, a disabled example scheduled task.
- Assistant home Setup card: each config table, its state and the next step.
- assistant_data_access action "Add rows for every table" writes the default
  access out as editable rows.
- help/qbit-assistant.yaml (help-bundle format) on the app and every table,
  saying what it is for and how rows get created (STAND-IN(core=#1198)).
- Tests: guardrail (every table used at runtime, seeded or explained, with
  empty-state help); changelog seeds a fresh H2 schema insert-only; seeded
  rows drive persona, skills in the model request, budget warning, policy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:28:10 -05:00
james.maes befb90a48a Merge pull request #41 from QRun-IO/feature/orb-0.6.9
ci: qqq-orb 0.6.9 (refresh SNAPSHOT dependencies)
2026-10-14 10:56:29 -05:00
james.maes bca3ea9d83 Merge pull request #43 from QRun-IO/feature/provider-spi-package
refactor!: provider SPI in its own package com.kingsrook.qbits.assistant.spi
2026-10-14 10:55:28 -05:00
james.maesandClaude Opus 5.5 8655ea21d4 refactor!: provider SPI in its own package com.kingsrook.qbits.assistant.spi
The qbit-assistant-provider-api artifact now holds exactly one package,
com.kingsrook.qbits.assistant.spi (23 types including ModelPricing), so
consumers such as qbit-marketing depend only on that artifact and the
qbit-bom G1 guardrails can allow it by package. BREAKING: imports change
from ...assistant.provider.X / ...assistant.engine.ModelPricing to
...assistant.spi.X (UPGRADE.md has the table). ArchUnit forbids split
packages between the API and the implementation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:51:34 -05:00
james.maes 4b7b955a1f Merge pull request #42 from QRun-IO/feature/email-api
refactor(email): use qbit-email's public api package; no G1 allow-list
2026-10-14 10:25:26 -05:00
james.maesandClaude Opus 5.5 ceeb134c90 refactor(email): use qbit-email's public api package; no G1 allow-list
Moves the optional email integration from qbit-email internals to
com.kingsrook.qbits.email.api (qbit-email 0.1.0-RC.2) with identical
behaviour, and moves to qbit-build-parent 2.1.1, whose default G1
allow-lists include qbit-email's API (QRun-IO/qbit-bom#12), so the
per-repo allow-lists are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:20:42 -05:00
james.maesandClaude Opus 5.5 b49e4f4ee0 ci: qqq-orb 0.6.9 (refresh SNAPSHOT dependencies)
Moves qqq-orb@0.6.8 to qqq-orb@0.6.9, which passes --update-snapshots so cached SNAPSHOT dependencies are re-checked on every build (QRun-IO/qqq-orb#22).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:05:56 -05:00
james.maes a15d66ecc7 Merge pull request #40 from QRun-IO/feature/x1-y1-channel-wall
feat: the hard wall between the assistant and its configuration, and audit attribution (X1/Y1)
2026-10-14 09:02:36 -05:00
james.maes 886ee06cc4 Merge remote-tracking branch 'origin/develop' into feature/record-history-opt-in
# Conflicts:
#	CHANGELOG.md
2026-10-14 08:58:56 -05:00
james.maesandClaude Opus 5.5 1020c3b324 feat: withRecordHistory(true) host opt-in for get_record_history
Default off (owner: hard wall; kof22 does not opt in). When on, calls
core's ClientChannelsMetaData.withAssistantAuditReads() (QRun-IO/qqq#1193:
audits readable, never writable, worm_audit_* still denied) through a
STAND-IN(core=QRun-IO/qqq#1193, removeBy=RC.8) lookup, falling back on
RC.6 to removing exactly the audit* pattern. Other patterns are never
touched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:53:10 -05:00
james.maes 73b58d7391 Merge pull request #39 from QRun-IO/feature/email-qbit
feat(email): scheduled-result email adapter for qbit-email
2026-10-14 08:27:25 -05:00
james.maes 64268e63d0 Merge remote-tracking branch 'origin/develop' into feature/x1-y1-channel-wall
# Conflicts:
#	CHANGELOG.md
#	UPGRADE.md
2026-10-14 08:19:55 -05:00
james.maesandClaude Opus 5.5 b0b80ead3d feat: the hard wall and audit attribution (X1/Y1)
X1:
- Every assistant_* table, process, report and widget (and every object
  the qbit produces) is denied to client channel assistant: the
  assistant_* pattern in ClientChannelsMetaData plus
  QPermissionRules.withDeniedClients on each object.
- Startup fails if any of them is not denied.
- Every UI API call carries assistant/<turnId> (scheduled:
  assistant/scheduled/<taskId>); confirm and explain-access carry the
  owning turn.
- Tests: assistant.admin through the assistant is denied (403, absent
  from list_tables and /metaData) on every configuration object, while the
  same admin through next-ui is allowed; the startup check fails on one
  undenied object; core's security-surface patterns are present.
Y1:
- assistant_tool_call.audit_href: an /app link to the written record's
  audit rows filtered by clientChannel, built with the shared filter-link
  builder.
- Tests: an assistant write audits the human actor with
  clientChannel assistant/<turnId>; the same write from the UI audits
  next-ui. Hosts enable the audit clientChannel field (documented).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:17:35 -05:00
127 changed files with 3316 additions and 484 deletions

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
version: 2.1
orbs:
qqq-orb: kingsrook/qqq-orb@0.6.8
qqq-orb: kingsrook/qqq-orb@0.6.9
commands:
configure-package-install:
+1 -1
View File
@@ -29,7 +29,7 @@ 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-provider-api`: standalone provider SPI/value types and `ModelPricing`, all exclusively in `com.kingsrook.qbits.assistant.spi`; 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`.
+33 -1
View File
@@ -1,14 +1,46 @@
# Changelog
## Unreleased - Starter defaults
- Every seed changeset's rollback is empty: a seeded row cannot be told apart from a host's own row with the same name, and Liquibase also rolls back changesets it marked ran, so a name-based undo would delete host data.
- Fix the defaults changelog: every precondition is a portable `sql-check` (the formatted-SQL `table-exists` form failed to parse); the policy, budget and prices are seeded only into empty tables and the profile only when no default exists, so an existing host never changes on deploy. The test now runs the changelog through Liquibase 4.33, including an already configured host and a missing table.
- The Setup card prices the default model the way calls are costed (registry, price rows, configured prices); "Add rows for every table" reads fresh data-access rows.
- Ship `db/liquibase/qbit-assistant/assistant-defaults.changelog.sql`: insert-only starter rows for the policy, a default profile, four starter skills offered through it, a monthly instance budget (warn, downgrade, stop), current Anthropic prices and a disabled example scheduled task.
- Add the Setup card to the Assistant home: every configuration table with its state (set up, needs setup, using defaults, optional) and the next step.
- Add the `assistant_data_access` table action "Add rows for every table", which writes the default access out as editable rows.
- Add `help/qbit-assistant.yaml` (QQQ help-bundle format) for the app and every assistant table, saying what each table is for and how its rows get created; applied by `AssistantHelp` until core help bundles ship in a release.
- Guardrail tests: every table is used at runtime, seeded or explained, and has empty-state help; the changelog seeds a fresh schema and the seeded rows change the assistant's behavior.
## Unreleased - X1/Y1
- Add default-off `withRecordHistory(true)` host opt-in: remove only `audit*` on RC.6, or invoke core #1193 audit-read/write-deny support when available. Preserve user permissions and all other channel denials; kof22 does not opt in.
- Register explicit assistant-channel denials on every assistant table, process,
report and widget, preserving core security patterns; fail runtime startup
when an assistant object is exposed.
- Bump the assistant UI API extension to 1.1.0 for optional owned-turn attribution
on explain-access, and regenerate its OpenAPI contract.
- Preserve turn attribution in knowledge discovery, RAG reads and explain-access
calls; confirm actions retain the original turn and scheduled runs retain the task.
- Build audit links with the presentation filter-link builder, filtering by
record, table and channel, including deletes; widen audit link storage to TEXT.
Document the host audit migration and the QBit column widening.
- Verify the admin denial matrix through the real invoker and Next UI, startup
failures, and confirmed-write human/channel attribution with decoded audit links.
## Unreleased - Email
### Changed
- Switch scheduled-result email delivery to qbit-email 0.1.0-RC.2's published `com.kingsrook.qbits.email.api` package, preserving templates, categories, idempotency keys, linked records and status mapping.
- Add optional qbit-email 0.1.0-RC.1 delivery of a scheduled task's result (`AssistantEmail.sendScheduledResult`): template `assistant_scheduled_result`, link-only secure message for results above PUBLIC using core's `DataClassification`, owner-recipient hook, opt-in and `emailExternalApproved` gates. Scheduled-task wiring remains a separate integration step.
## Unreleased - Provider API extraction (#30)
- Extract provider SPI/value types and `engine.ModelPricing` into `qbit-assistant-provider-api`, preserving package names. Its only production dependency is Jackson annotations.
- **BREAKING:** move all 23 provider API types, including `ModelPricing`, to `com.kingsrook.qbits.assistant.spi`. Recompile consumers with the new imports; see UPGRADE.md. Enforce exclusive package ownership and API dependency boundaries with ArchUnit.
- Extract provider SPI/value types and `ModelPricing` into `qbit-assistant-provider-api`, now exclusively in `com.kingsrook.qbits.assistant.spi`. 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
+2 -1
View File
@@ -29,7 +29,7 @@ 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-provider-api`: standalone provider SPI/value types and `ModelPricing`, all exclusively in `com.kingsrook.qbits.assistant.spi`; 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`.
@@ -57,6 +57,7 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
- Never use `QSystemUserSession` or construct a `QSession` in main code (ArchUnit).
- Every route uses `ExecutorSessionUtils.setupSession` (the user's own session).
- `AssistantSecurity` only ever adds the user's OWN id key, or the all-access key for `assistant.admin` holders.
- **Channel wall:** every `assistant_*` object (table, process, report and widget) is denied to the `assistant` channel; startup fails otherwise. Requests carry `assistant/<turnId>` or `assistant/scheduled/<taskId>`; administration uses `next-ui`.
- **Data access:**
- The vector index stores no record content: only source/table/key/field/chunk, content hash and embedding. Retrieval re-reads every hit through the UI API as the asking user, then applies the existing get_record exposure/masking path.
- Only `persistence/` calls QueryAction, InsertAction and the other data actions (ArchUnit).
+59 -9
View File
@@ -20,15 +20,47 @@ Design spec: `QRun-IO/qqq` `docs/specs/2026-10-11-qqq-assistant-qbit-design.md`.
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`. No assistant runtime dependency is needed. All API types, including
`ModelPricing`, live exclusively in `com.kingsrook.qbits.assistant.spi`. External
consumers such as qbit-marketing allow that package in their G1 guardrails;
qbit-bom will add it to the G1 defaults. See [migration imports](UPGRADE.md#provider-api-extraction-30).
`AssistantProviderType.validate(ProviderSettings)` receives unresolved settings
(including `apiKeyEnvName`), without a database entity or resolved credentials.
Hosts register providers through `AssistantQBitConfig` in `qbit-assistant`.
Data tools use the QQQ UI API (`/qqq/v1`) with the signed-in user's session. The UI API enforces permissions and record locks; assistant data-access policy and field exposure can only narrow access. Only record-free navigation and help use the core AI tool kernel. Every tool result reaches the model wrapped as untrusted data.
## Host audit attribution (X1/Y1)
Use QQQ **4.1.0-RC.6 or later**. The QBit explicitly denies every assistant
configuration table, process, report and widget to the `assistant` channel;
runtime initialization fails if an assistant object is exposed. The same human
can still administer it through Next UI (`next-ui`). Core's security-surface
deny patterns remain in place.
Enable the audit client field in the host's core audit provider **after adding
the backing column**:
```java
new AuditsMetaDataProvider().withClientChannelField(true)
.defineAll(qInstance, backendName, null);
```
Add nullable `audit.client_channel VARCHAR(100)` using the host's migration and
column-naming conventions. For kof22 this requires a feature-named changeset
under `changesets/features/`; this QBit does not migrate the host's audit table.
The actor remains the signed-in human; `clientChannel` distinguishes
`assistant/<turnId>`, `assistant/scheduled/<taskId>` and `next-ui`.
Apply the X1/Y1 DDL in [UPGRADE.md](UPGRADE.md) to widen the existing
`assistant_tool_call.audit_href` column to TEXT for encoded filters.
Successful record writes store `assistant_tool_call.auditHref` as an `/app/audit`
filter link over `recordId`, `auditTable.name` and `clientChannel`. The human
opens this link with their UI permissions; the assistant cannot read audits by
default. Without the audit channel field, the QBit leaves `auditHref` empty.
Confirmed scheduled actions use their original turn id when the human approves.
## RAG setup
Knowledge retrieval is optional. The vector index stores **no record content**:
@@ -141,6 +173,15 @@ The key never goes in config, tables or logs. It lives in the application's secr
The provider's `toString` never contains the key. Its errors carry only the HTTP status and the API error type.
### Starter defaults
Include `db/liquibase/qbit-assistant/assistant-defaults.changelog.sql` (shipped in
the jar) after your assistant DDL. A new installation then has a policy row, a
default profile with four starter skills, a $50/month instance budget, current
Anthropic prices and a disabled example scheduled task. Every row is insert-only,
so administrators can edit them freely. The home's **Setup** card shows what is
set up and the next step for the rest (usually: add a provider).
## Provider settings (the Providers table)
Admins set up providers on the **Providers** screen (`assistant_provider`), a standard QQQ table:
@@ -470,7 +511,7 @@ Apache 2.0.
When the optional [qbit-email](https://github.com/QRun-IO/qbit-email) is on the classpath **and** produced in the instance, a finished scheduled-task run can be emailed to its owner. Without it nothing changes: `AssistantEmail.sendScheduledResult` returns `NOT_AVAILABLE`. The dependency is `optional`; every reference to it is in `AssistantEmailSender`.
This integration uses qbit-email **0.1.0-RC.1**. Scheduled tasks (P-g) are available, but the runner does not yet wire email delivery. Hosts can call `AssistantEmail.sendScheduledResult(qInstance, config, new ScheduledResultNotice(...))` after a run, supplying its owner, classification, absolute run link and explicit opt-in as `emailResult`. Persisting a task-level email opt-in and wiring the runner remain a separate integration step.
This integration uses qbit-email **0.1.0-RC.2** through its published `com.kingsrook.qbits.email.api` package. Scheduled tasks (P-g) are available, but the runner does not yet wire email delivery. Hosts can call `AssistantEmail.sendScheduledResult(qInstance, config, new ScheduledResultNotice(...))` after a run, supplying its owner, classification, absolute run link and explicit opt-in as `emailResult`. Persisting a task-level email opt-in and wiring the runner remain a separate integration step.
Gates, in order (each returns a status and queues nothing):
1. the task opted in (`NOT_REQUESTED`);
@@ -649,10 +690,19 @@ channel-denied, including their effective permission rules. A host that intends
to expose record history can opt in during instance configuration:
```java
ClientChannelsMetaData.ofOrWithNew(qInstance)
.getDeniedPatterns().get("assistant").remove("audit*");
new AssistantQBitConfig()
.withRecordHistory(true);
```
This removes only the default audit pattern; other channel denials and the
signed-in user's audit permissions still apply. Keep explicit denials for audit
surfaces the host does not intend to expose. The default remains unchanged.
This is an owner decision and a hard wall: record history defaults to disabled;
**kof22 does not opt in**. On QQQ RC.6, the opt-in removes exactly `audit*`
from the assistant's denied patterns (the former manual configuration step).
RC.6 does not add a separate channel write denial for audits. On later RCs that
include QRun-IO/qqq#1193, it calls `withAssistantAuditReads()`, which allows audit
reads but denies audit writes. The user's own audit permissions still apply.
Other patterns, including `worm_audit_*`, `assistant_*`, and security surfaces,
remain untouched, as do explicit object and permission-rule denials.
History visibility uses core's `isDenied` read/all-operation check. RC.6 has no
operation argument; the later write-only deny patterns must remain separate
from that check so they do not hide the read tool.
+62 -6
View File
@@ -1,10 +1,66 @@
# Upgrading qbit-assistant
## Starter defaults, setup card and table help
The jar now ships `db/liquibase/qbit-assistant/assistant-defaults.changelog.sql`.
Include it from the host's master changelog **after** the changesets that create
the `assistant_*` tables:
```yaml
- include: { file: db/liquibase/qbit-assistant/assistant-defaults.changelog.sql }
```
It is insert-only and never changes a host that already configured the
assistant: the policy, the **$50/month instance budget** (warn 80%, downgrade
100%, stop 120%) and the Anthropic list prices are seeded only into empty tables,
and the `default` profile only when no default profile exists. Price rows take
precedence over `AssistantQBitConfig.withPricing`, which is why they are seeded
only when there are none. The four starter skills and a disabled example
scheduled task are always added (by name); on a host with its own default
profile the skills are not linked to it, and the Setup card says so. Requires
Liquibase 4.x or later (preconditions are `sql-check`s).
No DDL changes. New: the Assistant home's Setup card, the `assistant_data_access`
table action **Add rows for every table** (`assistantDataAccessAddDefaults`,
admin only) and bundle help on every assistant table (`help/qbit-assistant.yaml`).
## X1/Y1: channel wall and audit attribution
Requires QQQ 4.1.0-RC.6. Every assistant table, process, report and widget is
explicitly denied to channel `assistant`; startup rejects an exposed object.
Host customizations must preserve these denials. Administration uses `next-ui`.
Before enabling `AuditsMetaDataProvider.withClientChannelField(true)`, add the
nullable `audit.client_channel VARCHAR(100)` column using the host migration
conventions. kof22 needs a feature changeset under `changesets/features/` (for
example `2026-10-14-audit-client-channel.sql`); no host migration is applied here.
Existing audit rows remain null. The test fixture enables the field.
The existing `assistant_tool_call.auditHref` column now points to audit rows
filtered by record, table and client channel, including for deletes. It is empty
when the host has not enabled the audit channel field. Apply the X1/Y1 DDL in
`docs/schema-postgres.sql` to widen the existing `assistant_tool_call.audit_href`
column to TEXT: encoded filters with long table names or string record ids can
exceed the old 500-character limit. No new QBit column is required. `GET /assistant/access` accepts an optional owned `turnId` for attribution.
## Provider API extraction (#30)
Hosts that depend on `qbit-assistant` keep working: it now brings in
`qbit-assistant-provider-api` transitively. Package names are unchanged, including
`com.kingsrook.qbits.assistant.engine.ModelPricing`. No schema migration is needed.
**BREAKING:** every provider API type now lives in the exclusive package
`com.kingsrook.qbits.assistant.spi`. Update imports and recompile consumers.
Hosts still receive `qbit-assistant-provider-api` transitively from `qbit-assistant`.
No schema migration is needed.
| Old import | New import |
|---|---|
| `com.kingsrook.qbits.assistant.provider.X` (API types) | `com.kingsrook.qbits.assistant.spi.X` |
| `com.kingsrook.qbits.assistant.engine.ModelPricing` | `com.kingsrook.qbits.assistant.spi.ModelPricing` |
Internal classes such as `provider.RegistryModelProvider` and
`provider.fake.ScriptedModelProvider` stay in the runtime and are not API types.
External consumers such as qbit-marketing depend only on
`com.kingsrook.qbits:qbit-assistant-provider-api` for these contracts and allow
package `com.kingsrook.qbits.assistant.spi` in their guardrails. qbit-bom will add
this package to the G1 defaults.
External providers and applications using only provider/pricing types should
replace their `qbit-assistant` dependency with
@@ -17,7 +73,7 @@ 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
Providers using the default validator need only the import migration. Keep the runtime and
provider artifacts on the same version; old validator overrides must be rebuilt.
## P-g: scheduled tasks
@@ -42,8 +98,8 @@ See [scheduled-task setup](README.md#scheduled-tasks) for kof22 provisioning.
QQQ RC.5 has no conditional UpdateInput. The runner uses a database-unique
schedule-slot insertion as its optimistic claim, then advances `nextRunAt`
before starting work. Crashes can skip a slot; they never retry it. No ArchUnit
exception or fabricated session is introduced. Core client-channel enforcement
arrives with the client-channel RC; the header and tool audit are already sent.
exception or fabricated session is introduced. Core RC.6 enforces client-channel denials; scheduled requests carry
`assistant/scheduled/<taskId>`.
Notifications use the optional Alerts QBit notification SPI with a single-user
audience. Without it, delivery logs run id/status only. All output modes retain
+16 -1
View File
@@ -16,6 +16,13 @@ All of it is in standard QQQ tables with full record screens (D6), prefixed `ass
`assistant_turn` keeps the per-turn totals and `assistant_usage_daily` the budget rollups, as before. `assistant_usage` adds the per-call detail the home needs: the provider, the failures and latency.
## Provider SPI boundary
All provider contracts and value types, including `ModelPricing`, belong exclusively
to `com.kingsrook.qbits.assistant.spi` in `qbit-assistant-provider-api`. Consumers
can depend on this artifact alone and allow its package in G1. Runtime provider
registry and fake implementations remain in their existing internal packages.
## API keys
Decision: **reference, not storage.** A provider row holds the NAME of an environment variable (`apiKeyEnvName`); `ProviderService` resolves it through `config.getSecretResolver()` (default `System::getenv`) when it builds a client.
@@ -59,7 +66,7 @@ The result goes on the row (`lastTest*`) and into `assistant_usage` (`TEST`), so
## Cost
`cost = input × in + output × out + cacheRead × read + cacheWrite5m × w5 + cacheWrite1h × w1`, in micro-dollars, rounded up (`ModelPricing.costMicros`).
`cost = input × in + output × out + cacheRead × read + cacheWrite5m × w5 + cacheWrite1h × w1`, in micro-dollars, rounded up (`com.kingsrook.qbits.assistant.spi.ModelPricing.costMicros`).
Prices come from the `assistant_model_price` row for (type, model). Empty cache prices are 0.1x, 1.25x and 2x the input price. A model without a row uses the `pricing` config map, else costs 0. A price change affects later calls only; each usage row keeps the cost it was recorded with.
@@ -287,6 +294,14 @@ AI exposure NONE is not enough, because the assistant acts as the user and an ad
- **Tests:** an assistant write produces audit rows with the human actor and `via=assistant/<turnId>`; the same write from the UI produces `via=next-ui`.
- kof22 needs a feature-named changeset (`changesets/features/...`) for the audit column.
### X1/Y1 as built
- Record history is an explicit, default-off host opt-in (`withRecordHistory(true)`); kof22 does not opt in. RC.6 removes only `audit*`; later core #1193 support allows audit reads with a write-only denial. Other walls and user audit permissions remain intact.
- RC.6's invoker fixes the `assistant` channel server-side and ignores spoofed client headers. Turn context survives knowledge discovery, RAG reads, confirm actions and owned-turn explain-access; scheduled calls retain `scheduled/<taskId>`.
- The producer enumerates assistant tables, processes, reports and widgets (including source-QBit metadata), adds explicit denied-client rules without replacing user permissions, and registers `assistant_*` idempotently. Runtime construction independently validates the wall with core's `isDenied` and fails on exposed objects.
- The fixture enables core's audit channel field. Hosts must migrate the nullable audit column before enabling it; kof22 needs its own feature changeset.
- Successful insert/update/delete tool calls link to `/app/audit` with `recordId`, `auditTable.name` and `clientChannel` criteria, using the same encoder as `present_results`. Hosts without the channel field get no audit link. Existing audit link storage widens to TEXT for long encoded filters.
- HTTP tests enumerate the configuration denial matrix for an admin, contrast Next UI access, reject broken startup metadata, and verify confirmed-write actor/channel plus the decoded audit filter.
### P. Providers, local models, HIPAA routing, budgets
1. **Providers and models as data, any number, each with an enabled toggle.**
- Kinds:
+4 -1
View File
@@ -103,7 +103,7 @@ CREATE TABLE assistant_tool_call
redactions_json TEXT,
ui_api_operation VARCHAR(255),
http_status INTEGER,
audit_href VARCHAR(500),
audit_href TEXT,
pending_action_id INTEGER,
create_date TIMESTAMP WITH TIME ZONE
);
@@ -574,3 +574,6 @@ CREATE TABLE assistant_scheduled_run (
UNIQUE (task_id, scheduled_for)
);
CREATE INDEX assistant_scheduled_run_owner ON assistant_scheduled_run(user_id, started_at);
-- X1/Y1: encoded audit filters can exceed the old 500-character record links.
ALTER TABLE assistant_tool_call ALTER COLUMN audit_href TYPE TEXT;
+2 -2
View File
@@ -29,7 +29,7 @@
<archunit.version>1.5.1</archunit.version>
<jackson.version>2.21.7</jackson.version>
<!-- optional email delivery; qbit-email from Maven Central -->
<qbit-email.version>0.1.0-RC.1</qbit-email.version>
<qbit-email.version>0.1.0-RC.2</qbit-email.version>
<!-- live evals call the real API; run them with -Dtest.excludedGroups= -Dgroups=live -->
<test.excludedGroups>live</test.excludedGroups>
</properties>
@@ -37,7 +37,7 @@
<parent>
<groupId>com.kingsrook</groupId>
<artifactId>qbit-build-parent</artifactId>
<version>2.0.0</version>
<version>2.1.1</version>
</parent>
<groupId>com.kingsrook.qbits</groupId>
@@ -40,15 +40,15 @@ import com.anthropic.models.messages.ToolUseBlock;
import com.anthropic.models.messages.ToolUseBlockParam;
import com.anthropic.models.messages.Usage;
import com.fasterxml.jackson.core.type.TypeReference;
import com.kingsrook.qbits.assistant.provider.CacheHint;
import com.kingsrook.qbits.assistant.provider.ModelMessage;
import com.kingsrook.qbits.assistant.provider.ModelPart;
import com.kingsrook.qbits.assistant.provider.ModelProviderException;
import com.kingsrook.qbits.assistant.provider.ModelRequest;
import com.kingsrook.qbits.assistant.provider.ModelResponse;
import com.kingsrook.qbits.assistant.provider.ModelToolDefinition;
import com.kingsrook.qbits.assistant.provider.ModelUsage;
import com.kingsrook.qbits.assistant.provider.SystemBlock;
import com.kingsrook.qbits.assistant.spi.CacheHint;
import com.kingsrook.qbits.assistant.spi.ModelMessage;
import com.kingsrook.qbits.assistant.spi.ModelPart;
import com.kingsrook.qbits.assistant.spi.ModelProviderException;
import com.kingsrook.qbits.assistant.spi.ModelRequest;
import com.kingsrook.qbits.assistant.spi.ModelResponse;
import com.kingsrook.qbits.assistant.spi.ModelToolDefinition;
import com.kingsrook.qbits.assistant.spi.ModelUsage;
import com.kingsrook.qbits.assistant.spi.SystemBlock;
/*******************************************************************************
@@ -29,14 +29,14 @@ import com.anthropic.core.http.StreamResponse;
import com.anthropic.helpers.MessageAccumulator;
import com.anthropic.models.messages.MessageCreateParams;
import com.anthropic.models.messages.RawMessageStreamEvent;
import com.kingsrook.qbits.assistant.provider.AssistantModelProvider;
import com.kingsrook.qbits.assistant.provider.CompletableStreamHandle;
import com.kingsrook.qbits.assistant.provider.ModelCapabilities;
import com.kingsrook.qbits.assistant.provider.ModelEventListener;
import com.kingsrook.qbits.assistant.provider.ModelProviderException;
import com.kingsrook.qbits.assistant.provider.ModelRequest;
import com.kingsrook.qbits.assistant.provider.ModelUsage;
import com.kingsrook.qbits.assistant.provider.StreamHandle;
import com.kingsrook.qbits.assistant.spi.AssistantModelProvider;
import com.kingsrook.qbits.assistant.spi.CompletableStreamHandle;
import com.kingsrook.qbits.assistant.spi.ModelCapabilities;
import com.kingsrook.qbits.assistant.spi.ModelEventListener;
import com.kingsrook.qbits.assistant.spi.ModelProviderException;
import com.kingsrook.qbits.assistant.spi.ModelRequest;
import com.kingsrook.qbits.assistant.spi.ModelUsage;
import com.kingsrook.qbits.assistant.spi.StreamHandle;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
@@ -18,8 +18,8 @@ package com.kingsrook.qbits.assistant.provider.anthropic;
import java.util.List;
import com.kingsrook.qbits.assistant.provider.AssistantModelProvider;
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
import com.kingsrook.qbits.assistant.spi.AssistantModelProvider;
import com.kingsrook.qbits.assistant.spi.AssistantProviderType;
/*******************************************************************************
@@ -24,16 +24,16 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.core.type.TypeReference;
import com.kingsrook.qbits.assistant.provider.CacheHint;
import com.kingsrook.qbits.assistant.provider.ModelEventListener;
import com.kingsrook.qbits.assistant.provider.ModelMessage;
import com.kingsrook.qbits.assistant.provider.ModelPart;
import com.kingsrook.qbits.assistant.provider.ModelProviderException;
import com.kingsrook.qbits.assistant.provider.ModelRequest;
import com.kingsrook.qbits.assistant.provider.ModelResponse;
import com.kingsrook.qbits.assistant.provider.ModelToolDefinition;
import com.kingsrook.qbits.assistant.provider.StreamHandle;
import com.kingsrook.qbits.assistant.provider.SystemBlock;
import com.kingsrook.qbits.assistant.spi.CacheHint;
import com.kingsrook.qbits.assistant.spi.ModelEventListener;
import com.kingsrook.qbits.assistant.spi.ModelMessage;
import com.kingsrook.qbits.assistant.spi.ModelPart;
import com.kingsrook.qbits.assistant.spi.ModelProviderException;
import com.kingsrook.qbits.assistant.spi.ModelRequest;
import com.kingsrook.qbits.assistant.spi.ModelResponse;
import com.kingsrook.qbits.assistant.spi.ModelToolDefinition;
import com.kingsrook.qbits.assistant.spi.StreamHandle;
import com.kingsrook.qbits.assistant.spi.SystemBlock;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@@ -26,10 +26,10 @@ import java.util.Set;
import com.anthropic.core.ObjectMappers;
import com.fasterxml.jackson.core.type.TypeReference;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.provider.ModelProviderException;
import com.kingsrook.qbits.assistant.provider.ModelRequest;
import com.kingsrook.qbits.assistant.provider.ModelToolDefinition;
import com.kingsrook.qbits.assistant.provider.fake.ScriptedModelProvider;
import com.kingsrook.qbits.assistant.spi.ModelProviderException;
import com.kingsrook.qbits.assistant.spi.ModelRequest;
import com.kingsrook.qbits.assistant.spi.ModelToolDefinition;
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.testing.TurnRunner;
import com.kingsrook.qqq.backend.core.context.QContext;
@@ -24,7 +24,6 @@ import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.engine.ConnectionTest;
import com.kingsrook.qbits.assistant.engine.ModelPricing;
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
import com.kingsrook.qbits.assistant.model.AssistantModelPrice;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
@@ -32,6 +31,7 @@ import com.kingsrook.qbits.assistant.provider.anthropic.AnthropicModelProvider;
import com.kingsrook.qbits.assistant.provider.anthropic.AnthropicProviderType;
import com.kingsrook.qbits.assistant.provider.anthropic.RecordedAnthropicServer;
import com.kingsrook.qbits.assistant.provider.fake.ScriptedModelProvider;
import com.kingsrook.qbits.assistant.spi.ModelPricing;
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.testing.TurnRunner;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
@@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.provider;
package com.kingsrook.qbits.assistant.spi;
/*******************************************************************************
@@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.provider;
package com.kingsrook.qbits.assistant.spi;
import java.util.List;
@@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.provider;
package com.kingsrook.qbits.assistant.spi;
/*******************************************************************************
@@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.provider;
package com.kingsrook.qbits.assistant.spi;
import java.time.Duration;
@@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.provider;
package com.kingsrook.qbits.assistant.spi;
/*******************************************************************************
@@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.provider;
package com.kingsrook.qbits.assistant.spi;
import java.util.List;
@@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.provider;
package com.kingsrook.qbits.assistant.spi;
import java.util.List;
@@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.provider;
package com.kingsrook.qbits.assistant.spi;
/*******************************************************************************
@@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.provider;
package com.kingsrook.qbits.assistant.spi;
/*******************************************************************************
@@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.provider;
package com.kingsrook.qbits.assistant.spi;
/*******************************************************************************
@@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.provider;
package com.kingsrook.qbits.assistant.spi;
import java.util.ArrayList;
@@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.provider;
package com.kingsrook.qbits.assistant.spi;
import java.util.LinkedHashMap;
@@ -14,13 +14,12 @@
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.engine;
package com.kingsrook.qbits.assistant.spi;
import java.io.Serializable;
import java.util.LinkedHashMap;
import java.util.Map;
import com.kingsrook.qbits.assistant.provider.ModelUsage;
/*******************************************************************************
@@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.provider;
package com.kingsrook.qbits.assistant.spi;
/*******************************************************************************
@@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.provider;
package com.kingsrook.qbits.assistant.spi;
import java.util.ArrayList;
@@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.provider;
package com.kingsrook.qbits.assistant.spi;
import java.util.ArrayList;
@@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.provider;
package com.kingsrook.qbits.assistant.spi;
import java.util.Map;
@@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.provider;
package com.kingsrook.qbits.assistant.spi;
/*******************************************************************************
@@ -14,12 +14,12 @@
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.provider;
package com.kingsrook.qbits.assistant.spi;
import java.util.function.Supplier;
import javax.net.ssl.SSLContext;
import com.fasterxml.jackson.annotation.JsonIgnore;
import javax.net.ssl.SSLContext;
/*******************************************************************************
@@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.provider;
package com.kingsrook.qbits.assistant.spi;
import java.time.Duration;
@@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.provider;
package com.kingsrook.qbits.assistant.spi;
/*******************************************************************************
@@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.provider;
package com.kingsrook.qbits.assistant.spi;
/*******************************************************************************
@@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.provider;
package com.kingsrook.qbits.assistant.spi;
import java.io.ByteArrayInputStream;
@@ -1,75 +0,0 @@
/*
* 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,46 @@
/*
* 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.spi;
import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import org.junit.jupiter.api.Test;
import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage;
import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideOutsideOfPackage;
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
{
/*******************************************************************************
** Import the entire artifact so an accidental package outside spi also fails.
*******************************************************************************/
@Test
void dependsOnlyOnApiTypes()
{
String spiPackage = "com.kingsrook.qbits.assistant.spi";
JavaClasses imported = new ClassFileImporter().importUrl(AssistantModelProvider.class.getProtectionDomain().getCodeSource().getLocation());
classes().should().resideInAPackage(spiPackage).check(imported);
noClasses().should().dependOnClassesThat(resideInAPackage("com.kingsrook.qbits..").and(resideOutsideOfPackage(spiPackage))).check(imported);
noClasses().should().dependOnClassesThat().resideInAnyPackage("com.kingsrook.qqq..").check(imported);
}
}
@@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.provider;
package com.kingsrook.qbits.assistant.spi;
import java.time.Duration;
@@ -23,11 +23,10 @@ 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 javax.net.ssl.SSLContext;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -14,11 +14,10 @@
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.provider;
package com.kingsrook.qbits.assistant.spi;
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;
@@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.provider;
package com.kingsrook.qbits.assistant.spi;
import java.util.List;
@@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.provider;
package com.kingsrook.qbits.assistant.spi;
import java.security.KeyStore;
@@ -24,11 +24,11 @@ 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.provider.AssistantModelProvider;
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
import com.kingsrook.qbits.assistant.provider.DiscoveredModel;
import com.kingsrook.qbits.assistant.provider.ProviderSettings;
import com.kingsrook.qbits.assistant.provider.anthropic.AnthropicModelProvider;
import com.kingsrook.qbits.assistant.spi.AssistantModelProvider;
import com.kingsrook.qbits.assistant.spi.AssistantProviderType;
import com.kingsrook.qbits.assistant.spi.DiscoveredModel;
import com.kingsrook.qbits.assistant.spi.ProviderSettings;
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
import software.amazon.awssdk.regions.Region;
@@ -27,13 +27,13 @@ import java.util.Set;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.engine.ProviderService;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qbits.assistant.provider.ModelEventListener;
import com.kingsrook.qbits.assistant.provider.ModelMessage;
import com.kingsrook.qbits.assistant.provider.ModelProviderException;
import com.kingsrook.qbits.assistant.provider.ModelRequest;
import com.kingsrook.qbits.assistant.provider.ModelToolDefinition;
import com.kingsrook.qbits.assistant.provider.ProviderSettings;
import com.kingsrook.qbits.assistant.provider.anthropic.AnthropicModelProvider;
import com.kingsrook.qbits.assistant.spi.ModelEventListener;
import com.kingsrook.qbits.assistant.spi.ModelMessage;
import com.kingsrook.qbits.assistant.spi.ModelProviderException;
import com.kingsrook.qbits.assistant.spi.ModelRequest;
import com.kingsrook.qbits.assistant.spi.ModelToolDefinition;
import com.kingsrook.qbits.assistant.spi.ProviderSettings;
import com.kingsrook.qbits.assistant.testing.CloudProviderTestServer;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
@@ -36,19 +36,19 @@ import java.util.concurrent.atomic.AtomicReference;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.kingsrook.qbits.assistant.provider.AssistantModelProvider;
import com.kingsrook.qbits.assistant.provider.CompletableStreamHandle;
import com.kingsrook.qbits.assistant.provider.EmbeddingRequest;
import com.kingsrook.qbits.assistant.provider.EmbeddingResponse;
import com.kingsrook.qbits.assistant.provider.ModelCapabilities;
import com.kingsrook.qbits.assistant.provider.ModelEventListener;
import com.kingsrook.qbits.assistant.provider.ModelPart;
import com.kingsrook.qbits.assistant.provider.ModelProviderException;
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.ProviderSettings;
import com.kingsrook.qbits.assistant.provider.StreamHandle;
import com.kingsrook.qbits.assistant.spi.AssistantModelProvider;
import com.kingsrook.qbits.assistant.spi.CompletableStreamHandle;
import com.kingsrook.qbits.assistant.spi.EmbeddingRequest;
import com.kingsrook.qbits.assistant.spi.EmbeddingResponse;
import com.kingsrook.qbits.assistant.spi.ModelCapabilities;
import com.kingsrook.qbits.assistant.spi.ModelEventListener;
import com.kingsrook.qbits.assistant.spi.ModelPart;
import com.kingsrook.qbits.assistant.spi.ModelProviderException;
import com.kingsrook.qbits.assistant.spi.ModelRequest;
import com.kingsrook.qbits.assistant.spi.ModelResponse;
import com.kingsrook.qbits.assistant.spi.ModelUsage;
import com.kingsrook.qbits.assistant.spi.ProviderSettings;
import com.kingsrook.qbits.assistant.spi.StreamHandle;
/*******************************************************************************
@@ -18,12 +18,12 @@ package com.kingsrook.qbits.assistant.provider.openai;
import java.util.List;
import com.kingsrook.qbits.assistant.provider.AssistantModelProvider;
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
import com.kingsrook.qbits.assistant.provider.DiscoveredModel;
import com.kingsrook.qbits.assistant.provider.HealthResult;
import com.kingsrook.qbits.assistant.provider.ModelProviderException;
import com.kingsrook.qbits.assistant.provider.ProviderSettings;
import com.kingsrook.qbits.assistant.spi.AssistantModelProvider;
import com.kingsrook.qbits.assistant.spi.AssistantProviderType;
import com.kingsrook.qbits.assistant.spi.DiscoveredModel;
import com.kingsrook.qbits.assistant.spi.HealthResult;
import com.kingsrook.qbits.assistant.spi.ModelProviderException;
import com.kingsrook.qbits.assistant.spi.ProviderSettings;
/*******************************************************************************
@@ -33,12 +33,12 @@ import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import javax.net.ssl.SSLHandshakeException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kingsrook.qbits.assistant.provider.DiscoveredModel;
import com.kingsrook.qbits.assistant.provider.ModelProviderException;
import com.kingsrook.qbits.assistant.provider.ProviderSettings;
import com.kingsrook.qbits.assistant.spi.DiscoveredModel;
import com.kingsrook.qbits.assistant.spi.ModelProviderException;
import com.kingsrook.qbits.assistant.spi.ProviderSettings;
import javax.net.ssl.SSLHandshakeException;
/*******************************************************************************
@@ -24,12 +24,12 @@ import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.kingsrook.qbits.assistant.provider.ModelCapabilities;
import com.kingsrook.qbits.assistant.provider.ModelMessage;
import com.kingsrook.qbits.assistant.provider.ModelPart;
import com.kingsrook.qbits.assistant.provider.ModelRequest;
import com.kingsrook.qbits.assistant.provider.ModelToolDefinition;
import com.kingsrook.qbits.assistant.provider.SystemBlock;
import com.kingsrook.qbits.assistant.spi.ModelCapabilities;
import com.kingsrook.qbits.assistant.spi.ModelMessage;
import com.kingsrook.qbits.assistant.spi.ModelPart;
import com.kingsrook.qbits.assistant.spi.ModelRequest;
import com.kingsrook.qbits.assistant.spi.ModelToolDefinition;
import com.kingsrook.qbits.assistant.spi.SystemBlock;
/*******************************************************************************
@@ -21,10 +21,10 @@ import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.provider.ModelMessage;
import com.kingsrook.qbits.assistant.provider.ModelProviderException;
import com.kingsrook.qbits.assistant.provider.ModelRequest;
import com.kingsrook.qbits.assistant.provider.ProviderSettings;
import com.kingsrook.qbits.assistant.spi.ModelMessage;
import com.kingsrook.qbits.assistant.spi.ModelProviderException;
import com.kingsrook.qbits.assistant.spi.ModelRequest;
import com.kingsrook.qbits.assistant.spi.ProviderSettings;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
@@ -22,16 +22,16 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.databind.JsonNode;
import com.kingsrook.qbits.assistant.provider.ModelCapabilities;
import com.kingsrook.qbits.assistant.provider.ModelEventListener;
import com.kingsrook.qbits.assistant.provider.ModelMessage;
import com.kingsrook.qbits.assistant.provider.ModelPart;
import com.kingsrook.qbits.assistant.provider.ModelProviderException;
import com.kingsrook.qbits.assistant.provider.ModelRequest;
import com.kingsrook.qbits.assistant.provider.ModelResponse;
import com.kingsrook.qbits.assistant.provider.ModelToolDefinition;
import com.kingsrook.qbits.assistant.provider.ProviderSettings;
import com.kingsrook.qbits.assistant.provider.SystemBlock;
import com.kingsrook.qbits.assistant.spi.ModelCapabilities;
import com.kingsrook.qbits.assistant.spi.ModelEventListener;
import com.kingsrook.qbits.assistant.spi.ModelMessage;
import com.kingsrook.qbits.assistant.spi.ModelPart;
import com.kingsrook.qbits.assistant.spi.ModelProviderException;
import com.kingsrook.qbits.assistant.spi.ModelRequest;
import com.kingsrook.qbits.assistant.spi.ModelResponse;
import com.kingsrook.qbits.assistant.spi.ModelToolDefinition;
import com.kingsrook.qbits.assistant.spi.ProviderSettings;
import com.kingsrook.qbits.assistant.spi.SystemBlock;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
@@ -18,9 +18,9 @@ package com.kingsrook.qbits.assistant.provider.openai;
import java.util.List;
import com.kingsrook.qbits.assistant.provider.EmbeddingRequest;
import com.kingsrook.qbits.assistant.provider.ModelProviderException;
import com.kingsrook.qbits.assistant.provider.ProviderSettings;
import com.kingsrook.qbits.assistant.spi.EmbeddingRequest;
import com.kingsrook.qbits.assistant.spi.ModelProviderException;
import com.kingsrook.qbits.assistant.spi.ProviderSettings;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -24,14 +24,14 @@ import java.nio.file.Path;
import java.security.KeyStore;
import java.time.Duration;
import java.util.Set;
import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qbits.assistant.provider.ModelProviderException;
import com.kingsrook.qbits.assistant.provider.ProviderSettings;
import com.kingsrook.qbits.assistant.spi.ModelProviderException;
import com.kingsrook.qbits.assistant.spi.ProviderSettings;
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qqq.backend.core.context.QContext;
import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import static org.assertj.core.api.Assertions.assertThat;
@@ -36,9 +36,9 @@ import com.kingsrook.qbits.assistant.persistence.AssistantStore;
import com.kingsrook.qbits.assistant.processes.DiscoverModelsProcess;
import com.kingsrook.qbits.assistant.processes.TestConnectionProcess;
import com.kingsrook.qbits.assistant.processes.TestModelConnectionProcess;
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.spi.AssistantProviderType;
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.testing.EvalSupport;
import com.kingsrook.qbits.assistant.testing.GoldenTasks;
@@ -23,14 +23,14 @@ import com.anthropic.client.okhttp.AnthropicOkHttpClient;
import com.anthropic.core.Timeout;
import com.anthropic.vertex.backends.VertexBackend;
import com.google.auth.oauth2.GoogleCredentials;
import com.kingsrook.qbits.assistant.provider.AssistantModelProvider;
import com.kingsrook.qbits.assistant.provider.ModelCapabilities;
import com.kingsrook.qbits.assistant.provider.ModelEventListener;
import com.kingsrook.qbits.assistant.provider.ModelRequest;
import com.kingsrook.qbits.assistant.provider.ProviderSettings;
import com.kingsrook.qbits.assistant.provider.StreamHandle;
import com.kingsrook.qbits.assistant.provider.anthropic.AnthropicModelProvider;
import com.kingsrook.qbits.assistant.provider.openai.OpenAiCompatibleModelProvider;
import com.kingsrook.qbits.assistant.spi.AssistantModelProvider;
import com.kingsrook.qbits.assistant.spi.ModelCapabilities;
import com.kingsrook.qbits.assistant.spi.ModelEventListener;
import com.kingsrook.qbits.assistant.spi.ModelRequest;
import com.kingsrook.qbits.assistant.spi.ProviderSettings;
import com.kingsrook.qbits.assistant.spi.StreamHandle;
/*******************************************************************************
@@ -19,10 +19,10 @@ package com.kingsrook.qbits.assistant.provider.vertex;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.assistant.provider.AssistantModelProvider;
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
import com.kingsrook.qbits.assistant.provider.DiscoveredModel;
import com.kingsrook.qbits.assistant.provider.ProviderSettings;
import com.kingsrook.qbits.assistant.spi.AssistantModelProvider;
import com.kingsrook.qbits.assistant.spi.AssistantProviderType;
import com.kingsrook.qbits.assistant.spi.DiscoveredModel;
import com.kingsrook.qbits.assistant.spi.ProviderSettings;
/*******************************************************************************
@@ -29,11 +29,11 @@ import com.google.auth.oauth2.GoogleCredentials;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.engine.ProviderService;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qbits.assistant.provider.ModelMessage;
import com.kingsrook.qbits.assistant.provider.ModelProviderException;
import com.kingsrook.qbits.assistant.provider.ModelRequest;
import com.kingsrook.qbits.assistant.provider.ModelToolDefinition;
import com.kingsrook.qbits.assistant.provider.ProviderSettings;
import com.kingsrook.qbits.assistant.spi.ModelMessage;
import com.kingsrook.qbits.assistant.spi.ModelProviderException;
import com.kingsrook.qbits.assistant.spi.ModelRequest;
import com.kingsrook.qbits.assistant.spi.ModelToolDefinition;
import com.kingsrook.qbits.assistant.spi.ProviderSettings;
import com.kingsrook.qbits.assistant.testing.CloudProviderTestServer;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
+14
View File
@@ -73,6 +73,20 @@
<artifactId>archunit-junit5</artifactId>
<scope>test</scope>
</dependency>
<!-- AssistantDefaultsChangelogTest runs the shipped seed changelog against H2 (PostgreSQL mode). -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>2.3.232</version>
<scope>test</scope>
</dependency>
<!-- ...through real Liquibase (the version kof22 runs), so preconditions and bookkeeping are exercised. -->
<dependency>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-core</artifactId>
<version>4.33.0</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
@@ -17,7 +17,6 @@
package com.kingsrook.qbits.assistant;
import javax.net.ssl.SSLContext;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -32,11 +31,11 @@ import java.util.regex.Pattern;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.kingsrook.qbits.assistant.classification.ClassificationSource;
import com.kingsrook.qbits.assistant.classification.ConfiguredClassificationSource;
import com.kingsrook.qbits.assistant.engine.ModelPricing;
import com.kingsrook.qbits.assistant.provider.AssistantModelProvider;
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
import com.kingsrook.qbits.assistant.provider.TrustedCaBundle;
import com.kingsrook.qbits.assistant.rag.VectorStore;
import com.kingsrook.qbits.assistant.spi.AssistantModelProvider;
import com.kingsrook.qbits.assistant.spi.AssistantProviderType;
import com.kingsrook.qbits.assistant.spi.ModelPricing;
import com.kingsrook.qbits.assistant.spi.TrustedCaBundle;
import com.kingsrook.qbits.assistant.uiapi.UiApiGateway;
import com.kingsrook.qqq.backend.core.actions.ai.AiExposure;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
@@ -45,6 +44,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomize
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import javax.net.ssl.SSLContext;
/*******************************************************************************
@@ -90,6 +90,7 @@ public class AssistantQBitConfig implements QBitConfig
private String conversationEncryptionKeyEnvName;
private Boolean enabled = true;
private Boolean recordHistory = false;
private String backendName;
private String providerName = DEFAULT_PROVIDER_NAME;
private AssistantModelRouting models = new AssistantModelRouting();
@@ -1758,6 +1759,28 @@ public class AssistantQBitConfig implements QBitConfig
/*******************************************************************************
** Whether the host opts into record history; false by default.
*******************************************************************************/
public Boolean getRecordHistory()
{
return (recordHistory);
}
/*******************************************************************************
** Owner decision — hard wall: record history requires explicit host opt-in.
** kof22 does not opt in. The user's own audit permissions still apply.
*******************************************************************************/
public AssistantQBitConfig withRecordHistory(boolean recordHistory)
{
this.recordHistory = recordHistory;
return (this);
}
/*******************************************************************************
** Getter for hipaaMode
*******************************************************************************/
@@ -25,10 +25,13 @@ import com.kingsrook.qbits.assistant.api.AssistantRoutes;
import com.kingsrook.qbits.assistant.developer.AssistantDeveloperPanels;
import com.kingsrook.qbits.assistant.home.AssistantAccessWidget;
import com.kingsrook.qbits.assistant.home.AssistantHome;
import com.kingsrook.qbits.assistant.metadata.AssistantChannelWall;
import com.kingsrook.qbits.assistant.metadata.AssistantHelp;
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.DataAccessDefaultsProcess;
import com.kingsrook.qbits.assistant.processes.DiscoverModelsProcess;
import com.kingsrook.qbits.assistant.processes.KnowledgeIndexProcess;
import com.kingsrook.qbits.assistant.processes.ScheduledTaskProcess;
@@ -41,6 +44,7 @@ import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.ClientChannelsMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer;
@@ -178,7 +182,7 @@ public class AssistantQBitProducer implements QBitMetaDataProducer<AssistantQBit
QProcessMetaData testConnection = TestConnectionProcess.process();
testConnection.setSourceQBitName(qBitMetaData.getName());
qInstance.addProcess(testConnection);
for(QProcessMetaData process : List.of(DiscoverModelsProcess.process(), TestModelConnectionProcess.process()))
for(QProcessMetaData process : List.of(DiscoverModelsProcess.process(), TestModelConnectionProcess.process(), DataAccessDefaultsProcess.process()))
{
process.setSourceQBitName(qBitMetaData.getName());
qInstance.addProcess(process);
@@ -200,13 +204,44 @@ public class AssistantQBitProducer implements QBitMetaDataProducer<AssistantQBit
QAppMetaData app = builder.buildAdminApp(tables);
qInstance.addApp(app);
AssistantHelp.apply(qInstance);
AssistantChannelWall.register(qInstance);
if(Boolean.TRUE.equals(qBitConfig.getRecordHistory()))
{
enableRecordHistory(ClientChannelsMetaData.ofOrWithNew(qInstance));
}
postProduceActions(new MetaDataProducerMultiOutput(), qInstance);
return (new MetaDataProducerMultiOutput());
}
/*******************************************************************************
** STAND-IN(core=QRun-IO/qqq#1193, removeBy=RC.8): call withAssistantAuditReads() directly once the qbit builds on a QQQ RC that has it
*******************************************************************************/
private void enableRecordHistory(ClientChannelsMetaData channels)
{
try
{
channels.getClass().getMethod("withAssistantAuditReads").invoke(channels);
}
catch(NoSuchMethodException e)
{
List<String> denied = channels.getDeniedPatterns().get("assistant");
if(denied != null)
{
denied.removeIf("audit*"::equals);
}
}
catch(ReflectiveOperationException e)
{
throw (new IllegalStateException("Could not enable assistant audit reads.", e));
}
}
/*******************************************************************************
** Declare budget alerts as top-level core metadata for admin visibility.
*******************************************************************************/
@@ -32,6 +32,7 @@ import com.kingsrook.qbits.assistant.engine.ProfileService;
import com.kingsrook.qbits.assistant.engine.ProviderService;
import com.kingsrook.qbits.assistant.engine.RateLimiter;
import com.kingsrook.qbits.assistant.engine.RunningTurn;
import com.kingsrook.qbits.assistant.metadata.AssistantChannelWall;
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
import com.kingsrook.qbits.assistant.rag.InMemoryVectorStore;
import com.kingsrook.qbits.assistant.rag.RagService;
@@ -103,6 +104,7 @@ public class AssistantRuntime
*******************************************************************************/
public AssistantRuntime(QInstance qInstance, AssistantQBitConfig config, Clock clock)
{
AssistantChannelWall.validate(qInstance);
this.qInstance = qInstance;
this.config = config;
this.clock = clock;
@@ -103,7 +103,17 @@ public class AccessExplainer
*******************************************************************************/
public List<TableAccess> explain(UiApiGateway gateway, String sessionUUID, UiApiCredentials credentials, PolicyService.ToolPolicy policy, String tableName, String prompt) throws QException
{
UiApiResult response = gateway.invoke(new UiApiCall("GET", "/metaData").withSessionUUID(sessionUUID).withCredentials(credentials).withInvokedBy("assistant-explain"));
return (explain(gateway, sessionUUID, credentials, policy, tableName, prompt, null));
}
/*******************************************************************************
** Preserve a known turn's attribution on the metadata request.
*******************************************************************************/
public List<TableAccess> explain(UiApiGateway gateway, String sessionUUID, UiApiCredentials credentials, PolicyService.ToolPolicy policy, String tableName, String prompt, String turnId) throws QException
{
UiApiResult response = gateway.invoke(new UiApiCall("GET", "/metaData").withSessionUUID(sessionUUID).withCredentials(credentials).withInvokedBy("assistant-explain").withClientDetail(turnId));
if(!response.isSuccess())
{
throw (new QException("GET /metaData answered HTTP " + response.status() + (response.errorMessage() == null ? "" : ": " + response.errorMessage())));
@@ -627,6 +627,7 @@ public class AssistantApi
store.insert(new AssistantToolCall()
.withTurnId(action.getTurnId())
.withClientChannel("assistant" + (action.getTurnId() == null ? "" : "/" + action.getTurnId()))
.withConversationId(action.getConversationId())
.withUserId(userId)
.withToolCallId(action.getToolCallId())
@@ -733,10 +734,24 @@ public class AssistantApi
** reason.
*******************************************************************************/
public Map<String, Object> access(String table, String prompt) throws QException, AssistantApiException
{
return (access(table, prompt, null));
}
/*******************************************************************************
** A supplied turn must belong to this user before using it for attribution.
*******************************************************************************/
public Map<String, Object> access(String table, String prompt, Integer turnId) throws QException, AssistantApiException
{
requireAccess();
if(turnId != null)
{
requireTurn(turnId);
}
List<Map<String, Object>> tables = new ArrayList<>();
for(AccessExplainer.TableAccess access : runtime.getAccessExplainer().explain(runtime.getUiApiGateway(), session.getUuid(), credentials, runtime.getPolicyService().current(false).tools(), table, prompt))
for(AccessExplainer.TableAccess access : runtime.getAccessExplainer().explain(runtime.getUiApiGateway(), session.getUuid(), credentials, runtime.getPolicyService().current(false).tools(), table, prompt, turnId == null ? null : String.valueOf(turnId)))
{
if(!access.uiVisible() && access.mode() == DataAccessMode.NONE)
{
@@ -61,7 +61,7 @@ import io.javalin.http.HttpStatus;
public final class AssistantSpecs
{
public static final String EXTENSION_NAME = "assistant";
public static final String EXTENSION_VERSION = "1.0.0";
public static final String EXTENSION_VERSION = "1.1.0";
public static final String PREFIX = "/assistant";
@@ -1312,7 +1312,7 @@ public final class AssistantSpecs
@Override
public List<Parameter> defineRequestParameters()
{
return (List.of(queryParam("table", Type.STRING, "Only this table."), queryParam("prompt", Type.STRING, "Only the tables this text mentions.")));
return (List.of(queryParam("table", Type.STRING, "Only this table."), queryParam("prompt", Type.STRING, "Only the tables this text mentions."), queryParam("turnId", Type.INTEGER, "Attribute this request to an owned turn.")));
}
@@ -1329,7 +1329,7 @@ public final class AssistantSpecs
protected Object handle(AssistantApi api, JsonInput input) throws Exception
{
String table = input.getQueryParam("table");
return (api.access(StringUtils.hasContent(table) ? table : null, input.getQueryParam("prompt")));
return (api.access(StringUtils.hasContent(table) ? table : null, input.getQueryParam("prompt"), input.getQueryParamInteger("turnId")));
}
}
@@ -55,7 +55,7 @@ public final class AssistantEmail
{
try
{
Class.forName("com.kingsrook.qbits.email.outbox.EmailService");
Class.forName("com.kingsrook.qbits.email.api.EmailApi");
}
catch(ClassNotFoundException | LinkageError e)
{
@@ -24,13 +24,11 @@ package com.kingsrook.qbits.assistant.email;
import java.io.Serializable;
import java.util.LinkedHashMap;
import java.util.Map;
import com.kingsrook.qbits.email.EmailRuntime;
import com.kingsrook.qbits.email.outbox.EmailCategory;
import com.kingsrook.qbits.email.outbox.EmailRequest;
import com.kingsrook.qbits.email.outbox.EmailSendResult;
import com.kingsrook.qbits.email.outbox.EmailService;
import com.kingsrook.qbits.email.template.EmailTemplateDefinition;
import com.kingsrook.qbits.email.template.EmailTemplates;
import com.kingsrook.qbits.email.api.EmailApi;
import com.kingsrook.qbits.email.api.EmailApiCategory;
import com.kingsrook.qbits.email.api.EmailApiRequest;
import com.kingsrook.qbits.email.api.EmailApiResult;
import com.kingsrook.qbits.email.api.EmailApiTemplate;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
@@ -66,7 +64,7 @@ final class AssistantEmailSender
*******************************************************************************/
static boolean isInstalled(QInstance qInstance)
{
return (EmailRuntime.config(qInstance) != null);
return (EmailApi.isInstalled(qInstance));
}
@@ -79,7 +77,7 @@ final class AssistantEmailSender
{
boolean linkOnly = notice.classification() != null && notice.classification().isAtLeast(DataClassification.CONFIDENTIAL);
EmailRequest request = new EmailRequest().withTo(recipient)
EmailApiRequest request = new EmailApiRequest().withTo(recipient)
.withIdempotencyKey("assistant:scheduled:" + notice.runId())
.withLinkedRecord(notice.runTable(), notice.runId())
.withSender(SENDER_QBIT, SENDER_PROCESS);
@@ -100,7 +98,7 @@ final class AssistantEmailSender
}
request.withVariables(variables);
return (map(EmailService.send(request)));
return (map(EmailApi.send(request)));
}
@@ -123,7 +121,7 @@ final class AssistantEmailSender
/*******************************************************************************
** Outbox result to the qbit's own vocabulary.
*******************************************************************************/
private static ScheduledEmailStatus map(EmailSendResult result)
private static ScheduledEmailStatus map(EmailApiResult result)
{
return (switch(result.status())
{
@@ -141,10 +139,10 @@ final class AssistantEmailSender
*******************************************************************************/
static void ensureTemplate() throws QException
{
EmailTemplates.ensure(new EmailTemplateDefinition()
EmailApi.ensureTemplate(new EmailApiTemplate()
.withName(TEMPLATE_NAME)
.withLabel("Assistant scheduled task result")
.withCategory(EmailCategory.NOTIFICATION)
.withCategory(EmailApiCategory.NOTIFICATION)
.withKind("MARKDOWN")
.withOwnerQbit(SENDER_QBIT)
.withSubject("Assistant task result: {{taskName}}")
@@ -23,15 +23,16 @@ import java.util.List;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.model.AssistantModel;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
import com.kingsrook.qbits.assistant.provider.HealthResult;
import com.kingsrook.qbits.assistant.provider.ModelMessage;
import com.kingsrook.qbits.assistant.provider.ModelProviderException;
import com.kingsrook.qbits.assistant.provider.ModelRequest;
import com.kingsrook.qbits.assistant.provider.ModelResponse;
import com.kingsrook.qbits.assistant.provider.RegistryModelProvider;
import com.kingsrook.qbits.assistant.provider.StreamHandle;
import com.kingsrook.qbits.assistant.rag.EmbeddingService;
import com.kingsrook.qbits.assistant.spi.AssistantProviderType;
import com.kingsrook.qbits.assistant.spi.HealthResult;
import com.kingsrook.qbits.assistant.spi.ModelMessage;
import com.kingsrook.qbits.assistant.spi.ModelPricing;
import com.kingsrook.qbits.assistant.spi.ModelProviderException;
import com.kingsrook.qbits.assistant.spi.ModelRequest;
import com.kingsrook.qbits.assistant.spi.ModelResponse;
import com.kingsrook.qbits.assistant.spi.StreamHandle;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
@@ -28,16 +28,16 @@ import java.util.Set;
import java.util.TreeMap;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.model.AssistantMessage;
import com.kingsrook.qbits.assistant.provider.CacheHint;
import com.kingsrook.qbits.assistant.provider.ModelMessage;
import com.kingsrook.qbits.assistant.provider.ModelPart;
import com.kingsrook.qbits.assistant.provider.ModelRequest;
import com.kingsrook.qbits.assistant.provider.ModelToolDefinition;
import com.kingsrook.qbits.assistant.provider.SystemBlock;
import com.kingsrook.qbits.assistant.spi.CacheHint;
import com.kingsrook.qbits.assistant.spi.ModelMessage;
import com.kingsrook.qbits.assistant.spi.ModelPart;
import com.kingsrook.qbits.assistant.spi.ModelRequest;
import com.kingsrook.qbits.assistant.spi.ModelToolDefinition;
import com.kingsrook.qbits.assistant.spi.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.qbits.assistant.tools.bulkload.BulkLoadTools;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolDefinition;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolKernel;
import com.kingsrook.qqq.backend.core.context.QContext;
@@ -106,6 +106,16 @@ public class ContextBuilder
** kernel's navigate and search_help.
*******************************************************************************/
public List<ModelToolDefinition> tools(PolicyService.ToolPolicy policy)
{
return (tools(policy, null));
}
/*******************************************************************************
** Attribute tool-discovery requests to the turn that needs them.
*******************************************************************************/
private List<ModelToolDefinition> tools(PolicyService.ToolPolicy policy, String turnId)
{
Map<String, ModelToolDefinition> byName = new TreeMap<>();
for(ModelToolDefinition definition : toolbox.definitions(policy, toolbox.anyWritableTable()))
@@ -123,7 +133,7 @@ public class ContextBuilder
.withStrict(isStrict(definition.getName(), !Boolean.FALSE.equals(definition.getStrict()))));
}
}
if(knowledgeTool != null && knowledgeTool.offered(policy, QContext.getQSession()))
if(knowledgeTool != null && knowledgeTool.offered(policy, QContext.getQSession(), turnId))
{
byName.put(KnowledgeTool.NAME, knowledgeTool.definition());
}
@@ -216,12 +226,23 @@ public class ContextBuilder
*******************************************************************************/
public ModelRequest build(QSession session, String model, String effort, List<AssistantMessage> history, String userText, Map<String, Object> pageContext, PolicyService.ToolPolicy toolPolicy,
String profileInstructions)
{
return (build(session, model, effort, history, userText, pageContext, toolPolicy, profileInstructions, null));
}
/*******************************************************************************
** Include the turn in any UI API calls needed to discover available tools.
*******************************************************************************/
public ModelRequest build(QSession session, String model, String effort, List<AssistantMessage> history, String userText, Map<String, Object> pageContext, PolicyService.ToolPolicy toolPolicy,
String profileInstructions, String turnId)
{
ModelRequest request = new ModelRequest()
.withModel(model)
.withEffort(effort)
.withMaxTokens(config.getModels().getMainMaxOutputTokens())
.withTools(tools(toolPolicy));
.withTools(tools(toolPolicy, turnId));
request.getSystem().add(new SystemBlock(SystemPrompt.staticBlock(appDigest()), CacheHint.LONG));
request.getSystem().add(new SystemBlock(userBlock(session) + (StringUtils.hasContent(profileInstructions) ? "\n" + profileInstructions : ""), CacheHint.SHORT));
@@ -30,11 +30,11 @@ import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.model.AssistantModel;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qbits.assistant.model.AssistantTurn;
import com.kingsrook.qbits.assistant.provider.ModelEventListener;
import com.kingsrook.qbits.assistant.provider.ModelMessage;
import com.kingsrook.qbits.assistant.provider.ModelProviderException;
import com.kingsrook.qbits.assistant.provider.ModelRequest;
import com.kingsrook.qbits.assistant.provider.ModelResponse;
import com.kingsrook.qbits.assistant.spi.ModelEventListener;
import com.kingsrook.qbits.assistant.spi.ModelMessage;
import com.kingsrook.qbits.assistant.spi.ModelProviderException;
import com.kingsrook.qbits.assistant.spi.ModelRequest;
import com.kingsrook.qbits.assistant.spi.ModelResponse;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -24,11 +24,11 @@ import java.util.Set;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.model.AssistantModel;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
import com.kingsrook.qbits.assistant.provider.DiscoveredModel;
import com.kingsrook.qbits.assistant.provider.ModelCapabilities;
import com.kingsrook.qbits.assistant.provider.ModelProviderException;
import com.kingsrook.qbits.assistant.provider.ProviderSettings;
import com.kingsrook.qbits.assistant.spi.AssistantProviderType;
import com.kingsrook.qbits.assistant.spi.DiscoveredModel;
import com.kingsrook.qbits.assistant.spi.ModelCapabilities;
import com.kingsrook.qbits.assistant.spi.ModelProviderException;
import com.kingsrook.qbits.assistant.spi.ProviderSettings;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
@@ -34,15 +34,15 @@ 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;
import com.kingsrook.qbits.assistant.provider.ModelProviderException;
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.provider.SystemBlock;
import com.kingsrook.qbits.assistant.scheduled.ScheduledTurn;
import com.kingsrook.qbits.assistant.spi.CacheHint;
import com.kingsrook.qbits.assistant.spi.ModelMessage;
import com.kingsrook.qbits.assistant.spi.ModelProviderException;
import com.kingsrook.qbits.assistant.spi.ModelRequest;
import com.kingsrook.qbits.assistant.spi.ModelResponse;
import com.kingsrook.qbits.assistant.spi.ModelUsage;
import com.kingsrook.qbits.assistant.spi.StreamHandle;
import com.kingsrook.qbits.assistant.spi.SystemBlock;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
@@ -23,9 +23,9 @@ import java.util.concurrent.FutureTask;
import java.util.concurrent.TimeUnit;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qbits.assistant.provider.HealthResult;
import com.kingsrook.qbits.assistant.provider.ModelProviderException;
import com.kingsrook.qbits.assistant.provider.ProviderSettings;
import com.kingsrook.qbits.assistant.spi.HealthResult;
import com.kingsrook.qbits.assistant.spi.ModelProviderException;
import com.kingsrook.qbits.assistant.spi.ProviderSettings;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
@@ -36,10 +36,11 @@ import com.kingsrook.qbits.assistant.model.AssistantModel;
import com.kingsrook.qbits.assistant.model.AssistantModelPrice;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
import com.kingsrook.qbits.assistant.provider.AssistantModelProvider;
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
import com.kingsrook.qbits.assistant.provider.ProviderSettings;
import com.kingsrook.qbits.assistant.provider.RegistryModelProvider;
import com.kingsrook.qbits.assistant.spi.AssistantModelProvider;
import com.kingsrook.qbits.assistant.spi.AssistantProviderType;
import com.kingsrook.qbits.assistant.spi.ModelPricing;
import com.kingsrook.qbits.assistant.spi.ProviderSettings;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
@@ -18,7 +18,7 @@ package com.kingsrook.qbits.assistant.engine;
import java.util.concurrent.atomic.AtomicReference;
import com.kingsrook.qbits.assistant.provider.StreamHandle;
import com.kingsrook.qbits.assistant.spi.StreamHandle;
/*******************************************************************************
@@ -29,7 +29,7 @@ import java.util.Set;
import com.kingsrook.qbits.assistant.classification.ClassificationSource;
import com.kingsrook.qbits.assistant.classification.Classifications;
import com.kingsrook.qbits.assistant.model.AssistantConversation;
import com.kingsrook.qbits.assistant.provider.ModelPart;
import com.kingsrook.qbits.assistant.spi.ModelPart;
import com.kingsrook.qqq.backend.core.actions.ai.AiRecordRef;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolResult;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
@@ -44,18 +44,19 @@ 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;
import com.kingsrook.qbits.assistant.provider.AssistantModelProvider;
import com.kingsrook.qbits.assistant.provider.CacheHint;
import com.kingsrook.qbits.assistant.provider.ModelCapabilities;
import com.kingsrook.qbits.assistant.provider.ModelEventListener;
import com.kingsrook.qbits.assistant.provider.ModelMessage;
import com.kingsrook.qbits.assistant.provider.ModelPart;
import com.kingsrook.qbits.assistant.provider.ModelProviderException;
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.spi.AssistantModelProvider;
import com.kingsrook.qbits.assistant.spi.CacheHint;
import com.kingsrook.qbits.assistant.spi.ModelCapabilities;
import com.kingsrook.qbits.assistant.spi.ModelEventListener;
import com.kingsrook.qbits.assistant.spi.ModelMessage;
import com.kingsrook.qbits.assistant.spi.ModelPart;
import com.kingsrook.qbits.assistant.spi.ModelPricing;
import com.kingsrook.qbits.assistant.spi.ModelProviderException;
import com.kingsrook.qbits.assistant.spi.ModelRequest;
import com.kingsrook.qbits.assistant.spi.ModelResponse;
import com.kingsrook.qbits.assistant.spi.ModelUsage;
import com.kingsrook.qbits.assistant.spi.StreamHandle;
import com.kingsrook.qbits.assistant.tools.KnowledgeTool;
import com.kingsrook.qbits.assistant.tools.PresentResultsTool;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
@@ -262,7 +263,7 @@ public class TurnEngine implements Runnable
String appName = runtime.appNameFor(pageContext);
ProfileService.Persona persona = runtime.getProfileService().persona(session, appName, scheduledTask == null ? null : scheduledTask.getProfileId());
String profileText = ProfileService.instructions(persona, runtime.getProfileService().relevantSkills(persona.profileId(), userText, appName));
ModelRequest request = runtime.getContextBuilder().build(session, model, route.effort(), history, userText, pageContext, toolPolicy, profileText);
ModelRequest request = runtime.getContextBuilder().build(session, model, route.effort(), history, userText, pageContext, toolPolicy, profileText, turn.getId() == null ? null : String.valueOf(turn.getId()));
Integer maxPerRequest = active.row() == null ? null : active.row().getMaxOutputTokensPerRequest();
if(maxPerRequest != null && (request.getMaxTokens() == null || request.getMaxTokens() > maxPerRequest))
{
@@ -885,7 +886,7 @@ public class TurnEngine implements Runnable
.withUserId(userId)
.withToolCallId(use.getToolUseId())
.withToolName(use.getToolName())
.withClientChannel(scheduledTask == null ? null : "assistant/scheduled/" + scheduledTask.getId())
.withClientChannel(scheduledTask == null ? "assistant/" + turn.getId() : "assistant/scheduled/" + scheduledTask.getId())
.withInputJson(truncate(redactInput(PresentResultsTool.NAME.equals(use.getToolName()) ? (presentation ? result.getRender() : Map.of()) : use.getInput()), MAX_INPUT_JSON_CHARS))
.withOutcome(outcome)
.withPermissionCheck(result.getPermissionCheck())
@@ -19,9 +19,10 @@ package com.kingsrook.qbits.assistant.engine;
import com.kingsrook.qbits.assistant.model.AssistantUsage;
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
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.spi.ModelPricing;
import com.kingsrook.qbits.assistant.spi.ModelProviderException;
import com.kingsrook.qbits.assistant.spi.ModelResponse;
import com.kingsrook.qbits.assistant.spi.ModelUsage;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
@@ -17,7 +17,15 @@
package com.kingsrook.qbits.assistant.home;
import com.kingsrook.qbits.assistant.model.AssistantTurn;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.AssistantSecurity;
import com.kingsrook.qbits.assistant.engine.BudgetService;
@@ -29,23 +37,15 @@ import com.kingsrook.qbits.assistant.model.AssistantBudgetOverride;
import com.kingsrook.qbits.assistant.model.AssistantConversation;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
import com.kingsrook.qbits.assistant.model.AssistantTurn;
import com.kingsrook.qbits.assistant.model.AssistantUsage;
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
import com.kingsrook.qbits.assistant.spi.AssistantProviderType;
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 java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/*******************************************************************************
@@ -75,6 +75,7 @@ public class AssistantHomeData
private final List<Totals> byProvider = new ArrayList<>();
private Long fallbackCount = 0L;
private List<AssistantSetupStatus.Item> setup = List.of();
private Boolean hipaaMode;
private Integer hipaaRetentionDays;
private LocalDate today;
@@ -313,6 +314,7 @@ public class AssistantHomeData
data.providers.add(new ProviderHealth(row, type == null ? row.getProviderType() : type.getLabel(), status, (status.startsWith("Set") || status.startsWith("Not needed")), service.validate(row), active != null && active.getId().equals(row.getId()),
providerMonth.getOrDefault(row.getId(), 0L), ProviderService.capMicros(row), service.kind(row), service.location(row), service.models(row.getId()).stream().filter(m -> Boolean.TRUE.equals(m.getEnabled())).count()));
}
data.setup = AssistantSetupStatus.load(runtime, data.providers);
return (data);
}
@@ -578,4 +580,15 @@ public class AssistantHomeData
{
return (fallbackCount);
}
/*******************************************************************************
** The Setup card: each configuration table, its state and the next step.
*******************************************************************************/
public List<AssistantSetupStatus.Item> getSetup()
{
return (setup);
}
}
@@ -90,6 +90,7 @@ public final class AssistantHomeView
public static String render(AssistantHomeData data, String prefix)
{
StringBuilder html = new StringBuilder("<div class=\"assistant-home\" style=\"" + ROOT + "\">");
setup(html, data, prefix);
status(html, data, prefix);
providers(html, data, prefix);
html.append("<div style=\"").append(GRID).append("\">");
@@ -111,6 +112,52 @@ public final class AssistantHomeView
/*******************************************************************************
** The Setup card: each part of the assistant, set up or not, and the next
** step, linked to its table.
*******************************************************************************/
private static void setup(StringBuilder html, AssistantHomeData data, String prefix)
{
if(data.getSetup().isEmpty())
{
return;
}
open(html, "Setup");
long todo = data.getSetup().stream().filter(i -> i.state() == AssistantSetupStatus.State.TODO).count();
html.append("<p style=\"margin:0 0 .6rem;font-size:.9rem;\">")
.append(todo == 0 ? "Everything the assistant needs is set up." : esc(todo + (todo == 1 ? " item needs" : " items need") + " attention."))
.append("</p><ul style=\"list-style:none;margin:0;padding:0;\">");
for(AssistantSetupStatus.Item item : data.getSetup())
{
String color = switch(item.state())
{
case OK -> GREEN;
case TODO -> RED;
case DEFAULT -> AMBER;
case OPTIONAL -> GREY;
};
String label = switch(item.state())
{
case OK -> "Set up";
case TODO -> "Needs setup";
case DEFAULT -> "Using defaults";
case OPTIONAL -> "Optional";
};
html.append("<li style=\"").append(ROW).append("\"><div style=\"display:flex;flex-wrap:wrap;align-items:center;gap:.5rem .75rem;\">")
.append("<strong><a style=\"").append(LINK).append("\" href=\"").append(esc(prefix + "/" + item.tableName())).append("\">").append(esc(item.label())).append("</a></strong>")
.append(chip(color, label)).append("</div><div style=\"font-size:.9rem;margin-top:.35rem;\">").append(esc(item.detail())).append("</div>");
if(item.nextStep() != null)
{
html.append("<div style=\"").append(MUTED).append("font-size:.9rem;margin-top:.2rem;\">Next: ").append(esc(item.nextStep())).append("</div>");
}
html.append("</li>");
}
html.append("</ul>");
close(html);
}
/*******************************************************************************
** The status row: is the assistant answering, with what, at what cost.
*******************************************************************************/
@@ -0,0 +1,231 @@
/*
* 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.home;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.engine.ProviderService;
import com.kingsrook.qbits.assistant.model.AssistantBudget;
import com.kingsrook.qbits.assistant.model.AssistantDataAccess;
import com.kingsrook.qbits.assistant.model.AssistantModelPrice;
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.AssistantScheduledTask;
import com.kingsrook.qbits.assistant.model.AssistantSkill;
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
import com.kingsrook.qbits.assistant.spi.ModelPricing;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
/*******************************************************************************
** The Setup card on the Assistant home: each configuration table, whether it
** is set up, and the next step. Only reads the QBit's own configuration
** tables (small), as the signed-in administrator.
*******************************************************************************/
public final class AssistantSetupStatus
{
private static final int LIMIT = 1000;
/*******************************************************************************
** OK: working. TODO: the assistant needs it. DEFAULT: working on built-in
** defaults (nothing to do). OPTIONAL: a feature that is off until set up.
*******************************************************************************/
public enum State
{
OK, TODO, DEFAULT, OPTIONAL
}
/*******************************************************************************
** One line of the card.
*******************************************************************************/
public record Item(String label, String tableName, State state, String detail, String nextStep)
{
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AssistantSetupStatus()
{
}
/*******************************************************************************
** Build the items. `providers` is what the home already loaded.
*******************************************************************************/
public static List<Item> load(AssistantRuntime runtime, List<AssistantHomeData.ProviderHealth> providers) throws QException
{
AssistantStore store = runtime.getStore();
List<Item> items = new ArrayList<>();
///////////////
// providers //
///////////////
AssistantHomeData.ProviderHealth active = providers.stream().filter(AssistantHomeData.ProviderHealth::active).findFirst().orElse(null);
if(active != null && active.keyPresent() && active.problems().isEmpty())
{
items.add(new Item("Provider", AssistantProvider.TABLE_NAME, State.OK, active.row().getName() + " answers questions.", null));
}
else if(providers.isEmpty())
{
items.add(new Item("Provider", AssistantProvider.TABLE_NAME, State.TODO, "No provider yet.", "Add one with New: pick the type and model, and name the environment variable that holds the API key."));
}
else
{
items.add(new Item("Provider", AssistantProvider.TABLE_NAME, State.TODO, "The default provider cannot be used.", "Turn one on, set it as default, make sure its key variable is set, then run Test connection."));
}
//////////////////////////////////////////////////////////////////////
// prices: resolved exactly as calls are costed (registry row, price //
// row, then the configured price table); local models cost nothing //
//////////////////////////////////////////////////////////////////////
int priceRows = store.query(AssistantModelPrice.TABLE_NAME, new QQueryFilter().withLimit(LIMIT), AssistantModelPrice.class).size();
String rowsText = priceRows + " price row" + (priceRows == 1 ? "" : "s");
if(active == null)
{
items.add(new Item("Model prices", AssistantModelPrice.TABLE_NAME, State.DEFAULT, rowsText + "; checked once a provider answers.", null));
}
else
{
AssistantProvider row = active.row();
ModelPricing price = runtime.getProviderService().pricing(new ProviderService.Active(row, row.getProviderType(), row.getModel(), null), row.getModel());
boolean local = "LOCAL".equals(active.location());
boolean known = price != null && (positive(price.getInputUsdPerMTok()) || positive(price.getOutputUsdPerMTok()));
items.add(known || local
? new Item("Model prices", AssistantModelPrice.TABLE_NAME, State.OK, local && !known ? "The default model is local and costs nothing. " + rowsText + "."
: "The default model is priced at $" + price.getInputUsdPerMTok() + " in / $" + price.getOutputUsdPerMTok() + " out per million tokens. " + rowsText + ".", null)
: new Item("Model prices", AssistantModelPrice.TABLE_NAME, State.TODO, "The default model (" + row.getModel() + ") has no price, so its calls cost $0 and cost budgets never trigger.",
"Add a price row for its provider type and model."));
}
////////////
// policy //
////////////
List<AssistantPolicy> policies = store.query(AssistantPolicy.TABLE_NAME, new QQueryFilter().withLimit(1), AssistantPolicy.class);
if(policies.isEmpty())
{
items.add(new Item("Policy", AssistantPolicy.TABLE_NAME, State.DEFAULT, "No policy row: the code configuration applies.", "Add one to control the kill switch, confirmations and tool limits here."));
}
else if(Boolean.TRUE.equals(policies.get(0).getKillSwitch()))
{
items.add(new Item("Policy", AssistantPolicy.TABLE_NAME, State.TODO, "The kill switch is on: the assistant answers nothing.", "Turn it off on the policy row when ready."));
}
else
{
items.add(new Item("Policy", AssistantPolicy.TABLE_NAME, State.OK, "Writes " + (Boolean.FALSE.equals(policies.get(0).getWritesRequireConfirmation()) ? "do not wait" : "wait") + " for confirmation.", null));
}
////////////////////////
// profiles and skills //
////////////////////////
List<AssistantProfile> profiles = enabled(store.query(AssistantProfile.TABLE_NAME, new QQueryFilter().withLimit(LIMIT), AssistantProfile.class));
boolean hasDefault = profiles.stream().anyMatch(p -> Boolean.TRUE.equals(p.getIsDefault()));
items.add(profiles.isEmpty()
? new Item("Profiles", AssistantProfile.TABLE_NAME, State.DEFAULT, "No profile: the built-in assistant answers, with no skills.", "Add a default profile to name the assistant, set its tone and offer skills.")
: new Item("Profiles", AssistantProfile.TABLE_NAME, hasDefault ? State.OK : State.DEFAULT,
profiles.size() + " profile" + (profiles.size() == 1 ? "" : "s") + (hasDefault ? ", with a default." : ", none of them the default."),
hasDefault ? null : "Mark one as the default so it applies everywhere else."));
Set<Integer> linkedSkillIds = new HashSet<>();
Set<Integer> profileIds = new HashSet<>(profiles.stream().map(AssistantProfile::getId).toList());
for(AssistantProfileSkill link : store.query(AssistantProfileSkill.TABLE_NAME, new QQueryFilter().withLimit(LIMIT * 5), AssistantProfileSkill.class))
{
if(profileIds.contains(link.getProfileId()))
{
linkedSkillIds.add(link.getSkillId());
}
}
List<AssistantSkill> skills = enabled(store.query(AssistantSkill.TABLE_NAME, new QQueryFilter().withLimit(LIMIT), AssistantSkill.class));
long offered = skills.stream().filter(s -> linkedSkillIds.contains(s.getId())).count();
items.add(skills.isEmpty()
? new Item("Skills", AssistantSkill.TABLE_NAME, State.OPTIONAL, "No skills.", "Write know-how for your business terms, then link it to a profile.")
: offered == 0
? new Item("Skills", AssistantSkill.TABLE_NAME, State.TODO, skills.size() + " skill" + (skills.size() == 1 ? "" : "s") + ", but none is linked to a turned-on profile, so the model never sees them.", "Link them to a profile (Profile skills).")
: new Item("Skills", AssistantSkill.TABLE_NAME, State.OK, offered + " of " + skills.size() + " skill" + (skills.size() == 1 ? " is" : "s are") + " offered through a profile.", null));
/////////////
// budgets //
/////////////
List<AssistantBudget> budgets = store.budgets();
items.add(budgets.isEmpty()
? new Item("Budgets", AssistantBudget.TABLE_NAME, State.TODO, "No budget: spending is limited only by the policy's configured caps.", "Add a monthly instance budget (warn, downgrade, stop).")
: new Item("Budgets", AssistantBudget.TABLE_NAME, State.OK, budgets.size() + " budget" + (budgets.size() == 1 ? "" : "s") + " checked before every question.", null));
/////////////////
// data access //
/////////////////
int accessRows = store.query(AssistantDataAccess.TABLE_NAME, new QQueryFilter().withLimit(LIMIT), AssistantDataAccess.class).size();
items.add(accessRows == 0
? new Item("Data access", AssistantDataAccess.TABLE_NAME, State.DEFAULT, "Defaults: the assistant may read what each user can read; it proposes no changes.",
"Run Add rows for every table to review them, then raise tables to READ_WRITE where it may propose changes.")
: new Item("Data access", AssistantDataAccess.TABLE_NAME, State.OK, accessRows + " row" + (accessRows == 1 ? "" : "s") + "; tables without a row use the default.", null));
///////////////////////////////////////
// optional: knowledge and schedules //
///////////////////////////////////////
long sources = enabled(store.query(AssistantRagSource.TABLE_NAME, new QQueryFilter().withLimit(LIMIT), AssistantRagSource.class)).size();
items.add(sources == 0
? new Item("Knowledge sources", AssistantRagSource.TABLE_NAME, State.OPTIONAL, "Off: search by meaning is not offered.", "Add a source (a readable table, its text fields and an embedding model), then index it.")
: new Item("Knowledge sources", AssistantRagSource.TABLE_NAME, State.OK, sources + " source" + (sources == 1 ? "" : "s") + " turned on.", null));
List<AssistantScheduledTask> tasks = store.query(AssistantScheduledTask.TABLE_NAME, new QQueryFilter().withLimit(LIMIT), AssistantScheduledTask.class);
long onTasks = tasks.stream().filter(t -> Boolean.TRUE.equals(t.getEnabled())).count();
items.add(onTasks == 0
? new Item("Scheduled tasks", AssistantScheduledTask.TABLE_NAME, State.OPTIONAL, tasks.isEmpty() ? "None." : tasks.size() + " task" + (tasks.size() == 1 ? "" : "s") + ", all turned off.",
"Set a run-as user and an allowed credential variable on a task, then turn it on.")
: new Item("Scheduled tasks", AssistantScheduledTask.TABLE_NAME, State.OK, onTasks + " task" + (onTasks == 1 ? "" : "s") + " turned on.", null));
return (items);
}
/*******************************************************************************
** Rows not explicitly turned off.
*******************************************************************************/
private static <T extends QRecordEntity> List<T> enabled(List<T> rows)
{
return (rows.stream().filter(row -> !Boolean.FALSE.equals(row.toQRecord().getValueBoolean("enabled"))).toList());
}
/*******************************************************************************
**
*******************************************************************************/
private static boolean positive(Double value)
{
return (value != null && value > 0);
}
}
@@ -0,0 +1,96 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.metadata;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import com.kingsrook.qbits.assistant.AssistantQBitProducer;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.ClientChannelsMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.SourceQBitAware;
/*******************************************************************************
** Register and verify the assistant's configuration wall beneath user permissions.
*******************************************************************************/
public final class AssistantChannelWall
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AssistantChannelWall()
{
}
/*******************************************************************************
** Enumerate the instance so new assistant metadata cannot miss registration.
** Preserve host permissions and other denied channels.
*******************************************************************************/
public static void register(QInstance qInstance)
{
ClientChannelsMetaData.ofOrWithNew(qInstance).withDeniedPattern("assistant", "assistant_*");
for(MetaDataWithPermissionRules object : objects(qInstance))
{
QPermissionRules rules = object.getPermissionRules();
rules = rules == null ? qInstance.getDefaultPermissionRules().clone() : rules.clone();
Set<String> denied = new HashSet<>(rules.getDeniedClients() == null ? Set.of() : rules.getDeniedClients());
denied.add("assistant");
object.setPermissionRules(rules.withDeniedClients(denied));
}
}
/*******************************************************************************
** Fail before runtime initialization if a host customization opens the wall.
*******************************************************************************/
public static void validate(QInstance qInstance)
{
ClientChannelsMetaData channels = ClientChannelsMetaData.of(qInstance);
for(MetaDataWithPermissionRules object : objects(qInstance))
{
QPermissionRules rules = object.getPermissionRules() == null ? qInstance.getDefaultPermissionRules() : object.getPermissionRules();
if(!channels.isDenied("assistant", object.getName(), rules))
{
throw (new IllegalStateException("Assistant channel wall: " + object.getName() + " must be denied to client channel assistant before startup."));
}
}
}
/*******************************************************************************
** Tables, processes, reports and widgets, including host-added assistant objects.
*******************************************************************************/
private static List<MetaDataWithPermissionRules> objects(QInstance qInstance)
{
List<MetaDataWithPermissionRules> objects = new ArrayList<>();
objects.addAll(qInstance.getTables().values());
objects.addAll(qInstance.getProcesses().values());
objects.addAll(qInstance.getReports().values());
objects.addAll(qInstance.getWidgets().values());
return (objects.stream().filter(object -> (object.getName() != null && object.getName().startsWith("assistant"))
|| (object instanceof SourceQBitAware source && (AssistantQBitProducer.GROUP_ID + ":" + AssistantQBitProducer.ARTIFACT_ID).equals(source.getSourceQBitName()))).toList());
}
}
@@ -0,0 +1,126 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.metadata;
import java.io.IOException;
import java.io.InputStream;
import java.util.LinkedHashMap;
import java.util.Map;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
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.help.QHelpContent;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Applies help/qbit-assistant.yaml (the QQQ help-bundle format) to the
** Assistant app and its assistant_* tables: what each table is for and how
** its rows get created, shown on the table screen and when it is empty.
** STAND-IN(core=#1198): replace with HelpBundles.register once the pinned
** QQQ release includes it; the bundle file stays the same.
*******************************************************************************/
public final class AssistantHelp
{
public static final String RESOURCE = "/help/qbit-assistant.yaml";
public static final String SLOT = "help";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private AssistantHelp()
{
}
/*******************************************************************************
** The bundle's entries by key, validated (fails startup if malformed).
*******************************************************************************/
public static Map<String, JsonNode> entries() throws QException
{
try(InputStream stream = AssistantHelp.class.getResourceAsStream(RESOURCE))
{
if(stream == null)
{
throw (new IOException("missing resource"));
}
JsonNode bundle = new ObjectMapper(new YAMLFactory()).readTree(stream);
if(!"qbit-assistant".equals(bundle.path("bundle").asText()) || !"QBIT".equals(bundle.path("layer").asText()))
{
throw (new IOException("invalid bundle header"));
}
Map<String, JsonNode> entries = new LinkedHashMap<>();
for(JsonNode entry : bundle.path("entries"))
{
String key = entry.path("key").asText();
String summary = entry.path("summary").asText();
if(key.isBlank() || summary.isBlank() || summary.length() > 120 || entry.path("content").asText().isBlank() || entries.put(key, entry) != null)
{
throw (new IOException("invalid or duplicate entry: " + key));
}
}
return (entries);
}
catch(IOException | RuntimeException e)
{
throw (new QException("Could not load " + RESOURCE, e));
}
}
/*******************************************************************************
** Apply the help to the app and every assistant_* table in the instance.
** A host's own help on a table is kept (the bundle only fills the slot).
*******************************************************************************/
public static void apply(QInstance qInstance) throws QException
{
Map<String, JsonNode> entries = entries();
for(QTableMetaData table : qInstance.getTables().values())
{
JsonNode entry = entries.get("table:" + table.getName() + ";slot:" + SLOT);
if(entry != null && (table.getHelpContent() == null || !table.getHelpContent().containsKey(SLOT)))
{
table.withHelpContent(SLOT, content(entry));
}
}
QAppMetaData app = qInstance.getApp(AssistantMetaDataBuilder.APP_NAME);
JsonNode appEntry = entries.get("app:" + AssistantMetaDataBuilder.APP_NAME + ";slot:" + SLOT);
if(app != null && appEntry != null && (app.getHelpContent() == null || !app.getHelpContent().containsKey(SLOT)))
{
app.withHelpContent(SLOT, content(appEntry));
}
}
/*******************************************************************************
** Summary as the first paragraph, then the content, as markdown.
*******************************************************************************/
static QHelpContent content(JsonNode entry)
{
return (new QHelpContent().withContentAsMarkdown(entry.path("summary").asText().strip() + "\n\n" + entry.path("content").asText().strip()));
}
}
@@ -51,7 +51,7 @@ 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.AssistantProviderType;
import com.kingsrook.qbits.assistant.spi.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.actions.tables.query.QCriteriaOperator;
@@ -26,7 +26,7 @@ import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.model.AssistantModel;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
import com.kingsrook.qbits.assistant.spi.AssistantProviderType;
import com.kingsrook.qqq.backend.core.actions.values.QCustomPossibleValueProvider;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -100,7 +100,7 @@ public class AssistantToolCall extends QRecordEntity
@QField(isEditable = false)
private Integer httpStatus;
@QField(isEditable = false, maxLength = 500)
@QField(isEditable = false)
private String auditHref;
@QField(isEditable = false, possibleValueSourceName = AssistantPendingAction.TABLE_NAME)
@@ -51,8 +51,8 @@ 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.qbits.assistant.spi.ModelUsage;
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;
@@ -0,0 +1,268 @@
/*
* 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.processes;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.access.DataAccessMode;
import com.kingsrook.qbits.assistant.access.DataAccessService;
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
import com.kingsrook.qbits.assistant.model.AssistantDataAccess;
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
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.ProcessSummaryLine;
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.actions.tables.query.QQueryFilter;
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.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractExtractStep;
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.ProcessSummaryProviderInterface;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
/*******************************************************************************
** "Add rows for every table": a table action on assistant_data_access that
** writes one row per application table that has none yet, with the access
** the assistant already gets by default (READ, or NONE where the table's AI
** exposure is NONE). It changes no behavior on its own; it makes the defaults
** visible and editable. Existing rows are never touched. Standard streamed
** ETL: Review lists what will be added, Result what was added.
*******************************************************************************/
public final class DataAccessDefaultsProcess
{
public static final String NAME = "assistantDataAccessAddDefaults";
public static final String NOTE = "Added by 'Add rows for every table': the assistant's default for this table. The user's own permissions still apply.";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private DataAccessDefaultsProcess()
{
}
/*******************************************************************************
** The process: a table-level action (no records need to be selected).
*******************************************************************************/
public static QProcessMetaData process()
{
Map<String, Serializable> values = new HashMap<>();
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, AssistantDataAccess.TABLE_NAME);
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, AssistantDataAccess.TABLE_NAME);
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, false);
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE,
"These rows will be added: one per table that has none, with the access the assistant already has by default. Existing rows are not changed.");
QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(Extract.class, Transform.class, Load.class, values);
process.setName(NAME);
process.setLabel("Add rows for every table");
process.setIcon(new QIcon("playlist_add"));
process.setTableName(AssistantDataAccess.TABLE_NAME);
process.setMinInputRecords(0);
process.setPermissionRules(AssistantMetaDataBuilder.adminRules());
return (process);
}
/*******************************************************************************
** The rows that are missing: one per application table without a row.
*******************************************************************************/
public static List<AssistantDataAccess> missingRows(QInstance qInstance, DataAccessService access, AssistantStore store) throws QException
{
////////////////////////////////////////////////////////////////////
// fresh rows: a row deleted on another server must not come back //
// from this server's cache as a "default" //
////////////////////////////////////////////////////////////////////
access.invalidate();
Set<String> existing = new HashSet<>();
for(AssistantDataAccess row : store.query(AssistantDataAccess.TABLE_NAME, new QQueryFilter().withLimit(10_000), AssistantDataAccess.class))
{
if(row.getObjectType() == null || AssistantDataAccess.TYPE_TABLE.equals(row.getObjectType()))
{
existing.add(row.getObjectName());
}
}
List<AssistantDataAccess> rs = new ArrayList<>();
for(QTableMetaData table : qInstance.getTables().values().stream().sorted((a, b) -> a.getName().compareTo(b.getName())).toList())
{
if(table.getName().startsWith("assistant_") || existing.contains(table.getName()))
{
continue;
}
DataAccessMode mode = access.forTable(table.getName()).mode();
rs.add(new AssistantDataAccess().withObjectType(AssistantDataAccess.TYPE_TABLE).withObjectName(table.getName())
.withMode(mode.name()).withNotes(NOTE));
}
return (rs);
}
/*******************************************************************************
** The running assistant (or a clear error).
*******************************************************************************/
private static AssistantRuntime runtime() throws QException
{
AssistantRuntime runtime = AssistantRuntime.of(QContext.getQInstance());
if(runtime == null)
{
throw (new QException("The assistant is not running on this server."));
}
return (runtime);
}
/*******************************************************************************
** Extract: the missing rows, computed (no query of the selected records).
*******************************************************************************/
public static class Extract extends AbstractExtractStep
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public Integer doCount(RunBackendStepInput input) throws QException
{
AssistantRuntime runtime = runtime();
return (missingRows(QContext.getQInstance(), runtime.getDataAccessService(), runtime.getStore()).size());
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
AssistantRuntime runtime = runtime();
for(AssistantDataAccess row : missingRows(QContext.getQInstance(), runtime.getDataAccessService(), runtime.getStore()))
{
getRecordPipe().addRecord(row.toQRecord());
}
}
}
/*******************************************************************************
** Transform: counts by mode for the review screen.
*******************************************************************************/
public static class Transform extends AbstractTransformStep implements ProcessSummaryProviderInterface
{
private final Map<String, ProcessSummaryLine> byMode = new HashMap<>();
/*******************************************************************************
**
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
for(QRecord record : input.getRecords())
{
String mode = record.getValueString("mode");
byMode.computeIfAbsent(mode, m -> DiscoverModelsProcess.line("NONE".equals(m) ? Status.INFO : Status.OK,
"table will get a row with access " + m, "tables will get a row with access " + m,
"table got a row with access " + m, "tables got a row with access " + m)).incrementCountAndAddPrimaryKey(record.getValue("objectName"));
output.getRecords().add(record);
}
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
byMode.values().forEach(line -> line.addSelfToListIfAnyCount(lines));
if(lines.isEmpty())
{
lines.add(new ProcessSummaryLine(Status.INFO, null, "Every table already has a row."));
}
return (lines);
}
}
/*******************************************************************************
** Load: insert through the store (the user's own session; admin only).
*******************************************************************************/
public static class Load extends AbstractLoadStep implements ProcessSummaryProviderInterface
{
private final ProcessSummaryLine added = DiscoverModelsProcess.line(Status.OK, "row was added", "rows were added", "row was added", "rows were added");
/*******************************************************************************
**
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
AssistantRuntime runtime = runtime();
for(QRecord record : input.getRecords())
{
AssistantDataAccess row = runtime.getStore().insert(new AssistantDataAccess(record));
added.incrementCountAndAddPrimaryKey(row.getId());
output.getRecords().add(row.toQRecord());
}
runtime.getDataAccessService().invalidate();
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
added.addSelfToListIfAnyCount(lines);
return (lines);
}
}
}
@@ -18,6 +18,15 @@ package com.kingsrook.qbits.assistant.provider;
import com.kingsrook.qbits.assistant.model.AssistantModel;
import com.kingsrook.qbits.assistant.spi.AssistantModelProvider;
import com.kingsrook.qbits.assistant.spi.EmbeddingRequest;
import com.kingsrook.qbits.assistant.spi.EmbeddingResponse;
import com.kingsrook.qbits.assistant.spi.ModelCapabilities;
import com.kingsrook.qbits.assistant.spi.ModelEventListener;
import com.kingsrook.qbits.assistant.spi.ModelProviderException;
import com.kingsrook.qbits.assistant.spi.ModelRequest;
import com.kingsrook.qbits.assistant.spi.StreamHandle;
import com.kingsrook.qbits.assistant.spi.TokenCount;
/*******************************************************************************
@@ -24,16 +24,16 @@ import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Function;
import com.kingsrook.qbits.assistant.provider.AssistantModelProvider;
import com.kingsrook.qbits.assistant.provider.CompletableStreamHandle;
import com.kingsrook.qbits.assistant.provider.ModelCapabilities;
import com.kingsrook.qbits.assistant.provider.ModelEventListener;
import com.kingsrook.qbits.assistant.provider.ModelPart;
import com.kingsrook.qbits.assistant.provider.ModelProviderException;
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.spi.AssistantModelProvider;
import com.kingsrook.qbits.assistant.spi.CompletableStreamHandle;
import com.kingsrook.qbits.assistant.spi.ModelCapabilities;
import com.kingsrook.qbits.assistant.spi.ModelEventListener;
import com.kingsrook.qbits.assistant.spi.ModelPart;
import com.kingsrook.qbits.assistant.spi.ModelProviderException;
import com.kingsrook.qbits.assistant.spi.ModelRequest;
import com.kingsrook.qbits.assistant.spi.ModelResponse;
import com.kingsrook.qbits.assistant.spi.ModelUsage;
import com.kingsrook.qbits.assistant.spi.StreamHandle;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
@@ -30,11 +30,11 @@ import com.kingsrook.qbits.assistant.engine.PolicyService;
import com.kingsrook.qbits.assistant.engine.ProviderService;
import com.kingsrook.qbits.assistant.engine.UsageRecorder;
import com.kingsrook.qbits.assistant.model.AssistantDataAudit;
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.qbits.assistant.spi.EmbeddingRequest;
import com.kingsrook.qbits.assistant.spi.EmbeddingResponse;
import com.kingsrook.qbits.assistant.spi.ModelProviderException;
import com.kingsrook.qbits.assistant.spi.ModelResponse;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
@@ -145,7 +145,7 @@ public class RagService
{
PolicyService.ToolPolicy policy = context.policy();
UiApiToolbox.Context internal = new UiApiToolbox.Context(context.sessionUUID(), context.userTimezone(), context.gateway(),
new PolicyService.ToolPolicy(true, Set.of(), policy == null ? 50 : policy.maxRowsPerCall(), policy == null || policy.redactPii()), null);
new PolicyService.ToolPolicy(true, Set.of(), policy == null ? 50 : policy.maxRowsPerCall(), policy == null || policy.redactPii()), null, context.credentials(), context.turnId());
return (reader.execute(internal, "knowledge-read", UiApiToolbox.GET_RECORD, Map.of("table", table, "id", pk)));
}
@@ -0,0 +1,49 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.tools;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import com.kingsrook.qqq.backend.core.actions.ai.output.AiLinkBuilder;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
/*******************************************************************************
** Encode server-assembled filters in the format consumed by the Next UI.
*******************************************************************************/
public final class FilterLinkBuilder
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private FilterLinkBuilder()
{
}
/*******************************************************************************
** Callers validate model-supplied fields before constructing their criteria.
*******************************************************************************/
public static String href(String tableName, List<Map<String, Object>> criteria)
{
return (AiLinkBuilder.objectHref(tableName) + "?filter=" + URLEncoder.encode(JsonUtils.toJson(Map.of("criteria", criteria, "booleanOperator", "AND")), StandardCharsets.UTF_8));
}
}
@@ -28,11 +28,11 @@ import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.engine.PolicyService;
import com.kingsrook.qbits.assistant.engine.TurnEngine;
import com.kingsrook.qbits.assistant.model.AssistantRagSource;
import com.kingsrook.qbits.assistant.provider.ModelToolDefinition;
import com.kingsrook.qbits.assistant.rag.EmbeddingService;
import com.kingsrook.qbits.assistant.rag.RagService;
import com.kingsrook.qbits.assistant.rag.TextChunks;
import com.kingsrook.qbits.assistant.rag.VectorStore;
import com.kingsrook.qbits.assistant.spi.ModelToolDefinition;
import com.kingsrook.qqq.backend.core.actions.ai.AiRecordRef;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolOutcome;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolResult;
@@ -71,6 +71,16 @@ public class KnowledgeTool
** A source must be enabled and readable in the UI before offering the tool.
*******************************************************************************/
public Boolean offered(PolicyService.ToolPolicy policy, QSession session)
{
return (offered(policy, session, null));
}
/*******************************************************************************
** Tool discovery is a UI API call too: retain the turn when known.
*******************************************************************************/
public Boolean offered(PolicyService.ToolPolicy policy, QSession session, String turnId)
{
if(session == null || (policy != null && !policy.allows(NAME)))
{
@@ -90,7 +100,7 @@ public class KnowledgeTool
{
return (false);
}
return (runtime.getAccessExplainer().explain(runtime.getUiApiGateway(), session.getUuid(), policy, null, null).stream()
return (runtime.getAccessExplainer().explain(runtime.getUiApiGateway(), session.getUuid(), null, policy, null, null, turnId).stream()
.anyMatch(t -> tables.contains(t.table()) && t.uiRead() && t.mode().allowsRead()));
}
catch(QException e)
@@ -20,7 +20,6 @@ package com.kingsrook.qbits.assistant.tools;
import java.io.InputStream;
import java.math.BigDecimal;
import java.math.MathContext;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
@@ -29,7 +28,7 @@ import java.util.Locale;
import java.util.Map;
import java.util.function.Function;
import com.fasterxml.jackson.core.type.TypeReference;
import com.kingsrook.qbits.assistant.provider.ModelToolDefinition;
import com.kingsrook.qbits.assistant.spi.ModelToolDefinition;
import com.kingsrook.qbits.assistant.uiapi.UiApiCall;
import com.kingsrook.qbits.assistant.uiapi.UiApiResult;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolOutcome;
@@ -388,7 +387,7 @@ public class PresentResultsTool
values.forEach(PresentResultsTool::scalar);
output.add(map("fieldName", fieldName, "operator", operator, "values", values));
}
return (safeHref(AiLinkBuilder.objectHref(tableName) + "?filter=" + URLEncoder.encode(JsonUtils.toJson(map("criteria", output, "booleanOperator", "AND")), StandardCharsets.UTF_8)));
return (safeHref(FilterLinkBuilder.href(tableName, output)));
}
@@ -30,7 +30,7 @@ import java.util.TreeMap;
import com.kingsrook.qbits.assistant.access.DataAccessMode;
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.spi.ModelToolDefinition;
import com.kingsrook.qbits.assistant.uiapi.UiApiCall;
import com.kingsrook.qbits.assistant.uiapi.UiApiGateway;
import com.kingsrook.qbits.assistant.uiapi.UiApiResult;
@@ -372,6 +372,9 @@ public class UiApiToolbox
/*******************************************************************************
** Offer history only when every audit table it queries is available to the
** assistant channel, including inherited permission rules.
** RC.6 isDenied checks the all-operation wall, with no operation argument.
** Core #1193 must keep write-only denials separate from this read check;
** do not consult write-deny patterns here when offering this read tool.
*******************************************************************************/
private boolean historyAllowed()
{
@@ -1285,10 +1288,19 @@ public class UiApiToolbox
default -> "Deleted";
};
String label = table == null ? tableName : label(table);
QTableMetaData audit = qInstance.getTable(AuditsMetaDataProvider.TABLE_NAME_AUDIT);
if(id != null && audit != null && audit.getField(AuditsMetaDataProvider.FIELD_CLIENT_CHANNEL) != null)
{
String channel = "assistant" + (context.turnId() == null ? "" : "/" + context.turnId());
outcome.auditHref = FilterLinkBuilder.href(AuditsMetaDataProvider.TABLE_NAME_AUDIT, List.of(
Map.of("fieldName", "recordId", "operator", "EQUALS", "values", List.of(id)),
Map.of("fieldName", "auditTable.name", "operator", "EQUALS", "values", List.of(tableName)),
Map.of("fieldName", AuditsMetaDataProvider.FIELD_CLIENT_CHANNEL, "operator", "EQUALS", "values", List.of(channel))));
}
if(id != null && !DELETE_RECORD.equals(toolName))
{
String href = AiLinkBuilder.recordHref(tableName, id);
outcome.auditHref = href;
outcome.result.getRecordRefs().add(new AiRecordRef().withTableName(tableName).withTableLabel(label).withId(id).withLabel(label + " " + id).withHref(href));
}
outcome.result.withOutcome(AiToolOutcome.OK).withTableName(tableName).withRowCount(1).withSummary(verb + " " + label + (id == null ? "" : " " + id))
@@ -25,7 +25,7 @@ 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.spi.ModelToolDefinition;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qbits.assistant.uiapi.UiApiCall;
import com.kingsrook.qbits.assistant.uiapi.UiApiResult;
@@ -0,0 +1,162 @@
-- liquibase formatted sql
-- qbit-assistant starter defaults: a working assistant out of the box.
-- Include it from the host's master changelog AFTER the changesets that create
-- the assistant_* tables (docs/schema-postgres.sql):
--
-- - include: { file: db/liquibase/qbit-assistant/assistant-defaults.changelog.sql }
--
-- Rules for this file:
-- * insert-only: every row is inserted only when its natural key is absent,
-- so an administrator's edits always survive and nothing is ever updated;
-- * rollback is empty: a seeded row cannot be told apart from a host's own
-- row with the same name, and Liquibase also rolls back changesets it marked
-- ran, so undoing would delete the host's data (remove seeded rows by hand);
-- * no secrets: environment variable NAMES only, never values;
-- * portable SQL (PostgreSQL and H2): INSERT ... SELECT ... WHERE NOT EXISTS;
-- * every changeset has a precondition on its table, so a host that has not
-- created a table yet simply skips it (MARK_RAN).
-- AssistantDefaultsChangelogTest runs this file twice against a fresh schema.
-- changeset qbit-assistant:defaults-1
-- preconditions onFail:MARK_RAN onError:MARK_RAN
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM assistant_policy
-- comment: The single policy row, only when the host has none. Nulls inherit the code configuration (models, config budgets). Writes wait for the user's confirmation, PII is redacted, 50 rows per call.
INSERT INTO assistant_policy (name, kill_switch, writes_require_confirmation, redact_pii, max_rows_per_call, create_date, modify_date)
SELECT 'default', FALSE, TRUE, TRUE, 50, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
WHERE NOT EXISTS (SELECT 1 FROM assistant_policy);
-- rollback empty
-- changeset qbit-assistant:defaults-2
-- preconditions onFail:MARK_RAN onError:MARK_RAN
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM assistant_profile WHERE name = 'default' OR is_default = TRUE
-- comment: The default profile (persona): used for every app and user that no more specific profile matches. Skipped when the host already has a default profile, so there is never a second default.
INSERT INTO assistant_profile (name, display_name, icon, personality, greeting, starter_prompts, is_default, enabled, create_date, modify_date)
SELECT 'default', 'Assistant', 'auto_awesome',
'Be concise, friendly and plain-spoken. Lead with the answer, then the detail that supports it. When something is ambiguous, ask one short question instead of guessing. Offer a link to the list or record you used so the user can check it.',
'Hi! I can find and count records, explain a record and its history, and open the right screen for you. What would you like to know?',
'What can you help me with?
Which tables can I look at?
What changed in the last 7 days?',
TRUE, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
WHERE NOT EXISTS (SELECT 1 FROM assistant_profile WHERE name = 'default' OR is_default = TRUE);
-- rollback empty
-- changeset qbit-assistant:defaults-3
-- preconditions onFail:MARK_RAN onError:MARK_RAN
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM assistant_skill WHERE name = 'find-and-count'
-- comment: Starter skill. Skills are know-how text only: they never grant tools, data or permissions.
INSERT INTO assistant_skill (name, description, instructions, enabled, trigger_keywords, create_date, modify_date)
SELECT 'find-and-count', 'Finding, filtering and counting records.',
'When the user asks to find, list or count records:
1. If you do not already know the table and field names, call list_tables and describe_table first. Never guess names.
2. For "how many" questions use count_records; for lists use query_records with a filter, not search_records.
3. Say in one short phrase which filter you used (for example "open orders created this month").
4. Always include a filterLinks item for the list you counted, so the user can open exactly those records.
5. If the result was truncated, say so and offer the link instead of listing more.',
TRUE, 'find,show,list,which,how many,count,filter,search,look up', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
WHERE NOT EXISTS (SELECT 1 FROM assistant_skill WHERE name = 'find-and-count');
-- rollback empty
-- changeset qbit-assistant:defaults-4
-- preconditions onFail:MARK_RAN onError:MARK_RAN
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM assistant_skill WHERE name = 'explain-a-record'
-- comment: Starter skill: summarizing one record and its change history.
INSERT INTO assistant_skill (name, description, instructions, enabled, trigger_keywords, create_date, modify_date)
SELECT 'explain-a-record', 'Summarizing a record and who changed it.',
'When the user asks about one record (what it is, its status, what changed, who changed it):
1. Read it with get_record. If they ask what changed or who changed it, also call get_record_history.
2. Summarize in plain language: what the record is, its current state, and the few fields that matter for the question. Use the field labels, not the field names.
3. For history, list changes newest first as "date - who - field: old -> new", at most ten, and say if there are more.
4. Link to the record.',
TRUE, 'explain,summarize,summary,tell me about,what is,status,history,changed,who changed,audit', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
WHERE NOT EXISTS (SELECT 1 FROM assistant_skill WHERE name = 'explain-a-record');
-- rollback empty
-- changeset qbit-assistant:defaults-5
-- preconditions onFail:MARK_RAN onError:MARK_RAN
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM assistant_skill WHERE name = 'safe-changes'
-- comment: Starter skill: proposing changes that the user confirms.
INSERT INTO assistant_skill (name, description, instructions, enabled, trigger_keywords, create_date, modify_date)
SELECT 'safe-changes', 'Proposing creates, updates and deletes safely.',
'When the user asks you to create, change or delete records:
1. If you have no create_record, update_record or delete_record tool for that table, say the assistant cannot change it and offer to open the screen where they can.
2. Read the record first, then restate exactly what will change (field: old -> new) before proposing it.
3. Propose one change at a time unless the user clearly asked for several. Changes wait for the user to confirm them in the panel; never say a change is done until a tool result says so.
4. For many rows from a file, use the bulk load tools and always run bulk_load_dry_run and show its summary before bulk_load_run.',
TRUE, 'change,update,set,edit,create,add,new,delete,remove,fix,rename,import,load', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
WHERE NOT EXISTS (SELECT 1 FROM assistant_skill WHERE name = 'safe-changes');
-- rollback empty
-- changeset qbit-assistant:defaults-6
-- preconditions onFail:MARK_RAN onError:MARK_RAN
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM assistant_skill WHERE name = 'access-questions'
-- comment: Starter skill: explaining missing or denied data without guessing.
INSERT INTO assistant_skill (name, description, instructions, enabled, trigger_keywords, create_date, modify_date)
SELECT 'access-questions', 'Explaining why data is missing or not available.',
'When the user cannot find something, or a tool returns DENIED or NOT_FOUND:
1. Say plainly that the information is not available to them through the assistant. Do not guess what it contains and do not try other tools to get around it.
2. Explain the likely reason in one sentence: their permissions, a filter that excludes it, or the table being off for the assistant.
3. Suggest the next step: check the filter, open the list screen, or ask an administrator for access.',
TRUE, 'cannot see,can''t see,missing,not found,denied,access,permission,not allowed,hidden', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
WHERE NOT EXISTS (SELECT 1 FROM assistant_skill WHERE name = 'access-questions');
-- rollback empty
-- changeset qbit-assistant:defaults-7
-- preconditions onFail:MARK_RAN onError:MARK_RAN
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM assistant_profile_skill WHERE 1 = 0
-- comment: Offer the starter skills through the default profile (skills reach the model only through a profile). Pairs that already exist are left alone.
INSERT INTO assistant_profile_skill (profile_id, skill_id, create_date, modify_date)
SELECT p.id, s.id, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
FROM assistant_profile p, assistant_skill s
WHERE p.name = 'default'
AND s.name IN ('find-and-count', 'explain-a-record', 'safe-changes', 'access-questions')
AND NOT EXISTS (SELECT 1 FROM assistant_profile_skill ps WHERE ps.profile_id = p.id AND ps.skill_id = s.id);
-- rollback empty
-- changeset qbit-assistant:defaults-8
-- preconditions onFail:MARK_RAN onError:MARK_RAN
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM assistant_budget
-- comment: Only when the host has no budget at all, so an existing host's limits never change on deploy. A monthly cost budget for the whole instance: warn at 80%, switch to the cheapest allowed model (local first) at 100%, stop at 120%. Change the limit to suit; overrides raise it for a while.
INSERT INTO assistant_budget (name, enabled, scope_type, period, metric, limit_value, warn_percent, at_limit_action, hard_ceiling_percent, notes, create_date, modify_date)
SELECT 'Monthly spend (whole instance)', TRUE, 'INSTANCE', 'MONTH', 'COST_USD', 50, 80, 'DOWNGRADE', 120,
'Starter budget. Warns everyone at 80% of $50 a month, switches to the cheapest allowed model at 100% (or stops when there is none), and stops at 120%. Set Downgrade Model to choose the fallback; add an override to raise it temporarily.',
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
WHERE NOT EXISTS (SELECT 1 FROM assistant_budget);
-- rollback empty
-- changeset qbit-assistant:defaults-9
-- preconditions onFail:MARK_RAN onError:MARK_RAN
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM assistant_model_price
-- comment: Anthropic first-party list prices, USD per million tokens (2026-10), only when the host has no price rows (rows take precedence over AssistantQBitConfig.withPricing, so a host's configured prices are never overridden on deploy). Empty cache prices use the standard multipliers (read 0.1x, 5 minute write 1.25x, 1 hour write 2x input).
INSERT INTO assistant_model_price (provider_type, model, input_usd_per_m_tok, output_usd_per_m_tok, cache_read_usd_per_m_tok, cache_write5m_usd_per_m_tok, cache_write1h_usd_per_m_tok, notes, create_date, modify_date)
SELECT v.provider_type, v.model, v.input_price, v.output_price, v.cache_read, v.cache_write5m, v.cache_write1h, v.notes, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
FROM (VALUES
('anthropic', 'claude-fable-5-1', 10.00, 50.00, 0.25, 12.50, 20.00, 'Anthropic list price, 2026-10'),
('anthropic', 'claude-fable-5', 10.00, 50.00, CAST(NULL AS NUMERIC), CAST(NULL AS NUMERIC), CAST(NULL AS NUMERIC), 'Anthropic list price, 2026-10'),
('anthropic', 'claude-opus-5-5', 4.00, 20.00, 0.20, 5.00, 8.00, 'Anthropic list price, 2026-10'),
('anthropic', 'claude-opus-5', 5.00, 25.00, 0.50, 6.25, 10.00, 'Anthropic list price, 2026-10'),
('anthropic', 'claude-opus-4-8', 5.00, 25.00, 0.50, 6.25, 10.00, 'Anthropic list price, 2026-10'),
('anthropic', 'claude-opus-4-7', 5.00, 25.00, 0.50, 6.25, 10.00, 'Anthropic list price, 2026-10'),
('anthropic', 'claude-opus-4-6', 5.00, 25.00, 0.50, 6.25, 10.00, 'Anthropic list price, 2026-10'),
('anthropic', 'claude-sonnet-5-5', 2.00, 10.00, 0.20, 2.50, 4.00, 'Anthropic list price, 2026-10'),
('anthropic', 'claude-sonnet-5', 2.00, 10.00, 0.20, 2.50, 4.00, 'Anthropic list price, 2026-10'),
('anthropic', 'claude-sonnet-4-6', 3.00, 15.00, 0.30, 3.75, 6.00, 'Anthropic list price, 2026-10'),
('anthropic', 'claude-haiku-5-5', 0.10, 0.50, 0.01, 0.125, 0.20, 'Anthropic list price, 2026-10 (prompts up to 100K tokens)'),
('anthropic', 'claude-haiku-4-5', 1.00, 5.00, 0.10, 1.25, 2.00, 'Anthropic list price, 2026-10')
) AS v (provider_type, model, input_price, output_price, cache_read, cache_write5m, cache_write1h, notes)
WHERE NOT EXISTS (SELECT 1 FROM assistant_model_price p);
-- rollback empty
-- changeset qbit-assistant:defaults-10
-- preconditions onFail:MARK_RAN onError:MARK_RAN
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM assistant_scheduled_task WHERE name = 'Example: weekly change summary'
-- comment: A disabled example scheduled task that shows the shape of one. It never runs until an administrator sets the run-as user and an allowed credential variable and turns it on.
INSERT INTO assistant_scheduled_task (name, enabled, prompt, run_as_user_id, credential_env_name, profile_id, cron_expression, timezone, output_mode, write_mode_requested, max_cost_usd_per_run, create_date, modify_date)
SELECT 'Example: weekly change summary', FALSE,
'Summarize what changed in the last 7 days in the tables I use most: counts of new and changed records per table, and anything that looks unusual. Include links to the lists.',
'set-the-run-as-user-id', 'ASSISTANT_SCHEDULED_TOKEN', p.id, '0 0 8 ? * MON', 'UTC', 'RECORD', FALSE, 0.50,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
FROM (SELECT MIN(id) AS id FROM assistant_profile WHERE name = 'default') p
WHERE NOT EXISTS (SELECT 1 FROM assistant_scheduled_task WHERE name = 'Example: weekly change summary');
-- rollback empty
@@ -0,0 +1,138 @@
bundle: qbit-assistant
version: 1
layer: QBIT
# Help for the Assistant app and every assistant_* table, in the QQQ help-bundle
# format (qqq#1198). Each table entry says what the table is for and how its rows
# get created, so an empty table explains itself. AssistantHelp applies it until
# the core HelpBundles registry ships in a QQQ release (STAND-IN(core=#1198)).
# Summaries stay under 120 characters (AssistantHelpTest).
entries:
- key: app:assistantAdmin;slot:help
summary: "Set up and watch the in-app assistant: providers, budgets, profiles, skills, data access and usage."
content: |
The Setup card on the home lists every part of the assistant with its status and the next step. A new installation starts with a default profile, four starter skills, a monthly budget, a policy row, Anthropic prices and a disabled example scheduled task. Add a provider to start answering.
- key: table:assistant_provider;slot:help
summary: "A model provider the assistant can call: its type, model, endpoint and the variable that holds its key."
content: |
**How rows get created:** an administrator adds one with New (pick the type and model, and name the environment variable that holds the API key; the key itself is never stored). The default provider answers questions; Test connection checks it and Discover models fills the model list.
- key: table:assistant_model;slot:help
summary: "The models each provider offers, with what they support and their prices."
content: |
**How rows get created:** Discover models on a provider adds them, or an administrator adds one by hand. Budgets, fallbacks, routing and knowledge sources pick models from this list. Embedding models are used only for knowledge sources.
- key: table:assistant_model_price;slot:help
summary: "List prices per provider type and model, used to work out what each call costs."
content: |
**How rows get created:** the qbit seeds the current Anthropic list prices; add a row for any other priced model. Every call's cost in Usage is computed from these prices, and cost budgets count that cost. A model with no price costs zero, which is right for local models.
- key: table:assistant_policy;slot:help
summary: "The single policy row: kill switch, write confirmation, PII redaction, tool list and row limits."
content: |
**How rows get created:** the qbit seeds one `default` row. Only the first row is used. Empty values fall back to the code configuration. Turn on the kill switch to stop every answer at once.
- key: table:assistant_budget;slot:help
summary: "Spending or token limits per instance, user, role, provider or model, with warn, downgrade and stop."
content: |
**How rows get created:** the qbit seeds a monthly budget for the whole instance ($50: warn at 80%, switch to the cheapest allowed model at 100%, stop at 120%). Add more for users, roles, providers or models. Every question is checked against every budget that applies before the model is called.
- key: table:assistant_budget_override;slot:help
summary: "A temporary raise or unlimited window for one budget, with a reason and an expiry."
content: |
**How rows get created:** an administrator adds one when a budget is reached and work must continue. Empty means no budget is currently raised. Overrides expire on their own.
- key: table:assistant_budget_event;slot:help
summary: "Each time a budget warned, downgraded or stopped someone, once per period and state."
content: |
**How rows get created:** written by the assistant when usage crosses a budget threshold; it also raises an alert. Empty means no budget has been reached yet.
- key: table:assistant_budget_usage;slot:help
summary: "Accounting copy of every call's tokens and cost, kept for shared budgets."
content: |
**How rows get created:** written with every model call. Instance, provider and model budgets add up these rows. Empty means the assistant has not answered anything yet.
- key: table:assistant_profile;slot:help
summary: "Who the assistant is in an app or for a role: name, tone, greeting, starter prompts and skills."
content: |
**How rows get created:** the qbit seeds a `default` profile that applies everywhere. Add a profile with an app name or a permission to change the assistant for that app or role; the most specific match wins. Profiles change tone and know-how only, never permissions.
- key: table:assistant_skill;slot:help
summary: "Know-how text the assistant follows when a question matches its keywords or app."
content: |
**How rows get created:** the qbit seeds four starter skills (finding and counting, explaining a record, safe changes, access questions). Write your own for your business terms. A skill reaches the model only when it is linked to a profile and the question matches its keywords or app; it never grants tools or data.
- key: table:assistant_profile_skill;slot:help
summary: "Which skills each profile offers to the model."
content: |
**How rows get created:** the qbit links the starter skills to the default profile. Add a row to offer a skill through another profile.
- key: table:assistant_data_access;slot:help
summary: "What the assistant may do with each table or process: NONE, READ or READ and write."
content: |
**How rows get created:** a table with no row gets the default: READ (or NONE for sensitive tables), always limited by the user's own permissions; processes default to NONE. Use **Add rows for every table** to write those defaults out as rows you can edit, then raise a table to READ_WRITE to let the assistant propose changes there.
- key: table:assistant_rag_source;slot:help
summary: "A table the assistant can search by meaning (knowledge source), with its fields and embedding model."
content: |
**How rows get created:** an administrator adds one: pick a table the assistant can read, its text fields and an enabled embedding model, then run Index knowledge source. The index keeps no record text; every hit is re-read as the asking user. Empty means search_knowledge is not offered.
- key: table:assistant_scheduled_task;slot:help
summary: "A question the assistant answers on a schedule as a chosen user, with the result saved or sent."
content: |
**How rows get created:** the qbit seeds a disabled example. To use it, set the run-as user, an allowed credential variable and the schedule, then turn it on. Scheduled writes always wait for the user's confirmation in the panel.
- key: table:assistant_scheduled_run;slot:help
summary: "One run of a scheduled task: when, status, cost and the conversation it produced."
content: |
**How rows get created:** written each time a scheduled task runs. Empty means no task has run yet; see Scheduled tasks.
- key: table:assistant_conversation;slot:help
summary: "One chat between a user and the assistant, from the panel or a scheduled task."
content: |
**How rows get created:** created when someone asks the assistant a question. Users see only their own; administrators see all. Retention removes old ones.
- key: table:assistant_message;slot:help
summary: "The messages of each conversation: questions, answers and tool results."
content: |
**How rows get created:** written as a conversation happens. Content is encrypted in HIPAA mode and removed by retention.
- key: table:assistant_turn;slot:help
summary: "One question and its answer: status, model, tokens, cost, timing and routing."
content: |
**How rows get created:** written for every question the assistant answers. Use it to see failures, slow answers and costly questions.
- key: table:assistant_turn_event;slot:help
summary: "The step-by-step event stream of each answer, for replay and troubleshooting."
content: |
**How rows get created:** written while an answer streams. Retention removes old events.
- key: table:assistant_tool_call;slot:help
summary: "Every tool the assistant called: which, with what input, the result status and timing."
content: |
**How rows get created:** written for each tool call during an answer. Denied calls show where data access or permissions stopped the assistant.
- key: table:assistant_pending_action;slot:help
summary: "A create, update or delete the assistant proposed, waiting for the user to confirm it."
content: |
**How rows get created:** written when the assistant proposes a change on a READ_WRITE table. It runs once, only after the user confirms it in the panel. Empty means no change has been proposed.
- key: table:assistant_feedback;slot:help
summary: "Thumbs up or down and comments users gave on answers."
content: |
**How rows get created:** written when a user rates an answer in the panel. Empty means nobody has rated an answer yet.
- key: table:assistant_usage;slot:help
summary: "One row per model call: tokens, cost, provider, model and purpose."
content: |
**How rows get created:** written with every model call, including connection tests. Costs come from Model prices.
- key: table:assistant_usage_daily;slot:help
summary: "Daily token and cost totals per user and for the instance, for the configured limits."
content: |
**How rows get created:** updated after every answer. The policy's daily and monthly limits read these totals.
- key: table:assistant_data_audit;slot:help
summary: "Where data went: which provider and model received which tables' data, with its classification."
content: |
**How rows get created:** written before each model or embedding call that carries application data. Use it to show that sensitive data went only to allowed providers.
@@ -212,7 +212,7 @@ info:
description: "QQQ Assistant: conversations, streamed turns, feedback, pending actions\
\ and data access explanations"
title: "QQQ Middleware API extension: assistant"
version: "1.0.0"
version: "1.1.0"
openapi: "3.0.3"
paths:
/qqq/v1/assistant/config:
@@ -1038,6 +1038,12 @@ paths:
required: false
schema:
type: "string"
- description: "Attribute this request to an owned turn."
in: "query"
name: "turnId"
required: false
schema:
type: "integer"
responses:
400:
content:
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff. Show more