Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fc0eed499
|
||
|
|
23779790ab | ||
|
|
bbee0d1017 | ||
|
|
079e8d39e2
|
||
|
|
dd5edf84d8
|
||
|
|
ef933d6bea | ||
|
|
385b61019b
|
||
|
|
a7184d3be9
|
||
|
|
2a3691c0ea
|
||
|
|
b6982eff9f
|
||
|
|
2a7bf77c6d
|
||
|
|
1012b4b497 | ||
|
|
76f9b286c6
|
||
|
|
9b4708fafc | ||
|
|
15938e07e1
|
||
|
|
b2c97d1000 | ||
|
|
ae8e394a7b | ||
|
|
1bae6e26ba
|
||
|
|
80d529e741
|
||
|
|
b50bc4a5c7
|
||
|
|
6a1bac6a9e | ||
|
|
9c3af04a91
|
||
|
|
0bfa4e4d01 | ||
|
|
3d288ddfe9
|
||
|
|
6d6b4696a5 | ||
|
|
ba8e7a6562
|
||
|
|
c751fc055c
|
||
|
|
50898a927d
|
||
|
|
f60009f595
|
||
|
|
ffa7e25bf4
|
||
|
|
00861af42c
|
||
|
|
3b56c93da7 | ||
|
|
a5c38d74a8
|
||
|
|
e8beb7444a
|
||
|
|
fdf8d72367
|
||
|
|
8aac6cf92d
|
||
|
|
02215634bf
|
||
|
|
303cf9a148
|
||
|
|
969a7b9101
|
||
|
|
8e47d9a6f5
|
||
|
|
09787bfe22
|
||
|
|
6c36917beb
|
||
|
|
befb90a48a | ||
|
|
bca3ea9d83 | ||
|
|
8655ea21d4
|
||
|
|
4b7b955a1f | ||
|
|
ceeb134c90
|
||
|
|
b49e4f4ee0 |
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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`.
|
||||
|
||||
+75
-17
@@ -1,16 +1,77 @@
|
||||
# Changelog
|
||||
|
||||
### Changed: W1 core Preferences (F13 / #29)
|
||||
- Replace the bespoke assistant preferences operations with the core `assistant`
|
||||
section at `/qqq/v1/preferences/assistant`, backed by the existing visible table.
|
||||
User ownership, partial updates, policy validation, read-only explanations and
|
||||
explicit assistant-channel denial apply to the section.
|
||||
- Keep effective defaults on `/assistant/config`; preserve Read-only/Write tools,
|
||||
low-risk auto-confirm, develop's core invoker, provider routing and audit context.
|
||||
- Require per-task approval and Write-mode policy for scheduled writes; gate bulk
|
||||
write definitions and calls by mode. Preserve default-off audit history on core develop.
|
||||
- Target QQQ 4.1.0-RC.8; use the local PREFS snapshot override until publication.
|
||||
## Unreleased - Central health
|
||||
|
||||
### Added
|
||||
|
||||
- Dynamic `assistant.provider.<id>` and `assistant.model.<id>` dependency checks in component `assistant`, category AI, reusing provider diagnostics and minimal connection calls with deadlines, cost-cap/kill-switch skips and TEST usage accounting.
|
||||
- LOCAL breaker reuse of central provider outcomes younger than 60 seconds, with its existing lazy probe as fallback.
|
||||
- Generic Assistant health status card linking to filtered System Health; registration, status, accounting, deadline and sentinel-secret tests.
|
||||
- QQQ 4.1 compatibility for the header shim: setting extra headers avoids duplicates now that core forwards them too.
|
||||
|
||||
### Removed
|
||||
|
||||
- The home's independent answering/last-test health indicators and per-provider health/down-until display. Central health owns status and history. Manual Test connection actions, `lastTest*` fields and the circuit breaker remain.
|
||||
|
||||
## Unreleased - Jobs tray
|
||||
|
||||
- Track assistant answers in the core Jobs tray through `JobTracking`, with thinking, tool and writing progress and Stop support.
|
||||
- Require QQQ 4.1.0-RC.8 and the host's durable jobs runtime and jobs tables; answers remain untracked when the runtime is absent.
|
||||
- Default to `JobTrackingRunTracker`; opt out with `withRunTracker(AssistantRunTracker.NOOP)`.
|
||||
|
||||
## Unreleased - In-app help
|
||||
|
||||
- Ship the host-neutral Assistant help bundle through QQQ's native registry, replacing the temporary loader.
|
||||
- Cover Assistant tables, fields, actions, widgets and selectors; retain live metadata references and add a bundle coverage gate.
|
||||
- Document HOST bundle overrides and remove deployment-specific provider, classification, retention and backup guidance.
|
||||
|
||||
## Unreleased - Transcript redaction
|
||||
|
||||
- Tokenize classified PII/PHI in stored messages, tool inputs/results, replay events, pending actions and derived summaries using QQQ secured-data tokens. Preserve eligible model requests and live SSE.
|
||||
- Stage content until turn completion, redact split deltas and classify criteria even when no records match; protect assistant logs with a scoped core redactor.
|
||||
- Reject proposals whose parameters were redacted; prevent confirmation before turn completion.
|
||||
- Read core table-level classification metadata and add secured-data canary tests for storage, audits, logs and in-memory model access.
|
||||
- Preserve durable-turn watchdog, supersede, shutdown and restart recovery with turn-scoped transcript redaction; widen token-bearing columns and entity limits, with H2-safe upgrade SQL.
|
||||
- Require QQQ 4.1.0-RC.7 (qqq#1182); retain the default audit-history opt-in and avoid duplicated routing headers against current core. RC.7 is released.
|
||||
## Unreleased - Foreign keys
|
||||
|
||||
- Reference DDL: add the missing foreign keys (conversation tree, usage, data audit,
|
||||
budget accounting, profile skills) with orphan quarantine and their indexes; make
|
||||
`assistant_model.provider_id` `ON DELETE CASCADE`. A Postgres guardrail test fails
|
||||
any declared relation without a foreign key.
|
||||
|
||||
## Unreleased - Durable turns
|
||||
|
||||
- Renew user-owned run leases with heartbeats, poll cross-replica Stop, enforce
|
||||
hard deadlines, and finalize active runs during graceful shutdown. Recover
|
||||
abandoned runs on replay, active discovery, or the next question.
|
||||
- Default to superseding a prior answer in the same conversation. Accept explicit
|
||||
concurrent questions; the per-user cap supersedes the oldest run instead of
|
||||
refusing another question. Per-minute limits and budgets still apply.
|
||||
- Add `GET /qqq/v1/assistant/turns/active`, `turn.phase` progress and `startedAt`
|
||||
on `message.start`. Cancel is idempotent and returns HTTP 200.
|
||||
- Add the optional `AssistantRunTracker` Jobs tray SPI (no-op by default),
|
||||
pending QQQ core job tracking (#1210).
|
||||
- Add the durable-turn schema upgrade; apply it before deploying this runtime.
|
||||
A partial unique index fences duplicate terminal events from concurrent reapers.
|
||||
- UI API extension `assistant` 1.2.0 (additive): `turns/active`, the optional
|
||||
`concurrency` on a new turn, 503 `SHUTTING_DOWN`, `turn.phase`,
|
||||
`message.start.startedAt`, cancelReason `SUPERSEDED`. The 429 response stays
|
||||
documented for budgets and per-minute limits; the "answer already in progress"
|
||||
refusal is no longer returned. Cancel keeps 202 documented for older replicas.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -32,11 +93,14 @@
|
||||
|
||||
### 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
|
||||
@@ -118,12 +182,6 @@ Phase 0/1 of the QQQ Assistant: read-only Q&A and navigation. Requires QQQ 4.1 w
|
||||
- the AI tool kernel (`qqq-backend-core` `actions.ai`, QRun-IO/qqq#1078)
|
||||
- the developer-panel contract (QRun-IO/qqq#1076).
|
||||
|
||||
### Added (0.1.0-RC.7): write mode (owner decision 2026-10-13)
|
||||
- Per-conversation **Read-only** (default) or **Write** mode: `writeMode` on conversations (PATCH, create), the write tools exist only in Write mode, and a write tool called in Read-only mode is refused before any request (`mode:READ_ONLY`).
|
||||
- Policy: `writeModeAllowed`, `writeModePermission` (role), `autoConfirmAllowed`. Data access: `autoConfirmOk` per table.
|
||||
- `assistant_user_preference` (own rows): default mode of new conversations, auto-confirm of low-risk writes (single-record insert/update on auto-confirm-OK tables; never deletes). Core `GET`/`PATCH /qqq/v1/preferences/assistant`; `/config` reports `features.writeMode`, `features.autoConfirm` and `preferences`.
|
||||
- The denial matrix covers the write tools in both modes.
|
||||
|
||||
### Changed (0.1.0-RC.7, needs QQQ with QRun-IO/qqq#1128)
|
||||
- The assistant's endpoints are spec'd `/qqq/v1` operations (UI API extension `assistant` 1.0.0, prefix `/assistant`): same URLs, bodies and statuses, now in the served OpenAPI; turns and events are `text/event-stream`. The raw routes are gone.
|
||||
- Tools call core's `UiApiInvoker` with the caller's own request credentials (`UiApiCredentials`); the loopback stand-in is removed. The assistant's own operations are never invocable by its tools.
|
||||
|
||||
@@ -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`.
|
||||
@@ -52,6 +52,8 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
|
||||
|
||||
## Invariants (do not break)
|
||||
|
||||
- **Transcript redaction:** stored transcripts never contain raw PII/PHI values; tokens via `ClassifiedValueTokens`. Redact storage copies before truncation/encryption; eligible model inputs and live SSE remain unchanged.
|
||||
|
||||
- **Sessions:**
|
||||
- Scheduled runs log in as the designated user through `POST /qqq/v1/manageSession`; resolve only its returned UUID, verify the user id, and never construct or elevate a session. The QQQ-supplied scheduler context touches only assistant bookkeeping.
|
||||
- Never use `QSystemUserSession` or construct a `QSession` in main code (ArchUnit).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# QBit Assistant
|
||||
|
||||
An in-app AI assistant for any QQQ application. The user asks a question in a chat panel in the Next UI. The assistant reads the application's data **with exactly that user's permissions**, answers with numbers taken from the data, and links to the records and lists it used. Conversations start Read-only. Users can select Write mode when policy permits it; changes normally wait for their confirmation.
|
||||
An in-app AI assistant for any QQQ application. The user asks a question in a chat panel in the Next UI. The assistant reads the application's data **with exactly that user's permissions**, answers with numbers taken from the data, and links to the records and lists it used. This first version only reads data and opens screens. It cannot change anything.
|
||||
|
||||
Design spec: `QRun-IO/qqq` `docs/specs/2026-10-11-qqq-assistant-qbit-design.md`.
|
||||
|
||||
@@ -20,42 +20,38 @@ 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.
|
||||
|
||||
## Write mode and personal preferences
|
||||
## Transcript privacy
|
||||
|
||||
The **Assistant** section on the core Preferences page controls **Start conversations
|
||||
in Write mode** and **Auto-confirm low-risk changes**. It is available to users
|
||||
with `assistant.hasAccess`, without requiring `assistant.admin` or permission on
|
||||
the backing table. The standard visible `assistant_user_preference` table remains
|
||||
available to administrators.
|
||||
QQQ **4.1.0-RC.7** supplies the secured-data tokens and log hook (`qqq#1182`).
|
||||
Stored transcripts never contain the raw PII/PHI values read by a turn or supplied
|
||||
in classified tool criteria/fields. Messages, tool logs, replay events, knowledge
|
||||
snippets and pending payloads store `ClassifiedValueTokens` tokens; scheduled
|
||||
summaries use the redacted message. The model and live user stream retain the
|
||||
values permitted by PHI routing. History and replay show tokens instead.
|
||||
|
||||
Clients read and partially update settings through `GET` / `PATCH
|
||||
/qqq/v1/preferences/assistant`, with a PATCH body such as
|
||||
`{"values":{"defaultWriteMode":true}}`. There is no user-id parameter. The former
|
||||
`/qqq/v1/assistant/preferences` operations are removed. The panel reads effective
|
||||
defaults from `/qqq/v1/assistant/config` under `preferences`; conversation responses
|
||||
include `writeMode`. Policy can make the effective default Read-only even when
|
||||
the saved preference is Write.
|
||||
Content is finalized at turn completion; interrupted servers leave value-free
|
||||
placeholders. Proposals requiring redacted parameters are rejected and must be
|
||||
completed in the application. Existing transcripts are not migrated. See
|
||||
[transcript redaction](docs/DESIGN.md#transcript-redaction-secured-data-15).
|
||||
The dependency upgrade stays draft until RC.7 is published; local verification:
|
||||
|
||||
Write tools, including bulk profile saves and bulk loads, are absent in Read-only
|
||||
mode. Write mode requires policy permission and READ_WRITE data access. Low-risk
|
||||
auto-confirm is limited to single-record inserts/updates on `auto_confirm_ok`
|
||||
tables, with both policy and user opt-in. Its preference is read-only with a reason
|
||||
when policy disallows it. Scheduled tasks additionally require their own approval
|
||||
and always wait for confirmation. The assistant client channel cannot access the
|
||||
preferences section.
|
||||
```powershell
|
||||
.\mvnw.cmd -q verify "-Dqqq.snapshot.version=4.1.0-RC7PRE-SNAPSHOT"
|
||||
```
|
||||
|
||||
## Host audit attribution (X1/Y1)
|
||||
|
||||
Use QQQ **4.1.0-RC.8 or later** (core Preferences, qqq#1162). Until RC.8 is published, build with `-Dqqq.snapshot.version=4.1.0-PREFS-SNAPSHOT`. The QBit explicitly denies every assistant
|
||||
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
|
||||
@@ -196,6 +192,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:
|
||||
@@ -215,12 +220,41 @@ Admins set up providers on the **Providers** screen (`assistant_provider`), a st
|
||||
|
||||
**Test connection** is a record action on a provider (select one or more). It is a standard streamed ETL: Review lists what will be called and anything wrong with the row (missing key, disallowed variable or URL) before any call. Result has one line per provider: `connected to claude-sonnet-5-5 in 812 ms`, or the error with its kind and HTTP status. It makes one short live call (a 1-sentence prompt, at most 32 output tokens, effort low), saves the result on the row, and records the call in `assistant_usage` with purpose `TEST`.
|
||||
|
||||
## Health checks
|
||||
|
||||
The producer registers dynamic central checks in component `assistant`, category `AI`:
|
||||
|
||||
| Name | Kind | Interval | Timeout |
|
||||
| --- | --- | --- | --- |
|
||||
| `assistant.provider.<id>` | DEPENDENCY | 300s; 60s for LOCAL providers | 10s |
|
||||
| `assistant.model.<id>` | DEPENDENCY | 21600s (6 hours) | 30s |
|
||||
|
||||
Suppliers include enabled provider/model rows and refresh with the central registry.
|
||||
Provider checks reuse `AssistantProviderType.checkHealth`. Unsupported probes use
|
||||
circuit-breaker state only when less than ten minutes old, otherwise return UNKNOWN.
|
||||
Model checks reuse `ConnectionTest` (embeddings for embedding models), respect the
|
||||
provider's monthly cost cap and the kill switch, and leave manual `lastTest*` fields
|
||||
unchanged. A capped check returns UNKNOWN, `skipped: cost cap`, even on demand.
|
||||
The automated runner records usage under its own owner key. Provider caps use the
|
||||
existing shared accounting projection without access to other users' usage records.
|
||||
Usage purpose is a string, so health diagnostics reuse `TEST` (not a new `HEALTH` enum).
|
||||
All checks honor their deadlines; messages and details omit provider error text,
|
||||
credentials, endpoints, prompts and completions.
|
||||
|
||||
The LOCAL circuit breaker consults central results less than 60 seconds old before
|
||||
its lazy probe. UP/DEGRADED avoids a duplicate probe, DOWN rejects the call, and
|
||||
UNKNOWN or stale/missing results retain the existing probe fallback. The breaker
|
||||
and manual Test connection actions remain available.
|
||||
|
||||
This integration uses the core health SPI released in QQQ 4.1.0-RC.8.
|
||||
The POM pins that release; verify with `./mvnw -o verify`.
|
||||
|
||||
## The app home
|
||||
|
||||
The **Assistant** app's home is the one place for the assistant's health, usage and cost (no other dashboard repeats it). All of it comes from the tables below, read as the viewing admin; an empty table gives an empty state, never sample data.
|
||||
The **Assistant** app's home shows configuration, usage and cost read as the viewing admin. A generic status card summarizes the central runner's latest results and links to System Health filtered to `assistant`; it shows UNKNOWN before results are available. Health history and check lists live in System Health.
|
||||
|
||||
- **Health:** is it answering (kill switch, default provider, key present, last test), the 7-day error rate of model calls, cost and requests this month against the instance's monthly limit.
|
||||
- **Providers:** one row per provider: type, model, masked key status, last test, cost this month against its cap, limits, and any problem with its settings.
|
||||
- **Configuration and usage:** default provider, key status, the 7-day error rate of model calls, cost and requests this month against the instance's monthly limit.
|
||||
- **Providers:** one row per provider: type, model, masked key status, cost this month against its cap, limits, and any problem with its settings.
|
||||
- **Usage, last 30 days:** bars of model requests per day, totals (conversations, requests, tokens in and out, cost) and the last 7 days in words.
|
||||
- **Costs this month:** month to date against the policy limit and each provider cap, then by model. Cost is tokens times the model's price from **Model prices**.
|
||||
- **By user, this month**, **Top tools** (calls, failures, average time) and **Recent failures** (model calls and tool calls, linked).
|
||||
@@ -378,7 +412,7 @@ Table names follow the QRun-IO naming rule: lowercase snake_case with the `assis
|
||||
|
||||
```bash
|
||||
./mvnw verify # against the committed QQQ version
|
||||
./mvnw verify -Dqqq.snapshot.version=4.1.0-PREFS-SNAPSHOT # local core Preferences build until RC.8 is published
|
||||
./mvnw verify -Dqqq.snapshot.version=4.1.0-ASSISTDEV-SNAPSHOT # against a local QQQ build with #1078 + #1076
|
||||
```
|
||||
|
||||
Two test groups run in every build and fail it on any error:
|
||||
@@ -525,7 +559,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`);
|
||||
@@ -639,8 +673,8 @@ never automatically move to cloud. Provider state/signatures are removed when
|
||||
switching, preserving neutral tool history. Each attempted call is audited as usage.
|
||||
|
||||
NETWORK/TIMEOUT marks a provider DOWN for 60 seconds; success marks it UP. Local
|
||||
health is probed when unknown/stale with a two-second timeout. Provider cards show
|
||||
health and down-until, failures show fallback totals, and turn records show the
|
||||
health uses recent central results or a lazy probe with a two-second timeout. The
|
||||
central health card links to System Health; failures show fallback totals, and turn records show the
|
||||
route and final destination. See [UPGRADE.md](UPGRADE.md) for the additive schema.
|
||||
|
||||
## Scheduled tasks
|
||||
@@ -720,3 +754,18 @@ remain untouched, as do explicit object and permission-rule denials.
|
||||
History visibility uses core's `isDenied` read/all-operation check. RC.6 has no
|
||||
operation argument; the later write-only deny patterns must remain separate
|
||||
from that check so they do not hide the read tool.
|
||||
|
||||
## In-app help
|
||||
|
||||
The QBit registers [`help/qbit-assistant.yaml`](qbit-assistant/src/main/resources/help/qbit-assistant.yaml)
|
||||
with QQQ's help-bundle registry during production. Hosts with help content storage use QQQ's
|
||||
startup bundle sync; registration is harmless when the host has no `helpContent` table.
|
||||
|
||||
To customize the wording, register a separate bundle with `layer: HOST` using
|
||||
`HelpBundles.register(qInstance, "help/my-application.yaml")`. Entries with the same key and
|
||||
role override this QBIT bundle. User-edited help remains protected by core's sync.
|
||||
Keep live `{{...}}` references so labels and metadata values follow the host's configuration.
|
||||
|
||||
`AssistantTablesGuardrailTest.everyOwnedTargetHasBundleHelp` checks coverage and live references
|
||||
against the produced test instance with `HelpBundleAssertions`; it runs in the normal Maven build.
|
||||
Update the bundle's literal version alongside the artifact version when releasing.
|
||||
+117
-26
@@ -1,32 +1,110 @@
|
||||
# Upgrading qbit-assistant
|
||||
|
||||
## W1 preferences migration (F13 / #29)
|
||||
## Transcript redaction (Unreleased)
|
||||
|
||||
Requires QQQ **4.1.0-RC.8** for core Preferences (qqq#1162). Until publication,
|
||||
verify with `.\mvnw.cmd -q verify "-Dqqq.snapshot.version=4.1.0-PREFS-SNAPSHOT"`.
|
||||
The root default is RC.8; do not commit the local snapshot override.
|
||||
Apply the transcript-redaction column widening block in `docs/schema-postgres.sql`
|
||||
to existing databases. Stop reasons, request IDs, tool-call IDs, feedback/data-audit/
|
||||
budget-override reasons and scheduled-run summaries/errors now use `TEXT`; token
|
||||
expansion must not fail a write or truncate secured-data tokens. The corresponding
|
||||
entity field limits are removed, including pending-action and tool-result summaries.
|
||||
|
||||
Replace `GET` / `PATCH /qqq/v1/assistant/preferences` with
|
||||
`GET` / `PATCH /qqq/v1/preferences/assistant`. PATCH now wraps changed fields in
|
||||
`values`, for example `{"values":{"defaultWriteMode":true}}`; GET returns the
|
||||
core section metadata and `values`. Do not send a user id. Core returns 400 for
|
||||
invalid or policy-read-only values and 404 for a hidden section. These removed
|
||||
operations were on the unmerged W1 branch; the fragment and baseline now describe
|
||||
the core integration. The panel's initial toggle continues to use effective
|
||||
`preferences.defaultWriteMode` from `/qqq/v1/assistant/config`.
|
||||
## Foreign keys (2026-10-14)
|
||||
|
||||
Existing `assistant_user_preference` rows need no data migration. Hosts installing
|
||||
W1 for the first time must apply its DDL in `docs/schema-postgres.sql` (conversation
|
||||
`write_mode`, policy fields, data-access `auto_confirm_ok`, and the preference
|
||||
table). Keep it as a standard visible admin table. The Preferences page is scoped
|
||||
by `assistant.hasAccess`, not the admin-only Assistant app. The provider explicitly
|
||||
refuses the `assistant` channel because core's section channel checks only apply
|
||||
to app-bound sections.
|
||||
Recommended: apply the "Foreign keys" block at the end of
|
||||
`docs/schema-postgres.sql` (after the index audit). It is guarded and safe to re-run.
|
||||
- Adds the missing foreign keys across the conversation tables, usage, data audit,
|
||||
budget accounting and profile skills, with the indexes they need.
|
||||
- Detail rows of a conversation `CASCADE`; the message/turn cycle, usage and the data
|
||||
audit use `SET NULL`, so usage keeps its cost rows when a conversation is deleted.
|
||||
- Usage, budget accounting and the data audit hold their provider (`NO ACTION`):
|
||||
disable a provider with history instead of deleting it.
|
||||
- `assistant_model.provider_id` changes from `NO ACTION` to `CASCADE`
|
||||
(constraint renamed `fk_assistant_model_provider`).
|
||||
- Rows that point at a missing parent are first copied to `qqq_fk_quarantine`
|
||||
(created if absent), then nulled, or deleted for the `NOT NULL` profile-skill
|
||||
links. Check that table after the upgrade.
|
||||
|
||||
Scheduled writes now require task approval plus global/role Write-mode policy.
|
||||
Bulk write tools obey the same conversation mode. Record history stays default-off
|
||||
when running on core versions whose channel defaults allow audit reads.
|
||||
No code change.
|
||||
|
||||
## Index audit (2026-10-14)
|
||||
|
||||
Recommended, not required: apply the "Index audit" block at the end of
|
||||
`docs/schema-postgres.sql`. It adds six indexes, every one `IF NOT EXISTS`:
|
||||
- usage by turn (read on each scheduled run)
|
||||
- the three retention date filters (turn events, tool calls, inactive conversations)
|
||||
- the two `ON DELETE SET NULL` foreign keys on `assistant_scheduled_run`
|
||||
|
||||
No code change.
|
||||
|
||||
## Durable turns (Unreleased)
|
||||
|
||||
Apply the **Durable turns** block at the end of `docs/schema-postgres.sql` before
|
||||
starting the new runtime. It adds nullable `heartbeat_at`, `lease_owner`,
|
||||
`cancel_requested_at`, `phase`, `concurrency_mode` and the `(user_id, status)`
|
||||
index. A partial unique index on terminal events also prevents two replicas from
|
||||
inserting separate `message.end` frames for the same turn. Check for historical
|
||||
duplicate terminal frames before applying it (resolve any duplicates through your
|
||||
normal migration process). Fresh installs run the full schema, including ALTERs.
|
||||
Existing rows with no heartbeat use `started_at` for lease expiry. During the
|
||||
first upgrade from a runtime without heartbeats, drain old replicas' turns first:
|
||||
a new replica cannot distinguish an old live turn from a dead owner after 45 s.
|
||||
|
||||
Defaults: heartbeat 5 s, lease 45 s, hard duration 300 s, force-finalization grace
|
||||
20 s, shutdown grace 8 s. Configure these through the corresponding
|
||||
`AssistantQBitConfig` withers. Heartbeat must be shorter than the lease. The JVM
|
||||
shutdown hook is enabled by default; tests disable `registerShutdownHook` and
|
||||
managed hosts may call `runtime.shutdown(Duration)` themselves. Give the process
|
||||
termination budget enough time for the configured shutdown grace and persistence.
|
||||
|
||||
`POST /conversations/{id}/turns` accepts `concurrency: "supersede" | "concurrent"`
|
||||
(case-insensitive). Default `SUPERSEDE` stops prior runs in that conversation;
|
||||
`CONCURRENT` permits them. The default per-user cap is now 3; reaching it stops
|
||||
the oldest run instead of returning the old in-progress 429. Per-minute and
|
||||
budget 429 responses still apply. Superseded turns record `cancelReason=SUPERSEDED`.
|
||||
|
||||
Cancel now returns HTTP **200**, including for terminal turns, with the current
|
||||
`status`. The OpenAPI fragment retains the old 202 response for mixed-version
|
||||
rollouts. Active runs are discoverable through `GET /qqq/v1/assistant/turns/active`;
|
||||
reattach through the existing events route. Consumers should recognize
|
||||
`turn.phase` and `message.start.startedAt`. Starts during shutdown return
|
||||
503 `SHUTTING_DOWN`; clients may retry against a ready replica.
|
||||
|
||||
`JobTrackingRunTracker` is now the default `AssistantRunTracker`, using core
|
||||
`JobTracking` in QQQ **4.1.0-RC.8** to publish answers in the Jobs tray. The host
|
||||
must provide the jobs tables (through qbit-jobs / core `DurableJobsMetaDataProvider`)
|
||||
and run `DurableJobsRuntime`. Without that runtime, answers complete normally
|
||||
and are simply not tracked.
|
||||
|
||||
Each job is owned by the assistant user, links to its `assistant_conversation`,
|
||||
and reports thinking, tool and writing phases. Tool progress contains only the
|
||||
tool label; terminal messages are fixed and never contain prompts or answers.
|
||||
The tray's Stop cancels the assistant turn. All tracker callbacks remain isolated
|
||||
from turn execution. Opt out with `withRunTracker(AssistantRunTracker.NOOP)`;
|
||||
hosts may still supply their own `AssistantRunTracker`.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -49,9 +127,22 @@ exceed the old 500-character limit. No new QBit column is required. `GET /assist
|
||||
|
||||
## 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
|
||||
@@ -64,7 +155,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
|
||||
|
||||
|
||||
+49
-36
@@ -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.
|
||||
|
||||
@@ -238,7 +245,7 @@ The golden and red-team evals now run through the UI API too.
|
||||
- **Tools call core's `UiApiInvoker`** (`CoreUiApiGateway`), and the loopback stand-in is removed.
|
||||
- The request's credentials (`UiApiCredentials.fromContext`: session cookies, Authorization, API key, exactly as they came in) are captured by the spec that starts the turn, confirms an action or explains access. They are passed to the tools, so OAuth2, table auth and API-key sessions all work. They are never logged.
|
||||
- The gateway also refuses the assistant's own operations, so a turn can never start a turn.
|
||||
- QQQ 4.1.0-RC.6 contains #1128; W1 now targets RC.8 for core Preferences (see W as built).
|
||||
- QQQ 4.1.0-RC.6 (the default `qqq.snapshot.version`) contains #1128, so no local build is needed.
|
||||
|
||||
## Owner decisions (2026-10-13), continued: write mode, the hard wall, audit attribution, providers
|
||||
|
||||
@@ -264,34 +271,6 @@ Owner: *"the assistant should be able to go into 'write mode' too - with a user
|
||||
- The ArchUnit rule (no core actions from the tool code) and the denial matrix stay.
|
||||
- The matrix is extended to every write operation, in both modes.
|
||||
|
||||
### W as built (F13 / #29)
|
||||
|
||||
- W1 is integrated with develop's spec'd operations, `CoreUiApiGateway`, client
|
||||
attribution, provider routing, presentation and bulk-load tools. Conversation
|
||||
`writeMode` and all policy/data-access gates remain in force.
|
||||
- `UserPreferencesMetaData` registers section `assistant` (Assistant, smart_toy),
|
||||
scoped to `assistant.hasAccess`. It has no app scope because the Assistant app
|
||||
requires admin access. `AssistantPreferencesProvider` implements core's SPI,
|
||||
loads and updates `assistant_user_preference` through `AssistantStore`, and
|
||||
obtains ownership only from the request context. The visible table remains the
|
||||
administrative interface. No new preference storage is introduced.
|
||||
- Core serves `GET` / `PATCH /qqq/v1/preferences/assistant`; the bespoke
|
||||
`/assistant/preferences` specs are removed. PATCH uses `values` and preserves
|
||||
omitted settings. Policy rejects unavailable Write defaults and auto-confirm.
|
||||
Fresh field metadata marks auto-confirm read-only with policy help text when
|
||||
unavailable, without mutating shared metadata. Core validates types and names.
|
||||
- `/assistant/config.preferences` returns effective defaults for the panel;
|
||||
new conversations apply the saved default only if Write mode is available.
|
||||
The provider refuses `assistant` (including scheduled details) before accessing
|
||||
rows: core only applies section channel rules through an app binding.
|
||||
- Bulk write tools are absent and forged calls are denied in Read-only mode.
|
||||
Scheduled mode requires the task approval and policy's global/role permission;
|
||||
scheduled tools always require confirmation, regardless of user auto-confirm.
|
||||
- HTTP tests cover permission visibility, own-user GET/PATCH, rejected identity
|
||||
injection, policy metadata/validation, defaults and channel denial. W1's denial
|
||||
matrix and both record and bulk mode paths remain covered. Build against QQQ
|
||||
RC.8, or `-Dqqq.snapshot.version=4.1.0-PREFS-SNAPSHOT` before publication.
|
||||
|
||||
### X. The hard wall between the assistant and its configuration
|
||||
Owner: *"there should also be a hard wall between the assistant and the configuration for the assistant. he should never be able to see his own configuration tables etc."*
|
||||
|
||||
@@ -451,7 +430,7 @@ AI exposure NONE is not enough, because the assistant acts as the user and an ad
|
||||
model and effort. Empty slots retain provider row/default/policy selection.
|
||||
- The documented heuristic is a pure rule table: >600 characters, two distinct app
|
||||
table names, complexity keywords, pending write context, >8 previous turns or a
|
||||
previous tool limit. W1 additionally persists conversation write mode.
|
||||
previous tool limit. This branch has no conversation write-mode field.
|
||||
- Classifier transport is non-streaming, bounded to four output tokens/three seconds,
|
||||
and contains only the fixed instruction and user question. PHI guards apply before
|
||||
dispatch. Errors/unexpected replies use the heuristic. ROUTE usage enters budget
|
||||
@@ -564,8 +543,12 @@ AI exposure NONE is not enough, because the assistant acts as the user and an ad
|
||||
failed calls and knowledge-query embeddings. STOP is checked at dispatch
|
||||
boundaries; an in-flight call can exceed the cap, and no next call is sent.
|
||||
- Every scheduled UI API request, including metadata and knowledge retrieval,
|
||||
carries `X-QQQ-Client: assistant/scheduled/<taskId>` through `CoreUiApiGateway`.
|
||||
Tool-call rows also record `clientChannel`; core enforces the configuration wall.
|
||||
carries `X-QQQ-Client: assistant/scheduled/<taskId>`. The only uiapi change is
|
||||
additive `LoopbackUiApiGateway.invokeWithClient`; custom gateways currently
|
||||
fail closed for scheduled requests because their channel contract is unknown.
|
||||
Tool-call rows also record `clientChannel`. Core channel deny enforcement
|
||||
remains pending the client-channel RC; existing AI exposure/data-access
|
||||
restrictions continue to apply on RC.5.
|
||||
- Approve write mode, Revoke write mode and Run now are admin record actions
|
||||
using standard streamed ETL Review/Result. Review shows the exact prompt's
|
||||
SHA-256 and run-as user. Result rereads and compares the complete reviewed
|
||||
@@ -573,9 +556,10 @@ AI exposure NONE is not enough, because the assistant acts as the user and an ad
|
||||
A persisted approval hash additionally binds prompt, run-as user, credential
|
||||
reference, profile and requested mode. Editing any of these clears approval;
|
||||
ordinary saves cannot set approval or computed bookkeeping fields.
|
||||
- Scheduled runs narrow W1's tool policy: record and bulk write tools require
|
||||
both request and matching approval, the designated user's global/role Write-mode
|
||||
permission, a READ_WRITE table and the policy's allowed-tools list. All scheduled writes require panel confirmation, including
|
||||
- This base has create/update/delete tools, but no W1 conversation mode/policy
|
||||
switch. Scheduled runs explicitly narrow the existing tool policy: write tools
|
||||
require both request and matching approval, a READ_WRITE table and the policy's
|
||||
allowed-tools list. All scheduled writes require panel confirmation, including
|
||||
when normal policy allows immediate writes. A pending proposal adds a waiting
|
||||
notice to the run summary; the scheduler never confirms it.
|
||||
- RECORD keeps the run and conversation; NOTIFICATION and BOTH also submit an
|
||||
@@ -653,3 +637,32 @@ The shared golden task asks "How many images do I have by type?": five UI count
|
||||
calls establish 81 JPEG, 20 PNG and zero other configured types, then a presentation
|
||||
with two rows, 80.2%/19.8% shares, two pie points and working MIME filter links.
|
||||
The same task is included in the opt-in `live` Anthropic eval suite.
|
||||
|
||||
|
||||
## Transcript redaction (secured data §15)
|
||||
|
||||
`TranscriptRedactor` collects classified values from tools using `ClassificationSource`,
|
||||
including core table/field metadata. Stored record fields use
|
||||
`ClassifiedValueTokens.forRecordField`; criteria/proposals without a record use
|
||||
`forContext`. Core selects `shred:v1`, `hmac:v1`, or `changed:v1`. Knowledge snippets
|
||||
retain their source field's token. Matching is case-sensitive and includes canonical,
|
||||
displayed, escaped, encoded and explicitly truncated variants.
|
||||
|
||||
`AssistantStore` stages content in turn memory and writes placeholders until
|
||||
finalization, protecting backend writes and their audits. It redacts before truncation
|
||||
and encryption. This covers messages, tool/bulk logs, replay events, pending payloads
|
||||
and audit reasons; scheduled summaries derive from redacted messages. Model requests
|
||||
and live SSE remain unchanged under PHI routing. Persisted text deltas are joined
|
||||
before redaction, with the text in the first delta and remaining deltas empty;
|
||||
sequence numbers remain stable.
|
||||
|
||||
Crashes or unfinished tools at timeout leave placeholders. Proposals remain
|
||||
non-executable (`PREPARING`) until finalization; redacted proposals are rejected and
|
||||
must be completed in the application. A core `LogRedactorInterface` hook covers all
|
||||
threads until the turn and its tool workers finish, then releases the raw values.
|
||||
|
||||
History stays redacted; later turns re-read records. Existing transcripts are not
|
||||
migrated, and arbitrary text is not inferred to be classified without a matching
|
||||
observed value or classified criterion/field. Canary tests scan every assistant table,
|
||||
core audits and captured logs, while proving eligible models receive original values.
|
||||
Requires QQQ 4.1.0-RC.7 (`qqq#1182`); keep the PR draft until publication.
|
||||
+145
-25
@@ -49,7 +49,7 @@ CREATE TABLE assistant_turn
|
||||
status VARCHAR(16),
|
||||
classification VARCHAR(16),
|
||||
classification_tables VARCHAR(500),
|
||||
stop_reason VARCHAR(64),
|
||||
stop_reason TEXT,
|
||||
error_code VARCHAR(32),
|
||||
cancel_reason VARCHAR(64),
|
||||
model VARCHAR(200),
|
||||
@@ -88,7 +88,7 @@ CREATE TABLE assistant_tool_call
|
||||
turn_id INTEGER,
|
||||
conversation_id INTEGER,
|
||||
user_id VARCHAR(255),
|
||||
tool_call_id VARCHAR(128),
|
||||
tool_call_id TEXT,
|
||||
tool_name VARCHAR(64),
|
||||
input_json TEXT,
|
||||
outcome VARCHAR(16),
|
||||
@@ -118,7 +118,7 @@ CREATE TABLE assistant_feedback
|
||||
conversation_id INTEGER,
|
||||
user_id VARCHAR(255),
|
||||
rating INTEGER,
|
||||
reason VARCHAR(64),
|
||||
reason TEXT,
|
||||
comment TEXT,
|
||||
create_date TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
@@ -267,7 +267,7 @@ CREATE TABLE assistant_usage
|
||||
cache_read_tokens BIGINT,
|
||||
cache_write_tokens BIGINT,
|
||||
cost_micros BIGINT,
|
||||
request_id VARCHAR(100),
|
||||
request_id TEXT,
|
||||
create_date TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
CREATE INDEX assistant_usage_create_date ON assistant_usage (create_date);
|
||||
@@ -310,7 +310,7 @@ CREATE TABLE assistant_pending_action
|
||||
conversation_id INTEGER,
|
||||
turn_id INTEGER,
|
||||
user_id VARCHAR(255),
|
||||
tool_call_id VARCHAR(128),
|
||||
tool_call_id TEXT,
|
||||
tool_name VARCHAR(64),
|
||||
ui_api_operation VARCHAR(255),
|
||||
table_name VARCHAR(128),
|
||||
@@ -369,22 +369,6 @@ CREATE TABLE assistant_profile_skill
|
||||
CONSTRAINT assistant_profile_skill_pair UNIQUE (profile_id, skill_id)
|
||||
);
|
||||
|
||||
-- 0.1.0-RC.7: write mode (per conversation, user preference, policy) and auto-confirm.
|
||||
ALTER TABLE assistant_conversation ADD COLUMN IF NOT EXISTS write_mode BOOLEAN;
|
||||
ALTER TABLE assistant_policy ADD COLUMN IF NOT EXISTS write_mode_allowed BOOLEAN;
|
||||
ALTER TABLE assistant_policy ADD COLUMN IF NOT EXISTS write_mode_permission VARCHAR(128);
|
||||
ALTER TABLE assistant_policy ADD COLUMN IF NOT EXISTS auto_confirm_allowed BOOLEAN;
|
||||
ALTER TABLE assistant_data_access ADD COLUMN IF NOT EXISTS auto_confirm_ok BOOLEAN;
|
||||
CREATE TABLE assistant_user_preference
|
||||
(
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id VARCHAR(255) NOT NULL UNIQUE,
|
||||
default_write_mode BOOLEAN,
|
||||
auto_confirm_low_risk BOOLEAN,
|
||||
create_date TIMESTAMP WITH TIME ZONE,
|
||||
modify_date TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
CREATE TABLE assistant_data_audit
|
||||
(
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -398,7 +382,7 @@ CREATE TABLE assistant_data_audit
|
||||
classification VARCHAR(16),
|
||||
tables VARCHAR(500),
|
||||
decision VARCHAR(16),
|
||||
reason VARCHAR(1000),
|
||||
reason TEXT,
|
||||
create_date TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
CREATE INDEX assistant_data_audit_user_id ON assistant_data_audit (user_id);
|
||||
@@ -431,7 +415,7 @@ CREATE TABLE assistant_budget_override
|
||||
scope_value VARCHAR(200),
|
||||
extra_value NUMERIC,
|
||||
unlimited_until TIMESTAMP WITH TIME ZONE,
|
||||
reason VARCHAR(1000) NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
granted_by VARCHAR(200),
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
create_date TIMESTAMP WITH TIME ZONE,
|
||||
@@ -582,9 +566,9 @@ CREATE TABLE assistant_scheduled_run (
|
||||
status VARCHAR(20) NOT NULL CHECK (status IN ('RUNNING', 'OK', 'FAILED', 'SKIPPED', 'BLOCKED')),
|
||||
conversation_id INTEGER REFERENCES assistant_conversation(id) ON DELETE SET NULL,
|
||||
turn_id INTEGER REFERENCES assistant_turn(id) ON DELETE SET NULL,
|
||||
summary VARCHAR(2000),
|
||||
summary TEXT,
|
||||
cost_micros BIGINT,
|
||||
error VARCHAR(500),
|
||||
error TEXT,
|
||||
create_date TIMESTAMPTZ,
|
||||
modify_date TIMESTAMPTZ,
|
||||
UNIQUE (task_id, scheduled_for)
|
||||
@@ -593,3 +577,139 @@ CREATE INDEX assistant_scheduled_run_owner ON assistant_scheduled_run(user_id, s
|
||||
|
||||
-- X1/Y1: encoded audit filters can exceed the old 500-character record links.
|
||||
ALTER TABLE assistant_tool_call ALTER COLUMN audit_href TYPE TEXT;
|
||||
|
||||
|
||||
-- Durable turns: apply this additive block before deploying the new runtime.
|
||||
-- Like earlier additions, fresh installs run the CREATEs followed by ALTERs.
|
||||
ALTER TABLE assistant_turn ADD COLUMN heartbeat_at TIMESTAMP;
|
||||
ALTER TABLE assistant_turn ADD COLUMN lease_owner VARCHAR(100);
|
||||
ALTER TABLE assistant_turn ADD COLUMN cancel_requested_at TIMESTAMP;
|
||||
ALTER TABLE assistant_turn ADD COLUMN phase VARCHAR(64);
|
||||
ALTER TABLE assistant_turn ADD COLUMN concurrency_mode VARCHAR(16);
|
||||
CREATE INDEX assistant_turn_user_status ON assistant_turn (user_id, status);
|
||||
|
||||
-- Fence concurrent orphan reapers at the database boundary. A turn ends once.
|
||||
-- Check for pre-existing duplicate message.end rows before applying this index.
|
||||
CREATE UNIQUE INDEX assistant_turn_event_terminal ON assistant_turn_event (turn_id)
|
||||
WHERE event_type = 'message.end';
|
||||
|
||||
-- Index audit (2026-10-14): lookup, retention and FK paths with no leading index.
|
||||
-- ScheduledTurn.cost reads usage by turn on every scheduled run.
|
||||
CREATE INDEX IF NOT EXISTS assistant_usage_turn ON assistant_usage (turn_id);
|
||||
-- Retention: deleteTurnEventsBefore, clearToolResultsBefore and listConversationsInactiveSince filter by date.
|
||||
CREATE INDEX IF NOT EXISTS assistant_turn_event_create_date ON assistant_turn_event (create_date);
|
||||
CREATE INDEX IF NOT EXISTS assistant_tool_call_create_date ON assistant_tool_call (create_date);
|
||||
CREATE INDEX IF NOT EXISTS assistant_conversation_modify_date ON assistant_conversation (modify_date);
|
||||
-- ON DELETE SET NULL children: deleting a conversation or turn would scan scheduled runs without these.
|
||||
CREATE INDEX IF NOT EXISTS assistant_scheduled_run_conversation ON assistant_scheduled_run (conversation_id);
|
||||
CREATE INDEX IF NOT EXISTS assistant_scheduled_run_turn ON assistant_scheduled_run (turn_id);
|
||||
|
||||
-- Foreign keys (2026-10-14). Run on new and existing databases; every step is guarded
|
||||
-- and re-runnable. The rules follow how the qbit deletes:
|
||||
-- * AssistantStore.deleteConversation (also the retention purge) deletes turn events,
|
||||
-- tool calls, feedback, messages, turns, data audit and pending actions, then the
|
||||
-- conversation. The conversation's own detail rows CASCADE, so an admin delete of a
|
||||
-- conversation cleans up the same way.
|
||||
-- * The message <-> turn cycle (turn.user_message_id / assistant_message_id and
|
||||
-- message.turn_id) and the other optional pointers are SET NULL. Rows are inserted
|
||||
-- parent first, so nothing is deferred.
|
||||
-- * assistant_usage keeps its cost rows when a conversation goes (SET NULL), and the
|
||||
-- data audit trail is never cascaded into (SET NULL).
|
||||
-- * Provider references from usage, budget accounting and the data audit are NO ACTION
|
||||
-- (like assistant_turn.provider_id): disable a provider with history, do not delete
|
||||
-- it. A provider's model rows are its own (CASCADE).
|
||||
-- * assistant_budget_usage.usage_id has no foreign key on purpose: the projection
|
||||
-- outlives its usage row so budgets keep counting the spend.
|
||||
-- Rows that already point at a missing parent are recorded in qqq_fk_quarantine first,
|
||||
-- then nulled (nullable columns) or deleted with the full row recorded (NOT NULL).
|
||||
CREATE INDEX IF NOT EXISTS assistant_message_parent_message ON assistant_message (parent_message_id);
|
||||
CREATE INDEX IF NOT EXISTS assistant_message_turn ON assistant_message (turn_id);
|
||||
CREATE INDEX IF NOT EXISTS assistant_turn_user_message ON assistant_turn (user_message_id);
|
||||
CREATE INDEX IF NOT EXISTS assistant_turn_assistant_message ON assistant_turn (assistant_message_id);
|
||||
CREATE INDEX IF NOT EXISTS assistant_turn_provider ON assistant_turn (provider_id);
|
||||
CREATE INDEX IF NOT EXISTS assistant_tool_call_pending_action ON assistant_tool_call (pending_action_id);
|
||||
CREATE INDEX IF NOT EXISTS assistant_feedback_message ON assistant_feedback (message_id);
|
||||
CREATE INDEX IF NOT EXISTS assistant_pending_action_turn ON assistant_pending_action (turn_id);
|
||||
CREATE INDEX IF NOT EXISTS assistant_data_audit_provider ON assistant_data_audit (provider_id);
|
||||
CREATE INDEX IF NOT EXISTS assistant_budget_usage_provider ON assistant_budget_usage (provider_id);
|
||||
CREATE INDEX IF NOT EXISTS assistant_profile_skill_skill ON assistant_profile_skill (skill_id);
|
||||
CREATE INDEX IF NOT EXISTS assistant_budget_downgrade_model ON assistant_budget (downgrade_model_id);
|
||||
CREATE INDEX IF NOT EXISTS assistant_conversation_scheduled_task ON assistant_conversation (scheduled_task_id);
|
||||
CREATE INDEX IF NOT EXISTS assistant_model_fallback_model ON assistant_model (fallback_model_id);
|
||||
CREATE INDEX IF NOT EXISTS assistant_profile_simple_model ON assistant_profile (simple_model_id);
|
||||
CREATE INDEX IF NOT EXISTS assistant_profile_complex_model ON assistant_profile (complex_model_id);
|
||||
CREATE INDEX IF NOT EXISTS assistant_profile_classifier_model ON assistant_profile (classifier_model_id);
|
||||
CREATE INDEX IF NOT EXISTS assistant_rag_source_embedding_model ON assistant_rag_source (embedding_model_id);
|
||||
CREATE INDEX IF NOT EXISTS assistant_scheduled_task_profile ON assistant_scheduled_task (profile_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
r RECORD;
|
||||
old TEXT;
|
||||
BEGIN
|
||||
FOR r IN SELECT * FROM (VALUES
|
||||
('assistant_message', 'conversation_id', 'assistant_conversation', 'CASCADE', TRUE),
|
||||
('assistant_message', 'parent_message_id', 'assistant_message', 'SET NULL', TRUE),
|
||||
('assistant_message', 'turn_id', 'assistant_turn', 'SET NULL', TRUE),
|
||||
('assistant_turn', 'conversation_id', 'assistant_conversation', 'CASCADE', TRUE),
|
||||
('assistant_turn', 'user_message_id', 'assistant_message', 'SET NULL', TRUE),
|
||||
('assistant_turn', 'assistant_message_id', 'assistant_message', 'SET NULL', TRUE),
|
||||
('assistant_turn_event', 'turn_id', 'assistant_turn', 'CASCADE', TRUE),
|
||||
('assistant_tool_call', 'conversation_id', 'assistant_conversation', 'CASCADE', TRUE),
|
||||
('assistant_tool_call', 'turn_id', 'assistant_turn', 'CASCADE', TRUE),
|
||||
('assistant_tool_call', 'pending_action_id', 'assistant_pending_action', 'SET NULL', TRUE),
|
||||
('assistant_feedback', 'conversation_id', 'assistant_conversation', 'CASCADE', TRUE),
|
||||
('assistant_feedback', 'message_id', 'assistant_message', 'CASCADE', TRUE),
|
||||
('assistant_pending_action', 'conversation_id', 'assistant_conversation', 'CASCADE', TRUE),
|
||||
('assistant_pending_action', 'turn_id', 'assistant_turn', 'SET NULL', TRUE),
|
||||
('assistant_data_audit', 'conversation_id', 'assistant_conversation', 'SET NULL', TRUE),
|
||||
('assistant_data_audit', 'turn_id', 'assistant_turn', 'SET NULL', TRUE),
|
||||
('assistant_usage', 'conversation_id', 'assistant_conversation', 'SET NULL', TRUE),
|
||||
('assistant_usage', 'turn_id', 'assistant_turn', 'SET NULL', TRUE),
|
||||
('assistant_usage', 'provider_id', 'assistant_provider', 'NO ACTION', TRUE),
|
||||
('assistant_data_audit', 'provider_id', 'assistant_provider', 'NO ACTION', TRUE),
|
||||
('assistant_budget_usage', 'provider_id', 'assistant_provider', 'NO ACTION', TRUE),
|
||||
('assistant_profile_skill', 'profile_id', 'assistant_profile', 'CASCADE', FALSE),
|
||||
('assistant_profile_skill', 'skill_id', 'assistant_skill', 'CASCADE', FALSE)
|
||||
) AS v(child, col, parent, rule, nullable)
|
||||
LOOP
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema = current_schema() AND LOWER(constraint_name) = 'fk_' || r.child || '_' || r.col) THEN
|
||||
IF r.nullable THEN
|
||||
EXECUTE format('INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, changeset) '
|
||||
|| 'SELECT %L, %L, c.id::text, c.%I::text, ''NULLED'', ''qbit-assistant 2026-10-14 foreign keys'' FROM %I c '
|
||||
|| 'WHERE c.%I IS NOT NULL AND NOT EXISTS (SELECT 1 FROM %I p WHERE p.id = c.%I)', r.child, r.col, r.col, r.child, r.col, r.parent, r.col);
|
||||
EXECUTE format('UPDATE %I c SET %I = NULL WHERE c.%I IS NOT NULL AND NOT EXISTS (SELECT 1 FROM %I p WHERE p.id = c.%I)', r.child, r.col, r.col, r.parent, r.col);
|
||||
ELSE
|
||||
EXECUTE format('INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) '
|
||||
|| 'SELECT %L, %L, c.id::text, c.%I::text, ''DELETED'', row_to_json(c)::text, ''qbit-assistant 2026-10-14 foreign keys'' FROM %I c '
|
||||
|| 'WHERE NOT EXISTS (SELECT 1 FROM %I p WHERE p.id = c.%I)', r.child, r.col, r.col, r.child, r.parent, r.col);
|
||||
EXECUTE format('DELETE FROM %I c WHERE NOT EXISTS (SELECT 1 FROM %I p WHERE p.id = c.%I)', r.child, r.parent, r.col);
|
||||
END IF;
|
||||
EXECUTE format('ALTER TABLE %I ADD CONSTRAINT %I FOREIGN KEY (%I) REFERENCES %I (id) ON DELETE %s', r.child, 'fk_' || r.child || '_' || r.col, r.col, r.parent, r.rule);
|
||||
END IF;
|
||||
END LOOP;
|
||||
|
||||
-- assistant_model.provider_id: the inline NO ACTION key becomes CASCADE, named as on kof22.
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema = current_schema() AND LOWER(constraint_name) = 'fk_assistant_model_provider') THEN
|
||||
FOR old IN SELECT conname FROM pg_constraint
|
||||
WHERE contype = 'f' AND conrelid = 'assistant_model'::regclass AND confrelid = 'assistant_provider'::regclass
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE assistant_model DROP CONSTRAINT %I', old);
|
||||
END LOOP;
|
||||
ALTER TABLE assistant_model ADD CONSTRAINT fk_assistant_model_provider FOREIGN KEY (provider_id) REFERENCES assistant_provider (id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Transcript redaction: tokens can expand beyond the original payload bounds.
|
||||
-- Apply to existing databases as well; SET DATA TYPE is also valid in H2.
|
||||
ALTER TABLE assistant_turn ALTER COLUMN stop_reason SET DATA TYPE TEXT;
|
||||
ALTER TABLE assistant_tool_call ALTER COLUMN tool_call_id SET DATA TYPE TEXT;
|
||||
ALTER TABLE assistant_feedback ALTER COLUMN reason SET DATA TYPE TEXT;
|
||||
ALTER TABLE assistant_usage ALTER COLUMN request_id SET DATA TYPE TEXT;
|
||||
ALTER TABLE assistant_pending_action ALTER COLUMN tool_call_id SET DATA TYPE TEXT;
|
||||
ALTER TABLE assistant_data_audit ALTER COLUMN reason SET DATA TYPE TEXT;
|
||||
ALTER TABLE assistant_budget_override ALTER COLUMN reason SET DATA TYPE TEXT;
|
||||
ALTER TABLE assistant_scheduled_run ALTER COLUMN summary SET DATA TYPE TEXT;
|
||||
ALTER TABLE assistant_scheduled_run ALTER COLUMN error SET DATA TYPE TEXT;
|
||||
@@ -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>
|
||||
@@ -202,7 +202,7 @@
|
||||
</build>
|
||||
|
||||
<profiles>
|
||||
<!-- The assistant needs QQQ 4.1.0-RC.6: the AI tool kernel (qqq#1078), the -->
|
||||
<!-- The assistant needs QQQ 4.1.0-RC.7: the AI tool kernel (qqq#1078), the -->
|
||||
<!-- developer panels (qqq#1076) and the UI API extension point, invoker -->
|
||||
<!-- and credentials (qqq#1128). Until -->
|
||||
<!-- qbit-build-parent imports the 4.1 BOM, this -->
|
||||
@@ -215,7 +215,8 @@
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<!-- requires QQQ RC.8 (core Preferences, qqq#1162); until it is published build with -Dqqq.snapshot.version=4.1.0-PREFS-SNAPSHOT -->
|
||||
<!-- RC.7 provides classified-value tokens, log redaction and native help bundles; -->
|
||||
<!-- RC.8 provides core job tracking (JobTracking, the Jobs tray). -->
|
||||
<qqq.snapshot.version>4.1.0-RC.8</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
|
||||
+9
-9
@@ -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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
+8
-8
@@ -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;
|
||||
|
||||
+2
-2
@@ -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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
+10
-10
@@ -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;
|
||||
|
||||
+3
-3
@@ -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;
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.provider;
|
||||
package com.kingsrook.qbits.assistant.spi;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.provider;
|
||||
package com.kingsrook.qbits.assistant.spi;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.provider;
|
||||
package com.kingsrook.qbits.assistant.spi;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.provider;
|
||||
package com.kingsrook.qbits.assistant.spi;
|
||||
|
||||
|
||||
import java.time.Duration;
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.provider;
|
||||
package com.kingsrook.qbits.assistant.spi;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.provider;
|
||||
package com.kingsrook.qbits.assistant.spi;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.provider;
|
||||
package com.kingsrook.qbits.assistant.spi;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.provider;
|
||||
package com.kingsrook.qbits.assistant.spi;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.provider;
|
||||
package com.kingsrook.qbits.assistant.spi;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.provider;
|
||||
package com.kingsrook.qbits.assistant.spi;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.provider;
|
||||
package com.kingsrook.qbits.assistant.spi;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.provider;
|
||||
package com.kingsrook.qbits.assistant.spi;
|
||||
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
+1
-2
@@ -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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.provider;
|
||||
package com.kingsrook.qbits.assistant.spi;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.provider;
|
||||
package com.kingsrook.qbits.assistant.spi;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.provider;
|
||||
package com.kingsrook.qbits.assistant.spi;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.provider;
|
||||
package com.kingsrook.qbits.assistant.spi;
|
||||
|
||||
|
||||
import java.util.Map;
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.provider;
|
||||
package com.kingsrook.qbits.assistant.spi;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
+2
-2
@@ -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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.provider;
|
||||
package com.kingsrook.qbits.assistant.spi;
|
||||
|
||||
|
||||
import java.time.Duration;
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.provider;
|
||||
package com.kingsrook.qbits.assistant.spi;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.provider;
|
||||
package com.kingsrook.qbits.assistant.spi;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.provider;
|
||||
package com.kingsrook.qbits.assistant.spi;
|
||||
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
-75
@@ -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);
|
||||
}
|
||||
}
|
||||
+46
@@ -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);
|
||||
}
|
||||
}
|
||||
+2
-3
@@ -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;
|
||||
+1
-2
@@ -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;
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.provider;
|
||||
package com.kingsrook.qbits.assistant.spi;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.provider;
|
||||
package com.kingsrook.qbits.assistant.spi;
|
||||
|
||||
|
||||
import java.security.KeyStore;
|
||||
+4
-4
@@ -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;
|
||||
|
||||
+6
-6
@@ -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;
|
||||
|
||||
+13
-13
@@ -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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
+6
-6
@@ -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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
+4
-4
@@ -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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
+6
-6
@@ -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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
+4
-4
@@ -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;
|
||||
|
||||
+10
-10
@@ -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;
|
||||
|
||||
+3
-3
@@ -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;
|
||||
|
||||
+4
-4
@@ -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;
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+6
-6
@@ -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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
+4
-4
@@ -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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
+5
-5
@@ -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;
|
||||
|
||||
@@ -73,6 +73,39 @@
|
||||
<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>
|
||||
<!-- the reference DDL guardrail (SchemaForeignKeyPostgresTest) -->
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>1.21.4</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<version>1.21.4</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<version>42.7.14</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
@@ -81,6 +114,7 @@
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>true</filtering>
|
||||
<includes>
|
||||
<include>help/*.yaml</include>
|
||||
<include>qbit-assistant.properties</include>
|
||||
</includes>
|
||||
</resource>
|
||||
@@ -88,6 +122,7 @@
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>false</filtering>
|
||||
<excludes>
|
||||
<exclude>help/*.yaml</exclude>
|
||||
<exclude>qbit-assistant.properties</exclude>
|
||||
</excludes>
|
||||
</resource>
|
||||
|
||||
+251
-5
@@ -32,11 +32,13 @@ 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.jobs.AssistantRunTracker;
|
||||
import com.kingsrook.qbits.assistant.jobs.JobTrackingRunTracker;
|
||||
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;
|
||||
@@ -99,12 +101,20 @@ public class AssistantQBitConfig implements QBitConfig
|
||||
private Integer maxParallelToolCalls = 4;
|
||||
private Integer maxRowsToModel = 50;
|
||||
private Integer maxCellChars = 500;
|
||||
private Integer turnHeartbeatSeconds = 5;
|
||||
private Integer turnLeaseSeconds = 45;
|
||||
private Integer turnForceFinalizeGraceSeconds = 20;
|
||||
private Integer shutdownGraceSeconds = 8;
|
||||
private String defaultConcurrencyMode = "SUPERSEDE";
|
||||
private Boolean registerShutdownHook = true;
|
||||
private AssistantRunTracker runTracker = new JobTrackingRunTracker();
|
||||
|
||||
private Integer maxTurnSeconds = 300;
|
||||
private Integer maxUserMessageChars = 8000;
|
||||
private Integer historyMessageLimit = 40;
|
||||
private AssistantBudgets budgets = new AssistantBudgets();
|
||||
private Integer maxTurnsPerUserPerMinute = 10;
|
||||
private Integer maxConcurrentTurnsPerUser = 1;
|
||||
private Integer maxConcurrentTurnsPerUser = 3;
|
||||
private Integer conversationRetentionDays = 90;
|
||||
private Integer toolResultRetentionDays = 30;
|
||||
private Integer approvalRetentionDays = 365;
|
||||
@@ -206,6 +216,23 @@ public class AssistantQBitConfig implements QBitConfig
|
||||
requirePositive(errors, "maxRowsToModel", maxRowsToModel);
|
||||
requirePositive(errors, "maxCellChars", maxCellChars);
|
||||
requirePositive(errors, "maxTurnSeconds", maxTurnSeconds);
|
||||
requirePositive(errors, "turnHeartbeatSeconds", turnHeartbeatSeconds);
|
||||
requirePositive(errors, "turnLeaseSeconds", turnLeaseSeconds);
|
||||
requirePositive(errors, "turnForceFinalizeGraceSeconds", turnForceFinalizeGraceSeconds);
|
||||
requirePositive(errors, "shutdownGraceSeconds", shutdownGraceSeconds);
|
||||
if(turnHeartbeatSeconds != null && turnLeaseSeconds != null && turnLeaseSeconds < 2 * turnHeartbeatSeconds)
|
||||
{
|
||||
errors.add("turnLeaseSeconds must be at least twice turnHeartbeatSeconds.");
|
||||
}
|
||||
if(!"SUPERSEDE".equals(defaultConcurrencyMode) && !"CONCURRENT".equals(defaultConcurrencyMode))
|
||||
{
|
||||
errors.add("defaultConcurrencyMode must be SUPERSEDE or CONCURRENT.");
|
||||
}
|
||||
if(turnHeartbeatSeconds != null && turnLeaseSeconds != null && turnHeartbeatSeconds >= turnLeaseSeconds)
|
||||
{
|
||||
errors.add("turnHeartbeatSeconds must be less than turnLeaseSeconds.");
|
||||
}
|
||||
|
||||
requirePositive(errors, "maxUserMessageChars", maxUserMessageChars);
|
||||
requirePositive(errors, "historyMessageLimit", historyMessageLimit);
|
||||
requirePositive(errors, "maxTurnsPerUserPerMinute", maxTurnsPerUserPerMinute);
|
||||
@@ -2008,4 +2035,223 @@ public class AssistantQBitConfig implements QBitConfig
|
||||
this.scheduledTasksSchedulerName = scheduledTasksSchedulerName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for turnHeartbeatSeconds
|
||||
*******************************************************************************/
|
||||
public Integer getTurnHeartbeatSeconds()
|
||||
{
|
||||
return (this.turnHeartbeatSeconds);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for turnHeartbeatSeconds
|
||||
*******************************************************************************/
|
||||
public void setTurnHeartbeatSeconds(Integer turnHeartbeatSeconds)
|
||||
{
|
||||
this.turnHeartbeatSeconds = turnHeartbeatSeconds;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for turnHeartbeatSeconds
|
||||
*******************************************************************************/
|
||||
public AssistantQBitConfig withTurnHeartbeatSeconds(Integer turnHeartbeatSeconds)
|
||||
{
|
||||
this.turnHeartbeatSeconds = turnHeartbeatSeconds;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for turnLeaseSeconds
|
||||
*******************************************************************************/
|
||||
public Integer getTurnLeaseSeconds()
|
||||
{
|
||||
return (this.turnLeaseSeconds);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for turnLeaseSeconds
|
||||
*******************************************************************************/
|
||||
public void setTurnLeaseSeconds(Integer turnLeaseSeconds)
|
||||
{
|
||||
this.turnLeaseSeconds = turnLeaseSeconds;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for turnLeaseSeconds
|
||||
*******************************************************************************/
|
||||
public AssistantQBitConfig withTurnLeaseSeconds(Integer turnLeaseSeconds)
|
||||
{
|
||||
this.turnLeaseSeconds = turnLeaseSeconds;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for turnForceFinalizeGraceSeconds
|
||||
*******************************************************************************/
|
||||
public Integer getTurnForceFinalizeGraceSeconds()
|
||||
{
|
||||
return (this.turnForceFinalizeGraceSeconds);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for turnForceFinalizeGraceSeconds
|
||||
*******************************************************************************/
|
||||
public void setTurnForceFinalizeGraceSeconds(Integer turnForceFinalizeGraceSeconds)
|
||||
{
|
||||
this.turnForceFinalizeGraceSeconds = turnForceFinalizeGraceSeconds;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for turnForceFinalizeGraceSeconds
|
||||
*******************************************************************************/
|
||||
public AssistantQBitConfig withTurnForceFinalizeGraceSeconds(Integer turnForceFinalizeGraceSeconds)
|
||||
{
|
||||
this.turnForceFinalizeGraceSeconds = turnForceFinalizeGraceSeconds;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for shutdownGraceSeconds
|
||||
*******************************************************************************/
|
||||
public Integer getShutdownGraceSeconds()
|
||||
{
|
||||
return (this.shutdownGraceSeconds);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for shutdownGraceSeconds
|
||||
*******************************************************************************/
|
||||
public void setShutdownGraceSeconds(Integer shutdownGraceSeconds)
|
||||
{
|
||||
this.shutdownGraceSeconds = shutdownGraceSeconds;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for shutdownGraceSeconds
|
||||
*******************************************************************************/
|
||||
public AssistantQBitConfig withShutdownGraceSeconds(Integer shutdownGraceSeconds)
|
||||
{
|
||||
this.shutdownGraceSeconds = shutdownGraceSeconds;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for defaultConcurrencyMode
|
||||
*******************************************************************************/
|
||||
public String getDefaultConcurrencyMode()
|
||||
{
|
||||
return (this.defaultConcurrencyMode);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for defaultConcurrencyMode
|
||||
*******************************************************************************/
|
||||
public void setDefaultConcurrencyMode(String defaultConcurrencyMode)
|
||||
{
|
||||
this.defaultConcurrencyMode = defaultConcurrencyMode;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for defaultConcurrencyMode
|
||||
*******************************************************************************/
|
||||
public AssistantQBitConfig withDefaultConcurrencyMode(String defaultConcurrencyMode)
|
||||
{
|
||||
this.defaultConcurrencyMode = defaultConcurrencyMode;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for registerShutdownHook
|
||||
*******************************************************************************/
|
||||
public Boolean getRegisterShutdownHook()
|
||||
{
|
||||
return (this.registerShutdownHook);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for registerShutdownHook
|
||||
*******************************************************************************/
|
||||
public void setRegisterShutdownHook(Boolean registerShutdownHook)
|
||||
{
|
||||
this.registerShutdownHook = registerShutdownHook;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for registerShutdownHook
|
||||
*******************************************************************************/
|
||||
public AssistantQBitConfig withRegisterShutdownHook(Boolean registerShutdownHook)
|
||||
{
|
||||
this.registerShutdownHook = registerShutdownHook;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for runTracker
|
||||
*******************************************************************************/
|
||||
@JsonIgnore
|
||||
public AssistantRunTracker getRunTracker()
|
||||
{
|
||||
return (this.runTracker == null ? AssistantRunTracker.NOOP : this.runTracker);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for runTracker
|
||||
*******************************************************************************/
|
||||
public void setRunTracker(AssistantRunTracker runTracker)
|
||||
{
|
||||
this.runTracker = runTracker;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for runTracker
|
||||
*******************************************************************************/
|
||||
public AssistantQBitConfig withRunTracker(AssistantRunTracker runTracker)
|
||||
{
|
||||
this.runTracker = runTracker;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
+9
-3
@@ -23,14 +23,16 @@ import java.util.List;
|
||||
import java.util.Properties;
|
||||
import com.kingsrook.qbits.assistant.api.AssistantRoutes;
|
||||
import com.kingsrook.qbits.assistant.developer.AssistantDeveloperPanels;
|
||||
import com.kingsrook.qbits.assistant.health.AssistantHealthChecks;
|
||||
import com.kingsrook.qbits.assistant.health.AssistantHealthWidget;
|
||||
import com.kingsrook.qbits.assistant.home.AssistantAccessWidget;
|
||||
import com.kingsrook.qbits.assistant.home.AssistantHome;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantChannelWall;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantInstanceMetaData;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantRecordScreens;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantPreferencesProvider;
|
||||
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;
|
||||
@@ -42,6 +44,7 @@ import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
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;
|
||||
@@ -181,7 +184,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);
|
||||
@@ -199,12 +202,14 @@ public class AssistantQBitProducer implements QBitMetaDataProducer<AssistantQBit
|
||||
qInstance.addProcess(process);
|
||||
}
|
||||
qInstance.addWidget(AssistantHome.widget());
|
||||
qInstance.addWidget(AssistantHealthWidget.widget());
|
||||
qInstance.addWidget(AssistantAccessWidget.widget());
|
||||
|
||||
QAppMetaData app = builder.buildAdminApp(tables);
|
||||
qInstance.addApp(app);
|
||||
// Registration is metadata-only; core startup sync guards hosts without helpContent.
|
||||
HelpBundles.register(qInstance, "help/qbit-assistant.yaml");
|
||||
|
||||
AssistantPreferencesProvider.register(qInstance);
|
||||
AssistantChannelWall.register(qInstance);
|
||||
if(Boolean.TRUE.equals(qBitConfig.getRecordHistory()))
|
||||
{
|
||||
@@ -215,6 +220,7 @@ public class AssistantQBitProducer implements QBitMetaDataProducer<AssistantQBit
|
||||
ClientChannelsMetaData.ofOrWithNew(qInstance).withDeniedPattern("assistant", "audit*");
|
||||
}
|
||||
postProduceActions(new MetaDataProducerMultiOutput(), qInstance);
|
||||
AssistantHealthChecks.register(qInstance);
|
||||
return (new MetaDataProducerMultiOutput());
|
||||
}
|
||||
|
||||
|
||||
@@ -17,8 +17,12 @@
|
||||
package com.kingsrook.qbits.assistant;
|
||||
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.net.InetAddress;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
@@ -50,6 +54,7 @@ import com.kingsrook.qqq.backend.core.actions.ai.AiToolKernelSettings;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.exposure.AiExposurePolicy;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.output.AiRedactor;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
@@ -62,6 +67,11 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
*******************************************************************************/
|
||||
public class AssistantRuntime
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(AssistantRuntime.class);
|
||||
private final String instanceId = instanceId();
|
||||
private volatile boolean shuttingDown;
|
||||
private Thread shutdownHook;
|
||||
|
||||
private final ScheduledTurn scheduledTurn = new ScheduledTurn();
|
||||
|
||||
private final RagService ragService;
|
||||
@@ -103,12 +113,22 @@ public class AssistantRuntime
|
||||
**
|
||||
*******************************************************************************/
|
||||
public AssistantRuntime(QInstance qInstance, AssistantQBitConfig config, Clock clock)
|
||||
{
|
||||
this(qInstance, config, clock, new AssistantStore(config));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit persistence collaborator for embedded runtimes and fault tests.
|
||||
*******************************************************************************/
|
||||
public AssistantRuntime(QInstance qInstance, AssistantQBitConfig config, Clock clock, AssistantStore store)
|
||||
{
|
||||
AssistantChannelWall.validate(qInstance);
|
||||
this.qInstance = qInstance;
|
||||
this.config = config;
|
||||
this.clock = clock;
|
||||
this.store = new AssistantStore(config);
|
||||
this.store = store;
|
||||
this.policyService = new PolicyService(config, store, clock);
|
||||
this.providerService = new ProviderService(config, store, clock);
|
||||
this.budgetService = new BudgetService(store, providerService, clock);
|
||||
@@ -157,6 +177,32 @@ public class AssistantRuntime
|
||||
this.knowledgeTool = new KnowledgeTool(this, ragService, vectors);
|
||||
this.bulkLoadTools = new BulkLoadTools(qInstance, dataAccessService, config.getBulkLoadRunner());
|
||||
this.contextBuilder = new ContextBuilder(config, kernel, toolbox).withKnowledgeTool(knowledgeTool).withBulkLoadTools(bulkLoadTools);
|
||||
if(Boolean.TRUE.equals(config.getRegisterShutdownHook()))
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// the hook holds the runtime weakly: a runtime (and its QInstance) //
|
||||
// that is dropped without shutdown() - every test that builds an //
|
||||
// instance - must not stay reachable from the JVM's hooks //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
WeakReference<AssistantRuntime> self = new WeakReference<>(this);
|
||||
Integer graceSeconds = config.getShutdownGraceSeconds();
|
||||
shutdownHook = new Thread(() ->
|
||||
{
|
||||
try
|
||||
{
|
||||
AssistantRuntime runtime = self.get();
|
||||
if(runtime != null)
|
||||
{
|
||||
runtime.shutdown(Duration.ofSeconds(graceSeconds));
|
||||
}
|
||||
}
|
||||
catch(Throwable ignored)
|
||||
{
|
||||
// Shutdown must never fail the JVM hook.
|
||||
}
|
||||
}, "assistant-shutdown-" + instanceId);
|
||||
Runtime.getRuntime().addShutdownHook(shutdownHook);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -312,8 +358,13 @@ public class AssistantRuntime
|
||||
/*******************************************************************************
|
||||
** Track a turn running here.
|
||||
*******************************************************************************/
|
||||
public RunningTurn register(RunningTurn turn)
|
||||
public synchronized RunningTurn register(RunningTurn turn)
|
||||
{
|
||||
if(shuttingDown)
|
||||
{
|
||||
turn.forceFinalize("SHUTDOWN");
|
||||
return (turn);
|
||||
}
|
||||
runningTurns.put(turn.getTurnId(), turn);
|
||||
return (turn);
|
||||
}
|
||||
@@ -345,7 +396,17 @@ public class AssistantRuntime
|
||||
*******************************************************************************/
|
||||
public void cancelAll(String reason)
|
||||
{
|
||||
runningTurns.values().forEach(t -> t.cancel(reason));
|
||||
runningTurns.values().forEach(t ->
|
||||
{
|
||||
try
|
||||
{
|
||||
t.cancel(reason);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Assistant cancellation failed; finalization will still run");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -355,8 +416,7 @@ public class AssistantRuntime
|
||||
*******************************************************************************/
|
||||
public void close()
|
||||
{
|
||||
runningTurns.values().forEach(t -> t.cancel("SHUTDOWN"));
|
||||
executor.shutdownNow();
|
||||
shutdown(Duration.ZERO);
|
||||
}
|
||||
|
||||
|
||||
@@ -504,4 +564,97 @@ public class AssistantRuntime
|
||||
return (bulkLoadTools);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stop accepting work, cancel, wait a bounded grace, then force finalization.
|
||||
*******************************************************************************/
|
||||
public void shutdown(Duration grace)
|
||||
{
|
||||
synchronized(this)
|
||||
{
|
||||
shuttingDown = true;
|
||||
}
|
||||
cancelAll("SHUTDOWN");
|
||||
long until = System.nanoTime() + Math.max(0, grace.toNanos());
|
||||
while(!runningTurns.isEmpty() && System.nanoTime() < until)
|
||||
{
|
||||
try
|
||||
{
|
||||
Thread.sleep(10);
|
||||
}
|
||||
catch(InterruptedException e)
|
||||
{
|
||||
Thread.currentThread().interrupt();
|
||||
break;
|
||||
}
|
||||
}
|
||||
runningTurns.values().forEach(t ->
|
||||
{
|
||||
try
|
||||
{
|
||||
t.forceFinalize("SHUTDOWN");
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Assistant shutdown finalization failed");
|
||||
}
|
||||
});
|
||||
executor.shutdownNow();
|
||||
if(shutdownHook != null && Thread.currentThread() != shutdownHook)
|
||||
{
|
||||
try
|
||||
{
|
||||
Runtime.getRuntime().removeShutdownHook(shutdownHook);
|
||||
}
|
||||
catch(IllegalStateException ignored)
|
||||
{
|
||||
// The JVM is already shutting down.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for shuttingDown
|
||||
*******************************************************************************/
|
||||
public boolean isShuttingDown()
|
||||
{
|
||||
return (shuttingDown);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for instanceId
|
||||
*******************************************************************************/
|
||||
public String getInstanceId()
|
||||
{
|
||||
return (instanceId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A stable, bounded hostname plus a per-runtime random suffix.
|
||||
*******************************************************************************/
|
||||
private static String instanceId()
|
||||
{
|
||||
String host = System.getenv("HOSTNAME");
|
||||
if(!StringUtils.hasContent(host))
|
||||
{
|
||||
try
|
||||
{
|
||||
host = InetAddress.getLocalHost().getHostName();
|
||||
}
|
||||
catch(Exception ignored)
|
||||
{
|
||||
host = "unknown";
|
||||
}
|
||||
}
|
||||
return (host.substring(0, Math.min(87, host.length())) + ":" + UUID.randomUUID().toString().substring(0, 8));
|
||||
}
|
||||
|
||||
}
|
||||
-12
@@ -132,18 +132,6 @@ public class DataAccessService
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether an admin marked the table's single-record inserts and updates as
|
||||
** low-risk (auto-confirmable, when the policy and the user allow it).
|
||||
*******************************************************************************/
|
||||
public boolean autoConfirmOk(String tableName)
|
||||
{
|
||||
AssistantDataAccess row = rows().get(key(AssistantDataAccess.TYPE_TABLE, tableName));
|
||||
return (row != null && Boolean.TRUE.equals(row.getAutoConfirmOk()) && forTable(tableName).mode().allowsWrite());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Drop the cache (after a change to the table).
|
||||
*******************************************************************************/
|
||||
|
||||
+279
-135
@@ -17,11 +17,14 @@
|
||||
package com.kingsrook.qbits.assistant.api;
|
||||
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
@@ -44,19 +47,21 @@ import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantTurn;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantTurnEvent;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantUserPreference;
|
||||
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
|
||||
import com.kingsrook.qbits.assistant.persistence.TranscriptRedactor;
|
||||
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
|
||||
import com.kingsrook.qbits.assistant.tools.bulkload.BulkLoadTools;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiToolOutcome;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiToolResult;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.output.AiRedactor;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -66,6 +71,8 @@ import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials;
|
||||
*******************************************************************************/
|
||||
public class AssistantApi
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(AssistantApi.class);
|
||||
|
||||
public static final int TITLE_MAX_CHARS = 60;
|
||||
|
||||
private final AssistantRuntime runtime;
|
||||
@@ -141,10 +148,7 @@ public class AssistantApi
|
||||
result.put("shortcut", config.getShortcut());
|
||||
result.put("model", reason == null ? activeProvider().model() : config.getModels().getMainModel());
|
||||
result.put("limits", Map.of("maxTurnSeconds", config.getMaxTurnSeconds(), "maxToolCallsPerTurn", config.getMaxToolCallsPerTurn()));
|
||||
boolean writeMode = reason == null && writeModeAvailable();
|
||||
result.put("features", Map.of("writes", runtime.getToolbox().anyWritableTable(), "pendingActions", true, "writeMode", writeMode,
|
||||
"autoConfirm", writeMode && runtime.getPolicyService().current(false).tools().autoConfirmAllowed()));
|
||||
result.put("preferences", reason == null ? preferences() : Map.of());
|
||||
result.put("features", Map.of("writes", runtime.getToolbox().anyWritableTable(), "pendingActions", true));
|
||||
ProfileService.Persona persona = runtime.getProfileService().persona(session, null);
|
||||
Map<String, Object> profile = new LinkedHashMap<>();
|
||||
profile.put("displayName", persona.displayName());
|
||||
@@ -184,12 +188,6 @@ public class AssistantApi
|
||||
String title = body.get("title") instanceof String s && StringUtils.hasContent(s) ? truncate(s.trim(), 255) : null;
|
||||
Map<String, Object> context = body.get("context") instanceof Map<?, ?> m ? ContextBuilder.sanitizePageContext((Map<String, Object>) m) : null;
|
||||
AssistantConversation conversation = store.createConversation(userId, title, context == null || context.isEmpty() ? null : JsonUtils.toJson(context));
|
||||
boolean write = body.get("writeMode") instanceof Boolean requested ? requested : defaultWriteMode();
|
||||
if(write && writeModeAvailable())
|
||||
{
|
||||
store.updateFields(AssistantConversation.TABLE_NAME, new QRecord().withValue("id", conversation.getId()).withValue("writeMode", true));
|
||||
conversation.setWriteMode(true);
|
||||
}
|
||||
return (toJson(conversation));
|
||||
}
|
||||
|
||||
@@ -265,19 +263,6 @@ public class AssistantApi
|
||||
conversation.setStatus((String) status);
|
||||
}
|
||||
|
||||
if(body.containsKey("writeMode"))
|
||||
{
|
||||
if(!(body.get("writeMode") instanceof Boolean writeMode))
|
||||
{
|
||||
throw (new AssistantApiException(400, "writeMode must be true or false."));
|
||||
}
|
||||
if(writeMode && !writeModeAvailable())
|
||||
{
|
||||
throw (new AssistantApiException(403, "WRITE_MODE_UNAVAILABLE", "Write mode is not available to you. An administrator controls it in the assistant policy."));
|
||||
}
|
||||
update.setValue("writeMode", writeMode);
|
||||
conversation.setWriteMode(writeMode);
|
||||
}
|
||||
store.updateFields(AssistantConversation.TABLE_NAME, update);
|
||||
return (toJson(conversation));
|
||||
}
|
||||
@@ -312,6 +297,19 @@ public class AssistantApi
|
||||
public RunningTurn startTurn(Integer conversationId, Map<String, Object> body) throws QException, AssistantApiException
|
||||
{
|
||||
requireAccess();
|
||||
if(runtime.isShuttingDown())
|
||||
{
|
||||
throw (new AssistantApiException(503, "SHUTTING_DOWN", "The server is restarting. Try again in a moment."));
|
||||
}
|
||||
String concurrency = config.getDefaultConcurrencyMode();
|
||||
if(body.containsKey("concurrency"))
|
||||
{
|
||||
if(!(body.get("concurrency") instanceof String mode) || !("supersede".equalsIgnoreCase(mode) || "concurrent".equalsIgnoreCase(mode)))
|
||||
{
|
||||
throw (new AssistantApiException(400, "concurrency must be supersede or concurrent."));
|
||||
}
|
||||
concurrency = mode.toUpperCase(Locale.ROOT);
|
||||
}
|
||||
AssistantConversation conversation = requireConversation(conversationId);
|
||||
|
||||
if(!(body.get("text") instanceof String text) || !StringUtils.hasContent(text))
|
||||
@@ -347,30 +345,43 @@ public class AssistantApi
|
||||
throw (new AssistantApiException(429, "BUDGET", budget.message()));
|
||||
}
|
||||
|
||||
long running = 0;
|
||||
for(AssistantTurn other : store.listRunningTurns(userId))
|
||||
{
|
||||
if(isRunning(reapIfOrphaned(other)))
|
||||
{
|
||||
running++;
|
||||
}
|
||||
}
|
||||
if(running >= config.getMaxConcurrentTurnsPerUser())
|
||||
{
|
||||
throw (new AssistantApiException(429, "RATE_LIMIT", "You already have an assistant answer in progress. Wait for it to finish or stop it."));
|
||||
}
|
||||
|
||||
String limited = runtime.getRateLimiter().tryAcquire(userId, config.getMaxTurnsPerUserPerMinute(), config.getMaxConcurrentTurnsPerUser());
|
||||
if(limited != null)
|
||||
{
|
||||
throw (new AssistantApiException(429, "RATE_LIMIT", limited));
|
||||
}
|
||||
|
||||
List<AssistantTurn> remaining = new ArrayList<>();
|
||||
for(AssistantTurn other : store.listRunningTurns(userId))
|
||||
{
|
||||
other = reapIfOrphaned(other);
|
||||
if(!isRunning(other) || "SUPERSEDED".equals(other.getCancelReason()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if("SUPERSEDE".equals(concurrency) && conversationId.equals(other.getConversationId()))
|
||||
{
|
||||
requestStop(other, "SUPERSEDED");
|
||||
}
|
||||
else
|
||||
{
|
||||
remaining.add(other);
|
||||
}
|
||||
}
|
||||
remaining.sort(Comparator.comparing(AssistantTurn::getStartedAt, Comparator.nullsFirst(Comparator.naturalOrder()))
|
||||
.thenComparing(AssistantTurn::getId));
|
||||
while(remaining.size() >= config.getMaxConcurrentTurnsPerUser())
|
||||
{
|
||||
requestStop(remaining.removeFirst(), "SUPERSEDED");
|
||||
}
|
||||
|
||||
TranscriptRedactor transcript = new TranscriptRedactor(runtime.getQInstance(), config.getClassificationSource());
|
||||
transcript.attach();
|
||||
try
|
||||
{
|
||||
if(!StringUtils.hasContent(conversation.getTitle()))
|
||||
{
|
||||
String title = deriveTitle(text);
|
||||
String title = text;
|
||||
store.updateFields(AssistantConversation.TABLE_NAME, new QRecord().withValue("id", conversationId).withValue("title", title));
|
||||
conversation.setTitle(title);
|
||||
}
|
||||
@@ -384,13 +395,18 @@ public class AssistantApi
|
||||
.withSeq(seq)
|
||||
.withPartsJson(new MessageParts().addText(text).toJson()));
|
||||
|
||||
Instant startedAt = runtime.getClock().instant();
|
||||
AssistantTurn turn = store.insert(new AssistantTurn()
|
||||
.withConversationId(conversationId)
|
||||
.withUserId(userId)
|
||||
.withUserMessageId(userMessage.getId())
|
||||
.withStatus("RUNNING")
|
||||
.withModel(active.model())
|
||||
.withStartedAt(runtime.getClock().instant())
|
||||
.withStartedAt(startedAt)
|
||||
.withHeartbeatAt(startedAt)
|
||||
.withLeaseOwner(runtime.getInstanceId())
|
||||
.withPhase("starting")
|
||||
.withConcurrencyMode(concurrency)
|
||||
.withToolCallCount(0)
|
||||
.withModelCallCount(0));
|
||||
|
||||
@@ -408,17 +424,49 @@ public class AssistantApi
|
||||
store.updateFields(AssistantTurn.TABLE_NAME, new QRecord().withValue("id", turn.getId()).withValue("assistantMessageId", assistantMessage.getId()));
|
||||
turn.setAssistantMessageId(assistantMessage.getId());
|
||||
|
||||
RunningTurn runningTurn = runtime.register(new RunningTurn(turn.getId(), userId));
|
||||
RunningTurn runningTurn = new RunningTurn(turn.getId(), userId);
|
||||
TurnEngine engine = new TurnEngine(runtime, session, userId, conversation, userMessage, assistantMessage, turn, text, pageContext, policy, runningTurn,
|
||||
budget.softExceeded() ? budget.message() : null).withCredentials(credentials);
|
||||
runtime.getExecutor().submit(engine);
|
||||
budget.softExceeded() ? budget.message() : null).withCredentials(credentials).withTranscript(transcript);
|
||||
try
|
||||
{
|
||||
if(config.getRunTracker() != null)
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////
|
||||
// never the conversation title: it is derived from the prompt //
|
||||
///////////////////////////////////////////////////////////////////
|
||||
runningTurn.setTrackingId(config.getRunTracker().start(userId, turn.getId(), conversationId, "Assistant answer"));
|
||||
config.getRunTracker().onCancel(runningTurn.getTrackingId(), () -> runningTurn.cancel("USER"));
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Assistant run tracker start failed", logPair("turnId", turn.getId()));
|
||||
}
|
||||
runtime.register(runningTurn);
|
||||
if(!runningTurn.isFinalized())
|
||||
{
|
||||
try
|
||||
{
|
||||
runtime.getExecutor().submit(engine);
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
runningTurn.forceFinalize("SHUTDOWN");
|
||||
throw (e);
|
||||
}
|
||||
}
|
||||
return (runningTurn);
|
||||
}
|
||||
catch(QException | RuntimeException e)
|
||||
{
|
||||
transcript.close();
|
||||
runtime.getRateLimiter().release(userId);
|
||||
throw (e);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TranscriptRedactor.detach();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -466,22 +514,94 @@ public class AssistantApi
|
||||
{
|
||||
requireAccess();
|
||||
AssistantTurn turn = requireTurn(turnId);
|
||||
if(runtime.getRunningTurn(turnId) == null)
|
||||
if(isRunning(turn))
|
||||
{
|
||||
turn = requestStop(turn, "USER");
|
||||
}
|
||||
return (Map.of("status", turn.getStatus()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Durable Stop, shared by the API and superseding another question.
|
||||
*******************************************************************************/
|
||||
private AssistantTurn requestStop(AssistantTurn turn, String reason) throws QException
|
||||
{
|
||||
synchronized(store)
|
||||
{
|
||||
turn = store.getTurn(userId, turn.getId());
|
||||
if(!isRunning(turn))
|
||||
{
|
||||
return (turn);
|
||||
}
|
||||
RunningTurn live = runtime.getRunningTurn(turn.getId());
|
||||
if(live == null && orphaned(turn))
|
||||
{
|
||||
return (reap(turn, reason));
|
||||
}
|
||||
Instant now = runtime.getClock().instant();
|
||||
if(!store.updateRunningTurn(userId, new QRecord().withValue("id", turn.getId())
|
||||
.withValue("status", "CANCELLING").withValue("cancelReason", reason).withValue("cancelRequestedAt", now)))
|
||||
{
|
||||
return (store.getTurn(userId, turn.getId()));
|
||||
}
|
||||
turn.setStatus("CANCELLING");
|
||||
turn.setCancelReason(reason);
|
||||
turn.setCancelRequestedAt(now);
|
||||
if(live != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
live.cancel(reason);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Assistant stream cancellation failed; watchdog will finalize");
|
||||
}
|
||||
}
|
||||
return (turn);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** GET /turns/active: the caller's runs, newest first, after orphan recovery.
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> activeTurns() throws QException, AssistantApiException
|
||||
{
|
||||
requireAccess();
|
||||
List<AssistantTurn> turns = new ArrayList<>();
|
||||
for(AssistantTurn turn : store.listRunningTurns(userId))
|
||||
{
|
||||
turn = reapIfOrphaned(turn);
|
||||
if(isRunning(turn))
|
||||
{
|
||||
turns.add(turn);
|
||||
}
|
||||
}
|
||||
if(!isRunning(turn))
|
||||
turns.sort(Comparator.comparing(AssistantTurn::getStartedAt, Comparator.nullsFirst(Comparator.naturalOrder()))
|
||||
.thenComparing(AssistantTurn::getId).reversed());
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(AssistantTurn turn : turns)
|
||||
{
|
||||
throw (new AssistantApiException(409, "This answer has already finished."));
|
||||
RunningTurn live = runtime.getRunningTurn(turn.getId());
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("turnId", turn.getId());
|
||||
row.put("conversationId", turn.getConversationId());
|
||||
row.put("assistantMessageId", turn.getAssistantMessageId());
|
||||
row.put("userMessageId", turn.getUserMessageId());
|
||||
row.put("status", turn.getStatus());
|
||||
row.put("phase", live == null ? turn.getPhase() : live.getPhase());
|
||||
row.put("startedAt", turn.getStartedAt() == null ? null : turn.getStartedAt().toString());
|
||||
row.put("heartbeatAt", turn.getHeartbeatAt() == null ? null : turn.getHeartbeatAt().toString());
|
||||
row.put("elapsedMs", turn.getStartedAt() == null ? 0 : Math.max(0, Duration.between(turn.getStartedAt(), runtime.getClock().instant()).toMillis()));
|
||||
row.put("live", live != null);
|
||||
row.put("concurrencyMode", turn.getConcurrencyMode());
|
||||
rows.add(row);
|
||||
}
|
||||
|
||||
store.updateFields(AssistantTurn.TABLE_NAME, new QRecord().withValue("id", turnId).withValue("status", "CANCELLING"));
|
||||
RunningTurn live = runtime.getRunningTurn(turnId);
|
||||
if(live != null && userId.equals(live.getUserId()))
|
||||
{
|
||||
live.cancel("USER");
|
||||
}
|
||||
return (Map.of("status", "CANCELLING"));
|
||||
return (Map.of("turns", rows));
|
||||
}
|
||||
|
||||
|
||||
@@ -497,60 +617,106 @@ public class AssistantApi
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A turn whose replica died: it still says RUNNING / CANCELLING, no turn
|
||||
** of this runtime owns it, and it started longer ago than maxTurnSeconds
|
||||
** + 60 s (a live engine always ends itself by then). On first sight it is
|
||||
** finished as ERROR: the assistant message gets status ERROR and a
|
||||
** notice, and synthetic error (INTERNAL) + message.end events are
|
||||
** persisted, so replays end. Returns the (possibly updated) turn.
|
||||
** A remote run loses its lease, exceeds the absolute deadline, or has a
|
||||
** cancellation request that its owner did not acknowledge in time.
|
||||
*******************************************************************************/
|
||||
private boolean orphaned(AssistantTurn turn)
|
||||
{
|
||||
Instant now = runtime.getClock().instant();
|
||||
Instant heartbeat = turn.getHeartbeatAt() == null ? turn.getStartedAt() : turn.getHeartbeatAt();
|
||||
return ((heartbeat != null && heartbeat.isBefore(now.minusSeconds(config.getTurnLeaseSeconds())))
|
||||
|| (turn.getStartedAt() != null && turn.getStartedAt().isBefore(now.minusSeconds(maxStreamSeconds())))
|
||||
|| ("CANCELLING".equals(turn.getStatus()) && turn.getCancelRequestedAt() != null
|
||||
&& turn.getCancelRequestedAt().isBefore(now.minusSeconds(config.getTurnLeaseSeconds()))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Recover an abandoned run and leave a terminal replay for refreshes.
|
||||
*******************************************************************************/
|
||||
AssistantTurn reapIfOrphaned(AssistantTurn turn) throws QException
|
||||
{
|
||||
if(!isRunning(turn) || runtime.getRunningTurn(turn.getId()) != null || turn.getStartedAt() == null)
|
||||
synchronized(store)
|
||||
{
|
||||
return (turn);
|
||||
}
|
||||
Instant stale = runtime.getClock().instant().minusSeconds(config.getMaxTurnSeconds() + 60L);
|
||||
if(!turn.getStartedAt().isBefore(stale))
|
||||
{
|
||||
return (turn);
|
||||
}
|
||||
|
||||
String text = "This answer was interrupted (the server restarted). Ask again.";
|
||||
if(turn.getAssistantMessageId() != null)
|
||||
{
|
||||
AssistantMessage message = store.getMessage(userId, turn.getAssistantMessageId());
|
||||
if(message != null)
|
||||
if(!isRunning(turn) || runtime.getRunningTurn(turn.getId()) != null || !orphaned(turn))
|
||||
{
|
||||
store.updateFields(AssistantMessage.TABLE_NAME, new QRecord()
|
||||
.withValue("id", message.getId())
|
||||
.withValue("status", "ERROR")
|
||||
.withValue("partsJson", MessageParts.fromJson(message.getPartsJson()).addNotice("error", text).toJson()));
|
||||
return (turn);
|
||||
}
|
||||
String reason = "CANCELLING".equals(turn.getStatus()) && turn.getCancelRequestedAt() != null
|
||||
&& turn.getCancelRequestedAt().isBefore(runtime.getClock().instant().minusSeconds(config.getTurnLeaseSeconds()))
|
||||
? (turn.getCancelReason() == null ? "USER" : turn.getCancelReason()) : null;
|
||||
return (reap(turn, reason));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Re-read before recovery; never overwrite an already terminal row or
|
||||
** append another terminal event. Each persistence step is independent.
|
||||
*******************************************************************************/
|
||||
private AssistantTurn reap(AssistantTurn turn, String cancelReason) throws QException
|
||||
{
|
||||
AssistantTurn current = store.getTurn(userId, turn.getId());
|
||||
if(!isRunning(current))
|
||||
{
|
||||
return (current);
|
||||
}
|
||||
if(!orphaned(current))
|
||||
{
|
||||
return (cancelReason == null ? current : requestStop(current, cancelReason));
|
||||
}
|
||||
// Recovery only appends fixed notices to already redacted stored parts (or
|
||||
// crash placeholders). It never reloads model/tool values or raw user text.
|
||||
String status = cancelReason == null ? "ERROR" : "CANCELLED";
|
||||
String text = cancelReason == null ? "This answer was interrupted (the server restarted). Ask again."
|
||||
: ("SUPERSEDED".equals(cancelReason) ? TurnEngine.SUPERSEDED_TEXT : "Stopped by you.");
|
||||
if(current.getAssistantMessageId() != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
AssistantMessage message = store.getMessage(userId, current.getAssistantMessageId());
|
||||
if(message != null)
|
||||
{
|
||||
store.updateFields(AssistantMessage.TABLE_NAME, new QRecord().withValue("id", message.getId()).withValue("status", status)
|
||||
.withValue("partsJson", MessageParts.fromJson(message.getPartsJson()).addNotice(cancelReason == null ? "error" : "info", text).toJson()));
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Could not persist recovered assistant message");
|
||||
}
|
||||
}
|
||||
|
||||
int seq = store.lastEventSeq(userId, turn.getId());
|
||||
store.insertEvent(turn.getId(), userId, seq + 1, TurnEvent.ERROR, new TurnEvent(seq + 1, TurnEvent.ERROR, Map.of("code", "INTERNAL", "message", text)).toJson());
|
||||
Map<String, Object> end = new java.util.LinkedHashMap<>();
|
||||
if(turn.getAssistantMessageId() != null)
|
||||
try
|
||||
{
|
||||
end.put("messageId", turn.getAssistantMessageId());
|
||||
int seq = store.lastEventSeq(userId, current.getId());
|
||||
List<AssistantTurnEvent> last = store.listEvents(userId, current.getId(), Math.max(0, seq - 1), 1);
|
||||
if(last.isEmpty() || !TurnEvent.MESSAGE_END.equals(last.getFirst().getEventType()))
|
||||
{
|
||||
if(cancelReason == null)
|
||||
{
|
||||
seq++;
|
||||
store.insertEvent(current.getId(), userId, seq, TurnEvent.ERROR,
|
||||
new TurnEvent(seq, TurnEvent.ERROR, Map.of("code", "INTERNAL", "message", text)).toJson());
|
||||
}
|
||||
seq++;
|
||||
Map<String, Object> end = new LinkedHashMap<>();
|
||||
end.put("messageId", current.getAssistantMessageId());
|
||||
end.put("status", status);
|
||||
end.put("stopReason", cancelReason == null ? "interrupted" : "cancelled");
|
||||
store.insertEvent(current.getId(), userId, seq, TurnEvent.MESSAGE_END, new TurnEvent(seq, TurnEvent.MESSAGE_END, end).toJson());
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Could not persist recovered assistant events");
|
||||
}
|
||||
end.put("status", "ERROR");
|
||||
end.put("stopReason", "interrupted");
|
||||
store.insertEvent(turn.getId(), userId, seq + 2, TurnEvent.MESSAGE_END, new TurnEvent(seq + 2, TurnEvent.MESSAGE_END, end).toJson());
|
||||
|
||||
Instant now = runtime.getClock().instant();
|
||||
store.updateFields(AssistantTurn.TABLE_NAME, new QRecord()
|
||||
.withValue("id", turn.getId())
|
||||
.withValue("status", "ERROR")
|
||||
.withValue("errorCode", "INTERNAL")
|
||||
.withValue("stopReason", "interrupted")
|
||||
.withValue("endedAt", now));
|
||||
turn.setStatus("ERROR");
|
||||
turn.setErrorCode("INTERNAL");
|
||||
turn.setEndedAt(now);
|
||||
return (turn);
|
||||
store.updateRunningTurn(userId, new QRecord().withValue("id", current.getId()).withValue("status", status)
|
||||
.withValue("errorCode", cancelReason == null ? "INTERNAL" : null).withValue("cancelReason", cancelReason)
|
||||
.withValue("stopReason", cancelReason == null ? "interrupted" : "cancelled").withValue("phase", "done").withValue("endedAt", now));
|
||||
return (store.getTurn(userId, current.getId()));
|
||||
}
|
||||
|
||||
|
||||
@@ -614,6 +780,12 @@ public class AssistantApi
|
||||
throw (new AssistantApiException(409, "This action is already " + action.getStatus().toLowerCase() + "."));
|
||||
}
|
||||
|
||||
AssistantTurn proposingTurn = store.getTurn(userId, action.getTurnId());
|
||||
if(proposingTurn != null && isRunning(proposingTurn))
|
||||
{
|
||||
throw (new AssistantApiException(409, "Wait for the assistant to finish before confirming this action."));
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// claim it first, so a double click cannot run the write twice //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
@@ -799,43 +971,6 @@ public class AssistantApi
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether this user may use Write mode at all: the policy allows it, they
|
||||
** hold its permission (when one is set), and some table is READ_WRITE.
|
||||
*******************************************************************************/
|
||||
public boolean writeModeAvailable() throws QException
|
||||
{
|
||||
return (runtime.getPolicyService().current(false).tools().writeModeAvailableTo(session) && runtime.getToolbox().anyWritableTable());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The default mode of the user's new conversations (Read-only unless they
|
||||
** chose Write).
|
||||
*******************************************************************************/
|
||||
private boolean defaultWriteMode() throws QException
|
||||
{
|
||||
AssistantUserPreference preference = store.getPreference(userId);
|
||||
return (preference != null && Boolean.TRUE.equals(preference.getDefaultWriteMode()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Effective defaults for the panel config, narrowed by current policy.
|
||||
*******************************************************************************/
|
||||
private Map<String, Object> preferences() throws QException
|
||||
{
|
||||
AssistantUserPreference preference = userId == null ? null : store.getPreference(userId);
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("defaultWriteMode", writeModeAvailable() && preference != null && Boolean.TRUE.equals(preference.getDefaultWriteMode()));
|
||||
map.put("autoConfirmLowRisk", runtime.getPolicyService().current(false).tools().autoConfirmAllowed() && preference != null && Boolean.TRUE.equals(preference.getAutoConfirmLowRisk()));
|
||||
return (map);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The configured turn time limit (replay loops are capped by it).
|
||||
*******************************************************************************/
|
||||
@@ -846,6 +981,16 @@ public class AssistantApi
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Hard upper bound for remote stream polling and stale-run recovery.
|
||||
*******************************************************************************/
|
||||
public long maxStreamSeconds()
|
||||
{
|
||||
return (config.getMaxTurnSeconds() + (long) config.getTurnForceFinalizeGraceSeconds() + config.getTurnLeaseSeconds());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The user may use the panel: enabled, signed in, assistant.hasAccess.
|
||||
*******************************************************************************/
|
||||
@@ -934,7 +1079,6 @@ public class AssistantApi
|
||||
map.put("title", conversation.getTitle());
|
||||
map.put("status", conversation.getStatus());
|
||||
map.put("pinned", Boolean.TRUE.equals(conversation.getPinned()));
|
||||
map.put("writeMode", Boolean.TRUE.equals(conversation.getWriteMode()));
|
||||
map.put("lastMessageAt", conversation.getLastMessageAt() == null ? null : conversation.getLastMessageAt().toString());
|
||||
map.put("unreadCount", conversation.getUnreadCount() == null ? 0 : conversation.getUnreadCount());
|
||||
map.put("createDate", conversation.getCreateDate() == null ? null : conversation.getCreateDate().toString());
|
||||
|
||||
@@ -17,12 +17,12 @@
|
||||
package com.kingsrook.qbits.assistant.api;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.assistant.api.specs.AssistantSpecs;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qbits.assistant.api.specs.AssistantSpecs;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinMetaData;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
@@ -21,6 +21,7 @@ import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.assistant.engine.TurnEvent;
|
||||
import com.kingsrook.qbits.assistant.engine.TurnEventLog;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
@@ -101,7 +102,7 @@ public final class TurnStreams
|
||||
OutputStream out = startStream(context);
|
||||
int last = after;
|
||||
long lastWrite = System.currentTimeMillis();
|
||||
long giveUpAt = System.currentTimeMillis() + (api.maxTurnSeconds() + 60L) * 1000L;
|
||||
long giveUpAt = System.currentTimeMillis() + api.maxStreamSeconds() * 1000L;
|
||||
AssistantApi.TurnEvents current = first;
|
||||
while(true)
|
||||
{
|
||||
@@ -113,10 +114,16 @@ public final class TurnStreams
|
||||
lastWrite = System.currentTimeMillis();
|
||||
ended |= event.isTerminal();
|
||||
}
|
||||
if(ended || (current.finished() && !current.more()) || System.currentTimeMillis() > giveUpAt)
|
||||
if(ended || (current.finished() && !current.more()))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if(System.currentTimeMillis() > giveUpAt)
|
||||
{
|
||||
write(out, new TurnEvent(last + 1, TurnEvent.ERROR, Map.of("code", "TIMEOUT", "message", "The event stream timed out. Reconnect to check this answer.")).toSseFrame());
|
||||
write(out, new TurnEvent(last + 2, TurnEvent.MESSAGE_END, Map.of("status", "ERROR", "stopReason", "stream_timeout")).toSseFrame());
|
||||
return;
|
||||
}
|
||||
if(!current.more())
|
||||
{
|
||||
Thread.sleep(REMOTE_POLL_MS);
|
||||
@@ -153,7 +160,7 @@ public final class TurnStreams
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void writeLive(OutputStream out, TurnEventLog log, int after) throws IOException, InterruptedException
|
||||
static void writeLive(OutputStream out, TurnEventLog log, int after) throws IOException, InterruptedException
|
||||
{
|
||||
int last = after;
|
||||
while(true)
|
||||
@@ -163,7 +170,12 @@ public final class TurnStreams
|
||||
{
|
||||
if(log.isClosed())
|
||||
{
|
||||
return;
|
||||
// The terminal event may have arrived after the empty poll.
|
||||
if(log.size() <= last)
|
||||
{
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
write(out, ping());
|
||||
continue;
|
||||
|
||||
+89
-16
@@ -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.1.0";
|
||||
public static final String EXTENSION_VERSION = "1.2.0";
|
||||
public static final String PREFIX = "/assistant";
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@ public final class AssistantSpecs
|
||||
.withSpec(StartTurnSpec.class)
|
||||
.withSpec(TurnEventsSpec.class)
|
||||
.withSpec(CancelTurnSpec.class)
|
||||
.withSpec(ActiveTurnsSpec.class)
|
||||
.withSpec(FeedbackSpec.class)
|
||||
.withSpec(GetActionSpec.class)
|
||||
.withSpec(ConfirmActionSpec.class)
|
||||
@@ -199,10 +200,9 @@ public final class AssistantSpecs
|
||||
"model", string("The model that answers."),
|
||||
"limits", freeObject("maxTurnSeconds and maxToolCallsPerTurn."),
|
||||
"features", freeObject("writes: some table allows writes; pendingActions: writes wait for confirmation."),
|
||||
"preferences", ref("AssistantPreferencesV1"),
|
||||
"profile", ref("AssistantProfileV1")));
|
||||
rs.put("AssistantConversationV1", object("One of the user's own conversations.",
|
||||
"id", integer(null), "title", string(null).withNullable(true), "status", string("ACTIVE or ARCHIVED."), "pinned", bool(null), "writeMode", bool("Write mode (default Read-only): the write tools exist only in Write mode."),
|
||||
"id", integer(null), "title", string(null).withNullable(true), "status", string("ACTIVE or ARCHIVED."), "pinned", bool(null),
|
||||
"lastMessageAt", string("ISO-8601 instant.").withNullable(true), "unreadCount", integer(null), "createDate", string("ISO-8601 instant.").withNullable(true)));
|
||||
rs.put("AssistantConversationListResponseV1", object("The user's conversations, newest first.", "conversations", array(ref("AssistantConversationV1"), null)));
|
||||
rs.put("AssistantMessageV1", object("A message: the user's text or the assistant's reply as parts (text, tool, record_refs, navigate, notice).",
|
||||
@@ -219,12 +219,14 @@ public final class AssistantSpecs
|
||||
"href", string("After a confirm: the written record's /app link.").withNullable(true)));
|
||||
rs.put("AssistantAccessResponseV1", object("Explain access: per table, the user's UI permissions, the assistant's data access, and each tool's verdict.",
|
||||
"tables", array(freeObject("table, label, dataAccess, dataAccessSource, ui {visible, read, insert, edit, delete}, operations [{tool, operation, allowed, reason}]."), null)));
|
||||
rs.put("AssistantTurnEventV1", object("One server-sent event of a turn (event: <type>, id: <seq>, data: this object). Types: message.start, text.delta, tool.start, tool.result, ui.navigate, notice, usage, message.end, error, ping.",
|
||||
rs.put("AssistantActiveTurnsResponseV1", object("The caller's active runs, newest first; stale leases are reaped.",
|
||||
"turns", array(object("An active assistant run.", "turnId", integer(null), "conversationId", integer(null),
|
||||
"assistantMessageId", integer(null), "userMessageId", integer(null), "status", string("RUNNING or CANCELLING."),
|
||||
"phase", string(null), "startedAt", string("ISO-8601."), "heartbeatAt", string("ISO-8601."),
|
||||
"elapsedMs", integer(null), "live", bool("Running on this replica."), "concurrencyMode", string("SUPERSEDE or CONCURRENT.")), null)));
|
||||
rs.put("AssistantTurnEventV1", object("One server-sent event of a turn (event: <type>, id: <seq>, data: this object). Types: message.start, turn.phase, text.delta, tool.start, tool.result, ui.navigate, notice, usage, message.end, error, ping.",
|
||||
"seq", integer("Sequence number; resume with ?after=<seq>."), "type", string("The event type (also the SSE event name)."),
|
||||
"data", freeObject("The event's fields, by type.")));
|
||||
rs.put("AssistantPreferencesV1", object("The user's own assistant preferences.",
|
||||
"defaultWriteMode", bool("New conversations start in Write mode (when write mode is available)."),
|
||||
"autoConfirmLowRisk", bool("Auto-confirm low-risk writes (only when the policy allows it; never deletes, bulk writes or processes).")));
|
||||
"data", freeObject("The event's fields, by type. message.start includes startedAt (ISO-8601). turn.phase includes phase (thinking, tool, writing), optional toolName and label.")));
|
||||
rs.put("AssistantErrorV1", object("An error.", "error", string("A message for the user."), "code", string("BUDGET, RATE_LIMIT, NOT_CONFIGURED, KILL_SWITCH, DISABLED, NO_PERMISSION...").withNullable(true)));
|
||||
return (rs);
|
||||
}
|
||||
@@ -566,6 +568,67 @@ public final class AssistantSpecs
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** GET /assistant/turns/active
|
||||
*******************************************************************************/
|
||||
public static class ActiveTurnsSpec extends AssistantJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** HTTP method.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected HttpMethod method()
|
||||
{
|
||||
return (HttpMethod.GET);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Route path.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String path()
|
||||
{
|
||||
return (PREFIX + "/turns/active");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Operation summary.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String summary()
|
||||
{
|
||||
return ("Find the user's active assistant runs");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Response schema.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("AssistantActiveTurnsResponseV1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Runs in the invoker's authenticated user session.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(AssistantApi api, JsonInput input) throws Exception
|
||||
{
|
||||
return (api.activeTurns());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** POST /assistant/conversations
|
||||
*******************************************************************************/
|
||||
@@ -598,8 +661,7 @@ public final class AssistantSpecs
|
||||
@Override
|
||||
protected Schema defineRequestBodySchema()
|
||||
{
|
||||
return (object("Optional title, the page context it starts from, and the mode (default: the user's preference).", "title", string(null),
|
||||
"context", freeObject("route, tableName, recordId, filter, selectedIds."), "writeMode", bool(null)));
|
||||
return (object("Optional title and the page context it starts from.", "title", string(null), "context", freeObject("route, tableName, recordId, filter, selectedIds.")));
|
||||
}
|
||||
|
||||
|
||||
@@ -724,8 +786,7 @@ public final class AssistantSpecs
|
||||
@Override
|
||||
protected Schema defineRequestBodySchema()
|
||||
{
|
||||
return (object("The changed properties.", "title", string(null), "pinned", bool(null), "status", string("ACTIVE or ARCHIVED."),
|
||||
"writeMode", bool("Switch to Write mode (true) or Read-only (false); 403 when write mode is not available to the user.")));
|
||||
return (object("The changed properties.", "title", string(null), "pinned", bool(null), "status", string("ACTIVE or ARCHIVED.")));
|
||||
}
|
||||
|
||||
|
||||
@@ -850,7 +911,7 @@ public final class AssistantSpecs
|
||||
@Override
|
||||
protected Schema defineRequestBodySchema()
|
||||
{
|
||||
return (object("The user's text and the page context hint.", "text", string("The user's message."), "pageContext", freeObject("Where the user is in the app.")));
|
||||
return (object("The user's text and the page context hint.", "text", string("The user's message."), "pageContext", freeObject("Where the user is in the app."), "concurrency", string("supersede or concurrent (case-insensitive); defaults to the host configuration.")));
|
||||
}
|
||||
|
||||
|
||||
@@ -1044,10 +1105,24 @@ public final class AssistantSpecs
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Older replicas can still return 202 during a rolling upgrade. New
|
||||
** replicas return 200 for both active and already finished turns.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<BasicResponse> defineAdditionalBasicResponses()
|
||||
{
|
||||
List<BasicResponse> responses = new java.util.ArrayList<>(super.defineAdditionalBasicResponses());
|
||||
responses.add(new BasicResponse(HttpStatus.ACCEPTED, "Legacy replica: cancellation accepted", "AssistantStatusResponseV1"));
|
||||
return (responses);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
protected HttpStatus successStatus()
|
||||
{
|
||||
return (HttpStatus.ACCEPTED);
|
||||
return (HttpStatus.OK);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1339,6 +1414,4 @@ public final class AssistantSpecs
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
+1
-1
@@ -80,7 +80,7 @@ public class ConfiguredClassificationSource implements ClassificationSource
|
||||
@Override
|
||||
public DataClassification ofTable(String table)
|
||||
{
|
||||
return (table == null || table.startsWith("assistant_") ? DataClassification.PUBLIC : Classifications.max(null, tables.get(table)));
|
||||
return (table == null || table.startsWith("assistant_") ? DataClassification.PUBLIC : Classifications.max(null, tables.get(table) == null ? AiDataClassifications.ofTable(coreTable(table)) : tables.get(table)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -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)
|
||||
{
|
||||
|
||||
+11
-13
@@ -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}}")
|
||||
|
||||
+73
-14
@@ -18,21 +18,25 @@ package com.kingsrook.qbits.assistant.engine;
|
||||
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.assistant.AssistantRuntime;
|
||||
import com.kingsrook.qbits.assistant.AssistantSecurity;
|
||||
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.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
@@ -123,6 +127,16 @@ public final class ConnectionTest
|
||||
** Probe health, then make and account for a minimal completion.
|
||||
*******************************************************************************/
|
||||
private static Result call(AssistantRuntime runtime, ProviderService.Active active, String userId)
|
||||
{
|
||||
return (call(runtime, active, userId, Instant.now().plusSeconds(runtime.getConfig().getProviderTestTimeoutSeconds()), true));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Share the minimal call and TEST accounting with central model health.
|
||||
*******************************************************************************/
|
||||
private static Result call(AssistantRuntime runtime, ProviderService.Active active, String userId, Instant deadline, Boolean probe)
|
||||
{
|
||||
List<ModelMessage> messages = new ArrayList<>();
|
||||
messages.add(ModelMessage.user(PING_PROMPT));
|
||||
@@ -135,19 +149,27 @@ public final class ConnectionTest
|
||||
UsageRecorder.Call usageCall = new UsageRecorder.Call(userId, null, null, active, UsageRecorder.PURPOSE_TEST);
|
||||
ModelPricing pricing = runtime.getProviderService().pricing(active, active.model());
|
||||
long started = System.nanoTime();
|
||||
StreamHandle handle = null;
|
||||
try
|
||||
{
|
||||
AssistantProviderType type = runtime.getConfig().getProviderTypes().get(active.providerType());
|
||||
HealthResult health = type.checkHealth(runtime.getProviderService().settings(active.row()));
|
||||
if(Boolean.FALSE.equals(health.healthy()))
|
||||
if(probe)
|
||||
{
|
||||
throw (new ModelProviderException(ModelProviderException.Kind.NETWORK, null, health.message()));
|
||||
AssistantProviderType type = runtime.getConfig().getProviderTypes().get(active.providerType());
|
||||
HealthResult health = type.checkHealth(runtime.getProviderService().settings(active.row()));
|
||||
if(Boolean.FALSE.equals(health.healthy()))
|
||||
{
|
||||
throw (new ModelProviderException(ModelProviderException.Kind.NETWORK, null, health.message()));
|
||||
}
|
||||
}
|
||||
StreamHandle handle = active.provider().stream(request, text ->
|
||||
if(!Instant.now().isBefore(deadline) || Thread.currentThread().isInterrupted())
|
||||
{
|
||||
return (new Result(STATUS_ERROR, null, null, "The test deadline passed."));
|
||||
}
|
||||
handle = active.provider().stream(request, text ->
|
||||
{
|
||||
// the reply text is not used
|
||||
});
|
||||
ModelResponse response = handle.await(Duration.ofSeconds(runtime.getConfig().getProviderTestTimeoutSeconds()));
|
||||
ModelResponse response = handle.await(Duration.between(Instant.now(), deadline));
|
||||
long latency = (System.nanoTime() - started) / 1_000_000;
|
||||
UsageRecorder.save(runtime.getStore(), UsageRecorder.row(usageCall, response, null, latency, pricing));
|
||||
if(ModelResponse.STOP_CANCELLED.equals(response.getStopReason()))
|
||||
@@ -169,8 +191,45 @@ public final class ConnectionTest
|
||||
catch(InterruptedException e)
|
||||
{
|
||||
Thread.currentThread().interrupt();
|
||||
UsageRecorder.save(runtime.getStore(), UsageRecorder.row(usageCall, null,
|
||||
new ModelProviderException(ModelProviderException.Kind.TIMEOUT, null, "The test was interrupted."),
|
||||
(System.nanoTime() - started) / 1_000_000, pricing));
|
||||
return (new Result(STATUS_ERROR, null, null, "The test was interrupted."));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if(handle != null)
|
||||
{
|
||||
handle.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Central health uses the same probe without changing manual lastTest fields.
|
||||
** Purpose is a string, so health calls reuse TEST rather than adding an enum.
|
||||
*******************************************************************************/
|
||||
public static Result checkModel(AssistantRuntime runtime, AssistantModel model, Instant deadline) throws QException
|
||||
{
|
||||
if(!Instant.now().isBefore(deadline) || Thread.currentThread().isInterrupted())
|
||||
{
|
||||
return (new Result(STATUS_ERROR, null, null, "The test deadline passed."));
|
||||
}
|
||||
if(!model.isChat())
|
||||
{
|
||||
return (testEmbedding(runtime, model));
|
||||
}
|
||||
AssistantProvider row = runtime.getStore().getProvider(model.getProviderId());
|
||||
var type = runtime.getConfig().getProviderTypes().get(row.getProviderType());
|
||||
var settings = runtime.getProviderService().settings(row);
|
||||
int seconds = (int) Math.max(1, Math.min(30, Duration.between(Instant.now(), deadline).toSeconds()));
|
||||
settings.withConnectTimeoutSeconds(Math.min(settings.getConnectTimeoutSeconds(), seconds))
|
||||
.withRequestTimeoutSeconds(Math.min(settings.getRequestTimeoutSeconds(), seconds));
|
||||
var provider = new RegistryModelProvider(type.create(settings), model);
|
||||
return (call(runtime, new ProviderService.Active(row, row.getProviderType(), model.getModelId(), provider),
|
||||
AssistantSecurity.userId(QContext.getQSession()), deadline, false));
|
||||
}
|
||||
|
||||
|
||||
@@ -245,7 +304,7 @@ public final class ConnectionTest
|
||||
try
|
||||
{
|
||||
new EmbeddingService(runtime).embed(model.getId(), List.of("Connection test."),
|
||||
DataClassification.PUBLIC, "", null, null);
|
||||
DataClassification.PUBLIC, "", null, null, UsageRecorder.PURPOSE_TEST);
|
||||
return (new Result(STATUS_OK, (int) ((System.nanoTime() - started) / 1_000_000), model.getModelId(), null));
|
||||
}
|
||||
catch(QException e)
|
||||
|
||||
+7
-7
@@ -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;
|
||||
|
||||
+5
-5
@@ -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;
|
||||
|
||||
|
||||
+5
-5
@@ -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;
|
||||
|
||||
+3
-47
@@ -24,7 +24,6 @@ import com.kingsrook.qbits.assistant.model.AssistantUsageDaily;
|
||||
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
@@ -82,50 +81,8 @@ public class PolicyService
|
||||
** @param redactPii redact secrets and personal identifiers from tool
|
||||
** results (default true)
|
||||
*******************************************************************************/
|
||||
public record ToolPolicy(boolean writesRequireConfirmation, Set<String> allowedTools, int maxRowsPerCall, boolean redactPii,
|
||||
boolean writeModeAllowed, String writeModePermission, boolean autoConfirmAllowed, boolean writeMode, boolean autoConfirm)
|
||||
public record ToolPolicy(boolean writesRequireConfirmation, Set<String> allowedTools, int maxRowsPerCall, boolean redactPii)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Write mode on, no auto-confirm, write mode allowed for everyone (tests
|
||||
** and hosts that build a policy by hand).
|
||||
*******************************************************************************/
|
||||
public ToolPolicy(boolean writesRequireConfirmation, Set<String> allowedTools, int maxRowsPerCall, boolean redactPii)
|
||||
{
|
||||
this(writesRequireConfirmation, allowedTools, maxRowsPerCall, redactPii, true, null, false, true, false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** This policy for a conversation in Write (true) or Read-only (false) mode.
|
||||
*******************************************************************************/
|
||||
public ToolPolicy withWriteMode(boolean writeMode)
|
||||
{
|
||||
return (new ToolPolicy(writesRequireConfirmation, allowedTools, maxRowsPerCall, redactPii, writeModeAllowed, writeModePermission, autoConfirmAllowed, writeMode, autoConfirm));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** This policy with the user's auto-confirm (only effective when the policy
|
||||
** allows it).
|
||||
*******************************************************************************/
|
||||
public ToolPolicy withAutoConfirm(boolean autoConfirm)
|
||||
{
|
||||
return (new ToolPolicy(writesRequireConfirmation, allowedTools, maxRowsPerCall, redactPii, writeModeAllowed, writeModePermission, autoConfirmAllowed, writeMode,
|
||||
autoConfirm && autoConfirmAllowed));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether this user may switch a conversation to Write mode (the policy
|
||||
** allows it, and they hold the write-mode permission when one is set).
|
||||
*******************************************************************************/
|
||||
public boolean writeModeAvailableTo(QSession session)
|
||||
{
|
||||
return (writeModeAllowed && (!StringUtils.hasContent(writeModePermission) || (session != null && session.hasPermission(writeModePermission.strip()))));
|
||||
}
|
||||
public static final int DEFAULT_MAX_ROWS_PER_CALL = 50;
|
||||
public static final int MAX_ROWS_PER_CALL_CEILING = 200;
|
||||
|
||||
@@ -136,7 +93,7 @@ public class PolicyService
|
||||
*******************************************************************************/
|
||||
public static ToolPolicy defaults()
|
||||
{
|
||||
return (new ToolPolicy(true, Set.of(), DEFAULT_MAX_ROWS_PER_CALL, true, true, null, false, false, false));
|
||||
return (new ToolPolicy(true, Set.of(), DEFAULT_MAX_ROWS_PER_CALL, true));
|
||||
}
|
||||
|
||||
|
||||
@@ -172,8 +129,7 @@ public class PolicyService
|
||||
}
|
||||
}
|
||||
int maxRows = row.getMaxRowsPerCall() == null || row.getMaxRowsPerCall() < 1 ? DEFAULT_MAX_ROWS_PER_CALL : Math.min(MAX_ROWS_PER_CALL_CEILING, row.getMaxRowsPerCall());
|
||||
return (new ToolPolicy(!Boolean.FALSE.equals(row.getWritesRequireConfirmation()), allowed, maxRows, !Boolean.FALSE.equals(row.getRedactPii()),
|
||||
!Boolean.FALSE.equals(row.getWriteModeAllowed()), row.getWriteModePermission(), Boolean.TRUE.equals(row.getAutoConfirmAllowed()), false, false));
|
||||
return (new ToolPolicy(!Boolean.FALSE.equals(row.getWritesRequireConfirmation()), allowed, maxRows, !Boolean.FALSE.equals(row.getRedactPii())));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+20
-3
@@ -23,10 +23,12 @@ 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.health.HealthRunner;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthStatus;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
@@ -72,6 +74,21 @@ public class ProviderHealth
|
||||
{
|
||||
return;
|
||||
}
|
||||
HealthRunner runner = HealthRunner.getForInstance(runtime.getQInstance());
|
||||
String checkName = "assistant.provider." + row.getId();
|
||||
Instant checked = runner == null ? null : runner.getLastOutcomeAt(checkName);
|
||||
var outcome = runner == null ? null : runner.getLastOutcome(checkName);
|
||||
if(checked != null && !checked.isAfter(now) && checked.plusSeconds(60).isAfter(now) && outcome != null)
|
||||
{
|
||||
if(outcome.getStatus() == HealthStatus.DOWN)
|
||||
{
|
||||
throw (new ModelProviderException(ModelProviderException.Kind.NETWORK, null, "Provider health check failed."));
|
||||
}
|
||||
if(outcome.getStatus() == HealthStatus.UP || outcome.getStatus() == HealthStatus.DEGRADED)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
ProviderSettings settings = runtime.getProviderService().settings(row).withConnectTimeoutSeconds(2).withRequestTimeoutSeconds(2);
|
||||
FutureTask<HealthResult> probe = new FutureTask<>(() -> runtime.getConfig().getProviderTypes().get(row.getProviderType()).checkHealth(settings));
|
||||
Thread.ofVirtual().name("assistant-health").start(probe);
|
||||
|
||||
+15
-4
@@ -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;
|
||||
@@ -480,13 +481,23 @@ public class ProviderService
|
||||
** micro-dollars, from the usage rows the session can see.
|
||||
*******************************************************************************/
|
||||
public long monthToDateCostMicros(Integer providerId) throws QException
|
||||
{
|
||||
return (monthToDateCostMicros(providerId, false));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared accounting permits health-runner caps without granting owner access.
|
||||
*******************************************************************************/
|
||||
public long monthToDateCostMicros(Integer providerId, Boolean sharedAccounting) throws QException
|
||||
{
|
||||
if(providerId == null)
|
||||
{
|
||||
return (0);
|
||||
}
|
||||
Instant monthStart = LocalDate.now(clock.withZone(ZoneOffset.UTC)).withDayOfMonth(1).atStartOfDay().toInstant(ZoneOffset.UTC);
|
||||
return (store.sumUsageCostSince(providerId, monthStart));
|
||||
return (store.sumUsageCostSince(providerId, monthStart, sharedAccounting));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -26,15 +26,12 @@ import java.util.Map;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Per-user turn rate limits (spec 3.9): turns started per minute and turns
|
||||
** running at once. In memory, per replica - with several replicas the
|
||||
** effective limit is per replica (the concurrent limit is also checked
|
||||
** against the turn table by the API).
|
||||
** Per-user starts per minute, in memory per replica. The API implements
|
||||
** concurrency by superseding turns, never by refusing another question.
|
||||
*******************************************************************************/
|
||||
public class RateLimiter
|
||||
{
|
||||
private final Map<String, Deque<Instant>> starts = new HashMap<>();
|
||||
private final Map<String, Integer> running = new HashMap<>();
|
||||
private final Clock clock;
|
||||
|
||||
|
||||
@@ -61,17 +58,12 @@ public class RateLimiter
|
||||
queue.pollFirst();
|
||||
}
|
||||
|
||||
if(running.getOrDefault(userId, 0) >= concurrent)
|
||||
{
|
||||
return ("You already have an assistant answer in progress. Wait for it to finish or stop it.");
|
||||
}
|
||||
if(queue.size() >= perMinute)
|
||||
{
|
||||
return ("Too many assistant requests in the last minute. Try again shortly.");
|
||||
}
|
||||
|
||||
queue.addLast(now);
|
||||
running.merge(userId, 1, Integer::sum);
|
||||
return (null);
|
||||
}
|
||||
|
||||
@@ -82,7 +74,7 @@ public class RateLimiter
|
||||
*******************************************************************************/
|
||||
public synchronized void release(String userId)
|
||||
{
|
||||
running.computeIfPresent(userId, (k, v) -> v <= 1 ? null : v - 1);
|
||||
// Only starts per minute are counted; completion does not refund a start.
|
||||
}
|
||||
|
||||
}
|
||||
+165
-5
@@ -17,8 +17,11 @@
|
||||
package com.kingsrook.qbits.assistant.engine;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import com.kingsrook.qbits.assistant.provider.StreamHandle;
|
||||
import java.util.function.Consumer;
|
||||
import com.kingsrook.qbits.assistant.spi.StreamHandle;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -30,6 +33,14 @@ public class RunningTurn
|
||||
private final String userId;
|
||||
private final TurnEventLog log = new TurnEventLog();
|
||||
private final AtomicReference<String> cancelReason = new AtomicReference<>();
|
||||
private final AtomicBoolean finalized = new AtomicBoolean();
|
||||
private Instant startedAt;
|
||||
private volatile String phase = "starting";
|
||||
private volatile String toolName;
|
||||
private volatile Thread engineThread;
|
||||
private volatile Consumer<String> forceFinalizer;
|
||||
private volatile long cancelledNanos;
|
||||
private volatile String trackingId;
|
||||
private volatile StreamHandle currentHandle;
|
||||
|
||||
|
||||
@@ -39,6 +50,7 @@ public class RunningTurn
|
||||
*******************************************************************************/
|
||||
public RunningTurn(Integer turnId, String userId)
|
||||
{
|
||||
this.startedAt = Instant.now();
|
||||
this.turnId = turnId;
|
||||
this.userId = userId;
|
||||
}
|
||||
@@ -50,12 +62,24 @@ public class RunningTurn
|
||||
*******************************************************************************/
|
||||
public void cancel(String reason)
|
||||
{
|
||||
if(cancelReason.compareAndSet(null, reason))
|
||||
if(!finalized.get() && cancelReason.compareAndSet(null, reason))
|
||||
{
|
||||
StreamHandle handle = currentHandle;
|
||||
if(handle != null)
|
||||
cancelledNanos = System.nanoTime();
|
||||
try
|
||||
{
|
||||
handle.cancel();
|
||||
StreamHandle handle = currentHandle;
|
||||
if(handle != null)
|
||||
{
|
||||
handle.cancel();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Thread thread = engineThread;
|
||||
if(thread != null && thread != Thread.currentThread())
|
||||
{
|
||||
thread.interrupt();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,4 +148,140 @@ public class RunningTurn
|
||||
return (log);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Claim terminal processing exactly once.
|
||||
*******************************************************************************/
|
||||
public boolean tryFinalize()
|
||||
{
|
||||
return (finalized.compareAndSet(false, true));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether terminal processing has been claimed.
|
||||
*******************************************************************************/
|
||||
public boolean isFinalized()
|
||||
{
|
||||
return (finalized.get());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for startedAt
|
||||
*******************************************************************************/
|
||||
public Instant getStartedAt()
|
||||
{
|
||||
return (startedAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for startedAt
|
||||
*******************************************************************************/
|
||||
public void setStartedAt(Instant startedAt)
|
||||
{
|
||||
this.startedAt = startedAt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for phase
|
||||
*******************************************************************************/
|
||||
public String getPhase()
|
||||
{
|
||||
return (phase);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for toolName
|
||||
*******************************************************************************/
|
||||
public String getToolName()
|
||||
{
|
||||
return (toolName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Record the current phase and optional tool.
|
||||
*******************************************************************************/
|
||||
public void setPhase(String phase, String toolName)
|
||||
{
|
||||
this.phase = phase;
|
||||
this.toolName = toolName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Record the thread to interrupt when cancelling.
|
||||
*******************************************************************************/
|
||||
public void setEngineThread(Thread engineThread)
|
||||
{
|
||||
this.engineThread = engineThread;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Install the engine finalizer before registration.
|
||||
*******************************************************************************/
|
||||
public void setForceFinalizer(Consumer<String> forceFinalizer)
|
||||
{
|
||||
this.forceFinalizer = forceFinalizer;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Force terminal processing using the original user session.
|
||||
*******************************************************************************/
|
||||
public void forceFinalize(String reason)
|
||||
{
|
||||
Consumer<String> finalizer = forceFinalizer;
|
||||
if(finalizer != null)
|
||||
{
|
||||
finalizer.accept(reason);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether cancellation has exceeded its bounded grace period.
|
||||
*******************************************************************************/
|
||||
public boolean cancelGraceElapsed(int seconds)
|
||||
{
|
||||
return (isCancelled() && cancelledNanos != 0 && System.nanoTime() - cancelledNanos >= seconds * 1_000_000_000L);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for trackingId
|
||||
*******************************************************************************/
|
||||
public String getTrackingId()
|
||||
{
|
||||
return (trackingId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for trackingId
|
||||
*******************************************************************************/
|
||||
public void setTrackingId(String trackingId)
|
||||
{
|
||||
this.trackingId = trackingId;
|
||||
}
|
||||
|
||||
}
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+413
-162
@@ -43,20 +43,21 @@ import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantScheduledTask;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantTurn;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantUserPreference;
|
||||
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.persistence.TranscriptRedactor;
|
||||
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;
|
||||
@@ -102,10 +103,11 @@ public class TurnEngine implements Runnable
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(TurnEngine.class);
|
||||
|
||||
private static final int MAX_INPUT_JSON_CHARS = 4000;
|
||||
public static final String SUPERSEDED_TEXT = "Stopped because you asked something else.";
|
||||
public static final String TIMEOUT_TEXT = "The assistant took too long to answer, so it was stopped. Ask again.";
|
||||
public static final String RESTART_TEXT = "The server restarted while answering. Ask again.";
|
||||
|
||||
private static final AiRedactor INPUT_REDACTOR = new AiRedactor(List.of());
|
||||
private static final int MAX_RESULT_JSON_CHARS = 64_000;
|
||||
private static final int TEXT_FLUSH_CHARS = 48;
|
||||
private static final long TEXT_FLUSH_MILLIS = 120;
|
||||
|
||||
@@ -135,7 +137,7 @@ public class TurnEngine implements Runnable
|
||||
private long lastTextFlush = System.currentTimeMillis();
|
||||
|
||||
private AssistantScheduledTask scheduledTask;
|
||||
|
||||
private PolicyService.ToolPolicy toolPolicy;
|
||||
|
||||
private Instant startedAt;
|
||||
private Instant deadline;
|
||||
@@ -151,7 +153,8 @@ public class TurnEngine implements Runnable
|
||||
|
||||
private volatile boolean finished = false;
|
||||
private UiApiCredentials credentials;
|
||||
private PolicyService.ToolPolicy toolPolicy;
|
||||
private volatile TranscriptRedactor transcript;
|
||||
private boolean engineRunning;
|
||||
|
||||
|
||||
|
||||
@@ -176,6 +179,10 @@ public class TurnEngine implements Runnable
|
||||
this.policy = policy;
|
||||
this.running = running;
|
||||
this.softBudgetNotice = softBudgetNotice;
|
||||
this.startedAt = turn.getStartedAt() == null ? runtime.getClock().instant() : turn.getStartedAt();
|
||||
this.deadline = startedAt.plusSeconds(config.getMaxTurnSeconds());
|
||||
running.setStartedAt(startedAt);
|
||||
running.setForceFinalizer(this::forceFinish);
|
||||
}
|
||||
|
||||
|
||||
@@ -193,14 +200,43 @@ public class TurnEngine implements Runnable
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Continue the API's provisional transcript scope on the engine thread.
|
||||
*******************************************************************************/
|
||||
public TurnEngine withTranscript(TranscriptRedactor transcript)
|
||||
{
|
||||
this.transcript = transcript;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Finalization may run before submission or on a watchdog/shutdown thread.
|
||||
** Bind the same turn scope before any of those paths writes transcript content.
|
||||
*******************************************************************************/
|
||||
private synchronized void attachTranscript()
|
||||
{
|
||||
if(transcript == null)
|
||||
{
|
||||
transcript = new TranscriptRedactor(runtime.getQInstance(), config.getClassificationSource());
|
||||
}
|
||||
transcript.attach();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run the turn to completion. Never throws.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run()
|
||||
{
|
||||
startedAt = runtime.getClock().instant();
|
||||
deadline = startedAt.plusSeconds(config.getMaxTurnSeconds());
|
||||
if(running.isFinalized())
|
||||
{
|
||||
return;
|
||||
}
|
||||
running.setEngineThread(Thread.currentThread());
|
||||
|
||||
String status = "COMPLETE";
|
||||
String stopReason = null;
|
||||
@@ -210,18 +246,28 @@ public class TurnEngine implements Runnable
|
||||
try
|
||||
{
|
||||
QContext.init(runtime.getQInstance(), session);
|
||||
synchronized(this)
|
||||
{
|
||||
if(running.isFinalized())
|
||||
{
|
||||
return;
|
||||
}
|
||||
engineRunning = true;
|
||||
attachTranscript();
|
||||
}
|
||||
startWatchdog();
|
||||
scheduledTask = runtime.getScheduledTurn().task(conversation.getId());
|
||||
if(conversation.getScheduledTaskId() != null && scheduledTask == null)
|
||||
{
|
||||
throw (new QException("Scheduled execution context is no longer available; start a new conversation."));
|
||||
}
|
||||
toolPolicy = scheduledTask == null ? toolPolicy() : ScheduledTurn.tools(runtime, scheduledTask, policy.tools());
|
||||
toolPolicy = scheduledTask == null ? policy.tools() : ScheduledTurn.tools(runtime, scheduledTask, policy.tools());
|
||||
ScheduledTurn.checkCost(runtime, conversation.getId(), turn.getId());
|
||||
classification = new TurnClassification(config.getClassificationSource(), conversation, pageContext);
|
||||
store.saveClassification(userId, conversation.getId(), turn.getId(), classification);
|
||||
startKillSwitchWatcher();
|
||||
|
||||
emit(TurnEvent.MESSAGE_START, map("turnId", turn.getId(), "conversationId", conversation.getId(), "userMessageId", userMessage.getId(), "messageId", assistantMessage.getId()));
|
||||
|
||||
emit(TurnEvent.MESSAGE_START, map("turnId", turn.getId(), "conversationId", conversation.getId(), "userMessageId", userMessage.getId(), "messageId", assistantMessage.getId(), "startedAt", startedAt.toString()));
|
||||
if(softBudgetNotice != null)
|
||||
{
|
||||
notice("warning", softBudgetNotice);
|
||||
@@ -233,6 +279,7 @@ public class TurnEngine implements Runnable
|
||||
ModelRouter.Route route;
|
||||
try
|
||||
{
|
||||
phase("thinking", null);
|
||||
route = router.route(session, conversation, turn, userText, pageContext, policy, classification, running, deadline, text -> notice("warning", text));
|
||||
}
|
||||
finally
|
||||
@@ -421,44 +468,88 @@ public class TurnEngine implements Runnable
|
||||
}
|
||||
finally
|
||||
{
|
||||
finished = true;
|
||||
finish(status, stopReason, errorCode, errorText);
|
||||
QContext.clear();
|
||||
// Clear interruption while persisting the terminal state.
|
||||
boolean interrupted = Thread.interrupted();
|
||||
if(running.isCancelled())
|
||||
{
|
||||
Stop stop = cancelledStop();
|
||||
status = stop.status();
|
||||
stopReason = stop.stopReason();
|
||||
errorCode = stop.errorCode();
|
||||
errorText = stop.message();
|
||||
}
|
||||
try
|
||||
{
|
||||
finish(status, stopReason, errorCode, errorText);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if(transcript != null)
|
||||
{
|
||||
transcript.close();
|
||||
}
|
||||
if(interrupted)
|
||||
{
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
QContext.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Poll the kill switch while the turn runs (with the user's own session),
|
||||
** so a flip cancels the turn - and every other turn here - even while it
|
||||
** waits on the model.
|
||||
** Renew the lease, poll cancellation and the kill switch with the user's
|
||||
** own session, and enforce a hard deadline even if a provider is wedged.
|
||||
*******************************************************************************/
|
||||
private void startKillSwitchWatcher()
|
||||
private void startWatchdog()
|
||||
{
|
||||
long pollMillis = config.getKillSwitchPollMillis() == null ? 2000 : config.getKillSwitchPollMillis();
|
||||
Thread.ofVirtual().name("assistant-killswitch-" + turn.getId()).start(() ->
|
||||
long heartbeatMillis = config.getTurnHeartbeatSeconds() * 1000L;
|
||||
long pollMillis = Math.min(heartbeatMillis, config.getKillSwitchPollMillis() == null ? 2000 : config.getKillSwitchPollMillis());
|
||||
Thread.ofVirtual().name("assistant-watchdog-" + turn.getId()).start(() ->
|
||||
{
|
||||
long lastHeartbeat = System.nanoTime();
|
||||
try
|
||||
{
|
||||
while(!finished && !running.isCancelled())
|
||||
QContext.init(runtime.getQInstance(), session);
|
||||
while(!running.isFinalized())
|
||||
{
|
||||
Thread.sleep(pollMillis);
|
||||
if(finished)
|
||||
if(running.isFinalized())
|
||||
{
|
||||
return;
|
||||
break;
|
||||
}
|
||||
QContext.init(runtime.getQInstance(), session);
|
||||
try
|
||||
{
|
||||
if(System.nanoTime() - lastHeartbeat >= heartbeatMillis * 1_000_000L)
|
||||
{
|
||||
lastHeartbeat = System.nanoTime();
|
||||
heartbeat();
|
||||
}
|
||||
if(runtime.getPolicyService().isKillSwitchOn())
|
||||
{
|
||||
runtime.cancelAll("KILL_SWITCH");
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Assistant watchdog tick failed", hipaaSafe(e), logPair("turnId", turn.getId()));
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
// Deadlines do not depend on successful database or policy reads.
|
||||
Instant now = runtime.getClock().instant();
|
||||
if(!now.isBefore(deadline))
|
||||
{
|
||||
safely(() -> running.cancel("TIMEOUT"));
|
||||
}
|
||||
if(!now.isBefore(deadline.plusSeconds(config.getTurnForceFinalizeGraceSeconds()))
|
||||
|| running.cancelGraceElapsed(config.getTurnForceFinalizeGraceSeconds()))
|
||||
{
|
||||
Stop stop = running.isCancelled() ? cancelledStop() : new Stop("ERROR", "TIMEOUT", TIMEOUT_TEXT, "timeout");
|
||||
finish(stop.status(), stop.stopReason(), stop.errorCode(), stop.message());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -466,11 +557,63 @@ public class TurnEngine implements Runnable
|
||||
{
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Renew only an active row, then observe persisted cancellation requests.
|
||||
*******************************************************************************/
|
||||
private synchronized void heartbeat() throws QException
|
||||
{
|
||||
if(running.isFinalized())
|
||||
{
|
||||
return;
|
||||
}
|
||||
AssistantTurn row = store.getTurn(userId, turn.getId());
|
||||
if(row == null || !("RUNNING".equals(row.getStatus()) || "CANCELLING".equals(row.getStatus())))
|
||||
{
|
||||
safely(() -> running.cancel("USER"));
|
||||
finish(row == null ? "ERROR" : row.getStatus(), row == null ? "interrupted" : row.getStopReason(),
|
||||
row == null ? "INTERNAL" : row.getErrorCode(), null);
|
||||
return;
|
||||
}
|
||||
store.updateFields(AssistantTurn.TABLE_NAME, new QRecord().withValue("id", turn.getId())
|
||||
.withValue("heartbeatAt", runtime.getClock().instant()).withValue("phase", running.getPhase()));
|
||||
row = store.getTurn(userId, turn.getId());
|
||||
if(row != null && (row.getCancelRequestedAt() != null || "CANCELLING".equals(row.getStatus())))
|
||||
{
|
||||
running.cancel(row.getCancelReason() == null ? "USER" : row.getCancelReason());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Finalize from shutdown using the original authenticated user's context.
|
||||
*******************************************************************************/
|
||||
private void forceFinish(String reason)
|
||||
{
|
||||
try
|
||||
{
|
||||
QContext.init(runtime.getQInstance(), session);
|
||||
safely(() -> running.cancel(reason));
|
||||
Stop stop = "SHUTDOWN".equals(reason) ? new Stop("ERROR", "INTERRUPTED", RESTART_TEXT, "interrupted") : cancelledStop();
|
||||
finish(stop.status(), stop.stopReason(), stop.errorCode(), stop.message());
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Why a turn stops.
|
||||
*******************************************************************************/
|
||||
@@ -505,14 +648,15 @@ public class TurnEngine implements Runnable
|
||||
}
|
||||
if(!runtime.getClock().instant().isBefore(deadline))
|
||||
{
|
||||
return (new Stop("ERROR", "TIMEOUT", "The assistant ran out of time for this question.", "timeout"));
|
||||
running.cancel("TIMEOUT");
|
||||
return (cancelledStop());
|
||||
}
|
||||
try
|
||||
{
|
||||
AssistantTurn row = store.getTurn(userId, turn.getId());
|
||||
if(row != null && "CANCELLING".equals(row.getStatus()))
|
||||
{
|
||||
running.cancel("USER");
|
||||
running.cancel(row.getCancelReason() == null ? "USER" : row.getCancelReason());
|
||||
return (cancelledStop());
|
||||
}
|
||||
}
|
||||
@@ -530,6 +674,18 @@ public class TurnEngine implements Runnable
|
||||
*******************************************************************************/
|
||||
private Stop cancelledStop()
|
||||
{
|
||||
if("TIMEOUT".equals(running.getCancelReason()))
|
||||
{
|
||||
return (new Stop("ERROR", "TIMEOUT", TIMEOUT_TEXT, "timeout"));
|
||||
}
|
||||
if("SHUTDOWN".equals(running.getCancelReason()))
|
||||
{
|
||||
return (new Stop("ERROR", "INTERRUPTED", RESTART_TEXT, "interrupted"));
|
||||
}
|
||||
if("SUPERSEDED".equals(running.getCancelReason()))
|
||||
{
|
||||
return (new Stop("CANCELLED", null, SUPERSEDED_TEXT, ModelResponse.STOP_CANCELLED));
|
||||
}
|
||||
if("KILL_SWITCH".equals(running.getCancelReason()))
|
||||
{
|
||||
String reason = StringUtils.hasContent(policy.killSwitchReason()) ? " " + policy.killSwitchReason() : "";
|
||||
@@ -619,6 +775,7 @@ public class TurnEngine implements Runnable
|
||||
@Override
|
||||
public void onTextDelta(String text)
|
||||
{
|
||||
phase("writing", null);
|
||||
bufferText(text);
|
||||
}
|
||||
};
|
||||
@@ -640,6 +797,7 @@ public class TurnEngine implements Runnable
|
||||
TurnDataRouting.beforeModelCall(runtime, classification, turn, active);
|
||||
fallback.beforeCall(active);
|
||||
modelCallCount++;
|
||||
phase("thinking", null);
|
||||
StreamHandle handle = provider.stream(request.copy(), fallback.listener(listener));
|
||||
running.setCurrentHandle(handle);
|
||||
try
|
||||
@@ -702,12 +860,14 @@ public class TurnEngine implements Runnable
|
||||
for(int i = 0; i < toolUses.size(); i++)
|
||||
{
|
||||
ModelPart use = toolUses.get(i);
|
||||
transcript.input(use.getInput());
|
||||
if(i >= allowed)
|
||||
{
|
||||
limitReached = true;
|
||||
continue;
|
||||
}
|
||||
toolCallCount++;
|
||||
phase("tool", use.getToolName());
|
||||
emit(TurnEvent.TOOL_START, map("messageId", assistantMessage.getId(), "toolCallId", use.getToolUseId(), "name", use.getToolName(), "summary", ToolLabels.running(use.getToolName())));
|
||||
|
||||
if(!allowedTools.contains(use.getToolName()))
|
||||
@@ -721,8 +881,14 @@ public class TurnEngine implements Runnable
|
||||
AiToolCall call = new AiToolCall().withId(use.getToolUseId()).withName(use.getToolName()).withInput(use.getInput());
|
||||
futures.add(runtime.getExecutor().submit(() ->
|
||||
{
|
||||
if(!transcript.retainWorker())
|
||||
{
|
||||
return;
|
||||
}
|
||||
transcript.attach();
|
||||
try
|
||||
{
|
||||
QContext.init(runtime.getQInstance(), session);
|
||||
permits.acquire();
|
||||
try
|
||||
{
|
||||
@@ -784,6 +950,12 @@ public class TurnEngine implements Runnable
|
||||
results[index] = new AiToolResult().withToolCallId(call.getId()).withToolName(call.getName()).withOutcome(AiToolOutcome.ERROR)
|
||||
.withSummary("Failed").withModelContent("The tool failed unexpectedly.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
transcript.releaseWorker();
|
||||
TranscriptRedactor.detach();
|
||||
QContext.clear();
|
||||
}
|
||||
}));
|
||||
}
|
||||
persistPendingEvents();
|
||||
@@ -881,6 +1053,7 @@ public class TurnEngine implements Runnable
|
||||
|
||||
try
|
||||
{
|
||||
String inputJson = redactInput(PresentResultsTool.NAME.equals(use.getToolName()) ? (presentation ? result.getRender() : Map.of()) : use.getInput());
|
||||
store.insert(new AssistantToolCall()
|
||||
.withTurnId(turn.getId())
|
||||
.withConversationId(conversation.getId())
|
||||
@@ -888,7 +1061,7 @@ public class TurnEngine implements Runnable
|
||||
.withToolCallId(use.getToolUseId())
|
||||
.withToolName(use.getToolName())
|
||||
.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))
|
||||
.withInputJson(BulkLoadTools.NAMES.contains(use.getToolName()) ? BulkLoadTools.forLog(inputJson) : inputJson)
|
||||
.withOutcome(outcome)
|
||||
.withPermissionCheck(result.getPermissionCheck())
|
||||
.withTableName(result.getTableName())
|
||||
@@ -897,7 +1070,7 @@ public class TurnEngine implements Runnable
|
||||
.withRowCount(result.getRowCount())
|
||||
.withDurationMs(result.getDurationMs())
|
||||
.withResultSummary(summary)
|
||||
.withResultJson(truncate(BulkLoadTools.NAMES.contains(use.getToolName()) ? BulkLoadTools.forLog(result.getModelContent()) : result.getModelContent(), MAX_RESULT_JSON_CHARS))
|
||||
.withResultJson(BulkLoadTools.NAMES.contains(use.getToolName()) ? BulkLoadTools.forLog(result.getModelContent()) : result.getModelContent())
|
||||
.withRedactionsJson(result.getRedactions() == null || result.getRedactions().isEmpty() ? null : JsonUtils.toJson(result.getRedactions()))
|
||||
.withUiApiOperation(extras == null ? ("search_knowledge".equals(use.getToolName()) ? "GET /table/{tableName}/{primaryKey}" : null) : extras.getOperation())
|
||||
.withHttpStatus(extras == null ? null : extras.getHttpStatus())
|
||||
@@ -912,34 +1085,6 @@ public class TurnEngine implements Runnable
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The policy's tool settings for this turn: Write mode only when the
|
||||
** conversation is in Write mode and write mode is available to this user;
|
||||
** auto-confirm only when the policy allows it and the user chose it.
|
||||
*******************************************************************************/
|
||||
PolicyService.ToolPolicy toolPolicy()
|
||||
{
|
||||
if(toolPolicy == null)
|
||||
{
|
||||
PolicyService.ToolPolicy base = policy.tools();
|
||||
boolean write = Boolean.TRUE.equals(conversation.getWriteMode()) && base.writeModeAvailableTo(session);
|
||||
boolean auto = false;
|
||||
try
|
||||
{
|
||||
AssistantUserPreference preference = store.getPreference(userId);
|
||||
auto = preference != null && Boolean.TRUE.equals(preference.getAutoConfirmLowRisk());
|
||||
}
|
||||
catch(QException | RuntimeException e)
|
||||
{
|
||||
LOG.debug("Could not read the user's assistant preferences", e);
|
||||
}
|
||||
toolPolicy = base.withWriteMode(write).withAutoConfirm(auto);
|
||||
}
|
||||
return (toolPolicy);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Where a data tool runs: the user's own session (by its UUID, for the UI
|
||||
** API), the gateway, the policy's tool settings, and the pending-action
|
||||
@@ -1025,93 +1170,103 @@ public class TurnEngine implements Runnable
|
||||
/*******************************************************************************
|
||||
** Persist the message, turn, conversation and usage; emit the end events.
|
||||
*******************************************************************************/
|
||||
private void finish(String status, String stopReason, String errorCode, String errorText)
|
||||
private synchronized void finish(String status, String stopReason, String errorCode, String errorText)
|
||||
{
|
||||
if(!running.tryFinalize())
|
||||
{
|
||||
return;
|
||||
}
|
||||
TranscriptRedactor previousTranscript = TranscriptRedactor.current();
|
||||
try
|
||||
{
|
||||
persistClassification();
|
||||
flushText(true);
|
||||
Instant now = runtime.getClock().instant();
|
||||
|
||||
if(errorText != null)
|
||||
{
|
||||
parts.addNotice("error", errorText);
|
||||
}
|
||||
|
||||
String model = this.model != null ? this.model : (policy == null ? null : policy.mainModel());
|
||||
attachTranscript();
|
||||
AssistantTurn persistedTurn = null;
|
||||
try
|
||||
{
|
||||
store.updateFields(AssistantMessage.TABLE_NAME, new QRecord()
|
||||
.withValue("id", assistantMessage.getId())
|
||||
.withValue("status", status)
|
||||
.withValue("model", model)
|
||||
.withValue("partsJson", parts.toJson()));
|
||||
|
||||
AssistantConversation current = store.getConversation(userId, conversation.getId());
|
||||
if(current != null)
|
||||
{
|
||||
store.updateFields(AssistantConversation.TABLE_NAME, new QRecord()
|
||||
.withValue("id", current.getId())
|
||||
.withValue("lastMessageAt", now)
|
||||
.withValue("unreadCount", (current.getUnreadCount() == null ? 0 : current.getUnreadCount()) + 1)
|
||||
.withValue("totalTokensIn", nz(current.getTotalTokensIn()) + nz(usage.getInputTokens()) + nz(usage.getCacheReadTokens()) + nz(usage.getCacheWriteTokens()))
|
||||
.withValue("totalTokensOut", nz(current.getTotalTokensOut()) + nz(usage.getOutputTokens()))
|
||||
.withValue("totalCostMicros", nz(current.getTotalCostMicros()) + costMicros));
|
||||
}
|
||||
|
||||
store.addUsage(userId, LocalDate.now(runtime.getClock().withZone(ZoneOffset.UTC)), usage, costMicros, true);
|
||||
persistedTurn = store.getTurn(userId, turn.getId());
|
||||
}
|
||||
catch(QException e)
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Could not persist the end of an assistant turn", hipaaSafe(e), logPair("turnId", turn.getId()));
|
||||
LOG.warn("Could not read final assistant status", hipaaSafe(e), logPair("turnId", turn.getId()));
|
||||
}
|
||||
boolean alreadyTerminal = persistedTurn != null && !("RUNNING".equals(persistedTurn.getStatus()) || "CANCELLING".equals(persistedTurn.getStatus()));
|
||||
if(alreadyTerminal)
|
||||
{
|
||||
status = persistedTurn.getStatus();
|
||||
stopReason = persistedTurn.getStopReason();
|
||||
errorCode = persistedTurn.getErrorCode();
|
||||
}
|
||||
String finalStatus = status;
|
||||
String finalStopReason = stopReason;
|
||||
String finalErrorCode = errorCode;
|
||||
String model = this.model != null ? this.model : (policy == null ? null : policy.mainModel());
|
||||
Instant now = runtime.getClock().instant();
|
||||
safely(() -> flushText(true));
|
||||
if(errorText != null && !alreadyTerminal)
|
||||
{
|
||||
parts.addNotice("CANCELLED".equals(status) ? "info" : "error", errorText);
|
||||
}
|
||||
running.setPhase("done", null);
|
||||
if(!alreadyTerminal)
|
||||
{
|
||||
safely(() -> persistClassification());
|
||||
safely(() -> store.updateFields(AssistantMessage.TABLE_NAME, new QRecord()
|
||||
.withValue("id", assistantMessage.getId()).withValue("status", finalStatus)
|
||||
.withValue("model", model).withValue("partsJson", parts.toJson())));
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// terminal events are persisted BEFORE the turn row turns final, //
|
||||
// so a replay of a finished turn always ends with message.end //
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
emit(TurnEvent.USAGE, map("turnId", turn.getId(), "inputTokens", usage.getInputTokens(), "outputTokens", usage.getOutputTokens(),
|
||||
"cacheReadTokens", usage.getCacheReadTokens(), "cacheWriteTokens", usage.getCacheWriteTokens(), "costMicros", costMicros));
|
||||
if(errorCode != null)
|
||||
{
|
||||
emit(TurnEvent.ERROR, map("code", errorCode, "message", errorText));
|
||||
}
|
||||
Map<String, Object> end = map("messageId", assistantMessage.getId(), "status", status);
|
||||
if(stopReason != null)
|
||||
emit(TurnEvent.MESSAGE_END, map("messageId", assistantMessage.getId(), "status", status, "stopReason", stopReason));
|
||||
if(!alreadyTerminal)
|
||||
{
|
||||
end.put("stopReason", stopReason);
|
||||
safely(this::persistPendingEvents);
|
||||
safely(() -> transcript.flush(store));
|
||||
// Accounting failures cannot skip the terminal row or local cleanup.
|
||||
safely(() ->
|
||||
{
|
||||
store.updateRunningTurn(userId, new QRecord()
|
||||
.withValue("id", turn.getId())
|
||||
.withValue("status", finalStatus)
|
||||
.withValue("phase", "done")
|
||||
.withValue("stopReason", finalStopReason)
|
||||
.withValue("errorCode", finalErrorCode)
|
||||
.withValue("cancelReason", running.getCancelReason())
|
||||
.withValue("model", model)
|
||||
.withValue("providerId", active == null ? null : active.providerId())
|
||||
.withValue("routeDecision", turn.getRouteDecision())
|
||||
.withValue("routeSource", turn.getRouteSource())
|
||||
.withValue("fallbackCount", turn.getFallbackCount())
|
||||
.withValue("endedAt", now)
|
||||
.withValue("toolCallCount", toolCallCount)
|
||||
.withValue("modelCallCount", modelCallCount)
|
||||
.withValue("inputTokens", usage.getInputTokens())
|
||||
.withValue("outputTokens", usage.getOutputTokens())
|
||||
.withValue("cacheReadTokens", usage.getCacheReadTokens())
|
||||
.withValue("cacheWriteTokens", usage.getCacheWriteTokens())
|
||||
.withValue("costMicros", costMicros)
|
||||
.withValue("providerRequestIds", requestIds.isEmpty() ? null : String.join(",", requestIds)));
|
||||
});
|
||||
safely(() ->
|
||||
{
|
||||
AssistantConversation current = store.getConversation(userId, conversation.getId());
|
||||
if(current != null)
|
||||
{
|
||||
store.updateFields(AssistantConversation.TABLE_NAME, new QRecord()
|
||||
.withValue("id", current.getId())
|
||||
.withValue("lastMessageAt", now)
|
||||
.withValue("unreadCount", (current.getUnreadCount() == null ? 0 : current.getUnreadCount()) + 1)
|
||||
.withValue("totalTokensIn", nz(current.getTotalTokensIn()) + nz(usage.getInputTokens()) + nz(usage.getCacheReadTokens()) + nz(usage.getCacheWriteTokens()))
|
||||
.withValue("totalTokensOut", nz(current.getTotalTokensOut()) + nz(usage.getOutputTokens()))
|
||||
.withValue("totalCostMicros", nz(current.getTotalCostMicros()) + costMicros));
|
||||
}
|
||||
});
|
||||
safely(() -> store.addUsage(userId, LocalDate.now(runtime.getClock().withZone(ZoneOffset.UTC)), usage, costMicros, true));
|
||||
}
|
||||
emit(TurnEvent.MESSAGE_END, end);
|
||||
persistPendingEvents();
|
||||
|
||||
try
|
||||
{
|
||||
store.updateFields(AssistantTurn.TABLE_NAME, new QRecord()
|
||||
.withValue("id", turn.getId())
|
||||
.withValue("status", status)
|
||||
.withValue("stopReason", truncate(stopReason, 64))
|
||||
.withValue("errorCode", errorCode)
|
||||
.withValue("cancelReason", running.getCancelReason())
|
||||
.withValue("model", model)
|
||||
.withValue("providerId", active == null ? null : active.providerId())
|
||||
.withValue("routeDecision", turn.getRouteDecision())
|
||||
.withValue("routeSource", turn.getRouteSource())
|
||||
.withValue("fallbackCount", turn.getFallbackCount())
|
||||
.withValue("endedAt", now)
|
||||
.withValue("toolCallCount", toolCallCount)
|
||||
.withValue("modelCallCount", modelCallCount)
|
||||
.withValue("inputTokens", usage.getInputTokens())
|
||||
.withValue("outputTokens", usage.getOutputTokens())
|
||||
.withValue("cacheReadTokens", usage.getCacheReadTokens())
|
||||
.withValue("cacheWriteTokens", usage.getCacheWriteTokens())
|
||||
.withValue("costMicros", costMicros)
|
||||
.withValue("providerRequestIds", requestIds.isEmpty() ? null : String.join(",", requestIds)));
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
LOG.warn("Could not persist the end of an assistant turn", hipaaSafe(e), logPair("turnId", turn.getId()));
|
||||
}
|
||||
|
||||
LOG.info("Assistant turn finished",
|
||||
logPair("turnId", turn.getId()),
|
||||
logPair("conversationId", conversation.getId()),
|
||||
@@ -1120,6 +1275,7 @@ public class TurnEngine implements Runnable
|
||||
logPair("status", status),
|
||||
logPair("stopReason", stopReason),
|
||||
logPair("errorCode", errorCode),
|
||||
logPair("cancelReason", running.getCancelReason()),
|
||||
logPair("modelCalls", modelCallCount),
|
||||
logPair("toolCalls", toolCallCount),
|
||||
logPair("inputTokens", usage.getInputTokens()),
|
||||
@@ -1129,29 +1285,104 @@ public class TurnEngine implements Runnable
|
||||
logPair("costMicros", costMicros),
|
||||
logPair("durationMs", Duration.between(startedAt, now).toMillis()));
|
||||
}
|
||||
catch(QException | RuntimeException e)
|
||||
{
|
||||
LOG.warn("Assistant turn finish failed", hipaaSafe(e), logPair("turnId", turn.getId()));
|
||||
}
|
||||
finally
|
||||
{
|
||||
finished = true;
|
||||
running.getLog().close();
|
||||
if(scheduledTask != null)
|
||||
safely(() ->
|
||||
{
|
||||
runtime.getScheduledTurn().detach(conversation.getId(), session.getUuid());
|
||||
if(scheduledTask != null)
|
||||
{
|
||||
runtime.getScheduledTurn().detach(conversation.getId(), session.getUuid());
|
||||
}
|
||||
});
|
||||
String finalStatus = status;
|
||||
String finalText = errorText;
|
||||
try
|
||||
{
|
||||
safely(() -> config.getRunTracker().finish(running.getTrackingId(), finalStatus, finalText));
|
||||
}
|
||||
finally
|
||||
{
|
||||
runtime.unregister(turn.getId());
|
||||
runtime.getRateLimiter().release(userId);
|
||||
if(transcript != null && !engineRunning)
|
||||
{
|
||||
transcript.close();
|
||||
}
|
||||
else
|
||||
{
|
||||
// A wedged provider can still log after forced finalization. The
|
||||
// engine's finally releases its log protection when it returns.
|
||||
TranscriptRedactor.detach();
|
||||
}
|
||||
if(previousTranscript != null && previousTranscript != transcript)
|
||||
{
|
||||
previousTranscript.attach();
|
||||
}
|
||||
}
|
||||
runtime.unregister(turn.getId());
|
||||
runtime.getRateLimiter().release(userId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One independently fallible finalization operation.
|
||||
*******************************************************************************/
|
||||
@FunctionalInterface
|
||||
private interface FinishStep
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Execute the step.
|
||||
*******************************************************************************/
|
||||
void run() throws Exception;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A failure in a collaborator must not strand the run.
|
||||
*******************************************************************************/
|
||||
private void safely(FinishStep step)
|
||||
{
|
||||
try
|
||||
{
|
||||
step.run();
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Assistant lifecycle step failed", hipaaSafe(e), logPair("turnId", turn.getId()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Emit and track only phase changes, including the current tool label.
|
||||
*******************************************************************************/
|
||||
private synchronized void phase(String phase, String toolName)
|
||||
{
|
||||
if(running.isFinalized() || (phase.equals(running.getPhase()) && Objects.equals(toolName, running.getToolName())))
|
||||
{
|
||||
return;
|
||||
}
|
||||
running.setPhase(phase, toolName);
|
||||
String label = toolName == null ? phase : ToolLabels.running(toolName);
|
||||
emit(TurnEvent.TURN_PHASE, map("phase", phase, "toolName", toolName, "label", label));
|
||||
safely(() -> config.getRunTracker().progress(running.getTrackingId(), phase, label));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Buffer streamed text; emit it in small chunks (provider thread).
|
||||
*******************************************************************************/
|
||||
private void bufferText(String text)
|
||||
private synchronized void bufferText(String text)
|
||||
{
|
||||
if(running.isFinalized())
|
||||
{
|
||||
return;
|
||||
}
|
||||
synchronized(textBuffer)
|
||||
{
|
||||
textBuffer.append(text);
|
||||
@@ -1164,7 +1395,7 @@ public class TurnEngine implements Runnable
|
||||
/*******************************************************************************
|
||||
** Emit buffered text as one text.delta (when big or old enough, or forced).
|
||||
*******************************************************************************/
|
||||
private void flushText(boolean force)
|
||||
private synchronized void flushText(boolean force)
|
||||
{
|
||||
String chunk;
|
||||
synchronized(textBuffer)
|
||||
@@ -1199,8 +1430,12 @@ public class TurnEngine implements Runnable
|
||||
** Append to the live log; queue for persistence (done on the engine
|
||||
** thread, which has the QContext).
|
||||
*******************************************************************************/
|
||||
private void emit(String type, Map<String, Object> data)
|
||||
private synchronized void emit(String type, Map<String, Object> data)
|
||||
{
|
||||
if(finished)
|
||||
{
|
||||
return;
|
||||
}
|
||||
toPersist.add(running.getLog().append(type, data));
|
||||
}
|
||||
|
||||
@@ -1209,18 +1444,44 @@ public class TurnEngine implements Runnable
|
||||
/*******************************************************************************
|
||||
** Persist queued events.
|
||||
*******************************************************************************/
|
||||
private void persistPendingEvents()
|
||||
private synchronized void persistPendingEvents()
|
||||
{
|
||||
if(!running.isFinalized() || finished)
|
||||
{
|
||||
return;
|
||||
}
|
||||
List<TurnEvent> events = new ArrayList<>();
|
||||
TurnEvent event;
|
||||
while((event = toPersist.poll()) != null)
|
||||
{
|
||||
events.add(event);
|
||||
}
|
||||
StringBuilder text = new StringBuilder();
|
||||
for(TurnEvent queued : events)
|
||||
{
|
||||
if(TurnEvent.TEXT_DELTA.equals(queued.getType()))
|
||||
{
|
||||
text.append(queued.getData().get("text"));
|
||||
}
|
||||
}
|
||||
Boolean firstText = true;
|
||||
for(TurnEvent queued : events)
|
||||
{
|
||||
try
|
||||
{
|
||||
store.insertEvent(turn.getId(), userId, event.getSeq(), event.getType(), event.toJson());
|
||||
String json = queued.toJson();
|
||||
if(TurnEvent.TEXT_DELTA.equals(queued.getType()))
|
||||
{
|
||||
Map<String, Object> data = new LinkedHashMap<>(queued.getData());
|
||||
data.put("text", firstText ? text.toString() : "");
|
||||
firstText = false;
|
||||
json = JsonUtils.toJson(data);
|
||||
}
|
||||
store.insertEvent(turn.getId(), userId, queued.getSeq(), queued.getType(), json);
|
||||
}
|
||||
catch(QException e)
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Could not persist assistant turn event", hipaaSafe(e), logPair("turnId", turn.getId()), logPair("seq", event.getSeq()));
|
||||
LOG.warn("Could not persist assistant turn event", hipaaSafe(e), logPair("turnId", turn.getId()), logPair("seq", queued.getSeq()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1267,16 +1528,6 @@ public class TurnEngine implements Runnable
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String truncate(String value, int max)
|
||||
{
|
||||
return (value == null || value.length() <= max ? value : value.substring(0, max));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -29,6 +29,7 @@ import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
*******************************************************************************/
|
||||
public class TurnEvent
|
||||
{
|
||||
public static final String TURN_PHASE = "turn.phase";
|
||||
public static final String MESSAGE_START = "message.start";
|
||||
public static final String TEXT_DELTA = "text.delta";
|
||||
public static final String TOOL_START = "tool.start";
|
||||
|
||||
+9
-3
@@ -17,11 +17,13 @@
|
||||
package com.kingsrook.qbits.assistant.engine;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.assistant.persistence.TranscriptRedactor;
|
||||
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;
|
||||
@@ -144,6 +146,10 @@ public final class UsageRecorder
|
||||
*******************************************************************************/
|
||||
private static String truncate(String value, int max)
|
||||
{
|
||||
if(value != null && value.length() > max && TranscriptRedactor.current() != null)
|
||||
{
|
||||
TranscriptRedactor.current().truncated(value, value.substring(0, max));
|
||||
}
|
||||
return (value == null || value.length() <= max ? value : value.substring(0, max));
|
||||
}
|
||||
|
||||
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
/*
|
||||
* 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.health;
|
||||
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.FutureTask;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import com.kingsrook.qbits.assistant.AssistantRuntime;
|
||||
import com.kingsrook.qbits.assistant.AssistantSecurity;
|
||||
import com.kingsrook.qbits.assistant.engine.ConnectionTest;
|
||||
import com.kingsrook.qbits.assistant.engine.ProviderService;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantModel;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProvider;
|
||||
import com.kingsrook.qbits.assistant.spi.HealthResult;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCategory;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheck;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckInput;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckKind;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckMetaData;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckOutcome;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckSupplier;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCheckSupplierMetaData;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthChecksMetaData;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthDetails;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthStatus;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Central health adapters reuse the existing probes without exposing their text.
|
||||
*******************************************************************************/
|
||||
public final class AssistantHealthChecks
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private AssistantHealthChecks()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Register dynamic namespaces once, even if production is repeated.
|
||||
*******************************************************************************/
|
||||
public static void register(QInstance instance)
|
||||
{
|
||||
HealthChecksMetaData registry = HealthChecksMetaData.ofOrWithNew(instance);
|
||||
register(registry, "assistant.provider", Providers.class);
|
||||
register(registry, "assistant.model", Models.class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Suppliers carry the component; individual definitions also declare it.
|
||||
*******************************************************************************/
|
||||
private static void register(HealthChecksMetaData registry, String prefix, Class<? extends HealthCheckSupplier> supplier)
|
||||
{
|
||||
if(registry.getSuppliers().stream().noneMatch(existing -> prefix.equals(existing.getNamePrefix())))
|
||||
{
|
||||
registry.withSupplier(new HealthCheckSupplierMetaData().withNamePrefix(prefix).withComponent("assistant").withSupplier(new QCodeReference(supplier)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Provider and model ids are integer primary keys, hence already lowercase.
|
||||
*******************************************************************************/
|
||||
private static HealthCheckMetaData definition(String name, Integer interval, Integer timeout, HealthCheck check)
|
||||
{
|
||||
return (new HealthCheckMetaData().withName(name).withLabel(name).withComponent("assistant")
|
||||
.withKind(HealthCheckKind.DEPENDENCY).withCategory(HealthCategory.AI)
|
||||
.withIntervalSeconds(interval).withTimeoutMs(timeout).withCheckInstance(check));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One definition per enabled provider; no I/O to providers during resolution.
|
||||
*******************************************************************************/
|
||||
public static class Providers implements HealthCheckSupplier
|
||||
{
|
||||
/***************************************************************************
|
||||
** Re-resolve rows when the central registry refreshes its suppliers.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public List<HealthCheckMetaData> supply(QInstance instance) throws QException
|
||||
{
|
||||
AssistantRuntime runtime = AssistantRuntime.of(instance);
|
||||
List<HealthCheckMetaData> checks = new ArrayList<>();
|
||||
for(AssistantProvider row : runtime.getStore().listProviders())
|
||||
{
|
||||
if(Boolean.TRUE.equals(row.getEnabled()))
|
||||
{
|
||||
checks.add(definition("assistant.provider." + row.getId(), "LOCAL".equals(runtime.getProviderService().location(row)) ? 60 : 300, 10000,
|
||||
input -> bounded(input, () -> provider(runtime, row.getId(), input))));
|
||||
}
|
||||
}
|
||||
return (checks);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One definition per enabled model, including models of disabled providers.
|
||||
*******************************************************************************/
|
||||
public static class Models implements HealthCheckSupplier
|
||||
{
|
||||
/***************************************************************************
|
||||
** Eligibility is checked again when a scheduled check actually executes.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public List<HealthCheckMetaData> supply(QInstance instance) throws QException
|
||||
{
|
||||
AssistantRuntime runtime = AssistantRuntime.of(instance);
|
||||
List<HealthCheckMetaData> checks = new ArrayList<>();
|
||||
for(AssistantModel row : runtime.getStore().listModels())
|
||||
{
|
||||
if(Boolean.TRUE.equals(row.getEnabled()))
|
||||
{
|
||||
checks.add(definition("assistant.model." + row.getId(), 21600, 30000,
|
||||
input -> bounded(input, () -> model(runtime, row.getId(), input))));
|
||||
}
|
||||
}
|
||||
return (checks);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Clamp transport timeouts and bound even an adapter that ignores them.
|
||||
** Keep the caller's session; never create or elevate a health-check session.
|
||||
*******************************************************************************/
|
||||
private static HealthCheckOutcome bounded(HealthCheckInput input, Callable<HealthCheckOutcome> call)
|
||||
{
|
||||
long remaining = Duration.between(Instant.now(), input.getDeadline()).toMillis();
|
||||
if(remaining <= 0)
|
||||
{
|
||||
return (HealthCheckOutcome.down("Health check timed out"));
|
||||
}
|
||||
var session = QContext.getQSession();
|
||||
FutureTask<HealthCheckOutcome> task = new FutureTask<>(() ->
|
||||
{
|
||||
QContext.init(input.getQInstance(), session);
|
||||
try
|
||||
{
|
||||
return (call.call());
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
});
|
||||
Thread.ofVirtual().name("assistant-central-health").start(task);
|
||||
try
|
||||
{
|
||||
return (task.get(remaining, TimeUnit.MILLISECONDS));
|
||||
}
|
||||
catch(InterruptedException e)
|
||||
{
|
||||
Thread.currentThread().interrupt();
|
||||
return (HealthCheckOutcome.down("Health check interrupted"));
|
||||
}
|
||||
catch(java.util.concurrent.TimeoutException e)
|
||||
{
|
||||
return (HealthCheckOutcome.down("Health check timed out"));
|
||||
}
|
||||
catch(java.util.concurrent.ExecutionException e)
|
||||
{
|
||||
return (HealthCheckOutcome.down("Health check failed"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
task.cancel(true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Unsupported probes may read recent traffic-produced breaker state only.
|
||||
*******************************************************************************/
|
||||
private static HealthCheckOutcome provider(AssistantRuntime runtime, Integer id, HealthCheckInput input) throws QException
|
||||
{
|
||||
AssistantProvider row = runtime.getStore().getProvider(id);
|
||||
if(row == null || !Boolean.TRUE.equals(row.getEnabled()))
|
||||
{
|
||||
return (HealthCheckOutcome.unknown("Provider disabled or unavailable"));
|
||||
}
|
||||
var type = runtime.getConfig().getProviderTypes().get(row.getProviderType());
|
||||
if(type == null)
|
||||
{
|
||||
return (HealthCheckOutcome.unknown("Provider type unavailable"));
|
||||
}
|
||||
var settings = runtime.getProviderService().settings(row);
|
||||
int seconds = (int) Math.max(1, Math.min(10, Duration.between(Instant.now(), input.getDeadline()).toSeconds()));
|
||||
settings.withConnectTimeoutSeconds(Math.min(settings.getConnectTimeoutSeconds(), seconds))
|
||||
.withRequestTimeoutSeconds(Math.min(settings.getRequestTimeoutSeconds(), seconds));
|
||||
HealthResult result = type.checkHealth(settings);
|
||||
HealthCheckOutcome outcome;
|
||||
if(result.healthy() == null)
|
||||
{
|
||||
Instant checked = row.getHealthCheckedAt();
|
||||
Instant now = runtime.getClock().instant();
|
||||
if(checked != null && !checked.isAfter(now) && checked.plusSeconds(600).isAfter(now) && List.of("UP", "DOWN", "DEGRADED").contains(row.getHealthState() == null ? "" : row.getHealthState()))
|
||||
{
|
||||
outcome = new HealthCheckOutcome().withStatus(HealthStatus.valueOf(row.getHealthState())).withMessage("Recent circuit-breaker state");
|
||||
}
|
||||
else
|
||||
{
|
||||
outcome = HealthCheckOutcome.unknown("health probe not supported");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
outcome = result.healthy() ? HealthCheckOutcome.up("Provider reachable") : HealthCheckOutcome.down("Provider health probe failed");
|
||||
}
|
||||
return (outcome.withLatencyMs(result.latencyMs()).withDetails(new HealthDetails().withValue("providerId", id)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Reuse the minimal connection call and its accounting, without lastTest writes.
|
||||
*******************************************************************************/
|
||||
private static HealthCheckOutcome model(AssistantRuntime runtime, Integer id, HealthCheckInput input) throws QException
|
||||
{
|
||||
AssistantModel row = runtime.getStore().listModels().stream().filter(model -> id.equals(model.getId())).findFirst().orElse(null);
|
||||
if(row == null || !Boolean.TRUE.equals(row.getEnabled()))
|
||||
{
|
||||
return (HealthCheckOutcome.unknown("Model disabled or unavailable"));
|
||||
}
|
||||
AssistantProvider provider = runtime.getStore().getProvider(row.getProviderId());
|
||||
if(provider == null || !Boolean.TRUE.equals(provider.getEnabled()))
|
||||
{
|
||||
return (HealthCheckOutcome.unknown("Provider disabled or unavailable"));
|
||||
}
|
||||
Long cap = ProviderService.capMicros(provider);
|
||||
if(cap != null && runtime.getProviderService().monthToDateCostMicros(provider.getId(), true) >= cap)
|
||||
{
|
||||
return (HealthCheckOutcome.unknown("skipped: cost cap"));
|
||||
}
|
||||
if(runtime.getPolicyService().current(true).killSwitch())
|
||||
{
|
||||
return (HealthCheckOutcome.unknown("skipped: kill switch"));
|
||||
}
|
||||
AssistantSecurity.ownerKey(QContext.getQSession(), runtime.getConfig());
|
||||
ConnectionTest.Result result = ConnectionTest.checkModel(runtime, row, input.getDeadline());
|
||||
return ((result.ok() ? HealthCheckOutcome.up("Model connection succeeded") : HealthCheckOutcome.down("Model connection failed"))
|
||||
.withDetails(new HealthDetails().withValue("modelId", id).withValue("providerId", provider.getId()))
|
||||
.withLatencyMs(result.latencyMs() == null ? null : result.latencyMs().longValue()));
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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.health;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
|
||||
import com.kingsrook.qbits.assistant.tools.FilterLinkBuilder;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthChecksMetaData;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthRunner;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthStatus;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.StatisticsData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A single generic status card links to the central checks, without probing.
|
||||
*******************************************************************************/
|
||||
public class AssistantHealthWidget extends AbstractWidgetRenderer
|
||||
{
|
||||
public static final String NAME = "assistantHealth";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** No HTML, history or per-provider check list on the Assistant home.
|
||||
*******************************************************************************/
|
||||
public static QWidgetMetaData widget()
|
||||
{
|
||||
return (new QWidgetMetaData().withName(NAME).withLabel("Assistant health")
|
||||
.withType(WidgetType.STATISTICS.getType()).withCodeReference(new QCodeReference(AssistantHealthWidget.class))
|
||||
.withGridColumns(12).withPermissionRules(AssistantMetaDataBuilder.adminRules()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Summarize the runner's latest results; full history lives in System Health.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
var instance = QContext.getQInstance();
|
||||
HealthRunner runner = HealthRunner.getForInstance(instance);
|
||||
HealthStatus status = HealthStatus.UNKNOWN;
|
||||
if(runner != null)
|
||||
{
|
||||
status = HealthStatus.aggregate(HealthChecksMetaData.ofOrWithNew(instance).resolveAll(instance).stream()
|
||||
.filter(check -> "assistant".equals(check.getComponent()))
|
||||
.map(check -> runner.getLastOutcome(check.getName()))
|
||||
.map(outcome -> outcome == null ? HealthStatus.UNKNOWN : outcome.getStatus()).toArray(HealthStatus[]::new));
|
||||
}
|
||||
return (new RenderWidgetOutput(new StatisticsData().withCount(status.name())
|
||||
.withCountContext("System Health")
|
||||
.withCountURL(FilterLinkBuilder.href("health_check", List.of(Map.of("fieldName", "component", "operator", "EQUALS", "values", List.of("assistant")))))));
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
|
||||
/*******************************************************************************
|
||||
** The Assistant app home: one full-width HTML widget, the single owner of the
|
||||
** assistant's health, usage and cost data (no other dashboard repeats it).
|
||||
** assistant's configuration, usage and cost data. Health lives in System Health.
|
||||
*******************************************************************************/
|
||||
public final class AssistantHome
|
||||
{
|
||||
|
||||
+24
-11
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
+60
-68
@@ -17,16 +17,6 @@
|
||||
package com.kingsrook.qbits.assistant.home;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.assistant.engine.BudgetService;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantRecordScreens;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantBudget;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantBudgetOverride;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantConversation;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantModelPrice;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProvider;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantUsage;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.text.NumberFormat;
|
||||
@@ -38,6 +28,16 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.assistant.engine.BudgetService;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantRecordScreens;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantBudget;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantBudgetOverride;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantConversation;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantModelPrice;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProvider;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantUsage;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -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("\">");
|
||||
@@ -112,44 +113,63 @@ public final class AssistantHomeView
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The status row: is the assistant answering, with what, at what cost.
|
||||
** 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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configuration and usage; health status belongs to the central card.
|
||||
*******************************************************************************/
|
||||
private static void status(StringBuilder html, AssistantHomeData data, String prefix)
|
||||
{
|
||||
open(html, "Health");
|
||||
open(html, "Configuration and usage");
|
||||
AssistantHomeData.ProviderHealth active = data.getProviders().stream().filter(AssistantHomeData.ProviderHealth::active).findFirst().orElse(null);
|
||||
|
||||
html.append("<div style=\"display:flex;flex-wrap:wrap;gap:.5rem .75rem;align-items:center;\">");
|
||||
if(data.isKillSwitch())
|
||||
{
|
||||
html.append(chip(RED, "Turned off by the kill switch"));
|
||||
}
|
||||
else if(data.getProviders().isEmpty())
|
||||
{
|
||||
html.append(chip(GREY, "No provider configured here (the application's built-in provider is used, if any)"));
|
||||
}
|
||||
else if(active == null)
|
||||
{
|
||||
html.append(chip(RED, "No provider is turned on"));
|
||||
}
|
||||
else if(!active.keyPresent() || !active.problems().isEmpty())
|
||||
{
|
||||
html.append(chip(RED, "Default provider cannot be used"));
|
||||
}
|
||||
else if("ERROR".equals(active.row().getLastTestStatus()))
|
||||
{
|
||||
html.append(chip(AMBER, "Last connection test failed"));
|
||||
}
|
||||
else
|
||||
{
|
||||
html.append(chip(GREEN, "Answering"));
|
||||
}
|
||||
html.append("</div><div style=\"").append(CELLS).append("\">");
|
||||
html.append("<div style=\"").append(CELLS).append("\">");
|
||||
cell(html, "Provider", active == null ? "None" : active.row().getName() + " (" + nvl(active.row().getModel(), "default model") + ")");
|
||||
cell(html, "HIPAA mode", Boolean.TRUE.equals(data.getHipaaMode()) ? "On" : "Off");
|
||||
cell(html, "PHI retention", data.getHipaaRetentionDays() == null ? "Normal retention" : data.getHipaaRetentionDays() + " days");
|
||||
cell(html, "API key", active == null ? "Not applicable" : active.keyStatus());
|
||||
cell(html, "Last test", active == null ? "Not applicable" : lastTest(active.row()));
|
||||
Integer rate = data.errorRatePercent7d();
|
||||
cell(html, "Error rate, 7 days", rate == null ? "No requests yet" : rate + "% of " + number(data.getCalls7d()) + " requests");
|
||||
cell(html, "Cost this month", money(data.getMonthCostMicros()) + (data.getInstanceMonthlyCapMicros() == null ? "" : " of " + money(data.getInstanceMonthlyCapMicros()) + " limit"));
|
||||
@@ -187,17 +207,8 @@ public final class AssistantHomeView
|
||||
{
|
||||
html.append(chip(GREY, "Off"));
|
||||
}
|
||||
if(row.getLastTestStatus() != null)
|
||||
{
|
||||
html.append(chip("OK".equals(row.getLastTestStatus()) ? GREEN : RED, "OK".equals(row.getLastTestStatus()) ? "Last test passed" : "Last test failed"));
|
||||
}
|
||||
html.append("</div><div style=\"").append(CELLS).append("\">");
|
||||
cell(html, "Type", nvl(p.typeLabel(), "Not set"));
|
||||
cell(html, "Health", nvl(row.getHealthState(), "Unknown"));
|
||||
if(row.getDownUntil() != null)
|
||||
{
|
||||
cell(html, "Down until", when(row.getDownUntil()));
|
||||
}
|
||||
cell(html, "Kind", AssistantRecordScreens.providerKindLabel(p.kind()));
|
||||
cell(html, "Location", "LOCAL".equals(p.location()) ? "Local" : "Cloud");
|
||||
if(Boolean.TRUE.equals(row.getHipaaEligible()))
|
||||
@@ -207,7 +218,6 @@ public final class AssistantHomeView
|
||||
cell(html, "Enabled models", String.valueOf(p.enabledModels()));
|
||||
cell(html, "Model", nvl(row.getModel(), "Default model"));
|
||||
cell(html, "API key", p.keyStatus());
|
||||
cell(html, "Last test", lastTest(row));
|
||||
cell(html, "Cost this month", money(p.monthCostMicros()) + (p.capMicros() == null ? ", no cap" : " of " + money(p.capMicros()) + " cap"));
|
||||
List<String> limits = new ArrayList<>();
|
||||
if(row.getMaxOutputTokensPerRequest() != null)
|
||||
@@ -455,24 +465,6 @@ public final class AssistantHomeView
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A provider's last test in words.
|
||||
*******************************************************************************/
|
||||
static String lastTest(AssistantProvider row)
|
||||
{
|
||||
if(row.getLastTestAt() == null)
|
||||
{
|
||||
return ("Not tested yet: run Test connection");
|
||||
}
|
||||
if("OK".equals(row.getLastTestStatus()))
|
||||
{
|
||||
return ("Passed " + WHEN.format(row.getLastTestAt()) + ", " + nvl(row.getLastTestModel(), "model") + " in " + row.getLastTestLatencyMs() + " ms");
|
||||
}
|
||||
return ("Failed " + WHEN.format(row.getLastTestAt()) + ": " + nvl(row.getLastTestError(), "no detail"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Micro-dollars as dollars ("$0.00", "<$0.01" for a little, "$12.34").
|
||||
*******************************************************************************/
|
||||
|
||||
+231
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.jobs;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Publishes assistant answers as jobs. The default, JobTrackingRunTracker,
|
||||
** uses QQQ core job tracking (the Jobs tray); NOOP turns it off, and hosts
|
||||
** may wire their own. Every call is isolated from the turn: a failure here
|
||||
** never affects the answer.
|
||||
*******************************************************************************/
|
||||
public interface AssistantRunTracker
|
||||
{
|
||||
AssistantRunTracker NOOP = new AssistantRunTracker()
|
||||
{
|
||||
/*******************************************************************************
|
||||
** No tracking is configured.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String start(String userId, Integer turnId, Integer conversationId, String label)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** No tracking is configured.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void progress(String trackingId, String phase, String message)
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** No tracking is configured.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void finish(String trackingId, String status, String message)
|
||||
{
|
||||
}
|
||||
};
|
||||
|
||||
/*******************************************************************************
|
||||
** Publish a run and return its tracking id.
|
||||
*******************************************************************************/
|
||||
String start(String userId, Integer turnId, Integer conversationId, String label) throws QException;
|
||||
|
||||
/*******************************************************************************
|
||||
** Publish a changed phase.
|
||||
*******************************************************************************/
|
||||
void progress(String trackingId, String phase, String message);
|
||||
|
||||
/*******************************************************************************
|
||||
** Publish the terminal outcome.
|
||||
*******************************************************************************/
|
||||
void finish(String trackingId, String status, String message) throws QException;
|
||||
|
||||
/*******************************************************************************
|
||||
** Wire the host's Stop action to the owner's cancellation path.
|
||||
*******************************************************************************/
|
||||
default void onCancel(String trackingId, Runnable cancel) throws QException
|
||||
{
|
||||
}
|
||||
}
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
* 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.jobs;
|
||||
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantConversation;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.jobs.model.JobStatus;
|
||||
import com.kingsrook.qqq.backend.core.jobs.progress.JobPhase;
|
||||
import com.kingsrook.qqq.backend.core.jobs.progress.JobProgress;
|
||||
import com.kingsrook.qqq.backend.core.jobs.progress.JobTracking;
|
||||
import com.kingsrook.qqq.backend.core.jobs.progress.TrackedJobRequest;
|
||||
import com.kingsrook.qqq.backend.core.jobs.runtime.DurableJobsRuntime;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Publishes each assistant answer to the Jobs tray as a QQQ tracking job
|
||||
** (core JobTracking, qqq#1210): processName "assistant.turn", phases
|
||||
** thinking / tool / writing, the tool's label as the message, and the tray's
|
||||
** Stop wired to the turn's own Stop. Never prompt or answer text. When the
|
||||
** durable jobs runtime is not running on this pod, answers are simply not
|
||||
** tracked.
|
||||
*******************************************************************************/
|
||||
public class JobTrackingRunTracker implements AssistantRunTracker
|
||||
{
|
||||
public static final String PROCESS_NAME = "assistant.turn";
|
||||
public static final String LABEL = "Assistant answer";
|
||||
|
||||
private final Map<String, JobProgress> reporters = new ConcurrentHashMap<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Start a tracking job linked to the conversation, or null when the jobs
|
||||
** runtime is not running here.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String start(String userId, Integer turnId, Integer conversationId, String label) throws QException
|
||||
{
|
||||
QInstance qInstance = QContext.getQInstance();
|
||||
if(qInstance == null || DurableJobsRuntime.get(qInstance) == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
Integer jobId = JobTracking.start(new TrackedJobRequest()
|
||||
.withUserId(userId)
|
||||
.withLabel(LABEL)
|
||||
.withProcessName(PROCESS_NAME)
|
||||
.withTableName(AssistantConversation.TABLE_NAME)
|
||||
.withRecordId(conversationId));
|
||||
String trackingId = String.valueOf(jobId);
|
||||
reporters.put(trackingId, JobTracking.progress(jobId).phases(
|
||||
JobPhase.of("thinking", "Thinking", 1),
|
||||
JobPhase.of("tool", "Using a tool", 1),
|
||||
JobPhase.of("writing", "Writing", 1)));
|
||||
return (trackingId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The current phase, with the tool's label as the message.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void progress(String trackingId, String phase, String message)
|
||||
{
|
||||
JobProgress reporter = trackingId == null ? null : reporters.get(trackingId);
|
||||
if(reporter == null || !("thinking".equals(phase) || "tool".equals(phase) || "writing".equals(phase)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
reporter.phase(phase);
|
||||
if("tool".equals(phase) && message != null)
|
||||
{
|
||||
reporter.message(message);
|
||||
reporter.flush();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** COMPLETE -> COMPLETED, CANCELLED -> CANCELLED, anything else -> FAILED.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void finish(String trackingId, String status, String message) throws QException
|
||||
{
|
||||
if(trackingId == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
JobProgress reporter = reporters.remove(trackingId);
|
||||
if(reporter != null)
|
||||
{
|
||||
reporter.flush();
|
||||
}
|
||||
JobTracking.finish(Integer.valueOf(trackingId), jobStatus(status), finishMessage(status));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The tray's Stop runs on this pod and stops the turn; the turn's own
|
||||
** finaliser then finishes the job as CANCELLED.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void onCancel(String trackingId, Runnable cancel) throws QException
|
||||
{
|
||||
if(trackingId != null)
|
||||
{
|
||||
JobTracking.onCancel(Integer.valueOf(trackingId), cancel);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The jobs status for an assistant turn status.
|
||||
*******************************************************************************/
|
||||
static JobStatus jobStatus(String status)
|
||||
{
|
||||
if("COMPLETE".equals(status))
|
||||
{
|
||||
return (JobStatus.COMPLETED);
|
||||
}
|
||||
if("CANCELLED".equals(status))
|
||||
{
|
||||
return (JobStatus.CANCELLED);
|
||||
}
|
||||
return (JobStatus.FAILED);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A fixed message (never the answer, the prompt or a provider error).
|
||||
*******************************************************************************/
|
||||
static String finishMessage(String status)
|
||||
{
|
||||
if("COMPLETE".equals(status))
|
||||
{
|
||||
return ("Answered.");
|
||||
}
|
||||
if("CANCELLED".equals(status))
|
||||
{
|
||||
return ("Stopped.");
|
||||
}
|
||||
return ("The answer did not finish.");
|
||||
}
|
||||
|
||||
}
|
||||
+2
-6
@@ -20,6 +20,7 @@ package com.kingsrook.qbits.assistant.metadata;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
import com.kingsrook.qbits.assistant.health.AssistantHealthWidget;
|
||||
import com.kingsrook.qbits.assistant.home.AssistantAccessWidget;
|
||||
import com.kingsrook.qbits.assistant.home.AssistantHome;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantBudget;
|
||||
@@ -47,7 +48,6 @@ 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.model.AssistantUserPreference;
|
||||
import com.kingsrook.qbits.assistant.processes.AssistantRetentionStep;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiExposure;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiTableMetaData;
|
||||
@@ -237,10 +237,6 @@ public class AssistantMetaDataBuilder
|
||||
.withUniqueKey(new UniqueKey("profileId", "skillId"))
|
||||
.withRecordLabelFormat("%s · %s").withRecordLabelFields("profileId", "skillId"));
|
||||
|
||||
tables.add(owned(base(AssistantUserPreference.TABLE_NAME, "Assistant User Preference", "tune", AssistantUserPreference.class), false)
|
||||
.withUniqueKey(new UniqueKey("userId"))
|
||||
.withRecordLabelFormat("%s").withRecordLabelFields("userId"));
|
||||
|
||||
tables.add(readOnly(owned(base(AssistantPendingAction.TABLE_NAME, "Assistant Pending Action", "pending_actions", AssistantPendingAction.class), false))
|
||||
.withRecordLabelFormat("%s #%s").withRecordLabelFields("toolName", "id"));
|
||||
text(tables.get(tables.size() - 1), "paramsJson");
|
||||
@@ -388,7 +384,7 @@ public class AssistantMetaDataBuilder
|
||||
.withLabel("Assistant")
|
||||
.withIcon(new QIcon("smart_toy"))
|
||||
.withPermissionRules(adminRules())
|
||||
.withWidgets(List.of(AssistantHome.WIDGET_NAME, AssistantAccessWidget.WIDGET_NAME));
|
||||
.withWidgets(List.of(AssistantHealthWidget.NAME, AssistantHome.WIDGET_NAME, AssistantAccessWidget.WIDGET_NAME));
|
||||
for(String name : NAV_TABLES)
|
||||
{
|
||||
tables.stream().filter(t -> t.getName().equals(name)).findFirst().ifPresent(app::withChild);
|
||||
|
||||
-178
@@ -1,178 +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.metadata;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
import com.kingsrook.qbits.assistant.AssistantRuntime;
|
||||
import com.kingsrook.qbits.assistant.AssistantSecurity;
|
||||
import com.kingsrook.qbits.assistant.engine.PolicyService;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantUserPreference;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceChange;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceRequestContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceSectionMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceValues;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferencesMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Core preferences over the standard assistant_user_preference table. Core
|
||||
** supplies the authenticated user and trusted scope, retaining audit identity.
|
||||
*******************************************************************************/
|
||||
public class AssistantPreferencesProvider implements UserPreferenceProviderInterface
|
||||
{
|
||||
public static final String SECTION = "assistant";
|
||||
public static final String AUTO_CONFIRM_REASON = "Auto-confirm is not allowed by the assistant policy.";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The admin app is intentionally not the scope: ordinary assistant users
|
||||
** must see their own preferences without assistant.admin.
|
||||
*******************************************************************************/
|
||||
public static void register(QInstance qInstance)
|
||||
{
|
||||
UserPreferencesMetaData.ofOrWithNew(qInstance).withSection(new Section()
|
||||
.withName(SECTION).withLabel("Assistant").withIcon(new QIcon("smart_toy"))
|
||||
.withDescription("Defaults for new conversations and confirmation of changes.")
|
||||
.withPermissionName(AssistantQBitConfig.ACCESS_PERMISSION)
|
||||
.withProvider(new QCodeReference(AssistantPreferencesProvider.class)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Core only applies channel rules to app-scoped sections. Explicitly deny
|
||||
** this unbound configuration section before loading or changing any rows.
|
||||
*******************************************************************************/
|
||||
private AssistantRuntime runtime(UserPreferenceRequestContext context) throws QException
|
||||
{
|
||||
if("assistant".equals(context.getSession().getClientChannelName()))
|
||||
{
|
||||
throw (new QNotFoundException("Preference section not found"));
|
||||
}
|
||||
AssistantRuntime runtime = AssistantRuntime.of(QContext.getQInstance());
|
||||
AssistantSecurity.ownerKey(context.getSession(), runtime.getConfig());
|
||||
return (runtime);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read only the context user's row, even if they hold an admin owner key.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public UserPreferenceValues load(UserPreferenceRequestContext context) throws QException
|
||||
{
|
||||
AssistantUserPreference preference = runtime(context).getStore().getPreference(context.getUserId());
|
||||
return (new UserPreferenceValues().withValues(Map.of(
|
||||
"defaultWriteMode", preference != null && Boolean.TRUE.equals(preference.getDefaultWriteMode()),
|
||||
"autoConfirmLowRisk", preference != null && Boolean.TRUE.equals(preference.getAutoConfirmLowRisk()))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Re-read policy at the write boundary; the submitted defaults grant nothing.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<String> validate(UserPreferenceRequestContext context, UserPreferenceChange change) throws QException
|
||||
{
|
||||
PolicyService.ToolPolicy policy = runtime(context).getPolicyService().current(true).tools();
|
||||
List<String> errors = new ArrayList<>();
|
||||
if(change.getValues().containsKey("autoConfirmLowRisk") && !policy.autoConfirmAllowed())
|
||||
{
|
||||
errors.add("Auto-confirm low-risk changes: " + AUTO_CONFIRM_REASON);
|
||||
}
|
||||
if(Boolean.TRUE.equals(change.getValues().get("defaultWriteMode")) && !policy.writeModeAvailableTo(context.getSession()))
|
||||
{
|
||||
errors.add("Start conversations in Write mode: Write mode is not available to you under the assistant policy.");
|
||||
}
|
||||
return (errors);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply only supplied fields; all ownership comes from the context.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void store(UserPreferenceRequestContext context, UserPreferenceChange change) throws QException
|
||||
{
|
||||
AssistantRuntime runtime = runtime(context);
|
||||
AssistantUserPreference preference = runtime.getStore().getPreference(context.getUserId());
|
||||
if(preference == null)
|
||||
{
|
||||
preference = runtime.getStore().insert(new AssistantUserPreference().withUserId(context.getUserId())
|
||||
.withDefaultWriteMode(false).withAutoConfirmLowRisk(false));
|
||||
}
|
||||
QRecord update = new QRecord().withValue("id", preference.getId());
|
||||
change.getValues().forEach(update::setValue);
|
||||
runtime.getStore().updateFields(AssistantUserPreference.TABLE_NAME, update);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Return fresh field metadata for each request, so policy editability never
|
||||
** mutates the shared instance metadata or leaks between concurrent users.
|
||||
*******************************************************************************/
|
||||
public static class Section extends UserPreferenceSectionMetaData
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Scalar read-only metadata uses the standard field help text for its reason.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QFieldMetaData> getFields()
|
||||
{
|
||||
boolean autoConfirmAllowed = false;
|
||||
AssistantRuntime runtime = AssistantRuntime.of(QContext.getQInstance());
|
||||
if(runtime != null && QContext.getQSession() != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
autoConfirmAllowed = runtime.getPolicyService().current(false).tools().autoConfirmAllowed();
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
// Fail closed when policy cannot be read; provider validation also rechecks it.
|
||||
}
|
||||
}
|
||||
QFieldMetaData autoConfirm = new QFieldMetaData("autoConfirmLowRisk", QFieldType.BOOLEAN)
|
||||
.withLabel("Auto-confirm low-risk changes").withIsEditable(autoConfirmAllowed);
|
||||
if(!autoConfirmAllowed)
|
||||
{
|
||||
autoConfirm.withHelpContent(new QHelpContent(AUTO_CONFIRM_REASON));
|
||||
}
|
||||
return (List.of(new QFieldMetaData("defaultWriteMode", QFieldType.BOOLEAN).withLabel("Start conversations in Write mode"), autoConfirm));
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-8
@@ -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;
|
||||
@@ -574,10 +574,8 @@ public final class AssistantRecordScreens
|
||||
.withHelpContent(new QHelpContent("The table or process name, e.g. blog_post. Tables without a row use the default: NONE for sensitive tables, READ for the others. Processes default to NONE."));
|
||||
table.getField("mode").withLabel("Assistant access").withDefaultValue(DataAccessMode.READ.name())
|
||||
.withHelpContent(new QHelpContent("NONE: the assistant never sees it. READ: the read tools may use it. READ and write: the create, update and delete tools may too. The assistant always acts with the user's own permissions and record locks; this can only narrow them. A sensitive table (AI exposure NONE) stays NONE."));
|
||||
table.getField("autoConfirmOk").withLabel("Auto-confirm OK")
|
||||
.withHelpContent(new QHelpContent("Single-record inserts and updates here count as low-risk: users who chose auto-confirm (where the policy allows it) skip the confirmation card. Never deletes."));
|
||||
sections(table,
|
||||
section("access", "Data Access", "admin_panel_settings", Tier.T1, "id", "objectType", "objectName", "mode", "autoConfirmOk"),
|
||||
section("access", "Data Access", "admin_panel_settings", Tier.T1, "id", "objectType", "objectName", "mode"),
|
||||
section("notes", "Notes", "notes", Tier.T2, "notes"));
|
||||
}
|
||||
|
||||
@@ -714,9 +712,6 @@ public final class AssistantRecordScreens
|
||||
help(table, "killSwitch", "On: the assistant takes no new requests from anyone.");
|
||||
help(table, "killSwitchReason", "Shown to users while the kill switch is on.");
|
||||
help(table, "writesRequireConfirmation", "On (or empty): every create, update or delete the assistant proposes waits for the user to confirm it in the panel. Off: writes run at once, still with the user's own permissions.");
|
||||
help(table, "writeModeAllowed", "On (or empty): users may switch a conversation to Write mode, where the create, update and delete tools exist. Off: every conversation stays Read-only.");
|
||||
help(table, "writeModePermission", "Optional: only users holding this permission (a role) may use Write mode.");
|
||||
help(table, "autoConfirmAllowed", "On: users may choose to auto-confirm low-risk writes (single-record inserts and updates on tables marked auto-confirm OK). Never deletes, bulk writes or processes. Off (or empty): every write waits for confirmation.");
|
||||
help(table, "allowedTools", "Comma-separated tool names the assistant may use (e.g. list_tables, query_records, count_records). Empty allows every tool.");
|
||||
help(table, "maxRowsPerCall", "Most rows one tool call returns to the model. Empty: 50; at most 200.");
|
||||
help(table, "redactPii", "On (or empty): API keys, tokens, private keys, card numbers and social security numbers are redacted from tool results before the model sees them.");
|
||||
@@ -733,7 +728,7 @@ public final class AssistantRecordScreens
|
||||
sections(table,
|
||||
section("policy", "Policy", "policy", Tier.T1, "id", "name", "killSwitch", "killSwitchReason"),
|
||||
section("model", "Model", "smart_toy", Tier.T2, "mainModel", "mainEffort"),
|
||||
section("tools", "Tools and data", "build", Tier.T2, "writeModeAllowed", "writeModePermission", "writesRequireConfirmation", "autoConfirmAllowed", "allowedTools", "maxRowsPerCall", "redactPii"),
|
||||
section("tools", "Tools and data", "build", Tier.T2, "writesRequireConfirmation", "allowedTools", "maxRowsPerCall", "redactPii"),
|
||||
section("userDaily", "Per user, per day", "person", Tier.T2, "userDailyTokensSoft", "userDailyTokensHard", "userDailyCostMicrosSoft", "userDailyCostMicrosHard"),
|
||||
section("instanceMonthly", "Whole instance, per month", "domain", Tier.T2, "instanceMonthlyTokensSoft", "instanceMonthlyTokensHard", "instanceMonthlyCostMicrosSoft", "instanceMonthlyCostMicrosHard"));
|
||||
}
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+1
-1
@@ -47,7 +47,7 @@ public class AssistantBudgetOverride extends QRecordEntity
|
||||
@QField()
|
||||
private Instant unlimitedUntil;
|
||||
|
||||
@QField(isRequired = true, maxLength = 1000)
|
||||
@QField(isRequired = true)
|
||||
private String reason;
|
||||
|
||||
@QField(isEditable = false, maxLength = 200)
|
||||
|
||||
-33
@@ -71,9 +71,6 @@ public class AssistantConversation extends QRecordEntity
|
||||
@QField(isEditable = false)
|
||||
private String contextJson;
|
||||
|
||||
@QField()
|
||||
private Boolean writeMode;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Long totalTokensIn;
|
||||
|
||||
@@ -512,36 +509,6 @@ public class AssistantConversation extends QRecordEntity
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for writeMode (Write mode for this conversation (default Read-only); the write tools exist only in Write mode)
|
||||
*******************************************************************************/
|
||||
public Boolean getWriteMode()
|
||||
{
|
||||
return (this.writeMode);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for writeMode
|
||||
*******************************************************************************/
|
||||
public void setWriteMode(Boolean writeMode)
|
||||
{
|
||||
this.writeMode = writeMode;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for writeMode
|
||||
*******************************************************************************/
|
||||
public AssistantConversation withWriteMode(Boolean writeMode)
|
||||
{
|
||||
this.writeMode = writeMode;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for maxClassification
|
||||
|
||||
-33
@@ -49,9 +49,6 @@ public class AssistantDataAccess extends QRecordEntity
|
||||
@QField(maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS)
|
||||
private String notes;
|
||||
|
||||
@QField()
|
||||
private Boolean autoConfirmOk;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
@@ -288,34 +285,4 @@ public class AssistantDataAccess extends QRecordEntity
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for autoConfirmOk (single-record inserts and updates here may be auto-confirmed (when the policy and the user allow it))
|
||||
*******************************************************************************/
|
||||
public Boolean getAutoConfirmOk()
|
||||
{
|
||||
return (this.autoConfirmOk);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for autoConfirmOk
|
||||
*******************************************************************************/
|
||||
public void setAutoConfirmOk(Boolean autoConfirmOk)
|
||||
{
|
||||
this.autoConfirmOk = autoConfirmOk;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for autoConfirmOk
|
||||
*******************************************************************************/
|
||||
public AssistantDataAccess withAutoConfirmOk(Boolean autoConfirmOk)
|
||||
{
|
||||
this.autoConfirmOk = autoConfirmOk;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
+1
-1
@@ -66,7 +66,7 @@ public class AssistantDataAudit extends QRecordEntity
|
||||
@QField(isEditable = false, maxLength = 16)
|
||||
private String decision;
|
||||
|
||||
@QField(isEditable = false, maxLength = 1000)
|
||||
@QField(isEditable = false)
|
||||
private String reason;
|
||||
|
||||
@QField(isEditable = false)
|
||||
|
||||
Loaded 100 of 160 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user