Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5125f9788
|
||
|
|
3de664e0fd | ||
|
|
2caa634044 | ||
|
|
c77e5b0123
|
||
|
|
2f848c08b7 | ||
|
|
cfad90eaee
|
||
|
|
f46735b1c0 | ||
|
|
86563c64d2 | ||
|
|
237dbf98d6 | ||
|
|
f14e445e9c
|
||
|
|
3ed622f116 | ||
|
|
82febe0f19 | ||
|
|
1ea23dfc49
|
||
|
|
54f91107eb | ||
|
|
38ac805fe7
|
||
|
|
20758cbc9b
|
||
|
|
5180ef6102
|
||
|
|
9d31de46a6 | ||
|
|
24b0b3b14a
|
||
|
|
f4eb3cc0c4 | ||
|
|
8e473bcb56
|
||
|
|
1c33e97664 | ||
|
|
fcaf2ded7e
|
||
|
|
fe2ec09a80
|
||
|
|
60435c27e3 | ||
|
|
2cea8ffd44 | ||
|
|
6b98d5216b
|
||
|
|
4b7eb81d6d
|
||
|
|
b8aff19826 | ||
|
|
9fc0eed499
|
||
|
|
0a23db241b
|
||
|
|
c838eb4f67
|
||
|
|
23779790ab | ||
|
|
6adf390bf8 | ||
|
|
bc92c89665
|
||
|
|
bbee0d1017 | ||
|
|
079e8d39e2
|
||
|
|
dd5edf84d8
|
||
|
|
d43402e262 | ||
|
|
fe725ac8ff
|
||
|
|
ef933d6bea | ||
|
|
385b61019b
|
||
|
|
a7184d3be9
|
||
|
|
2a3691c0ea
|
||
|
|
b6982eff9f
|
||
|
|
dfb05d8935 | ||
|
|
2a7bf77c6d
|
||
|
|
523fd0128a
|
||
|
|
1012b4b497 | ||
|
|
76f9b286c6
|
||
|
|
47115fe359 | ||
|
|
f2d2b50459
|
||
|
|
9b4708fafc | ||
|
|
15938e07e1
|
||
|
|
8c33db9fcf | ||
|
|
75a5dedf7f
|
||
|
|
b2c97d1000 | ||
|
|
ae8e394a7b | ||
|
|
1bae6e26ba
|
||
|
|
80d529e741
|
||
|
|
b50bc4a5c7
|
||
|
|
c1cceaf4c3 | ||
|
|
f8a114bbb8
|
||
|
|
6a1bac6a9e | ||
|
|
0bfa4e4d01 | ||
|
|
3d288ddfe9
|
||
|
|
c751fc055c
|
||
|
|
50898a927d
|
||
|
|
f60009f595
|
||
|
|
ffa7e25bf4
|
||
|
|
00861af42c
|
||
|
|
e8beb7444a
|
||
|
|
fdf8d72367
|
||
|
|
8aac6cf92d
|
||
|
|
02215634bf
|
||
|
|
303cf9a148
|
||
|
|
969a7b9101
|
||
|
|
8e47d9a6f5
|
||
|
|
09787bfe22
|
||
|
|
6c36917beb
|
||
|
|
3a7ff1c659 | ||
|
|
ea81166454
|
||
|
|
cfcc044055 | ||
|
|
620dee488d
|
||
|
|
e23206f1e8 | ||
|
|
c6bae3bcf2
|
||
|
|
2d8459bb24 | ||
|
|
ac06a96787
|
||
|
|
d4a1f64480 | ||
|
|
c28e782075
|
||
|
|
5c76a13245 | ||
|
|
8d1348888d
|
||
|
|
47cc572c36 | ||
|
|
8099f64bf2
|
||
|
|
ac6ec75deb | ||
|
|
1f48a370e3
|
||
|
|
d0ad2aa0fa | ||
|
|
16c8500f59
|
||
|
|
a8c5f586ee | ||
|
|
3dfa775b21
|
||
|
|
67f78fd660 | ||
|
|
ff67c6f7cc | ||
|
|
19551ac2af
|
||
|
|
7263ac627d
|
No files matched your search
+11
-57
@@ -1,60 +1,14 @@
|
||||
version: 2.1
|
||||
|
||||
setup: true
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.6.9
|
||||
|
||||
commands:
|
||||
configure-package-install:
|
||||
steps:
|
||||
- run:
|
||||
name: Keep package installation unattended
|
||||
command: |
|
||||
# These build machines are discarded after the job.
|
||||
sudo install -d /etc/needrestart/conf.d
|
||||
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
|
||||
|
||||
qqq-orb: kingsrook/qqq-orb@0.7.15
|
||||
workflows:
|
||||
test_only:
|
||||
jobs:
|
||||
- qqq-orb/mvn_test_only:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|release\/.*)/
|
||||
|
||||
publish_snapshot:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: snapshot
|
||||
filters:
|
||||
branches:
|
||||
only: [develop]
|
||||
|
||||
publish_release_candidate:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release_candidate
|
||||
filters:
|
||||
branches:
|
||||
only: [/release\/.*/]
|
||||
|
||||
publish_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release
|
||||
filters:
|
||||
branches:
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: [/^v\d+\.\d+\.\d+$/]
|
||||
setup:
|
||||
jobs:
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: 0.7.15
|
||||
filters:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
tags:
|
||||
only: /^v\d+\.\d+\.\d+$/
|
||||
@@ -0,0 +1,23 @@
|
||||
name: CodeQL
|
||||
'on':
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- release/**
|
||||
- hotfix/**
|
||||
pull_request: {}
|
||||
schedule:
|
||||
- cron: 41 16 * * 0
|
||||
workflow_dispatch: {}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
security-events: write
|
||||
packages: read
|
||||
concurrency:
|
||||
group: codeql-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
jobs:
|
||||
analyze:
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
|
||||
@@ -1,25 +1,74 @@
|
||||
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
|
||||
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
|
||||
# the release line and the release/* push runs CircleCI publish_release_candidate.
|
||||
name: Release candidate
|
||||
|
||||
on:
|
||||
'on':
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Print the plan without merging or creating branches
|
||||
description: Plan a cut without changing release state
|
||||
type: boolean
|
||||
default: false
|
||||
finalize:
|
||||
description: Retry finalization of an existing cut; do not cut another RC
|
||||
type: boolean
|
||||
default: false
|
||||
recover:
|
||||
description: Recover the same published RC after a deployment timeout; never
|
||||
reupload
|
||||
type: boolean
|
||||
default: false
|
||||
circle_build:
|
||||
description: Original Circle publisher build number (recovery only)
|
||||
type: string
|
||||
default: ''
|
||||
merge_sha:
|
||||
description: Existing cut merge_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
bump_sha:
|
||||
description: Existing cut bump_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
branch:
|
||||
description: Existing cut branch (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
version:
|
||||
description: Existing cut version (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [develop]
|
||||
|
||||
types:
|
||||
- closed
|
||||
branches:
|
||||
- develop
|
||||
repository_dispatch:
|
||||
types:
|
||||
- qbit-rc-published
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
actions: read
|
||||
checks: read
|
||||
jobs:
|
||||
release-rc:
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
|
||||
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
|
||||
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|
||||
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
|
||||
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
|
||||
'release:rc'))
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
|
||||
with:
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
|
||||
}}
|
||||
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
|
||||
== 'workflow_dispatch' && inputs.finalize) }}
|
||||
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
|
||||
}}
|
||||
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|
||||
|| '' }}
|
||||
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|
||||
|| inputs.merge_sha || '' }}
|
||||
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|
||||
|| inputs.bump_sha || '' }}
|
||||
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|
||||
|| inputs.branch || '' }}
|
||||
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|
||||
|| inputs.version || '' }}
|
||||
secrets: inherit
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
profile: qbit
|
||||
orb_version: 0.7.15
|
||||
default_branch: develop
|
||||
java_version: '21'
|
||||
browsers: true
|
||||
api_checks: false
|
||||
docs_only: false
|
||||
release_on_main: false
|
||||
release_tag_policy: stable
|
||||
test_branch_policy: non-release
|
||||
publish:
|
||||
snapshot: true
|
||||
release_candidate: true
|
||||
release: true
|
||||
asciidoc: false
|
||||
@@ -1,5 +1,91 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased - Answer visible at the end of the stream
|
||||
|
||||
- The turn's redacted transcript (the question and answer parts, pending actions, stored events) is now written before `message.end` is sent. The panel reloads the conversation the moment the stream ends, and used to read the empty placeholders (`[]`) the transcript redactor stores until its flush, so the newest question and answer showed as empty bubbles until the panel was closed and reopened.
|
||||
|
||||
## Unreleased - Optional provider health
|
||||
|
||||
- An enabled provider that is not the default and not the only route for any configured routing (an enabled profile's simple/complex model, a budget downgrade model or a knowledge source's embedding model, with no fallback on another usable provider) is optional: when it is unreachable, its `assistant.provider.<id>` and `assistant.model.<id>` checks report DEGRADED with a message naming the provider requests use instead, so one unfinished local server no longer turns the whole `assistant` component DOWN. The default provider and sole routes still report DOWN. A failed provider probe still carries `reachable=false`, so the circuit breaker keeps refusing the provider.
|
||||
- New `assistant_provider.setup_state` (Setup state: Active / Setting up, empty counts as Active). A provider that is Setting up is never routed to (default, profile models, fallbacks, budget downgrades, embeddings) and gets no provider or model health checks; Test connection and Discover models still work. Apply the Provider setup state DDL (UPGRADE.md).
|
||||
|
||||
## Unreleased - Fail soft (UI API extension 1.5.0)
|
||||
|
||||
- Every message part, tool render and presentation is validated before it is stored or streamed, and again when stored parts and events are read: all render types (records, record, count, widget, history, pending_action, bulk_load_dry_run) and presentation blocks always carry their lists. A render that cannot be repaired is replaced by an error notice ("This result could not be displayed."); unknown part and render types pass through. Problems are logged with the type and a fixed code only.
|
||||
- Tool calls have their own limit (`toolCallTimeoutSeconds`, default 60) besides the turn's (`maxTurnSeconds`, default 300). A timed-out or failing tool gets an ERROR (`is_error`) result for the model, an ERROR tool part and an error notice for the user; internal details go only to the log.
|
||||
- Every turn ends: terminal events are emitted independently, so a failing emit never skips `message.end` or the terminal turn row. Conversation detail repairs assistant messages left `STREAMING` (an orphaned run is reaped; a run that already ended gives its status), and the retention process finalizes runs still `RUNNING` past the hard stream limit (e.g. after a pod restart).
|
||||
- `POST /qqq/v1/assistant/client-errors` (extension 1.5.0): the panel reports errors it hit (`scope`, `message`, optional stacks, route, ids, part/render type, app version, `occurredAt`). Answers `202 {"accepted": true}`; over `clientErrorsPerUserPerMinute` (default 30) it answers `202 {"accepted": false}` and drops the report. Fields are bounded, message and stacks scrubbed (secrets, e-mails, long numbers, tokens, query strings), ids kept only when they are the user's own. Rows go to the new owner-locked `assistant_client_error` table (Assistant app nav, retention after `toolResultRetentionDays`), and the `assistant.client-errors` health check turns degraded at `clientErrorWarnPerHour` (default 20) reports in an hour. Apply the Fail soft DDL (UPGRADE.md).
|
||||
|
||||
## Unreleased - Empty renders and stream context (UI API extension 1.4.0)
|
||||
|
||||
- Tool result renders always carry their lists: a `records` render for a query that matched nothing now has `"rows": []` and `"columns": []` (QQQ's JSON omitted empty lists, and Next's Assistant crashed the page reading `rows.length`). Parts and events stored before the fix are completed when read. Extension 1.4.0 documents the shape as `AssistantRecordsRenderV1`.
|
||||
- Streamed text arrives on the provider's own (virtual) thread; the turn's progress updates now run in the turn's QContext, so job progress no longer fails with "Could not flush job progress" (NPE on the missing instance).
|
||||
|
||||
## Unreleased - UI API 1.6.0
|
||||
|
||||
- UI API extension assistant 1.3.0 (requires QQQ 4.1.0-RC.15, UI API 1.6.0): operationIds, standard error responses with BasicErrorResponseV1, extension tags and trace IDs. Preserve HTTP statuses and assistant-specific error codes.
|
||||
- Retain AssistantErrorV1 as a deprecated component for compatibility. Remove dangling `null` schema references from the two bodyless 204 responses (delete conversation and feedback), with an empty-content workaround for core schema traversal. Success payloads are unchanged; the contract test retains the release baseline with explicit exceptions for the error schema migration and these documentation corrections.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -30,6 +30,25 @@ Hosts register providers through `AssistantQBitConfig` in `qbit-assistant`.
|
||||
|
||||
Data tools use the QQQ UI API (`/qqq/v1`) with the signed-in user's session. The UI API enforces permissions and record locks; assistant data-access policy and field exposure can only narrow access. Only record-free navigation and help use the core AI tool kernel. Every tool result reaches the model wrapped as untrusted data.
|
||||
|
||||
## Transcript privacy
|
||||
|
||||
QQQ **4.1.0-RC.7** supplies the secured-data tokens and log hook (`qqq#1182`).
|
||||
Stored transcripts never contain the raw PII/PHI values read by a turn or supplied
|
||||
in classified tool criteria/fields. Messages, tool logs, replay events, knowledge
|
||||
snippets and pending payloads store `ClassifiedValueTokens` tokens; scheduled
|
||||
summaries use the redacted message. The model and live user stream retain the
|
||||
values permitted by PHI routing. History and replay show tokens instead.
|
||||
|
||||
Content is finalized at turn completion; interrupted servers leave value-free
|
||||
placeholders. Proposals requiring redacted parameters are rejected and must be
|
||||
completed in the application. Existing transcripts are not migrated. See
|
||||
[transcript redaction](docs/DESIGN.md#transcript-redaction-secured-data-15).
|
||||
The dependency upgrade stays draft until RC.7 is published; local verification:
|
||||
|
||||
```powershell
|
||||
.\mvnw.cmd -q verify "-Dqqq.snapshot.version=4.1.0-RC7PRE-SNAPSHOT"
|
||||
```
|
||||
|
||||
## Host audit attribution (X1/Y1)
|
||||
|
||||
Use QQQ **4.1.0-RC.6 or later**. The QBit explicitly denies every assistant
|
||||
@@ -201,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).
|
||||
@@ -625,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
|
||||
@@ -706,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.
|
||||
+108
@@ -1,5 +1,113 @@
|
||||
# Upgrading qbit-assistant
|
||||
|
||||
## Provider setup state (Unreleased)
|
||||
|
||||
Apply the **Provider setup state** block at the end of `docs/schema-postgres.sql`
|
||||
**before** deploying: the provider table now reads `setup_state`, so the new jar
|
||||
fails on `assistant_provider` without it. It adds a nullable `VARCHAR(16)` with
|
||||
default `'ACTIVE'` and a check for `ACTIVE` / `SETTING_UP`; existing rows behave as
|
||||
before. It is `IF NOT EXISTS` and safe to re-run.
|
||||
|
||||
To stop an unfinished provider from being routed to or health-checked, edit it in
|
||||
Assistant > Assistant Provider (Provider section) and set **Setup state** to **Setting up** (SQL:
|
||||
`UPDATE assistant_provider SET setup_state = 'SETTING_UP' WHERE id = <id>;`).
|
||||
Enabled providers that are neither the default nor the only route for a configured
|
||||
model now report DEGRADED, not DOWN, when unreachable. No configuration change.
|
||||
|
||||
## Fail soft (Unreleased, UI API extension assistant 1.5.0)
|
||||
|
||||
Apply the **Fail soft** block at the end of `docs/schema-postgres.sql` before
|
||||
deploying. It creates `assistant_client_error` (errors the panel reports through
|
||||
`POST /qqq/v1/assistant/client-errors`) with its indexes and `SET NULL` foreign keys
|
||||
to the conversation, message and turn. It is `IF NOT EXISTS` and safe to re-run.
|
||||
|
||||
New configuration, all optional: `toolCallTimeoutSeconds` (default 60),
|
||||
`clientErrorsPerUserPerMinute` (default 30) and `clientErrorWarnPerHour` (default
|
||||
20, when the `assistant.client-errors` health check turns degraded). The new table
|
||||
is in the Assistant app's nav for administrators.
|
||||
|
||||
## Transcript redaction (Unreleased)
|
||||
|
||||
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.
|
||||
|
||||
## Foreign keys (2026-10-14)
|
||||
|
||||
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.
|
||||
|
||||
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`.
|
||||
|
||||
@@ -637,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.
|
||||
+182
-9
@@ -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),
|
||||
@@ -382,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);
|
||||
@@ -415,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,
|
||||
@@ -566,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)
|
||||
@@ -577,3 +577,176 @@ 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;
|
||||
|
||||
-- Fail soft (UI API extension assistant 1.5.0): errors the panel reports through
|
||||
-- POST /qqq/v1/assistant/client-errors. Owner-locked like the other user rows; the
|
||||
-- message and stacks are scrubbed and bounded before they are stored. The ids are
|
||||
-- kept only when they are the reporting user's own rows, and SET NULL when those go.
|
||||
-- Retention deletes rows older than toolResultRetentionDays. Safe to re-run.
|
||||
CREATE TABLE IF NOT EXISTS assistant_client_error
|
||||
(
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id VARCHAR(255),
|
||||
scope VARCHAR(64),
|
||||
message TEXT,
|
||||
error_name VARCHAR(100),
|
||||
stack TEXT,
|
||||
component_stack TEXT,
|
||||
request_id VARCHAR(128),
|
||||
route VARCHAR(300),
|
||||
conversation_id INTEGER CONSTRAINT fk_assistant_client_error_conversation_id REFERENCES assistant_conversation (id) ON DELETE SET NULL,
|
||||
message_id INTEGER CONSTRAINT fk_assistant_client_error_message_id REFERENCES assistant_message (id) ON DELETE SET NULL,
|
||||
turn_id INTEGER CONSTRAINT fk_assistant_client_error_turn_id REFERENCES assistant_turn (id) ON DELETE SET NULL,
|
||||
part_type VARCHAR(64),
|
||||
render_type VARCHAR(64),
|
||||
app_version VARCHAR(64),
|
||||
occurred_at TIMESTAMP WITH TIME ZONE,
|
||||
create_date TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS assistant_client_error_user_id ON assistant_client_error (user_id);
|
||||
CREATE INDEX IF NOT EXISTS assistant_client_error_create_date ON assistant_client_error (create_date);
|
||||
CREATE INDEX IF NOT EXISTS assistant_client_error_conversation ON assistant_client_error (conversation_id);
|
||||
CREATE INDEX IF NOT EXISTS assistant_client_error_message ON assistant_client_error (message_id);
|
||||
CREATE INDEX IF NOT EXISTS assistant_client_error_turn ON assistant_client_error (turn_id);
|
||||
|
||||
-- Provider setup state: SETTING_UP marks a provider an admin is still building. The
|
||||
-- assistant never routes to it and its provider and model health checks do not run.
|
||||
-- Existing rows (and NULL) behave as ACTIVE, exactly as before. Safe to re-run.
|
||||
ALTER TABLE assistant_provider ADD COLUMN IF NOT EXISTS setup_state VARCHAR(16) DEFAULT 'ACTIVE'
|
||||
CONSTRAINT ck_assistant_provider_setup_state CHECK (setup_state IN ('ACTIVE', 'SETTING_UP'));
|
||||
@@ -20,7 +20,7 @@
|
||||
<!-- Multi-module build. The qqq-orb sets the revision here; the modules -->
|
||||
<!-- inherit it, and the flatten plugin resolves it in the published POMs. -->
|
||||
<properties>
|
||||
<revision>0.1.0-SNAPSHOT</revision>
|
||||
<revision>0.1.0-RC.24</revision>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
@@ -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,9 @@
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<qqq.snapshot.version>4.1.0-RC.6</qqq.snapshot.version>
|
||||
<!-- 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.15</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
|
||||
@@ -87,6 +87,25 @@
|
||||
<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>
|
||||
@@ -95,6 +114,7 @@
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>true</filtering>
|
||||
<includes>
|
||||
<include>help/*.yaml</include>
|
||||
<include>qbit-assistant.properties</include>
|
||||
</includes>
|
||||
</resource>
|
||||
@@ -102,6 +122,7 @@
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>false</filtering>
|
||||
<excludes>
|
||||
<exclude>help/*.yaml</exclude>
|
||||
<exclude>qbit-assistant.properties</exclude>
|
||||
</excludes>
|
||||
</resource>
|
||||
|
||||
+347
-2
@@ -17,6 +17,7 @@
|
||||
package com.kingsrook.qbits.assistant;
|
||||
|
||||
|
||||
import javax.net.ssl.SSLContext;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
@@ -31,6 +32,8 @@ 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.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;
|
||||
@@ -44,7 +47,6 @@ import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomize
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import javax.net.ssl.SSLContext;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -99,12 +101,23 @@ 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 toolCallTimeoutSeconds = 60;
|
||||
private Integer clientErrorsPerUserPerMinute = 30;
|
||||
private Integer clientErrorWarnPerHour = 20;
|
||||
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 +219,26 @@ 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, "toolCallTimeoutSeconds", toolCallTimeoutSeconds);
|
||||
requirePositive(errors, "clientErrorsPerUserPerMinute", clientErrorsPerUserPerMinute);
|
||||
requirePositive(errors, "clientErrorWarnPerHour", clientErrorWarnPerHour);
|
||||
requirePositive(errors, "maxUserMessageChars", maxUserMessageChars);
|
||||
requirePositive(errors, "historyMessageLimit", historyMessageLimit);
|
||||
requirePositive(errors, "maxTurnsPerUserPerMinute", maxTurnsPerUserPerMinute);
|
||||
@@ -720,6 +753,99 @@ public class AssistantQBitConfig implements QBitConfig
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for toolCallTimeoutSeconds (how long one tool call may run before it gets an error result; default 60)
|
||||
*******************************************************************************/
|
||||
public Integer getToolCallTimeoutSeconds()
|
||||
{
|
||||
return (this.toolCallTimeoutSeconds);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for toolCallTimeoutSeconds
|
||||
*******************************************************************************/
|
||||
public void setToolCallTimeoutSeconds(Integer toolCallTimeoutSeconds)
|
||||
{
|
||||
this.toolCallTimeoutSeconds = toolCallTimeoutSeconds;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for toolCallTimeoutSeconds
|
||||
*******************************************************************************/
|
||||
public AssistantQBitConfig withToolCallTimeoutSeconds(Integer toolCallTimeoutSeconds)
|
||||
{
|
||||
this.toolCallTimeoutSeconds = toolCallTimeoutSeconds;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for clientErrorsPerUserPerMinute (client error reports accepted per user and minute; more are acknowledged but dropped; default 30)
|
||||
*******************************************************************************/
|
||||
public Integer getClientErrorsPerUserPerMinute()
|
||||
{
|
||||
return (this.clientErrorsPerUserPerMinute);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for clientErrorsPerUserPerMinute
|
||||
*******************************************************************************/
|
||||
public void setClientErrorsPerUserPerMinute(Integer clientErrorsPerUserPerMinute)
|
||||
{
|
||||
this.clientErrorsPerUserPerMinute = clientErrorsPerUserPerMinute;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for clientErrorsPerUserPerMinute
|
||||
*******************************************************************************/
|
||||
public AssistantQBitConfig withClientErrorsPerUserPerMinute(Integer clientErrorsPerUserPerMinute)
|
||||
{
|
||||
this.clientErrorsPerUserPerMinute = clientErrorsPerUserPerMinute;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for clientErrorWarnPerHour (client error reports in the last hour at which the health check warns; default 20)
|
||||
*******************************************************************************/
|
||||
public Integer getClientErrorWarnPerHour()
|
||||
{
|
||||
return (this.clientErrorWarnPerHour);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for clientErrorWarnPerHour
|
||||
*******************************************************************************/
|
||||
public void setClientErrorWarnPerHour(Integer clientErrorWarnPerHour)
|
||||
{
|
||||
this.clientErrorWarnPerHour = clientErrorWarnPerHour;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for clientErrorWarnPerHour
|
||||
*******************************************************************************/
|
||||
public AssistantQBitConfig withClientErrorWarnPerHour(Integer clientErrorWarnPerHour)
|
||||
{
|
||||
this.clientErrorWarnPerHour = clientErrorWarnPerHour;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for maxUserMessageChars
|
||||
*******************************************************************************/
|
||||
@@ -2008,4 +2134,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);
|
||||
}
|
||||
|
||||
}
|
||||
+11
-2
@@ -23,10 +23,11 @@ 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.AssistantHelp;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantInstanceMetaData;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantRecordScreens;
|
||||
@@ -43,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;
|
||||
@@ -200,18 +202,25 @@ 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);
|
||||
AssistantHelp.apply(qInstance);
|
||||
// Registration is metadata-only; core startup sync guards hosts without helpContent.
|
||||
HelpBundles.register(qInstance, "help/qbit-assistant.yaml");
|
||||
|
||||
AssistantChannelWall.register(qInstance);
|
||||
if(Boolean.TRUE.equals(qBitConfig.getRecordHistory()))
|
||||
{
|
||||
enableRecordHistory(ClientChannelsMetaData.ofOrWithNew(qInstance));
|
||||
}
|
||||
else
|
||||
{
|
||||
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;
|
||||
@@ -26,6 +30,7 @@ import java.util.regex.Pattern;
|
||||
import com.kingsrook.qbits.assistant.access.AccessExplainer;
|
||||
import com.kingsrook.qbits.assistant.access.DataAccessService;
|
||||
import com.kingsrook.qbits.assistant.engine.BudgetService;
|
||||
import com.kingsrook.qbits.assistant.engine.ClientErrors;
|
||||
import com.kingsrook.qbits.assistant.engine.ContextBuilder;
|
||||
import com.kingsrook.qbits.assistant.engine.PolicyService;
|
||||
import com.kingsrook.qbits.assistant.engine.ProfileService;
|
||||
@@ -50,6 +55,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 +68,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;
|
||||
@@ -76,6 +87,7 @@ public class AssistantRuntime
|
||||
private final ProviderService providerService;
|
||||
private final BudgetService budgetService;
|
||||
private final RateLimiter rateLimiter;
|
||||
private final ClientErrors clientErrors;
|
||||
private final AiToolKernel kernel;
|
||||
private final ContextBuilder contextBuilder;
|
||||
private final DataAccessService dataAccessService;
|
||||
@@ -103,17 +115,28 @@ 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);
|
||||
this.policyService.withBudgetService(budgetService, providerService);
|
||||
this.rateLimiter = new RateLimiter(clock);
|
||||
this.clientErrors = new ClientErrors(clock);
|
||||
AiToolKernelSettings settings = kernelSettings(config);
|
||||
this.kernel = new AiToolKernel(qInstance, settings);
|
||||
AiExposurePolicy exposurePolicy = new AiExposurePolicy(settings);
|
||||
@@ -157,6 +180,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 +361,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 +399,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 +419,7 @@ public class AssistantRuntime
|
||||
*******************************************************************************/
|
||||
public void close()
|
||||
{
|
||||
runningTurns.values().forEach(t -> t.cancel("SHUTDOWN"));
|
||||
executor.shutdownNow();
|
||||
shutdown(Duration.ZERO);
|
||||
}
|
||||
|
||||
|
||||
@@ -421,6 +484,17 @@ public class AssistantRuntime
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for clientErrors (bounds, scrubbing and rate limit of client
|
||||
** error reports)
|
||||
*******************************************************************************/
|
||||
public ClientErrors getClientErrors()
|
||||
{
|
||||
return (clientErrors);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for kernel
|
||||
*******************************************************************************/
|
||||
@@ -504,4 +578,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));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
@@ -29,6 +32,8 @@ import com.kingsrook.qbits.assistant.AssistantRuntime;
|
||||
import com.kingsrook.qbits.assistant.AssistantSecurity;
|
||||
import com.kingsrook.qbits.assistant.access.AccessExplainer;
|
||||
import com.kingsrook.qbits.assistant.access.DataAccessMode;
|
||||
import com.kingsrook.qbits.assistant.engine.AbandonedTurns;
|
||||
import com.kingsrook.qbits.assistant.engine.ClientErrors;
|
||||
import com.kingsrook.qbits.assistant.engine.ContextBuilder;
|
||||
import com.kingsrook.qbits.assistant.engine.MessageParts;
|
||||
import com.kingsrook.qbits.assistant.engine.PolicyService;
|
||||
@@ -37,6 +42,7 @@ import com.kingsrook.qbits.assistant.engine.ProviderService;
|
||||
import com.kingsrook.qbits.assistant.engine.RunningTurn;
|
||||
import com.kingsrook.qbits.assistant.engine.TurnEngine;
|
||||
import com.kingsrook.qbits.assistant.engine.TurnEvent;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantClientError;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantConversation;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantFeedback;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantMessage;
|
||||
@@ -45,17 +51,20 @@ 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.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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -65,6 +74,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;
|
||||
@@ -198,7 +209,7 @@ public class AssistantApi
|
||||
List<Map<String, Object>> messages = new ArrayList<>();
|
||||
for(AssistantMessage message : store.listMessages(userId, id, beforeMessageId, pageSize))
|
||||
{
|
||||
messages.add(toJson(message));
|
||||
messages.add(toJson(repairIfAbandoned(message)));
|
||||
}
|
||||
|
||||
if(conversation.getUnreadCount() != null && conversation.getUnreadCount() > 0)
|
||||
@@ -215,6 +226,45 @@ public class AssistantApi
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A message still STREAMING whose run is not live here: an orphaned run is
|
||||
** recovered; a run that already ended (or is gone) gives the message its
|
||||
** terminal status. A run still alive on another replica is left alone.
|
||||
** Never throws: the conversation is shown either way.
|
||||
*******************************************************************************/
|
||||
private AssistantMessage repairIfAbandoned(AssistantMessage message)
|
||||
{
|
||||
if(!"STREAMING".equals(message.getStatus()) || (message.getTurnId() != null && runtime.getRunningTurn(message.getTurnId()) != null))
|
||||
{
|
||||
return (message);
|
||||
}
|
||||
try
|
||||
{
|
||||
AssistantTurn turn = message.getTurnId() == null ? null : store.getTurn(userId, message.getTurnId());
|
||||
if(isRunning(turn))
|
||||
{
|
||||
turn = reapIfOrphaned(turn);
|
||||
if(isRunning(turn))
|
||||
{
|
||||
return (message);
|
||||
}
|
||||
AssistantMessage reread = store.getMessage(userId, message.getId());
|
||||
if(reread != null && !"STREAMING".equals(reread.getStatus()))
|
||||
{
|
||||
return (reread);
|
||||
}
|
||||
}
|
||||
return (AbandonedTurns.repairMessage(store, userId, message, turn));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Could not check an unfinished assistant message", logPair("messageId", message.getId()));
|
||||
return (message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** PATCH /conversations/{id}
|
||||
*******************************************************************************/
|
||||
@@ -289,6 +339,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))
|
||||
@@ -324,30 +387,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);
|
||||
}
|
||||
@@ -361,13 +437,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));
|
||||
|
||||
@@ -385,17 +466,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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -443,22 +556,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));
|
||||
}
|
||||
|
||||
|
||||
@@ -474,60 +659,60 @@ 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));
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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))
|
||||
{
|
||||
end.put("messageId", turn.getAssistantMessageId());
|
||||
return (current);
|
||||
}
|
||||
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);
|
||||
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.
|
||||
AbandonedTurns.finalizeRun(store, runtime.getClock().instant(), userId, current, cancelReason);
|
||||
return (store.getTurn(userId, current.getId()));
|
||||
}
|
||||
|
||||
|
||||
@@ -566,6 +751,71 @@ public class AssistantApi
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** POST /client-errors: an error the panel hit, reported by the user's own
|
||||
** browser. Any signed-in user may report (also when the assistant is off
|
||||
** for them); only bounded, scrubbed fields are stored (ClientErrors), and
|
||||
** conversation / message / turn ids only when they are the user's own.
|
||||
** Over the per-user rate limit the report is acknowledged but dropped
|
||||
** (accepted: false), never a 429, so a looping client is not spammed.
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> reportClientError(Map<String, Object> body) throws QException, AssistantApiException
|
||||
{
|
||||
if(userId == null)
|
||||
{
|
||||
throw (new AssistantApiException(403, "NO_PERMISSION", "Sign in to report errors."));
|
||||
}
|
||||
if(body == null)
|
||||
{
|
||||
throw (new AssistantApiException(400, "A JSON object is required."));
|
||||
}
|
||||
String scope = ClientErrors.token(body.get("scope"), ClientErrors.SCOPE_MAX);
|
||||
if(scope == null)
|
||||
{
|
||||
throw (new AssistantApiException(400, "scope is required."));
|
||||
}
|
||||
String message = ClientErrors.text(body.get("message"), ClientErrors.MESSAGE_MAX);
|
||||
if(message == null)
|
||||
{
|
||||
throw (new AssistantApiException(400, "message is required."));
|
||||
}
|
||||
if(!runtime.getClientErrors().tryAcquire(userId, config.getClientErrorsPerUserPerMinute()))
|
||||
{
|
||||
return (Map.of("accepted", false));
|
||||
}
|
||||
|
||||
Integer conversationId = ClientErrors.id(body.get("conversationId"));
|
||||
Integer messageId = ClientErrors.id(body.get("messageId"));
|
||||
Integer turnId = ClientErrors.id(body.get("turnId"));
|
||||
AssistantClientError row = new AssistantClientError()
|
||||
.withUserId(userId)
|
||||
.withScope(scope)
|
||||
.withMessage(message)
|
||||
.withErrorName(ClientErrors.token(body.get("errorName"), ClientErrors.ERROR_NAME_MAX))
|
||||
.withStack(ClientErrors.text(body.get("stack"), ClientErrors.STACK_MAX))
|
||||
.withComponentStack(ClientErrors.text(body.get("componentStack"), ClientErrors.COMPONENT_STACK_MAX))
|
||||
.withRequestId(ClientErrors.token(body.get("requestId"), ClientErrors.REQUEST_ID_MAX))
|
||||
.withRoute(ClientErrors.route(body.get("route")))
|
||||
.withConversationId(conversationId != null && store.getConversation(userId, conversationId) != null ? conversationId : null)
|
||||
.withMessageId(messageId != null && store.getMessage(userId, messageId) != null ? messageId : null)
|
||||
.withTurnId(turnId != null && store.getTurn(userId, turnId) != null ? turnId : null)
|
||||
.withPartType(ClientErrors.token(body.get("partType"), ClientErrors.PART_TYPE_MAX))
|
||||
.withRenderType(ClientErrors.token(body.get("renderType"), ClientErrors.RENDER_TYPE_MAX))
|
||||
.withAppVersion(ClientErrors.token(body.get("appVersion"), ClientErrors.APP_VERSION_MAX))
|
||||
.withOccurredAt(runtime.getClientErrors().occurredAt(body.get("occurredAt")));
|
||||
AssistantClientError saved = store.insert(row);
|
||||
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// structured, no reported text: the row holds the (scrubbed) text //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
LOG.info("Assistant client error reported", logPair("clientErrorId", saved.getId()), logPair("scope", row.getScope()),
|
||||
logPair("partType", row.getPartType()), logPair("renderType", row.getRenderType()), logPair("conversationId", row.getConversationId()),
|
||||
logPair("messageId", row.getMessageId()), logPair("turnId", row.getTurnId()));
|
||||
return (Map.of("accepted", true));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** GET /actions/{id}: one of the user's pending actions.
|
||||
*******************************************************************************/
|
||||
@@ -591,6 +841,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 //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
@@ -786,6 +1042,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.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -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;
|
||||
|
||||
+232
-22
@@ -37,9 +37,11 @@ import com.kingsrook.qqq.middleware.javalin.specs.BasicResponse;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.AbstractJsonEndpointSpec;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtension;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.v1.responses.BasicErrorResponseV1;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.In;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
import com.kingsrook.qqq.openapi.model.Response;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
import com.kingsrook.qqq.openapi.model.Type;
|
||||
import io.javalin.http.ContentType;
|
||||
@@ -54,14 +56,14 @@ import io.javalin.http.HttpStatus;
|
||||
** routes, with the same URLs and bodies, so they appear in the served
|
||||
** /qqq/v1/openapi.yaml and a generated client covers them.
|
||||
**
|
||||
** Errors keep the assistant's shape: HTTP status plus {"error", "code"}.
|
||||
** Errors use BasicErrorResponseV1 and retain assistant-specific codes.
|
||||
** Turns and events are text/event-stream; each event type is documented as
|
||||
** AssistantTurnEventV1.
|
||||
*******************************************************************************/
|
||||
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.5.0";
|
||||
public static final String PREFIX = "/assistant";
|
||||
|
||||
|
||||
@@ -94,11 +96,13 @@ 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)
|
||||
.withSpec(RejectActionSpec.class)
|
||||
.withSpec(AccessSpec.class));
|
||||
.withSpec(AccessSpec.class)
|
||||
.withSpec(ClientErrorsSpec.class));
|
||||
}
|
||||
|
||||
|
||||
@@ -207,9 +211,31 @@ public final class AssistantSpecs
|
||||
rs.put("AssistantMessageV1", object("A message: the user's text or the assistant's reply as parts (text, tool, record_refs, navigate, notice).",
|
||||
"id", integer(null), "role", string("USER or ASSISTANT."), "status", string(null), "turnId", integer(null).withNullable(true),
|
||||
"createDate", string(null).withNullable(true), "parts", array(freeObject("A message part."), null)));
|
||||
rs.put("AssistantRecordsRenderV1", object("A tool part's render of type records (query results). columns and rows are always present - empty when the query matched nothing (since 1.4.0; older stored parts are completed when read).",
|
||||
"type", string("records"), "tableName", string(null), "tableLabel", string(null),
|
||||
"columns", array(object("A column.", "name", string(null), "label", string(null)), "Always present; empty when nothing matched."),
|
||||
"rows", array(object("A row.", "id", string("The primary key."), "href", string("The record's /app link."), "values", freeObject("Display values by column name.")), "Always present; empty when nothing matched."),
|
||||
"totalCount", integer("All matching rows.").withNullable(true), "truncated", bool("More rows match than are shown."), "openHref", string("The Query screen link.")));
|
||||
rs.put("AssistantConversationDetailResponseV1", object("A conversation and a page of its messages, oldest first.",
|
||||
"conversation", ref("AssistantConversationV1"), "messages", array(ref("AssistantMessageV1"), null)));
|
||||
rs.put("AssistantStatusResponseV1", object("An acknowledgement.", "status", string(null)));
|
||||
rs.put("AssistantClientErrorReportV1", object("An error the client hit while showing the assistant (since 1.5.0). scope and message are required; "
|
||||
+ "longer values are truncated, message and stacks are scrubbed of e-mail addresses, long numbers, tokens and query strings, and ids "
|
||||
+ "are kept only when they are the user's own.",
|
||||
"scope", string("Required. Where it happened: assistant-panel, assistant-message, assistant-part, assistant-render, assistant-stream, widget, record-section, page...").withMaxLength(64),
|
||||
"message", string("Required. The error message.").withMaxLength(500),
|
||||
"errorName", string("The error's name, e.g. TypeError.").withMaxLength(100).withNullable(true),
|
||||
"stack", string("The JavaScript stack.").withMaxLength(4000).withNullable(true),
|
||||
"componentStack", string("The React component stack.").withMaxLength(4000).withNullable(true),
|
||||
"requestId", string("The request or trace id the client saw.").withMaxLength(128).withNullable(true),
|
||||
"route", string("The page path (no host, query or fragment).").withMaxLength(300).withNullable(true),
|
||||
"conversationId", integer(null).withNullable(true), "messageId", integer(null).withNullable(true), "turnId", integer(null).withNullable(true),
|
||||
"partType", string("The message part type being shown.").withMaxLength(64).withNullable(true),
|
||||
"renderType", string("The render type being shown.").withMaxLength(64).withNullable(true),
|
||||
"appVersion", string("The client build.").withMaxLength(64).withNullable(true),
|
||||
"occurredAt", string("ISO-8601 instant.").withFormat("date-time").withNullable(true)));
|
||||
rs.put("AssistantClientErrorResponseV1", object("Whether the report was stored: false when the user's reports are over the per-minute limit (still 202).",
|
||||
"accepted", bool(null)));
|
||||
rs.put("AssistantActionV1", object("A write the assistant proposed and its state.",
|
||||
"id", integer(null), "status", string("PENDING, RUNNING, EXECUTED, FAILED, REJECTED or EXPIRED."), "toolName", string(null), "tableName", string(null),
|
||||
"recordId", string(null).withNullable(true), "summary", string(null), "httpStatus", integer("The UI API status of the write.").withNullable(true),
|
||||
@@ -218,10 +244,15 @@ 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("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)));
|
||||
"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("Deprecated since 1.3.0: errors use BasicErrorResponseV1.", "error", string("A message for the user."), "code", string("BUDGET, RATE_LIMIT, NOT_CONFIGURED, KILL_SWITCH, DISABLED, NO_PERMISSION...").withNullable(true)));
|
||||
return (rs);
|
||||
}
|
||||
|
||||
@@ -254,7 +285,7 @@ public final class AssistantSpecs
|
||||
/*******************************************************************************
|
||||
** Base of the assistant's JSON specs: the user's own AssistantApi (with the
|
||||
** request's credentials, forwarded to the UI API by the tools), errors in
|
||||
** the assistant's {"error","code"} shape with their status.
|
||||
** the standard error shape with their status and assistant-specific code.
|
||||
*******************************************************************************/
|
||||
public abstract static class AssistantJsonSpec extends AbstractJsonEndpointSpec
|
||||
{
|
||||
@@ -330,6 +361,26 @@ public final class AssistantSpecs
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A bodyless response has empty content. Core 1.6's schema traversal
|
||||
** requires a non-null map even when the response has no schema.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Map<Integer, Response> defineResponses()
|
||||
{
|
||||
Map<Integer, Response> responses = super.defineResponses();
|
||||
for(Response response : responses.values())
|
||||
{
|
||||
if(response.getContent() == null)
|
||||
{
|
||||
response.withContent(Map.of());
|
||||
}
|
||||
}
|
||||
return (responses);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Keep the request (for its credentials and the server's local port).
|
||||
*******************************************************************************/
|
||||
@@ -397,7 +448,7 @@ public final class AssistantSpecs
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Assistant errors keep their status and {"error","code"}; others go
|
||||
** Assistant errors keep their status and code in the standard shape; others go
|
||||
** through QQQ's mapping (400 / 401 / 403 / 404 / 500).
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
@@ -407,15 +458,16 @@ public final class AssistantSpecs
|
||||
AssistantApiException apiException = ExceptionUtils.findClassInRootChain(e, AssistantApiException.class);
|
||||
if(apiException != null)
|
||||
{
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("error", apiException.getMessage());
|
||||
context.contentType(ContentType.APPLICATION_JSON);
|
||||
QJavalinUtils.respondWithError(context, org.eclipse.jetty.http.HttpStatus.getCode(apiException.getStatus()), apiException.getMessage());
|
||||
if(apiException.getCode() != null)
|
||||
{
|
||||
body.put("code", apiException.getCode());
|
||||
context.result(JsonUtils.toJson(new BasicErrorResponseV1()
|
||||
.withError(apiException.getMessage())
|
||||
.withCode(apiException.getCode())
|
||||
.withTraceId(QJavalinUtils.getTraceId(context))
|
||||
.toMap()));
|
||||
}
|
||||
context.status(apiException.getStatus());
|
||||
context.contentType(ContentType.APPLICATION_JSON);
|
||||
context.result(JsonUtils.toJson(body));
|
||||
return;
|
||||
}
|
||||
QJavalinUtils.handleException(null, context, e);
|
||||
@@ -430,12 +482,12 @@ public final class AssistantSpecs
|
||||
public List<BasicResponse> defineAdditionalBasicResponses()
|
||||
{
|
||||
return (List.of(
|
||||
new BasicResponse(HttpStatus.BAD_REQUEST, "Invalid input", "AssistantErrorV1"),
|
||||
new BasicResponse(HttpStatus.FORBIDDEN, "No access to the assistant", "AssistantErrorV1"),
|
||||
new BasicResponse(HttpStatus.NOT_FOUND, "Not found (or not the user's own)", "AssistantErrorV1"),
|
||||
new BasicResponse(HttpStatus.CONFLICT, "Already finished or decided", "AssistantErrorV1"),
|
||||
new BasicResponse(HttpStatus.TOO_MANY_REQUESTS, "Budget or rate limit", "AssistantErrorV1"),
|
||||
new BasicResponse(HttpStatus.SERVICE_UNAVAILABLE, "Disabled, not configured, or the kill switch is on", "AssistantErrorV1")));
|
||||
new BasicResponse(HttpStatus.BAD_REQUEST, "Invalid input", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.FORBIDDEN, "No access to the assistant", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.NOT_FOUND, "Not found (or not the user's own)", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.CONFLICT, "Already finished or decided", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.TOO_MANY_REQUESTS, "Budget or rate limit", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.SERVICE_UNAVAILABLE, "Disabled, not configured, or the kill switch is on", BasicErrorResponseV1.class.getSimpleName())));
|
||||
}
|
||||
|
||||
|
||||
@@ -562,6 +614,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
|
||||
*******************************************************************************/
|
||||
@@ -844,7 +957,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.")));
|
||||
}
|
||||
|
||||
|
||||
@@ -1038,10 +1151,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1280,6 +1407,89 @@ public final class AssistantSpecs
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** POST /assistant/client-errors (since 1.5.0)
|
||||
*******************************************************************************/
|
||||
public static class ClientErrorsSpec extends AssistantJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** HTTP method.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected HttpMethod method()
|
||||
{
|
||||
return (HttpMethod.POST);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Route path.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String path()
|
||||
{
|
||||
return (PREFIX + "/client-errors");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Operation summary.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String summary()
|
||||
{
|
||||
return ("Report an error the client hit while showing the assistant (stored for administrators, rate limited per user)");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Request body.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Schema defineRequestBodySchema()
|
||||
{
|
||||
return (ref("AssistantClientErrorReportV1"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Response schema.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("AssistantClientErrorResponseV1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 202: accepted (or over the limit and dropped).
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected HttpStatus successStatus()
|
||||
{
|
||||
return (HttpStatus.ACCEPTED);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Runs in the invoker's authenticated user session.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(AssistantApi api, JsonInput input) throws Exception
|
||||
{
|
||||
return (api.reportClientError(input.getBody()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** GET /assistant/access
|
||||
*******************************************************************************/
|
||||
|
||||
+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)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
/*
|
||||
* 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.engine;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantMessage;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantTurn;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantTurnEvent;
|
||||
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.data.QRecord;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Terminal state for runs whose engine is gone (a pod restart, a crash, a
|
||||
** finalizer that could not write): the turn row, its assistant message and a
|
||||
** terminal replay (error + message.end) are written, so no reader ever sees
|
||||
** an answer "in progress" forever and the "answer already in progress"
|
||||
** lockout cannot persist.
|
||||
**
|
||||
** Used by the API when it reads an orphaned run (active turns, events,
|
||||
** conversation detail, the next question) and by the retention process,
|
||||
** which sweeps every run older than the hard stream limit. Only fixed
|
||||
** notices are appended to already stored (redacted) parts; nothing is
|
||||
** reloaded from the model, tools or the user's text.
|
||||
*******************************************************************************/
|
||||
public final class AbandonedTurns
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(AbandonedTurns.class);
|
||||
|
||||
public static final String INTERRUPTED_TEXT = "This answer was interrupted (the server restarted). Ask again.";
|
||||
public static final String UNFINISHED_TEXT = "This answer did not finish. Ask again.";
|
||||
|
||||
private static final int BATCH = 200;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** not instantiable
|
||||
*******************************************************************************/
|
||||
private AbandonedTurns()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Finalize one abandoned run as ERROR (no cancel reason: interrupted) or
|
||||
** CANCELLED (a stop nobody acknowledged). Each step is independent; the
|
||||
** turn row is written last and only while it still says it runs.
|
||||
*******************************************************************************/
|
||||
public static void finalizeRun(AssistantStore store, Instant now, String userId, AssistantTurn current, String cancelReason) throws QException
|
||||
{
|
||||
String status = cancelReason == null ? "ERROR" : "CANCELLED";
|
||||
String text = cancelReason == null ? INTERRUPTED_TEXT
|
||||
: ("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", logPair("turnId", current.getId()));
|
||||
}
|
||||
}
|
||||
try
|
||||
{
|
||||
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", logPair("turnId", current.getId()));
|
||||
}
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An assistant message still STREAMING whose run is over (or gone): give it
|
||||
** the run's terminal status (ERROR when the run is unknown) and, unless the
|
||||
** run completed, an error notice. Returns the repaired message (or the
|
||||
** given one when nothing changed or the repair could not be written).
|
||||
*******************************************************************************/
|
||||
public static AssistantMessage repairMessage(AssistantStore store, String userId, AssistantMessage message, AssistantTurn turn)
|
||||
{
|
||||
if(message == null || !"STREAMING".equals(message.getStatus()))
|
||||
{
|
||||
return (message);
|
||||
}
|
||||
String status = turn == null || turn.getStatus() == null || "RUNNING".equals(turn.getStatus()) || "CANCELLING".equals(turn.getStatus()) ? "ERROR" : turn.getStatus();
|
||||
try
|
||||
{
|
||||
MessageParts parts = MessageParts.fromJson(message.getPartsJson());
|
||||
if(!"COMPLETE".equals(status))
|
||||
{
|
||||
parts.addNotice("ERROR".equals(status) ? "error" : "info", "ERROR".equals(status) ? UNFINISHED_TEXT : "Stopped.");
|
||||
}
|
||||
String partsJson = parts.toJson();
|
||||
store.updateFields(AssistantMessage.TABLE_NAME, new QRecord().withValue("id", message.getId()).withValue("status", status).withValue("partsJson", partsJson));
|
||||
message.setStatus(status);
|
||||
message.setPartsJson(partsJson);
|
||||
LOG.info("Repaired an unfinished assistant message", logPair("messageId", message.getId()), logPair("turnId", message.getTurnId()), logPair("status", status));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Could not repair an unfinished assistant message", logPair("messageId", message.getId()), logPair("turnId", message.getTurnId()));
|
||||
}
|
||||
return (message);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Retention sweep: every run (of any user the running session can see)
|
||||
** still RUNNING or CANCELLING although it started before the hard stream
|
||||
** limit (maxTurnSeconds + force-finalize grace + lease) is finalized as
|
||||
** interrupted. Returns how many.
|
||||
*******************************************************************************/
|
||||
public static int sweep(AssistantQBitConfig config, AssistantStore store, Instant now) throws QException
|
||||
{
|
||||
Instant cutoff = now.minusSeconds(config.getMaxTurnSeconds() + (long) config.getTurnForceFinalizeGraceSeconds() + config.getTurnLeaseSeconds());
|
||||
int count = 0;
|
||||
Integer afterId = 0;
|
||||
while(true)
|
||||
{
|
||||
List<AssistantTurn> batch = store.listRunningTurnsStartedBefore(cutoff, afterId, BATCH);
|
||||
for(AssistantTurn turn : batch)
|
||||
{
|
||||
afterId = turn.getId();
|
||||
try
|
||||
{
|
||||
finalizeRun(store, now, turn.getUserId(), turn, null);
|
||||
count++;
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Could not finalize an abandoned assistant turn", logPair("turnId", turn.getId()));
|
||||
}
|
||||
}
|
||||
if(batch.size() < BATCH)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
return (count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
/*
|
||||
* 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.engine;
|
||||
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Deque;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.regex.Pattern;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.output.AiRedactor;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Client error reports (POST /assistant/client-errors): the bounds of every
|
||||
** field, the scrubbing of free text, and the per-user rate limit.
|
||||
**
|
||||
** Reported text may contain personal data (an error message can quote a
|
||||
** value), so message and stacks are scrubbed before they are stored: secrets
|
||||
** through the kernel's AiRedactor, then e-mail addresses, long digit runs
|
||||
** (more than 6 digits: phone, account and card numbers, ids), long opaque
|
||||
** tokens (32 or more letters and digits, mixed) and query strings. Short identifiers keep only printable characters.
|
||||
** A route keeps only its path.
|
||||
*******************************************************************************/
|
||||
public class ClientErrors
|
||||
{
|
||||
public static final int SCOPE_MAX = 64;
|
||||
public static final int MESSAGE_MAX = 500;
|
||||
public static final int ERROR_NAME_MAX = 100;
|
||||
public static final int STACK_MAX = 4000;
|
||||
public static final int COMPONENT_STACK_MAX = 4000;
|
||||
public static final int REQUEST_ID_MAX = 128;
|
||||
public static final int ROUTE_MAX = 300;
|
||||
public static final int PART_TYPE_MAX = 64;
|
||||
public static final int RENDER_TYPE_MAX = 64;
|
||||
public static final int APP_VERSION_MAX = 64;
|
||||
|
||||
private static final AiRedactor SECRETS = new AiRedactor(List.of());
|
||||
|
||||
private static final Pattern EMAIL = Pattern.compile("[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}");
|
||||
private static final Pattern LONG_DIGITS = Pattern.compile("\\d(?:[\\s.-]?\\d){6,}");
|
||||
private static final Pattern LONG_TOKEN = Pattern.compile("(?=[A-Za-z0-9_+=-]*\\d)(?=[A-Za-z0-9_+=-]*[A-Za-z])[A-Za-z0-9_+=-]{32,}");
|
||||
private static final Pattern QUERY_STRING = Pattern.compile("\\?[^\\s)\"']*");
|
||||
private static final Pattern CONTROL = Pattern.compile("[\\p{Cntrl}&&[^\\n\\t]]");
|
||||
private static final Pattern NOT_TOKEN = Pattern.compile("[^\\p{Graph} ]");
|
||||
|
||||
private final Clock clock;
|
||||
private final Map<String, Deque<Instant>> windows = new ConcurrentHashMap<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
public ClientErrors(Clock clock)
|
||||
{
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether one more report from this user fits in the last minute (and
|
||||
** count it if so).
|
||||
*******************************************************************************/
|
||||
public boolean tryAcquire(String userId, int perMinute)
|
||||
{
|
||||
Instant now = clock.instant();
|
||||
Deque<Instant> window = windows.computeIfAbsent(userId, key -> new ArrayDeque<>());
|
||||
synchronized(window)
|
||||
{
|
||||
while(!window.isEmpty() && !window.peekFirst().isAfter(now.minusSeconds(60)))
|
||||
{
|
||||
window.pollFirst();
|
||||
}
|
||||
if(window.size() >= perMinute)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
window.addLast(now);
|
||||
return (true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Free text (message, stacks): secrets, e-mails, long numbers, tokens and
|
||||
** query strings removed, control characters dropped, then truncated.
|
||||
** Null for a non-string or blank value.
|
||||
*******************************************************************************/
|
||||
public static String text(Object value, int max)
|
||||
{
|
||||
if(!(value instanceof String text) || text.isBlank())
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
String scrubbed = text.length() > max * 4 ? text.substring(0, max * 4) : text;
|
||||
scrubbed = SECRETS.redact(scrubbed, new HashSet<>());
|
||||
scrubbed = EMAIL.matcher(scrubbed).replaceAll("[email]");
|
||||
scrubbed = QUERY_STRING.matcher(scrubbed).replaceAll("?[query]");
|
||||
scrubbed = LONG_TOKEN.matcher(scrubbed).replaceAll("[token]");
|
||||
scrubbed = LONG_DIGITS.matcher(scrubbed).replaceAll("[number]");
|
||||
scrubbed = CONTROL.matcher(scrubbed).replaceAll("").strip();
|
||||
return (truncate(scrubbed, max));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A short identifier (scope, error name, request id, types, version):
|
||||
** printable characters only, truncated. Null for a non-string or blank value.
|
||||
*******************************************************************************/
|
||||
public static String token(Object value, int max)
|
||||
{
|
||||
if(!(value instanceof String text))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
String cleaned = NOT_TOKEN.matcher(text).replaceAll("").strip();
|
||||
cleaned = EMAIL.matcher(cleaned).replaceAll("[email]");
|
||||
return (cleaned.isEmpty() ? null : truncate(cleaned, max));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The path of a route: scheme, host, query and fragment removed; long
|
||||
** numbers and e-mails scrubbed. Null unless it is a path ("/...").
|
||||
*******************************************************************************/
|
||||
public static String route(Object value)
|
||||
{
|
||||
if(!(value instanceof String text))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
String path = text.strip().replaceFirst("^[A-Za-z][A-Za-z0-9+.-]*://[^/]*", "");
|
||||
int cut = path.indexOf('?');
|
||||
path = cut < 0 ? path : path.substring(0, cut);
|
||||
cut = path.indexOf('#');
|
||||
path = cut < 0 ? path : path.substring(0, cut);
|
||||
if(!path.startsWith("/") || path.startsWith("//"))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
path = NOT_TOKEN.matcher(path).replaceAll("").replace(" ", "");
|
||||
path = EMAIL.matcher(path).replaceAll("[email]");
|
||||
path = LONG_DIGITS.matcher(path).replaceAll("[number]");
|
||||
return (truncate(path, ROUTE_MAX));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A positive integer id, or null.
|
||||
*******************************************************************************/
|
||||
public static Integer id(Object value)
|
||||
{
|
||||
if(value instanceof Number number && number.doubleValue() == number.longValue() && number.longValue() > 0 && number.longValue() <= Integer.MAX_VALUE)
|
||||
{
|
||||
return (number.intValue());
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An ISO-8601 instant (with Z or an offset), or null; times in the future
|
||||
** become now.
|
||||
*******************************************************************************/
|
||||
public Instant occurredAt(Object value)
|
||||
{
|
||||
if(!(value instanceof String text) || text.length() > 64)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
Instant instant;
|
||||
try
|
||||
{
|
||||
instant = OffsetDateTime.parse(text.strip()).toInstant();
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
try
|
||||
{
|
||||
instant = Instant.parse(text.strip());
|
||||
}
|
||||
catch(RuntimeException e2)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
Instant now = clock.instant();
|
||||
return (instant.isAfter(now) ? now : instant);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String truncate(String value, int max)
|
||||
{
|
||||
return (value.length() <= max ? value : value.substring(0, max));
|
||||
}
|
||||
}
|
||||
+65
-7
@@ -18,9 +18,11 @@ 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.RegistryModelProvider;
|
||||
@@ -34,6 +36,7 @@ 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;
|
||||
|
||||
@@ -124,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));
|
||||
@@ -136,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()))
|
||||
@@ -170,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));
|
||||
}
|
||||
|
||||
|
||||
@@ -246,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)
|
||||
|
||||
@@ -50,11 +50,12 @@ public class MessageParts
|
||||
{
|
||||
try
|
||||
{
|
||||
List<Map<String, Object>> list = JsonUtils.toObject(json, new TypeReference<List<Map<String, Object>>>() {});
|
||||
if(list != null)
|
||||
{
|
||||
result.parts.addAll(list);
|
||||
}
|
||||
List<Object> list = JsonUtils.toObject(json, new TypeReference<List<Object>>() {});
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// stored parts are repaired on read: older rows may lack empty //
|
||||
// render lists, and a part that cannot be shown becomes a notice //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
result.parts.addAll(RenderShapes.parts(list));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
@@ -240,7 +241,7 @@ public class MessageParts
|
||||
*******************************************************************************/
|
||||
public synchronized String toJson()
|
||||
{
|
||||
return (JsonUtils.toJson(parts));
|
||||
return (RenderShapes.toJson(RenderShapes.parts(parts)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -27,6 +27,9 @@ 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.HealthCheckOutcome;
|
||||
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;
|
||||
|
||||
|
||||
@@ -35,6 +38,12 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
*******************************************************************************/
|
||||
public class ProviderHealth
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Health detail set to false when a provider probe failed, even when the
|
||||
** outcome is only DEGRADED because the provider is optional.
|
||||
*******************************************************************************/
|
||||
public static final String DETAIL_REACHABLE = "reachable";
|
||||
|
||||
private final AssistantRuntime runtime;
|
||||
|
||||
|
||||
@@ -63,6 +72,10 @@ public class ProviderHealth
|
||||
{
|
||||
throw (new ModelProviderException(ModelProviderException.Kind.AUTHENTICATION, null, "Provider is disabled."));
|
||||
}
|
||||
if(row.isSettingUp())
|
||||
{
|
||||
throw (new ModelProviderException(ModelProviderException.Kind.AUTHENTICATION, null, "Provider is not set up yet."));
|
||||
}
|
||||
if(isDown(row))
|
||||
{
|
||||
throw (new ModelProviderException(ModelProviderException.Kind.OVERLOADED, null, "Provider circuit is open."));
|
||||
@@ -72,6 +85,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 || isUnreachable(outcome))
|
||||
{
|
||||
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);
|
||||
@@ -141,4 +169,15 @@ public class ProviderHealth
|
||||
.withValue("healthState", state).withValue("healthCheckedAt", runtime.getClock().instant()).withValue("downUntil", downUntil));
|
||||
runtime.getProviderService().invalidate();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A central outcome that says the provider could not be reached, whatever
|
||||
** its status (an optional provider's failed probe is DEGRADED, not DOWN).
|
||||
*******************************************************************************/
|
||||
public static boolean isUnreachable(HealthCheckOutcome outcome)
|
||||
{
|
||||
return (outcome != null && outcome.getDetails() != null && Boolean.FALSE.equals(outcome.getDetails().getValues().get(DETAIL_REACHABLE)));
|
||||
}
|
||||
}
|
||||
+17
-6
@@ -158,14 +158,15 @@ public class ProviderService
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The default enabled provider row, or null.
|
||||
** The default usable (enabled and set up) provider row, else the first
|
||||
** usable one, or null.
|
||||
*******************************************************************************/
|
||||
public AssistantProvider activeRow() throws QException
|
||||
{
|
||||
AssistantProvider first = null;
|
||||
for(AssistantProvider row : rows())
|
||||
{
|
||||
if(Boolean.TRUE.equals(row.getEnabled()))
|
||||
if(row.isUsable())
|
||||
{
|
||||
if(Boolean.TRUE.equals(row.getIsDefault()))
|
||||
{
|
||||
@@ -481,13 +482,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));
|
||||
}
|
||||
|
||||
|
||||
@@ -772,7 +783,7 @@ public class ProviderService
|
||||
return (null);
|
||||
}
|
||||
AssistantProvider row = store.getProvider(model.getProviderId());
|
||||
if(row == null || !Boolean.TRUE.equals(row.getEnabled()) || ((requiresHipaa || Boolean.TRUE.equals(config.getHipaaMode())) && !Boolean.TRUE.equals(row.getHipaaEligible())))
|
||||
if(row == null || !row.isUsable() || ((requiresHipaa || Boolean.TRUE.equals(config.getHipaaMode())) && !Boolean.TRUE.equals(row.getHipaaEligible())))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
@@ -844,9 +855,9 @@ public class ProviderService
|
||||
throw (new ProviderSetupException("Select an enabled EMBEDDING model for this knowledge source."));
|
||||
}
|
||||
AssistantProvider row = store.getProvider(model.getProviderId());
|
||||
if(row == null || !Boolean.TRUE.equals(row.getEnabled()))
|
||||
if(row == null || !row.isUsable())
|
||||
{
|
||||
throw (new ProviderSetupException("The embedding provider is disabled or unavailable."));
|
||||
throw (new ProviderSetupException("The embedding provider is disabled, not set up yet or unavailable."));
|
||||
}
|
||||
if((requiresHipaa || Boolean.TRUE.equals(config.getHipaaMode())) && !Boolean.TRUE.equals(row.getHipaaEligible()))
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
/*
|
||||
* 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.engine;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The shapes clients rely on for message parts, tool result renders and
|
||||
** presentations (extension "assistant", AssistantMessageV1 parts and the
|
||||
** tool.result / presentation events). Every part, render and presentation
|
||||
** is checked before it is persisted or streamed, and again when stored
|
||||
** parts and events are read back (older rows are repaired on read):
|
||||
**
|
||||
** - required lists are always present, empty when there is nothing (a query
|
||||
** that matched no rows has "rows": [] and "columns": []);
|
||||
** - list entries that are not objects are dropped, required strings are
|
||||
** filled with safe defaults;
|
||||
** - a render that cannot be repaired (no type, a pending action without an
|
||||
** action id, a presentation with nothing to show) is dropped and a notice
|
||||
** part ({@link #UNDISPLAYABLE_TEXT}) takes its place;
|
||||
** - unknown part and render types pass through untouched.
|
||||
**
|
||||
** Problems are logged with the render type and a fixed problem code only;
|
||||
** never with values. QQQ's default JSON omits empty lists, so parts and
|
||||
** stream events are written with {@link #toJson(Object)}.
|
||||
*******************************************************************************/
|
||||
public final class RenderShapes
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(RenderShapes.class);
|
||||
|
||||
/** what the user sees instead of a result that cannot be shown */
|
||||
public static final String UNDISPLAYABLE_TEXT = "This result could not be displayed.";
|
||||
|
||||
private static final Pattern SAFE_TYPE = Pattern.compile("[a-z][a-z0-9_.-]{0,63}");
|
||||
private static final Set<String> NOTICE_LEVELS = Set.of("info", "warning", "error");
|
||||
private static final Set<String> ITEM_BLOCKS = Set.of("metrics", "filterLinks", "recordLinks", "actions");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** not instantiable
|
||||
*******************************************************************************/
|
||||
private RenderShapes()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** JSON that keeps empty lists (nulls are still left out).
|
||||
*******************************************************************************/
|
||||
public static String toJson(Object value)
|
||||
{
|
||||
return (JsonUtils.toJsonCustomized(value, builder -> builder.serializationInclusion(JsonInclude.Include.NON_NULL)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The notice part shown in place of a part or render that cannot be shown.
|
||||
*******************************************************************************/
|
||||
public static Map<String, Object> undisplayableNotice()
|
||||
{
|
||||
Map<String, Object> notice = new LinkedHashMap<>();
|
||||
notice.put("type", "notice");
|
||||
notice.put("level", "error");
|
||||
notice.put("text", UNDISPLAYABLE_TEXT);
|
||||
return (notice);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Normalized copies of message parts (never throws; never null). A part
|
||||
** that is not an object, or has no type, becomes an error notice; a tool
|
||||
** part whose render cannot be repaired loses its render and is followed by
|
||||
** an error notice; unknown part types pass through.
|
||||
*******************************************************************************/
|
||||
public static List<Map<String, Object>> parts(Object parts)
|
||||
{
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
if(!(parts instanceof List<?> list))
|
||||
{
|
||||
return (result);
|
||||
}
|
||||
for(Object entry : list)
|
||||
{
|
||||
try
|
||||
{
|
||||
addPart(result, entry);
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
problem(entry instanceof Map<?, ?> map ? map.get("type") : null, "part-exception");
|
||||
result.add(undisplayableNotice());
|
||||
}
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Normalize one part into the result.
|
||||
*******************************************************************************/
|
||||
private static void addPart(List<Map<String, Object>> result, Object entry)
|
||||
{
|
||||
if(!(entry instanceof Map<?, ?> map) || !(map.get("type") instanceof String type))
|
||||
{
|
||||
problem(null, "part-without-type");
|
||||
result.add(undisplayableNotice());
|
||||
return;
|
||||
}
|
||||
Map<String, Object> part = copy(map);
|
||||
switch(type)
|
||||
{
|
||||
case "text" -> part.put("text", string(part.get("text"), ""));
|
||||
case "notice" ->
|
||||
{
|
||||
part.put("level", part.get("level") instanceof String level && NOTICE_LEVELS.contains(level) ? level : "info");
|
||||
part.put("text", string(part.get("text"), ""));
|
||||
}
|
||||
case "navigate" ->
|
||||
{
|
||||
if(!(part.get("href") instanceof String href) || !TurnEngine.isAppHref(href))
|
||||
{
|
||||
problem(type, "navigate-without-app-href");
|
||||
return;
|
||||
}
|
||||
}
|
||||
case "record_refs" -> part.put("refs", objects(part.get("refs")));
|
||||
case "tool" ->
|
||||
{
|
||||
part.put("name", string(part.get("name"), "tool"));
|
||||
part.put("summary", string(part.get("summary"), ""));
|
||||
part.put("outcome", string(part.get("outcome"), "ERROR"));
|
||||
if(part.containsKey("render"))
|
||||
{
|
||||
Map<String, Object> render = render(part.get("render"));
|
||||
if(render == null)
|
||||
{
|
||||
part.remove("render");
|
||||
result.add(part);
|
||||
result.add(undisplayableNotice());
|
||||
return;
|
||||
}
|
||||
part.put("render", render);
|
||||
}
|
||||
}
|
||||
case "presentation" ->
|
||||
{
|
||||
Map<String, Object> presentation = presentation(part);
|
||||
result.add(presentation == null ? undisplayableNotice() : presentation);
|
||||
return;
|
||||
}
|
||||
default ->
|
||||
{
|
||||
//////////////////////////////////////////////////
|
||||
// unknown part types pass through untouched //
|
||||
//////////////////////////////////////////////////
|
||||
}
|
||||
}
|
||||
result.add(part);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A normalized copy of a tool result render, or null when it cannot be
|
||||
** repaired (the problem is logged without values). Unknown render types
|
||||
** pass through as a shallow copy.
|
||||
*******************************************************************************/
|
||||
public static Map<String, Object> render(Object value)
|
||||
{
|
||||
try
|
||||
{
|
||||
if(!(value instanceof Map<?, ?> map) || !(map.get("type") instanceof String type))
|
||||
{
|
||||
problem(null, "render-without-type");
|
||||
return (null);
|
||||
}
|
||||
Map<String, Object> render = copy(map);
|
||||
switch(type)
|
||||
{
|
||||
case "records" ->
|
||||
{
|
||||
tableLabel(render);
|
||||
List<Map<String, Object>> columns = new ArrayList<>();
|
||||
for(Map<String, Object> column : objects(render.get("columns")))
|
||||
{
|
||||
if(column.get("name") instanceof String name)
|
||||
{
|
||||
column.put("label", string(column.get("label"), name));
|
||||
columns.add(column);
|
||||
}
|
||||
}
|
||||
render.put("columns", columns);
|
||||
List<Map<String, Object>> rows = objects(render.get("rows"));
|
||||
for(Map<String, Object> row : rows)
|
||||
{
|
||||
row.put("values", row.get("values") instanceof Map<?, ?> values ? copy(values) : new LinkedHashMap<>());
|
||||
}
|
||||
render.put("rows", rows);
|
||||
render.put("totalCount", number(render.get("totalCount")));
|
||||
render.put("truncated", Boolean.TRUE.equals(render.get("truncated")));
|
||||
}
|
||||
case "record" ->
|
||||
{
|
||||
tableLabel(render);
|
||||
List<Map<String, Object>> fields = new ArrayList<>();
|
||||
for(Map<String, Object> field : objects(render.get("fields")))
|
||||
{
|
||||
if(field.get("name") instanceof String name)
|
||||
{
|
||||
field.put("label", string(field.get("label"), name));
|
||||
Object fieldValue = field.get("value");
|
||||
field.put("value", fieldValue == null || fieldValue instanceof String ? fieldValue : String.valueOf(fieldValue));
|
||||
fields.add(field);
|
||||
}
|
||||
}
|
||||
render.put("fields", fields);
|
||||
}
|
||||
case "count" ->
|
||||
{
|
||||
tableLabel(render);
|
||||
render.put("count", number(render.get("count")));
|
||||
}
|
||||
case "widget" ->
|
||||
{
|
||||
render.put("label", string(render.get("label"), string(render.get("widgetName"), "Widget")));
|
||||
render.put("data", render.get("data") instanceof Map<?, ?> data ? copy(data) : new LinkedHashMap<>());
|
||||
}
|
||||
case "history" ->
|
||||
{
|
||||
List<Map<String, Object>> entries = objects(render.get("entries"));
|
||||
for(Map<String, Object> entry : entries)
|
||||
{
|
||||
entry.put("details", objects(entry.get("details")));
|
||||
}
|
||||
render.put("entries", entries);
|
||||
}
|
||||
case "pending_action" ->
|
||||
{
|
||||
Object actionId = number(render.get("actionId"));
|
||||
if(actionId == null)
|
||||
{
|
||||
problem(type, "pending-action-without-id");
|
||||
return (null);
|
||||
}
|
||||
render.put("actionId", actionId);
|
||||
render.put("summary", string(render.get("summary"), ""));
|
||||
render.put("changes", objects(render.get("changes")));
|
||||
}
|
||||
case "bulk_load_dry_run" -> render.put("errors", objects(render.get("errors")));
|
||||
default ->
|
||||
{
|
||||
//////////////////////////////////////////////////
|
||||
// unknown render types pass through untouched //
|
||||
//////////////////////////////////////////////////
|
||||
}
|
||||
}
|
||||
return (render);
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
problem(value instanceof Map<?, ?> map ? map.get("type") : null, "render-exception");
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A normalized copy of a presentation part, or null when it has nothing to
|
||||
** show. Blocks that are not objects or have no kind are dropped; table,
|
||||
** chart and item blocks always carry their lists.
|
||||
*******************************************************************************/
|
||||
public static Map<String, Object> presentation(Object value)
|
||||
{
|
||||
try
|
||||
{
|
||||
if(!(value instanceof Map<?, ?> map))
|
||||
{
|
||||
problem("presentation", "presentation-not-object");
|
||||
return (null);
|
||||
}
|
||||
Map<String, Object> part = copy(map);
|
||||
part.put("type", "presentation");
|
||||
part.put("summary", string(part.get("summary"), ""));
|
||||
List<Map<String, Object>> blocks = new ArrayList<>();
|
||||
for(Map<String, Object> block : objects(part.get("blocks")))
|
||||
{
|
||||
if(!(block.get("kind") instanceof String kind))
|
||||
{
|
||||
problem("presentation", "block-without-kind");
|
||||
continue;
|
||||
}
|
||||
if("table".equals(kind))
|
||||
{
|
||||
block.put("title", string(block.get("title"), ""));
|
||||
List<Map<String, Object>> columns = new ArrayList<>();
|
||||
for(Map<String, Object> column : objects(block.get("columns")))
|
||||
{
|
||||
if(column.get("key") instanceof String key)
|
||||
{
|
||||
column.put("label", string(column.get("label"), key));
|
||||
columns.add(column);
|
||||
}
|
||||
}
|
||||
block.put("columns", columns);
|
||||
List<Map<String, Object>> rows = objects(block.get("rows"));
|
||||
for(Map<String, Object> row : rows)
|
||||
{
|
||||
row.put("cells", row.get("cells") instanceof Map<?, ?> cells ? copy(cells) : new LinkedHashMap<>());
|
||||
row.put("display", row.get("display") instanceof Map<?, ?> display ? copy(display) : new LinkedHashMap<>());
|
||||
}
|
||||
block.put("rows", rows);
|
||||
}
|
||||
else if("chart".equals(kind))
|
||||
{
|
||||
block.put("title", string(block.get("title"), ""));
|
||||
block.put("chartType", string(block.get("chartType"), "bar"));
|
||||
block.put("points", objects(block.get("points")));
|
||||
}
|
||||
else if(ITEM_BLOCKS.contains(kind))
|
||||
{
|
||||
block.put("items", objects(block.get("items")));
|
||||
}
|
||||
blocks.add(block);
|
||||
}
|
||||
part.put("blocks", blocks);
|
||||
part.put("markdown", string(part.get("markdown"), (String) part.get("summary")));
|
||||
if(blocks.isEmpty() && ((String) part.get("summary")).isBlank() && ((String) part.get("markdown")).isBlank())
|
||||
{
|
||||
problem("presentation", "presentation-empty");
|
||||
return (null);
|
||||
}
|
||||
return (part);
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
problem("presentation", "presentation-exception");
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Normalize an event's data in place: a tool.result render that cannot be
|
||||
** repaired is removed; a presentation event's part that cannot be shown is
|
||||
** replaced by the error notice part.
|
||||
*******************************************************************************/
|
||||
public static void normalizeEvent(String type, Map<String, Object> data)
|
||||
{
|
||||
if(data == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if(data.containsKey("render"))
|
||||
{
|
||||
Map<String, Object> render = render(data.get("render"));
|
||||
if(render == null)
|
||||
{
|
||||
data.remove("render");
|
||||
}
|
||||
else
|
||||
{
|
||||
data.put("render", render);
|
||||
}
|
||||
}
|
||||
if(TurnEvent.PRESENTATION.equals(type) && data.containsKey("part"))
|
||||
{
|
||||
Map<String, Object> part = presentation(data.get("part"));
|
||||
data.put("part", part == null ? undisplayableNotice() : part);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fill in a single part (in place) - kept for callers of the 1.4.0 helper.
|
||||
** A render that cannot be repaired is removed.
|
||||
*******************************************************************************/
|
||||
public static Map<String, Object> complete(Map<String, Object> part)
|
||||
{
|
||||
if(part != null)
|
||||
{
|
||||
normalizeEvent(null, part);
|
||||
}
|
||||
return (part);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** tableLabel defaults to the table name.
|
||||
*******************************************************************************/
|
||||
private static void tableLabel(Map<String, Object> render)
|
||||
{
|
||||
render.put("tableLabel", string(render.get("tableLabel"), string(render.get("tableName"), "")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Copies of the object entries of a list (others dropped); empty when the
|
||||
** value is not a list.
|
||||
*******************************************************************************/
|
||||
private static List<Map<String, Object>> objects(Object value)
|
||||
{
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
if(value instanceof List<?> list)
|
||||
{
|
||||
for(Object entry : list)
|
||||
{
|
||||
if(entry instanceof Map<?, ?> map)
|
||||
{
|
||||
result.add(copy(map));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A mutable copy with string keys.
|
||||
*******************************************************************************/
|
||||
private static Map<String, Object> copy(Map<?, ?> map)
|
||||
{
|
||||
Map<String, Object> copy = new LinkedHashMap<>();
|
||||
map.forEach((key, value) -> copy.put(String.valueOf(key), value));
|
||||
return (copy);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The value when it is a string; otherwise the default.
|
||||
*******************************************************************************/
|
||||
private static String string(Object value, String defaultValue)
|
||||
{
|
||||
return (value instanceof String s ? s : defaultValue);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A number (numeric strings are converted), or null.
|
||||
*******************************************************************************/
|
||||
private static Number number(Object value)
|
||||
{
|
||||
if(value instanceof Number n)
|
||||
{
|
||||
return (n);
|
||||
}
|
||||
if(value instanceof String s && s.matches("-?\\d{1,18}"))
|
||||
{
|
||||
return (Long.parseLong(s));
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Log a shape problem: the type (when it looks like a type) and a fixed
|
||||
** code, never values.
|
||||
*******************************************************************************/
|
||||
private static void problem(Object type, String code)
|
||||
{
|
||||
String safeType = type instanceof String s && SAFE_TYPE.matcher(s).matches() ? s : null;
|
||||
LOG.warn("Assistant part or render was not displayable", logPair("renderType", safeType), logPair("problem", code));
|
||||
}
|
||||
}
|
||||
+164
-4
@@ -17,7 +17,10 @@
|
||||
package com.kingsrook.qbits.assistant.engine;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
+534
-143
File diff suppressed because it is too large.
Load diff
@@ -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";
|
||||
@@ -66,6 +67,11 @@ public class TurnEvent
|
||||
}
|
||||
});
|
||||
}
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// renders and presentations are checked before they are streamed //
|
||||
// or stored, and again when stored events are replayed //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
RenderShapes.normalizeEvent(type, this.data);
|
||||
}
|
||||
|
||||
|
||||
@@ -109,7 +115,7 @@ public class TurnEvent
|
||||
*******************************************************************************/
|
||||
public String toJson()
|
||||
{
|
||||
return (JsonUtils.toJson(data));
|
||||
return (RenderShapes.toJson(data));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
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.spi.ModelPricing;
|
||||
@@ -145,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));
|
||||
}
|
||||
|
||||
|
||||
+479
@@ -0,0 +1,479 @@
|
||||
/*
|
||||
* 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.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.FutureTask;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
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.ProviderHealth;
|
||||
import com.kingsrook.qbits.assistant.engine.ProviderService;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantBudget;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantModel;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProfile;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProvider;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantRagSource;
|
||||
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.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Central health adapters reuse the existing probes without exposing their text.
|
||||
*******************************************************************************/
|
||||
public final class AssistantHealthChecks
|
||||
{
|
||||
public static final String CLIENT_ERRORS = "assistant.client-errors";
|
||||
public static final String NOT_SET_UP = "Not set up yet; checks are skipped";
|
||||
|
||||
private static final QLogger LOG = QLogger.getLogger(AssistantHealthChecks.class);
|
||||
|
||||
/*******************************************************************************
|
||||
** 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);
|
||||
if(registry.getChecks().stream().noneMatch(existing -> CLIENT_ERRORS.equals(existing.getName())))
|
||||
{
|
||||
registry.withCheck(new HealthCheckMetaData().withName(CLIENT_ERRORS).withLabel("Assistant client errors").withComponent("assistant")
|
||||
.withKind(HealthCheckKind.DEPENDENCY).withCategory(HealthCategory.AI)
|
||||
.withIntervalSeconds(300).withTimeoutMs(10000)
|
||||
.withCheckInstance(input -> bounded(input, () -> clientErrors(AssistantRuntime.of(input.getQInstance())))));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Errors the panel reported in the last hour (POST /assistant/client-errors):
|
||||
** DEGRADED at clientErrorWarnPerHour or more, so administrators see in
|
||||
** System Health that users are hitting broken screens. Only the count.
|
||||
*******************************************************************************/
|
||||
public static HealthCheckOutcome clientErrors(AssistantRuntime runtime) throws QException
|
||||
{
|
||||
if(runtime == null)
|
||||
{
|
||||
return (HealthCheckOutcome.unknown("Assistant not installed"));
|
||||
}
|
||||
int count = runtime.getStore().countClientErrorsSince(runtime.getClock().instant().minusSeconds(3600));
|
||||
Integer threshold = runtime.getConfig().getClientErrorWarnPerHour();
|
||||
String text = count + " client error" + (count == 1 ? "" : "s") + " reported in the last hour";
|
||||
HealthCheckOutcome outcome = threshold != null && count >= threshold ? HealthCheckOutcome.degraded(text) : HealthCheckOutcome.up(text);
|
||||
return (outcome.withDetails(new HealthDetails().withValue("lastHour", count).withValue("warnAt", threshold)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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 that is set up; 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(row.isUsable())
|
||||
{
|
||||
checks.add(definition("assistant.provider." + row.getId(), "LOCAL".equals(runtime.getProviderService().location(row)) ? 60 : 300, 10000,
|
||||
input -> optional(runtime, row.getId(), bounded(input, () -> provider(runtime, row.getId(), input)), false)));
|
||||
}
|
||||
}
|
||||
return (checks);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One definition per enabled model, including models of disabled providers,
|
||||
** but none for a provider that is still being set up.
|
||||
*******************************************************************************/
|
||||
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<>();
|
||||
Set<Integer> settingUp = runtime.getStore().listProviders().stream().filter(AssistantProvider::isSettingUp).map(AssistantProvider::getId).collect(Collectors.toSet());
|
||||
for(AssistantModel row : runtime.getStore().listModels())
|
||||
{
|
||||
if(Boolean.TRUE.equals(row.getEnabled()) && !settingUp.contains(row.getProviderId()))
|
||||
{
|
||||
checks.add(definition("assistant.model." + row.getId(), 21600, 30000,
|
||||
input -> optional(runtime, row.getProviderId(), bounded(input, () -> model(runtime, row.getId(), input)), true)));
|
||||
}
|
||||
}
|
||||
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"));
|
||||
}
|
||||
if(row.isSettingUp())
|
||||
{
|
||||
return (HealthCheckOutcome.up(NOT_SET_UP).withDetails(new HealthDetails().withValue("providerId", id)));
|
||||
}
|
||||
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"));
|
||||
}
|
||||
if(provider.isSettingUp())
|
||||
{
|
||||
return (HealthCheckOutcome.up(NOT_SET_UP).withDetails(new HealthDetails().withValue("modelId", id).withValue("providerId", provider.getId())));
|
||||
}
|
||||
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()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A failed provider (or model) check is DOWN only when the provider matters:
|
||||
** it is the default, or the only route for a configured profile model, budget
|
||||
** downgrade or knowledge source. Any other enabled provider is optional, so its
|
||||
** failure is DEGRADED with a message that says where requests go instead.
|
||||
** Provider failures also carry reachable=false, so the circuit breaker still
|
||||
** refuses the provider. If the routes cannot be read, the outcome stays DOWN.
|
||||
*******************************************************************************/
|
||||
static HealthCheckOutcome optional(AssistantRuntime runtime, Integer providerId, HealthCheckOutcome outcome, boolean model)
|
||||
{
|
||||
if(outcome == null || outcome.getStatus() != HealthStatus.DOWN)
|
||||
{
|
||||
return (outcome);
|
||||
}
|
||||
HealthDetails details = outcome.getDetails() == null ? new HealthDetails() : outcome.getDetails();
|
||||
outcome.withDetails(details);
|
||||
if(!model)
|
||||
{
|
||||
details.withValue(ProviderHealth.DETAIL_REACHABLE, false);
|
||||
}
|
||||
try
|
||||
{
|
||||
AssistantProvider row = providerId == null ? null : runtime.getStore().getProvider(providerId);
|
||||
if(row == null || !row.isUsable())
|
||||
{
|
||||
return (outcome);
|
||||
}
|
||||
String required = requiredBecause(runtime, row);
|
||||
if(required != null)
|
||||
{
|
||||
details.withValue("required", required);
|
||||
return (outcome);
|
||||
}
|
||||
AssistantProvider active = runtime.getProviderService().activeRow();
|
||||
String instead = active == null ? "the other providers" : "'" + active.getName() + "'";
|
||||
String what = model ? "Model connection failed; provider '" + row.getName() + "' is optional" : "Provider '" + row.getName() + "' is unreachable; it is optional";
|
||||
details.withValue("optional", true);
|
||||
return (outcome.withStatus(HealthStatus.DEGRADED).withMessage(what + " (not the default and not the only route), so requests use " + instead));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Could not tell whether an assistant provider is optional; reporting it as down", e);
|
||||
return (outcome);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Why a provider is required (null when it is optional): it is the provider
|
||||
** the assistant uses by default, or one of its enabled models is assigned to
|
||||
** a routing profile, a budget downgrade or a knowledge source and that model
|
||||
** has no fallback on another usable provider.
|
||||
*******************************************************************************/
|
||||
static String requiredBecause(AssistantRuntime runtime, AssistantProvider row) throws QException
|
||||
{
|
||||
AssistantProvider active = runtime.getProviderService().activeRow();
|
||||
if(active == null || Objects.equals(active.getId(), row.getId()))
|
||||
{
|
||||
return ("default provider");
|
||||
}
|
||||
|
||||
Map<Integer, AssistantModel> models = runtime.getStore().listModels().stream().collect(Collectors.toMap(AssistantModel::getId, Function.identity(), (a, b) -> a));
|
||||
Map<Integer, AssistantProvider> providers = runtime.getStore().listProviders().stream().collect(Collectors.toMap(AssistantProvider::getId, Function.identity(), (a, b) -> a));
|
||||
Set<Integer> routed = new HashSet<>();
|
||||
for(AssistantProfile profile : runtime.getStore().query(AssistantProfile.TABLE_NAME, new QQueryFilter(), AssistantProfile.class))
|
||||
{
|
||||
if(!Boolean.FALSE.equals(profile.getEnabled()) && profile.getRoutingMode() != null && !"OFF".equals(profile.getRoutingMode()))
|
||||
{
|
||||
routed.add(profile.getSimpleModelId());
|
||||
routed.add(profile.getComplexModelId());
|
||||
}
|
||||
}
|
||||
for(AssistantBudget budget : runtime.getStore().budgets())
|
||||
{
|
||||
routed.add(budget.getDowngradeModelId());
|
||||
}
|
||||
for(Integer id : routed)
|
||||
{
|
||||
AssistantModel model = id == null ? null : models.get(id);
|
||||
if(onProvider(model, row) && !hasFallback(runtime, model, row, models, providers))
|
||||
{
|
||||
return ("only route for a configured model");
|
||||
}
|
||||
}
|
||||
for(AssistantRagSource source : runtime.getStore().query(AssistantRagSource.TABLE_NAME, new QQueryFilter(), AssistantRagSource.class))
|
||||
{
|
||||
if(!Boolean.FALSE.equals(source.getEnabled()) && source.getEmbeddingModelId() != null && onProvider(models.get(source.getEmbeddingModelId()), row))
|
||||
{
|
||||
return ("only route for a knowledge source");
|
||||
}
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An enabled model of this provider.
|
||||
*******************************************************************************/
|
||||
private static boolean onProvider(AssistantModel model, AssistantProvider row)
|
||||
{
|
||||
return (model != null && Boolean.TRUE.equals(model.getEnabled()) && Objects.equals(model.getProviderId(), row.getId()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The fallback FallbackChain would try: the explicit chain when the model has
|
||||
** one, else (local providers only) another local provider of the same type.
|
||||
** Only enabled models of other usable providers count.
|
||||
*******************************************************************************/
|
||||
private static boolean hasFallback(AssistantRuntime runtime, AssistantModel model, AssistantProvider row, Map<Integer, AssistantModel> models, Map<Integer, AssistantProvider> providers)
|
||||
{
|
||||
if(model.getFallbackModelId() != null)
|
||||
{
|
||||
Set<Integer> seen = new HashSet<>();
|
||||
Integer next = model.getFallbackModelId();
|
||||
while(next != null && seen.add(next))
|
||||
{
|
||||
AssistantModel candidate = models.get(next);
|
||||
if(candidate == null)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
AssistantProvider owner = providers.get(candidate.getProviderId());
|
||||
if(Boolean.TRUE.equals(candidate.getEnabled()) && owner != null && owner.isUsable() && !Objects.equals(owner.getId(), row.getId()))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
next = candidate.getFallbackModelId();
|
||||
}
|
||||
return (false);
|
||||
}
|
||||
if(!"LOCAL".equals(runtime.getProviderService().location(row)))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
return (providers.values().stream().anyMatch(other -> other.isUsable() && !Objects.equals(other.getId(), row.getId())
|
||||
&& "LOCAL".equals(runtime.getProviderService().location(other)) && Objects.equals(other.getProviderType(), row.getProviderType())
|
||||
&& models.values().stream().anyMatch(candidate -> Boolean.TRUE.equals(candidate.getEnabled()) && Objects.equals(candidate.getProviderId(), other.getId()))));
|
||||
}
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* 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";
|
||||
|
||||
public static final String NOT_CHECKED = "Not checked yet";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Human words for a health status; unknown means no check has reported yet.
|
||||
*******************************************************************************/
|
||||
static String wording(HealthStatus status)
|
||||
{
|
||||
return (switch(status)
|
||||
{
|
||||
case UP -> "Healthy";
|
||||
case DEGRADED -> "Degraded";
|
||||
case DOWN -> "Down";
|
||||
case UNKNOWN -> NOT_CHECKED;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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(wording(status))
|
||||
.withCountContext("System Health. Provider checks run every 1 to 5 minutes; model checks every 6 hours")
|
||||
.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
|
||||
{
|
||||
|
||||
+13
-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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -159,44 +159,17 @@ public final class AssistantHomeView
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The status row: is the assistant answering, with what, at what cost.
|
||||
** 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"));
|
||||
@@ -234,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()))
|
||||
@@ -254,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)
|
||||
@@ -502,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").
|
||||
*******************************************************************************/
|
||||
|
||||
+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.");
|
||||
}
|
||||
|
||||
}
|
||||
-126
@@ -1,126 +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.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Applies help/qbit-assistant.yaml (the QQQ help-bundle format) to the
|
||||
** Assistant app and its assistant_* tables: what each table is for and how
|
||||
** its rows get created, shown on the table screen and when it is empty.
|
||||
** STAND-IN(core=#1198): replace with HelpBundles.register once the pinned
|
||||
** QQQ release includes it; the bundle file stays the same.
|
||||
*******************************************************************************/
|
||||
public final class AssistantHelp
|
||||
{
|
||||
public static final String RESOURCE = "/help/qbit-assistant.yaml";
|
||||
public static final String SLOT = "help";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private AssistantHelp()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The bundle's entries by key, validated (fails startup if malformed).
|
||||
*******************************************************************************/
|
||||
public static Map<String, JsonNode> entries() throws QException
|
||||
{
|
||||
try(InputStream stream = AssistantHelp.class.getResourceAsStream(RESOURCE))
|
||||
{
|
||||
if(stream == null)
|
||||
{
|
||||
throw (new IOException("missing resource"));
|
||||
}
|
||||
JsonNode bundle = new ObjectMapper(new YAMLFactory()).readTree(stream);
|
||||
if(!"qbit-assistant".equals(bundle.path("bundle").asText()) || !"QBIT".equals(bundle.path("layer").asText()))
|
||||
{
|
||||
throw (new IOException("invalid bundle header"));
|
||||
}
|
||||
Map<String, JsonNode> entries = new LinkedHashMap<>();
|
||||
for(JsonNode entry : bundle.path("entries"))
|
||||
{
|
||||
String key = entry.path("key").asText();
|
||||
String summary = entry.path("summary").asText();
|
||||
if(key.isBlank() || summary.isBlank() || summary.length() > 120 || entry.path("content").asText().isBlank() || entries.put(key, entry) != null)
|
||||
{
|
||||
throw (new IOException("invalid or duplicate entry: " + key));
|
||||
}
|
||||
}
|
||||
return (entries);
|
||||
}
|
||||
catch(IOException | RuntimeException e)
|
||||
{
|
||||
throw (new QException("Could not load " + RESOURCE, e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply the help to the app and every assistant_* table in the instance.
|
||||
** A host's own help on a table is kept (the bundle only fills the slot).
|
||||
*******************************************************************************/
|
||||
public static void apply(QInstance qInstance) throws QException
|
||||
{
|
||||
Map<String, JsonNode> entries = entries();
|
||||
for(QTableMetaData table : qInstance.getTables().values())
|
||||
{
|
||||
JsonNode entry = entries.get("table:" + table.getName() + ";slot:" + SLOT);
|
||||
if(entry != null && (table.getHelpContent() == null || !table.getHelpContent().containsKey(SLOT)))
|
||||
{
|
||||
table.withHelpContent(SLOT, content(entry));
|
||||
}
|
||||
}
|
||||
QAppMetaData app = qInstance.getApp(AssistantMetaDataBuilder.APP_NAME);
|
||||
JsonNode appEntry = entries.get("app:" + AssistantMetaDataBuilder.APP_NAME + ";slot:" + SLOT);
|
||||
if(app != null && appEntry != null && (app.getHelpContent() == null || !app.getHelpContent().containsKey(SLOT)))
|
||||
{
|
||||
app.withHelpContent(SLOT, content(appEntry));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Summary as the first paragraph, then the content, as markdown.
|
||||
*******************************************************************************/
|
||||
static QHelpContent content(JsonNode entry)
|
||||
{
|
||||
return (new QHelpContent().withContentAsMarkdown(entry.path("summary").asText().strip() + "\n\n" + entry.path("content").asText().strip()));
|
||||
}
|
||||
|
||||
}
|
||||
+7
-2
@@ -20,12 +20,14 @@ 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;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantBudgetEvent;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantBudgetOverride;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantBudgetUsage;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantClientError;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantConversation;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantDataAccess;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantDataAudit;
|
||||
@@ -113,7 +115,7 @@ public class AssistantMetaDataBuilder
|
||||
** calls on a conversation; model calls on a provider) or reached from the
|
||||
** home. Processes are table and record actions, never nav entries.
|
||||
*******************************************************************************/
|
||||
public static final List<String> NAV_TABLES = List.of(AssistantScheduledTask.TABLE_NAME, AssistantRagSource.TABLE_NAME, AssistantBudget.TABLE_NAME, AssistantProvider.TABLE_NAME, AssistantProfile.TABLE_NAME, AssistantSkill.TABLE_NAME, AssistantDataAccess.TABLE_NAME, AssistantConversation.TABLE_NAME, AssistantModelPrice.TABLE_NAME, AssistantPolicy.TABLE_NAME);
|
||||
public static final List<String> NAV_TABLES = List.of(AssistantScheduledTask.TABLE_NAME, AssistantRagSource.TABLE_NAME, AssistantBudget.TABLE_NAME, AssistantProvider.TABLE_NAME, AssistantProfile.TABLE_NAME, AssistantSkill.TABLE_NAME, AssistantDataAccess.TABLE_NAME, AssistantConversation.TABLE_NAME, AssistantClientError.TABLE_NAME, AssistantModelPrice.TABLE_NAME, AssistantPolicy.TABLE_NAME);
|
||||
|
||||
private final AssistantQBitConfig config;
|
||||
|
||||
@@ -178,6 +180,9 @@ public class AssistantMetaDataBuilder
|
||||
|
||||
tables.add(text(readOnly(owned(base(AssistantFeedback.TABLE_NAME, "Assistant Feedback", "thumbs_up_down", AssistantFeedback.class), false)), "comment"));
|
||||
|
||||
tables.add(text(readOnly(owned(base(AssistantClientError.TABLE_NAME, "Assistant Client Error", "bug_report", AssistantClientError.class), false)), "message", "stack", "componentStack")
|
||||
.withRecordLabelFormat("%s #%s").withRecordLabelFields("scope", "id"));
|
||||
|
||||
tables.add(base(AssistantPolicy.TABLE_NAME, "Assistant Policy", "policy", AssistantPolicy.class)
|
||||
.withRecordLabelFormat("%s").withRecordLabelFields("name"));
|
||||
|
||||
@@ -383,7 +388,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);
|
||||
|
||||
+21
-1
@@ -30,6 +30,7 @@ import com.kingsrook.qbits.assistant.engine.ProviderService;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantBudget;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantBudgetEvent;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantBudgetOverride;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantClientError;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantConversation;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantDataAccess;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantDataAudit;
|
||||
@@ -215,6 +216,7 @@ public final class AssistantRecordScreens
|
||||
case AssistantTurnEvent.TABLE_NAME -> turnEvent(table);
|
||||
case AssistantToolCall.TABLE_NAME -> toolCall(table);
|
||||
case AssistantFeedback.TABLE_NAME -> feedback(table);
|
||||
case AssistantClientError.TABLE_NAME -> clientError(table);
|
||||
case AssistantPolicy.TABLE_NAME -> policy(table);
|
||||
case AssistantUsageDaily.TABLE_NAME -> usageDaily(table);
|
||||
case AssistantProvider.TABLE_NAME -> provider(table);
|
||||
@@ -331,6 +333,7 @@ public final class AssistantRecordScreens
|
||||
settingsSource("assistant_price_model", modelValues, true, false),
|
||||
settingsSource("assistant_provider_kind", kinds, false, false),
|
||||
enumSource("assistant_provider_location", List.of(new QPossibleValue<>("LOCAL", "Local"), new QPossibleValue<>("CLOUD", "Cloud"))),
|
||||
enumSource("assistant_provider_setup_state", List.of(new QPossibleValue<>("ACTIVE", "Active"), new QPossibleValue<>("SETTING_UP", "Setting up"))),
|
||||
enumSource("assistant_routing_mode", List.of(new QPossibleValue<>("OFF", "Off"), new QPossibleValue<>("HEURISTIC", "Heuristic"), new QPossibleValue<>("CLASSIFIER", "Classifier"))),
|
||||
enumSource("assistant_model_kind", List.of(new QPossibleValue<>("CHAT", "Chat"), new QPossibleValue<>("EMBEDDING", "Embedding"))),
|
||||
enumSource("assistant_model_source", List.of(new QPossibleValue<>("DISCOVERED", "Discovered"), new QPossibleValue<>("MANUAL", "Manual"))),
|
||||
@@ -394,6 +397,7 @@ public final class AssistantRecordScreens
|
||||
pvs(table, "model", MODEL_PVS_NAME, "Model");
|
||||
pvs(table, "kind", "assistant_provider_kind", "Kind");
|
||||
pvs(table, "location", "assistant_provider_location", "Location");
|
||||
pvs(table, "setupState", "assistant_provider_setup_state", "Setup state");
|
||||
table.getField("hipaaEligible").withLabel("HIPAA-eligible");
|
||||
table.getField("apiVersion").withLabel("API version");
|
||||
table.getField("connectTimeoutSeconds").withLabel("Connect timeout (s)");
|
||||
@@ -412,6 +416,7 @@ public final class AssistantRecordScreens
|
||||
help(table, "providerType", "Which kind of provider this is. Only the types installed on this server are offered.");
|
||||
help(table, "model", "The model answers come from. Empty uses the policy's (or the application's) main model.");
|
||||
help(table, "enabled", "Off: the assistant never uses this provider.");
|
||||
help(table, "setupState", "Setting up: the provider is still being built, so the assistant never routes to it and its health checks do not run. Use Active once it works.");
|
||||
help(table, "isDefault", "The assistant uses the default enabled provider (else the first enabled one). Marking one default unmarks the others.");
|
||||
help(table, "apiKeyEnvName", "The NAME of the environment variable that holds the API key (e.g. ANTHROPIC_API_KEY), never the key itself. "
|
||||
+ "The key stays in the server's secret store; only allowed variable names are accepted. Empty uses the type's default variable. Bedrock and Vertex require this field to be empty and use server credentials. To replace the key, change it in the secret store.");
|
||||
@@ -422,7 +427,7 @@ public final class AssistantRecordScreens
|
||||
help(table, "maxConcurrentRequests", "How many model calls may run at the same time on this server. Empty: no limit.");
|
||||
table.getField(ProviderService.KEY_STATUS_FIELD).withLabel("API Key").withHelpContent(new QHelpContent("Whether the key variable is set on this server, with the key's last four characters. The key itself is never shown or returned."));
|
||||
sections(table,
|
||||
section("provider", "Provider", "settings_input_component", Tier.T1, "id", "name", "providerType", "model", "enabled", "isDefault"),
|
||||
section("provider", "Provider", "settings_input_component", Tier.T1, "id", "name", "providerType", "model", "enabled", "setupState", "isDefault"),
|
||||
section("connection", "Connection", "key", Tier.T2, "apiKeyEnvName", ProviderService.KEY_STATUS_FIELD, "baseUrl", "tlsCaBundle", "cloudProject"),
|
||||
section("limits", "Limits", "speed", Tier.T2, "monthlyCostCapUsd", "maxOutputTokensPerRequest", "maxConcurrentRequests"),
|
||||
section("registry", "Provider settings", "settings", Tier.T2, "kind", "location", "hipaaEligible", "region", "apiVersion", "connectTimeoutSeconds", "requestTimeoutSeconds"),
|
||||
@@ -695,6 +700,21 @@ public final class AssistantRecordScreens
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Errors the panel reported: what and where first, the stacks last.
|
||||
*******************************************************************************/
|
||||
private static void clientError(QTableMetaData table)
|
||||
{
|
||||
link(table, "messageId", AssistantMessage.TABLE_NAME, "Message");
|
||||
link(table, "turnId", AssistantTurn.TABLE_NAME, "Turn");
|
||||
sections(table,
|
||||
section("error", "Error", "bug_report", Tier.T1, "id", "scope", "errorName", "message", "occurredAt"),
|
||||
section("where", "Where", "place", Tier.T2, "route", "partType", "renderType", "appVersion", "requestId", "userId", "conversationId", "messageId", "turnId"),
|
||||
section("stacks", "Stacks", "code", Tier.T2, "stack", "componentStack"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The one table people edit: limits are in tokens or micro-dollars, soft
|
||||
** limits warn and hard limits stop.
|
||||
|
||||
+8
@@ -137,6 +137,14 @@ public class AssistantSettingsCustomizer implements TableCustomizerInterface
|
||||
{
|
||||
record.setValue("location", service.location(provider));
|
||||
}
|
||||
if(merged.getValue("setupState") == null)
|
||||
{
|
||||
record.setValue("setupState", AssistantProvider.SETUP_STATE_ACTIVE);
|
||||
}
|
||||
else if(!List.of(AssistantProvider.SETUP_STATE_ACTIVE, AssistantProvider.SETUP_STATE_SETTING_UP).contains(merged.getValueString("setupState")))
|
||||
{
|
||||
record.addError(new BadInputStatusMessage("Setup state must be Active or Setting up."));
|
||||
}
|
||||
for(String field : List.of("hipaaEligible", "connectTimeoutSeconds", "requestTimeoutSeconds"))
|
||||
{
|
||||
if(merged.getValue(field) == null)
|
||||
|
||||
+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)
|
||||
|
||||
+634
@@ -0,0 +1,634 @@
|
||||
/*
|
||||
* 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.model;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QField;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An error the panel (Next UI) hit while showing the assistant, reported by
|
||||
** the user's browser through POST /assistant/client-errors. Only bounded,
|
||||
** scrubbed fields are kept (no free text beyond them); the ids are kept only
|
||||
** when they are the reporting user's own rows.
|
||||
*******************************************************************************/
|
||||
public class AssistantClientError extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "assistant_client_error";
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@QField(isEditable = false, maxLength = 255)
|
||||
private String userId;
|
||||
|
||||
@QField(isEditable = false, maxLength = 64)
|
||||
private String scope;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private String message;
|
||||
|
||||
@QField(isEditable = false, maxLength = 100)
|
||||
private String errorName;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private String stack;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private String componentStack;
|
||||
|
||||
@QField(isEditable = false, maxLength = 128)
|
||||
private String requestId;
|
||||
|
||||
@QField(isEditable = false, maxLength = 300)
|
||||
private String route;
|
||||
|
||||
@QField(isEditable = false, possibleValueSourceName = AssistantConversation.TABLE_NAME)
|
||||
private Integer conversationId;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Integer messageId;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Integer turnId;
|
||||
|
||||
@QField(isEditable = false, maxLength = 64)
|
||||
private String partType;
|
||||
|
||||
@QField(isEditable = false, maxLength = 64)
|
||||
private String renderType;
|
||||
|
||||
@QField(isEditable = false, maxLength = 64)
|
||||
private String appVersion;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant occurredAt;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Default constructor.
|
||||
*******************************************************************************/
|
||||
public AssistantClientError()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor that populates from a QRecord.
|
||||
*******************************************************************************/
|
||||
public AssistantClientError(QRecord record) throws QException
|
||||
{
|
||||
populateFromQRecord(record);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for id
|
||||
*******************************************************************************/
|
||||
public Integer getId()
|
||||
{
|
||||
return (this.id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for id
|
||||
*******************************************************************************/
|
||||
public void setId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for id
|
||||
*******************************************************************************/
|
||||
public AssistantClientError withId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for userId
|
||||
*******************************************************************************/
|
||||
public String getUserId()
|
||||
{
|
||||
return (this.userId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for userId
|
||||
*******************************************************************************/
|
||||
public void setUserId(String userId)
|
||||
{
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for userId
|
||||
*******************************************************************************/
|
||||
public AssistantClientError withUserId(String userId)
|
||||
{
|
||||
this.userId = userId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for scope
|
||||
*******************************************************************************/
|
||||
public String getScope()
|
||||
{
|
||||
return (this.scope);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for scope
|
||||
*******************************************************************************/
|
||||
public void setScope(String scope)
|
||||
{
|
||||
this.scope = scope;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for scope
|
||||
*******************************************************************************/
|
||||
public AssistantClientError withScope(String scope)
|
||||
{
|
||||
this.scope = scope;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for message
|
||||
*******************************************************************************/
|
||||
public String getMessage()
|
||||
{
|
||||
return (this.message);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for message
|
||||
*******************************************************************************/
|
||||
public void setMessage(String message)
|
||||
{
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for message
|
||||
*******************************************************************************/
|
||||
public AssistantClientError withMessage(String message)
|
||||
{
|
||||
this.message = message;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for errorName
|
||||
*******************************************************************************/
|
||||
public String getErrorName()
|
||||
{
|
||||
return (this.errorName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for errorName
|
||||
*******************************************************************************/
|
||||
public void setErrorName(String errorName)
|
||||
{
|
||||
this.errorName = errorName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for errorName
|
||||
*******************************************************************************/
|
||||
public AssistantClientError withErrorName(String errorName)
|
||||
{
|
||||
this.errorName = errorName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for stack
|
||||
*******************************************************************************/
|
||||
public String getStack()
|
||||
{
|
||||
return (this.stack);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for stack
|
||||
*******************************************************************************/
|
||||
public void setStack(String stack)
|
||||
{
|
||||
this.stack = stack;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for stack
|
||||
*******************************************************************************/
|
||||
public AssistantClientError withStack(String stack)
|
||||
{
|
||||
this.stack = stack;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for componentStack
|
||||
*******************************************************************************/
|
||||
public String getComponentStack()
|
||||
{
|
||||
return (this.componentStack);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for componentStack
|
||||
*******************************************************************************/
|
||||
public void setComponentStack(String componentStack)
|
||||
{
|
||||
this.componentStack = componentStack;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for componentStack
|
||||
*******************************************************************************/
|
||||
public AssistantClientError withComponentStack(String componentStack)
|
||||
{
|
||||
this.componentStack = componentStack;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for requestId
|
||||
*******************************************************************************/
|
||||
public String getRequestId()
|
||||
{
|
||||
return (this.requestId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for requestId
|
||||
*******************************************************************************/
|
||||
public void setRequestId(String requestId)
|
||||
{
|
||||
this.requestId = requestId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for requestId
|
||||
*******************************************************************************/
|
||||
public AssistantClientError withRequestId(String requestId)
|
||||
{
|
||||
this.requestId = requestId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for route
|
||||
*******************************************************************************/
|
||||
public String getRoute()
|
||||
{
|
||||
return (this.route);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for route
|
||||
*******************************************************************************/
|
||||
public void setRoute(String route)
|
||||
{
|
||||
this.route = route;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for route
|
||||
*******************************************************************************/
|
||||
public AssistantClientError withRoute(String route)
|
||||
{
|
||||
this.route = route;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for conversationId
|
||||
*******************************************************************************/
|
||||
public Integer getConversationId()
|
||||
{
|
||||
return (this.conversationId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for conversationId
|
||||
*******************************************************************************/
|
||||
public void setConversationId(Integer conversationId)
|
||||
{
|
||||
this.conversationId = conversationId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for conversationId
|
||||
*******************************************************************************/
|
||||
public AssistantClientError withConversationId(Integer conversationId)
|
||||
{
|
||||
this.conversationId = conversationId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for messageId
|
||||
*******************************************************************************/
|
||||
public Integer getMessageId()
|
||||
{
|
||||
return (this.messageId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for messageId
|
||||
*******************************************************************************/
|
||||
public void setMessageId(Integer messageId)
|
||||
{
|
||||
this.messageId = messageId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for messageId
|
||||
*******************************************************************************/
|
||||
public AssistantClientError withMessageId(Integer messageId)
|
||||
{
|
||||
this.messageId = messageId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for turnId
|
||||
*******************************************************************************/
|
||||
public Integer getTurnId()
|
||||
{
|
||||
return (this.turnId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for turnId
|
||||
*******************************************************************************/
|
||||
public void setTurnId(Integer turnId)
|
||||
{
|
||||
this.turnId = turnId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for turnId
|
||||
*******************************************************************************/
|
||||
public AssistantClientError withTurnId(Integer turnId)
|
||||
{
|
||||
this.turnId = turnId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for partType
|
||||
*******************************************************************************/
|
||||
public String getPartType()
|
||||
{
|
||||
return (this.partType);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for partType
|
||||
*******************************************************************************/
|
||||
public void setPartType(String partType)
|
||||
{
|
||||
this.partType = partType;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for partType
|
||||
*******************************************************************************/
|
||||
public AssistantClientError withPartType(String partType)
|
||||
{
|
||||
this.partType = partType;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for renderType
|
||||
*******************************************************************************/
|
||||
public String getRenderType()
|
||||
{
|
||||
return (this.renderType);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for renderType
|
||||
*******************************************************************************/
|
||||
public void setRenderType(String renderType)
|
||||
{
|
||||
this.renderType = renderType;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for renderType
|
||||
*******************************************************************************/
|
||||
public AssistantClientError withRenderType(String renderType)
|
||||
{
|
||||
this.renderType = renderType;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for appVersion
|
||||
*******************************************************************************/
|
||||
public String getAppVersion()
|
||||
{
|
||||
return (this.appVersion);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for appVersion
|
||||
*******************************************************************************/
|
||||
public void setAppVersion(String appVersion)
|
||||
{
|
||||
this.appVersion = appVersion;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for appVersion
|
||||
*******************************************************************************/
|
||||
public AssistantClientError withAppVersion(String appVersion)
|
||||
{
|
||||
this.appVersion = appVersion;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for occurredAt
|
||||
*******************************************************************************/
|
||||
public Instant getOccurredAt()
|
||||
{
|
||||
return (this.occurredAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for occurredAt
|
||||
*******************************************************************************/
|
||||
public void setOccurredAt(Instant occurredAt)
|
||||
{
|
||||
this.occurredAt = occurredAt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for occurredAt
|
||||
*******************************************************************************/
|
||||
public AssistantClientError withOccurredAt(Instant occurredAt)
|
||||
{
|
||||
this.occurredAt = occurredAt;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for createDate
|
||||
*******************************************************************************/
|
||||
public Instant getCreateDate()
|
||||
{
|
||||
return (this.createDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for createDate
|
||||
*******************************************************************************/
|
||||
public void setCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for createDate
|
||||
*******************************************************************************/
|
||||
public AssistantClientError withCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
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)
|
||||
|
||||
+1
-2
@@ -22,7 +22,6 @@ import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QField;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -53,7 +52,7 @@ public class AssistantFeedback extends QRecordEntity
|
||||
@QField(isEditable = false)
|
||||
private Integer rating;
|
||||
|
||||
@QField(isEditable = false, maxLength = 64, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS)
|
||||
@QField(isEditable = false)
|
||||
private String reason;
|
||||
|
||||
@QField(isEditable = false)
|
||||
|
||||
+3
-4
@@ -22,7 +22,6 @@ import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QField;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -49,7 +48,7 @@ public class AssistantPendingAction extends QRecordEntity
|
||||
@QField(isEditable = false, maxLength = 255)
|
||||
private String userId;
|
||||
|
||||
@QField(isEditable = false, maxLength = 128)
|
||||
@QField(isEditable = false)
|
||||
private String toolCallId;
|
||||
|
||||
@QField(isEditable = false, maxLength = 64)
|
||||
@@ -67,7 +66,7 @@ public class AssistantPendingAction extends QRecordEntity
|
||||
@QField(isEditable = false)
|
||||
private String paramsJson;
|
||||
|
||||
@QField(isEditable = false, maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS)
|
||||
@QField(isEditable = false)
|
||||
private String summary;
|
||||
|
||||
@QField(isEditable = false, maxLength = 16)
|
||||
@@ -76,7 +75,7 @@ public class AssistantPendingAction extends QRecordEntity
|
||||
@QField(isEditable = false)
|
||||
private Integer httpStatus;
|
||||
|
||||
@QField(isEditable = false, maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS)
|
||||
@QField(isEditable = false)
|
||||
private String resultSummary;
|
||||
|
||||
@QField(isEditable = false)
|
||||
|
||||
+57
@@ -71,6 +71,12 @@ public class AssistantProvider extends QRecordEntity
|
||||
|
||||
public static final String TABLE_NAME = "assistant_provider";
|
||||
|
||||
public static final String SETUP_STATE_ACTIVE = "ACTIVE";
|
||||
public static final String SETUP_STATE_SETTING_UP = "SETTING_UP";
|
||||
|
||||
@QField(maxLength = 16)
|
||||
private String setupState;
|
||||
|
||||
@QField(isEditable = false, maxLength = 8)
|
||||
private String healthState;
|
||||
|
||||
@@ -1218,4 +1224,55 @@ public class AssistantProvider extends QRecordEntity
|
||||
this.downUntil = downUntil;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get setupState: ACTIVE (or empty) when in use, SETTING_UP while an admin
|
||||
** is still building it (never routed to, no health checks).
|
||||
*******************************************************************************/
|
||||
public String getSetupState()
|
||||
{
|
||||
return (this.setupState);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set setupState.
|
||||
*******************************************************************************/
|
||||
public void setSetupState(String setupState)
|
||||
{
|
||||
this.setupState = setupState;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** With setupState.
|
||||
*******************************************************************************/
|
||||
public AssistantProvider withSetupState(String setupState)
|
||||
{
|
||||
this.setupState = setupState;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether an admin marked this provider as not set up yet.
|
||||
*******************************************************************************/
|
||||
public boolean isSettingUp()
|
||||
{
|
||||
return (SETUP_STATE_SETTING_UP.equals(setupState));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Enabled and set up: the only rows the assistant routes to or health-checks.
|
||||
*******************************************************************************/
|
||||
public boolean isUsable()
|
||||
{
|
||||
return (Boolean.TRUE.equals(enabled) && !isSettingUp());
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -62,13 +62,13 @@ public class AssistantScheduledRun extends QRecordEntity
|
||||
@QField()
|
||||
private Integer turnId;
|
||||
|
||||
@QField(maxLength = 2000)
|
||||
@QField()
|
||||
private String summary;
|
||||
|
||||
@QField()
|
||||
private Long costMicros;
|
||||
|
||||
@QField(maxLength = 500)
|
||||
@QField()
|
||||
private String error;
|
||||
|
||||
@QField(isEditable = false)
|
||||
|
||||
+2
-2
@@ -55,7 +55,7 @@ public class AssistantToolCall extends QRecordEntity
|
||||
@QField(isEditable = false, maxLength = 255)
|
||||
private String userId;
|
||||
|
||||
@QField(isEditable = false, maxLength = 128)
|
||||
@QField(isEditable = false)
|
||||
private String toolCallId;
|
||||
|
||||
@QField(isEditable = false, maxLength = 64)
|
||||
@@ -85,7 +85,7 @@ public class AssistantToolCall extends QRecordEntity
|
||||
@QField(isEditable = false)
|
||||
private Long durationMs;
|
||||
|
||||
@QField(isEditable = false, maxLength = 500, valueTooLongBehavior = ValueTooLongBehavior.TRUNCATE_ELLIPSIS)
|
||||
@QField(isEditable = false)
|
||||
private String resultSummary;
|
||||
|
||||
@QField(isEditable = false)
|
||||
|
||||
+172
-1
@@ -74,7 +74,7 @@ public class AssistantTurn extends QRecordEntity
|
||||
@QField(isEditable = false, maxLength = 16)
|
||||
private String status;
|
||||
|
||||
@QField(isEditable = false, maxLength = 64)
|
||||
@QField(isEditable = false)
|
||||
private String stopReason;
|
||||
|
||||
@QField(isEditable = false, maxLength = 32)
|
||||
@@ -92,6 +92,21 @@ public class AssistantTurn extends QRecordEntity
|
||||
@QField(isEditable = false)
|
||||
private Instant endedAt;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant heartbeatAt;
|
||||
|
||||
@QField(isEditable = false, maxLength = 100)
|
||||
private String leaseOwner;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant cancelRequestedAt;
|
||||
|
||||
@QField(isEditable = false, maxLength = 64)
|
||||
private String phase;
|
||||
|
||||
@QField(isEditable = false, maxLength = 16)
|
||||
private String concurrencyMode;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Integer toolCallCount;
|
||||
|
||||
@@ -1011,4 +1026,160 @@ public class AssistantTurn extends QRecordEntity
|
||||
this.providerId = providerId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for heartbeatAt
|
||||
*******************************************************************************/
|
||||
public Instant getHeartbeatAt()
|
||||
{
|
||||
return (this.heartbeatAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for heartbeatAt
|
||||
*******************************************************************************/
|
||||
public void setHeartbeatAt(Instant heartbeatAt)
|
||||
{
|
||||
this.heartbeatAt = heartbeatAt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for heartbeatAt
|
||||
*******************************************************************************/
|
||||
public AssistantTurn withHeartbeatAt(Instant heartbeatAt)
|
||||
{
|
||||
this.heartbeatAt = heartbeatAt;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for leaseOwner
|
||||
*******************************************************************************/
|
||||
public String getLeaseOwner()
|
||||
{
|
||||
return (this.leaseOwner);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for leaseOwner
|
||||
*******************************************************************************/
|
||||
public void setLeaseOwner(String leaseOwner)
|
||||
{
|
||||
this.leaseOwner = leaseOwner;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for leaseOwner
|
||||
*******************************************************************************/
|
||||
public AssistantTurn withLeaseOwner(String leaseOwner)
|
||||
{
|
||||
this.leaseOwner = leaseOwner;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for cancelRequestedAt
|
||||
*******************************************************************************/
|
||||
public Instant getCancelRequestedAt()
|
||||
{
|
||||
return (this.cancelRequestedAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for cancelRequestedAt
|
||||
*******************************************************************************/
|
||||
public void setCancelRequestedAt(Instant cancelRequestedAt)
|
||||
{
|
||||
this.cancelRequestedAt = cancelRequestedAt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for cancelRequestedAt
|
||||
*******************************************************************************/
|
||||
public AssistantTurn withCancelRequestedAt(Instant cancelRequestedAt)
|
||||
{
|
||||
this.cancelRequestedAt = cancelRequestedAt;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for phase
|
||||
*******************************************************************************/
|
||||
public String getPhase()
|
||||
{
|
||||
return (this.phase);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for phase
|
||||
*******************************************************************************/
|
||||
public void setPhase(String phase)
|
||||
{
|
||||
this.phase = phase;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for phase
|
||||
*******************************************************************************/
|
||||
public AssistantTurn withPhase(String phase)
|
||||
{
|
||||
this.phase = phase;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for concurrencyMode
|
||||
*******************************************************************************/
|
||||
public String getConcurrencyMode()
|
||||
{
|
||||
return (this.concurrencyMode);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for concurrencyMode
|
||||
*******************************************************************************/
|
||||
public void setConcurrencyMode(String concurrencyMode)
|
||||
{
|
||||
this.concurrencyMode = concurrencyMode;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for concurrencyMode
|
||||
*******************************************************************************/
|
||||
public AssistantTurn withConcurrencyMode(String concurrencyMode)
|
||||
{
|
||||
this.concurrencyMode = concurrencyMode;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
+1
-1
@@ -84,7 +84,7 @@ public class AssistantUsage extends QRecordEntity
|
||||
@QField(isEditable = false)
|
||||
private Long costMicros;
|
||||
|
||||
@QField(isEditable = false, maxLength = 100)
|
||||
@QField(isEditable = false)
|
||||
private String requestId;
|
||||
|
||||
@QField(isEditable = false)
|
||||
|
||||
+159
-3
@@ -31,10 +31,12 @@ import com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
import com.kingsrook.qbits.assistant.AssistantRuntime;
|
||||
import com.kingsrook.qbits.assistant.classification.Classifications;
|
||||
import com.kingsrook.qbits.assistant.engine.TurnClassification;
|
||||
import com.kingsrook.qbits.assistant.engine.TurnEvent;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantBudget;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantBudgetEvent;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantBudgetOverride;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantBudgetUsage;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantClientError;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantConversation;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantDataAudit;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantFeedback;
|
||||
@@ -53,6 +55,7 @@ import com.kingsrook.qbits.assistant.model.AssistantUsage;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantUsageDaily;
|
||||
import com.kingsrook.qbits.assistant.scheduled.ScheduledTasks;
|
||||
import com.kingsrook.qbits.assistant.spi.ModelUsage;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
@@ -60,6 +63,7 @@ import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.ActionFlag;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput;
|
||||
@@ -161,7 +165,13 @@ public class AssistantStore
|
||||
{
|
||||
projectBudgetUsage(record);
|
||||
}
|
||||
return ((T) QRecordEntity.fromQRecord(entity.getClass(), cryptRecord(entity.tableName(), record, false)));
|
||||
rememberTranscript(entity.tableName(), entity.toQRecord(), record.getValue("id"));
|
||||
QRecord returned = cryptRecord(entity.tableName(), record, false);
|
||||
if(TranscriptRedactor.current() != null)
|
||||
{
|
||||
TranscriptRedactor.current().restore(returned, entity.toQRecord());
|
||||
}
|
||||
return ((T) QRecordEntity.fromQRecord(entity.getClass(), returned));
|
||||
}
|
||||
|
||||
|
||||
@@ -172,6 +182,7 @@ public class AssistantStore
|
||||
public void update(QRecordEntity entity) throws QException
|
||||
{
|
||||
requireOwnTable(entity.tableName());
|
||||
rememberTranscript(entity.tableName(), entity.toQRecord(), entity.toQRecord().getValue("id"));
|
||||
UpdateOutput output = new UpdateAction().execute(new UpdateInput(entity.tableName()).withRecord(cryptRecord(entity.tableName(), entity.toQRecord(), true)));
|
||||
QRecord record = output.getRecords().get(0);
|
||||
if(CollectionUtils.nullSafeHasContents(record.getErrors()))
|
||||
@@ -188,6 +199,7 @@ public class AssistantStore
|
||||
public void updateFields(String tableName, QRecord record) throws QException
|
||||
{
|
||||
requireOwnTable(tableName);
|
||||
rememberTranscript(tableName, record, record.getValue("id"));
|
||||
UpdateOutput output = new UpdateAction().execute(new UpdateInput(tableName).withRecord(cryptRecord(tableName, record, true)));
|
||||
QRecord result = output.getRecords().get(0);
|
||||
if(CollectionUtils.nullSafeHasContents(result.getErrors()))
|
||||
@@ -384,6 +396,27 @@ public class AssistantStore
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Re-read before a lifecycle update. A completed row is never reopened by
|
||||
** a late local Stop or overwritten by a second local finalizer. Every read
|
||||
** and update remains under the caller's own session and owner filter.
|
||||
*******************************************************************************/
|
||||
public boolean updateRunningTurn(String userId, QRecord changes) throws QException
|
||||
{
|
||||
synchronized(QContext.getQInstance())
|
||||
{
|
||||
AssistantTurn current = getTurn(userId, changes.getValueInteger("id"));
|
||||
if(current == null || !("RUNNING".equals(current.getStatus()) || "CANCELLING".equals(current.getStatus())))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
updateFields(AssistantTurn.TABLE_NAME, changes);
|
||||
return (true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One of the user's own pending actions, or null.
|
||||
*******************************************************************************/
|
||||
@@ -407,10 +440,72 @@ public class AssistantStore
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Runs of any user (that the running session can see) still RUNNING or
|
||||
** CANCELLING although they started before a time, by id after afterId
|
||||
** (the retention sweep of abandoned runs).
|
||||
*******************************************************************************/
|
||||
public List<AssistantTurn> listRunningTurnsStartedBefore(Instant cutoff, Integer afterId, int limit) throws QException
|
||||
{
|
||||
return (query(AssistantTurn.TABLE_NAME, new QQueryFilter()
|
||||
.withCriteria("status", QCriteriaOperator.IN, List.of("RUNNING", "CANCELLING"))
|
||||
.withCriteria("startedAt", QCriteriaOperator.LESS_THAN, cutoff)
|
||||
.withCriteria("id", QCriteriaOperator.GREATER_THAN, afterId == null ? 0 : afterId)
|
||||
.withOrderBy(new QFilterOrderBy("id", true))
|
||||
.withLimit(limit), AssistantTurn.class));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Persist one streamed event.
|
||||
*******************************************************************************/
|
||||
public void insertEvent(Integer turnId, String userId, int seq, String eventType, String dataJson) throws QException
|
||||
{
|
||||
if(TurnEvent.MESSAGE_END.equals(eventType) || TurnEvent.ERROR.equals(eventType))
|
||||
{
|
||||
// Serialize memory-backend callers. The Postgres partial unique index
|
||||
// arbitrates this same race between independent replicas.
|
||||
synchronized(QContext.getQInstance())
|
||||
{
|
||||
if(hasTerminalEvent(userId, turnId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
insertStreamEvent(turnId, userId, seq, eventType, dataJson);
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
if(!hasTerminalEvent(userId, turnId))
|
||||
{
|
||||
throw (e);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
insertStreamEvent(turnId, userId, seq, eventType, dataJson);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether a terminal replay frame has already been persisted by any owner.
|
||||
*******************************************************************************/
|
||||
private boolean hasTerminalEvent(String userId, Integer turnId) throws QException
|
||||
{
|
||||
return (!query(AssistantTurnEvent.TABLE_NAME, owned(userId).withCriteria("turnId", QCriteriaOperator.EQUALS, turnId)
|
||||
.withCriteria("eventType", QCriteriaOperator.EQUALS, TurnEvent.MESSAGE_END).withLimit(1), AssistantTurnEvent.class).isEmpty());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Persist one frame through the normal encrypted, user-owned store path.
|
||||
*******************************************************************************/
|
||||
private void insertStreamEvent(Integer turnId, String userId, int seq, String eventType, String dataJson) throws QException
|
||||
{
|
||||
insert(new AssistantTurnEvent()
|
||||
.withTurnId(turnId)
|
||||
@@ -651,6 +746,39 @@ public class AssistantStore
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** How many client error reports (that the running session can see) were
|
||||
** stored since a time.
|
||||
*******************************************************************************/
|
||||
public int countClientErrorsSince(Instant since) throws QException
|
||||
{
|
||||
Integer count = new CountAction().execute(new CountInput(AssistantClientError.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter().withCriteria("createDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, since))).getCount();
|
||||
return (count == null ? 0 : count);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete client error reports stored before a time; returns how many.
|
||||
*******************************************************************************/
|
||||
public int deleteClientErrorsBefore(Instant cutoff, int limit) throws QException
|
||||
{
|
||||
List<AssistantClientError> errors = query(AssistantClientError.TABLE_NAME, new QQueryFilter()
|
||||
.withCriteria("createDate", QCriteriaOperator.LESS_THAN, cutoff)
|
||||
.withLimit(limit), AssistantClientError.class);
|
||||
if(errors.isEmpty())
|
||||
{
|
||||
return (0);
|
||||
}
|
||||
List<Serializable> ids = new ArrayList<>();
|
||||
errors.forEach(e -> ids.add(e.getId()));
|
||||
new DeleteAction().execute(new DeleteInput(AssistantClientError.TABLE_NAME).withPrimaryKeys(ids));
|
||||
return (errors.size());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A page of assistant messages created before a time, by id after afterId.
|
||||
*******************************************************************************/
|
||||
@@ -739,11 +867,22 @@ public class AssistantStore
|
||||
** The cost (micro-dollars) of a provider's calls since a time.
|
||||
*******************************************************************************/
|
||||
public long sumUsageCostSince(Integer providerId, Instant since) throws QException
|
||||
{
|
||||
return (sumUsageCostSince(providerId, since, false));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Automated health uses the existing private accounting projection so a cap
|
||||
** includes all users, without reading their owner-locked usage records.
|
||||
*******************************************************************************/
|
||||
public long sumUsageCostSince(Integer providerId, Instant since, Boolean sharedAccounting) throws QException
|
||||
{
|
||||
long total = 0;
|
||||
for(QRecord record : queryRecords(AssistantUsage.TABLE_NAME, new QQueryFilter()
|
||||
for(QRecord record : queryRecords(sharedAccounting ? AssistantBudgetUsage.TABLE_NAME : AssistantUsage.TABLE_NAME, new QQueryFilter()
|
||||
.withCriteria("providerId", QCriteriaOperator.EQUALS, providerId)
|
||||
.withCriteria("createDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, since), MAX_SUM_ROWS))
|
||||
.withCriteria(sharedAccounting ? "occurredAt" : "createDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, since), MAX_SUM_ROWS))
|
||||
{
|
||||
Long cost = record.getValueLong("costMicros");
|
||||
total += cost == null ? 0 : cost;
|
||||
@@ -832,6 +971,10 @@ public class AssistantStore
|
||||
}
|
||||
QRecord copy = new QRecord();
|
||||
copy.setValues(new LinkedHashMap<>(record.getValues()));
|
||||
if(encrypt && TranscriptRedactor.current() != null)
|
||||
{
|
||||
copy = TranscriptRedactor.current().prepare(copy);
|
||||
}
|
||||
for(String field : ENCRYPTED_FIELDS.getOrDefault(tableName, List.of()))
|
||||
{
|
||||
if(copy.getValue(field) instanceof String text)
|
||||
@@ -844,6 +987,19 @@ public class AssistantStore
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stage transcript payloads until all classified values of the turn are known.
|
||||
*******************************************************************************/
|
||||
private void rememberTranscript(String table, QRecord record, Serializable id)
|
||||
{
|
||||
if(TranscriptRedactor.current() != null)
|
||||
{
|
||||
TranscriptRedactor.current().saved(table, record, id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Retention candidates are paged independently of deletion, so retained PHI cannot starve other rows.
|
||||
*******************************************************************************/
|
||||
|
||||
+495
@@ -0,0 +1,495 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.persistence;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
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 java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import com.kingsrook.qbits.assistant.classification.ClassificationSource;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.ClassifiedValueTokens;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.LogRedactorInterface;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
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.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Turn-local storage egress protection. Values and provisional payloads live only
|
||||
** in memory; QQQ actions receive placeholders until the complete turn is known.
|
||||
** The log hook is global while active, covering provider and tool worker threads.
|
||||
*******************************************************************************/
|
||||
public class TranscriptRedactor implements LogRedactorInterface, AutoCloseable
|
||||
{
|
||||
private static final ThreadLocal<TranscriptRedactor> CURRENT = new ThreadLocal<>();
|
||||
private static final Set<String> PAYLOAD_FIELDS = Set.of("partsJson", "inputJson", "resultJson", "redactionsJson", "dataJson", "paramsJson",
|
||||
"summary", "resultSummary", "reason", "title", "contextJson", "errorMessage", "errorText", "error", "requestId", "comment", "providerRequestId", "providerRequestIds", "auditHref", "toolCallId", "stopReason");
|
||||
private final ClassificationSource classifications;
|
||||
private final QInstance instance;
|
||||
private final Map<String, String> replacements = new LinkedHashMap<>();
|
||||
private final Map<List<String>, String> recordTokens = new LinkedHashMap<>();
|
||||
private final Map<List<String>, QRecord> pending = new LinkedHashMap<>();
|
||||
private Boolean flushing = false;
|
||||
private volatile Boolean closed = false;
|
||||
private volatile Boolean released = false;
|
||||
private Integer workers = 0;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Register only for this turn; close releases both values and the log hook.
|
||||
*******************************************************************************/
|
||||
public TranscriptRedactor(QInstance instance, ClassificationSource classifications)
|
||||
{
|
||||
this.instance = instance;
|
||||
this.classifications = classifications;
|
||||
QLogger.registerRedactor(this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit propagation works for pooled tool executors, without inheritable state.
|
||||
*******************************************************************************/
|
||||
public void attach()
|
||||
{
|
||||
CURRENT.set(this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Remove only the thread binding, retaining the turn's in-memory state.
|
||||
*******************************************************************************/
|
||||
public static void detach()
|
||||
{
|
||||
CURRENT.remove();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Storage and tools share the currently bound turn, if any.
|
||||
*******************************************************************************/
|
||||
public static TranscriptRedactor current()
|
||||
{
|
||||
return (CURRENT.get());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Capture the raw canonical value and the actual displayed/truncated variant.
|
||||
** Tokens remain bound to the original record field, including equal values.
|
||||
*******************************************************************************/
|
||||
public void observe(String tableName, String fieldName, Serializable primaryKey, Object value, Object displayed)
|
||||
{
|
||||
QTableMetaData table = instance.getTable(tableName);
|
||||
if(released || value == null || table == null || table.getField(fieldName) == null
|
||||
|| !classifications.ofField(tableName, fieldName).isAtLeast(DataClassification.PII))
|
||||
{
|
||||
return;
|
||||
}
|
||||
Serializable original = value instanceof Serializable serializable ? serializable : String.valueOf(value);
|
||||
String token = primaryKey == null
|
||||
? ClassifiedValueTokens.forContext(List.of("assistant", tableName, fieldName), original)
|
||||
: ClassifiedValueTokens.forRecordField(table, table.getField(fieldName), primaryKey, original);
|
||||
synchronized(this)
|
||||
{
|
||||
if(released)
|
||||
{
|
||||
return;
|
||||
}
|
||||
recordTokens.put(List.of(tableName, fieldName, String.valueOf(primaryKey), String.valueOf(displayed)), token);
|
||||
remember(ClassifiedValueTokens.canonical(original), token);
|
||||
remember(String.valueOf(value), token);
|
||||
if(displayed != null)
|
||||
{
|
||||
remember(String.valueOf(displayed), token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A snippet is another spelling of an already observed field, not a new value.
|
||||
*******************************************************************************/
|
||||
public synchronized void variant(String table, String field, String primaryKey, String text)
|
||||
{
|
||||
if(!closed && text != null && table != null && field != null && primaryKey != null)
|
||||
{
|
||||
recordTokens.entrySet().stream().filter(entry -> entry.getKey().subList(0, 3).equals(List.of(table, field, primaryKey)))
|
||||
.findFirst().ifPresent(entry -> remember(text, entry.getValue()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Also protect JSON-escaped and URL-encoded occurrences in envelopes and links.
|
||||
*******************************************************************************/
|
||||
private void remember(String value, String token)
|
||||
{
|
||||
if(value == null || value.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
replacements.putIfAbsent(value, token);
|
||||
String json = JsonUtils.toJson(value);
|
||||
replacements.putIfAbsent(json.substring(1, json.length() - 1), token);
|
||||
replacements.putIfAbsent(URLEncoder.encode(value, StandardCharsets.UTF_8), token);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Existing tool summaries may clip a value before storage. Remember that exact
|
||||
** prefix as another variant while keeping the live/model summary unchanged.
|
||||
*******************************************************************************/
|
||||
public synchronized void truncated(String original, String prefix)
|
||||
{
|
||||
for(var entry : new ArrayList<>(replacements.entrySet()))
|
||||
{
|
||||
int start = original.indexOf(entry.getKey());
|
||||
while(start >= 0)
|
||||
{
|
||||
if(start < prefix.length() && start + entry.getKey().length() > prefix.length())
|
||||
{
|
||||
remember(prefix.substring(start), entry.getValue());
|
||||
}
|
||||
start = original.indexOf(entry.getKey(), start + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Criteria and proposed values are classified even when a query returns no rows.
|
||||
*******************************************************************************/
|
||||
public void input(Map<String, Object> input)
|
||||
{
|
||||
if(input != null && input.get("table") instanceof String table)
|
||||
{
|
||||
criteria(table, input.get("filter"));
|
||||
if(input.get("values") instanceof Map<?, ?> values)
|
||||
{
|
||||
values.forEach((field, value) -> observe(table, String.valueOf(field), null, value, value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Traverse both model and QQQ filter spellings, including nested boolean groups.
|
||||
*******************************************************************************/
|
||||
private void criteria(String table, Object node)
|
||||
{
|
||||
if(node instanceof Map<?, ?> map)
|
||||
{
|
||||
Object field = map.containsKey("field") ? map.get("field") : map.get("fieldName");
|
||||
if(field instanceof String name && map.get("values") instanceof List<?> values)
|
||||
{
|
||||
values.forEach(value -> observe(table, name, null, value, value));
|
||||
}
|
||||
map.values().forEach(value -> criteria(table, value));
|
||||
}
|
||||
else if(node instanceof List<?> list)
|
||||
{
|
||||
list.forEach(value -> criteria(table, value));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Longest exact match wins; a single pass never redacts a generated token again.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public synchronized String redact(String text)
|
||||
{
|
||||
if(text == null || replacements.isEmpty())
|
||||
{
|
||||
return (text);
|
||||
}
|
||||
List<String> values = new ArrayList<>(replacements.keySet());
|
||||
values.sort(Comparator.comparingInt(String::length).reversed());
|
||||
Pattern pattern = Pattern.compile("(?:hmac|shred):v1:[A-Za-z0-9._-]+:[A-Za-z0-9_-]+|changed:v1|" + String.join("|", values.stream().map(Pattern::quote).toList()));
|
||||
Matcher matcher = pattern.matcher(text);
|
||||
return (matcher.replaceAll(match -> Matcher.quoteReplacement(replacements.getOrDefault(match.group(), match.group()))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Redact JSON leaves without damaging property names or numeric syntax. Enveloped
|
||||
** tool data retains its framing. Invalid/opaque content is still exact-redacted.
|
||||
*******************************************************************************/
|
||||
public String payload(String text)
|
||||
{
|
||||
if(text == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
int start = text.indexOf('{');
|
||||
int end = text.lastIndexOf('}');
|
||||
try
|
||||
{
|
||||
if(text.stripLeading().startsWith("["))
|
||||
{
|
||||
return (JsonUtils.toJson(tree(JsonUtils.toObject(text, Object.class), null, null)));
|
||||
}
|
||||
if(start >= 0 && end >= start)
|
||||
{
|
||||
Object json = JsonUtils.toObject(text.substring(start, end + 1), Object.class);
|
||||
return (redact(text.substring(0, start)) + JsonUtils.toJson(tree(json, null, null)) + redact(text.substring(end + 1)));
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
return (redact(text));
|
||||
}
|
||||
return (redact(text));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve record bindings in structured query/get results and render copies.
|
||||
*******************************************************************************/
|
||||
private synchronized Object tree(Object node, String table, String primaryKey)
|
||||
{
|
||||
if(node instanceof Map<?, ?> map)
|
||||
{
|
||||
Object namedTable = map.containsKey("table") ? map.get("table") : map.get("tableName");
|
||||
String resolvedTable = namedTable instanceof String name ? name : table;
|
||||
Object id = map.containsKey("id") ? map.get("id") : map.get("recordId");
|
||||
String resolvedKey = id == null ? primaryKey : String.valueOf(id);
|
||||
Map<String, Object> copy = new LinkedHashMap<>();
|
||||
map.forEach((key, value) ->
|
||||
{
|
||||
String token = recordTokens.get(List.of(String.valueOf(resolvedTable), String.valueOf(key), String.valueOf(resolvedKey), String.valueOf(value)));
|
||||
copy.put(redact(String.valueOf(key)), token == null ? tree(value, resolvedTable, resolvedKey) : token);
|
||||
});
|
||||
return (copy);
|
||||
}
|
||||
if(node instanceof List<?> list)
|
||||
{
|
||||
return (list.stream().map(value -> tree(value, table, primaryKey)).toList());
|
||||
}
|
||||
if(node instanceof String text)
|
||||
{
|
||||
return (redact(text));
|
||||
}
|
||||
if(node instanceof Number && replacements.containsKey(String.valueOf(node)))
|
||||
{
|
||||
return (replacements.get(String.valueOf(node)));
|
||||
}
|
||||
return (node);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Never put provisional content in a backend, its audits, or its SQL logs.
|
||||
*******************************************************************************/
|
||||
public synchronized QRecord prepare(QRecord original)
|
||||
{
|
||||
QRecord copy = new QRecord();
|
||||
copy.setValues(new LinkedHashMap<>(original.getValues()));
|
||||
for(String field : PAYLOAD_FIELDS)
|
||||
{
|
||||
if(copy.getValue(field) instanceof String text)
|
||||
{
|
||||
copy.setValue(field, flushing && !closed ? stored(field, text) : (field.endsWith("Json") ? (field.equals("partsJson") ? "[]" : "{}") : ClassifiedValueTokens.changed()));
|
||||
}
|
||||
}
|
||||
if(!flushing && copy.getValues().containsKey("paramsJson") && !AssistantPendingAction.STATUS_REJECTED.equals(copy.getValueString("status")))
|
||||
{
|
||||
copy.setValue("status", "PREPARING");
|
||||
}
|
||||
return (copy);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Truncate only after redaction, so a storage bound never cuts a raw value.
|
||||
*******************************************************************************/
|
||||
private String stored(String field, String text)
|
||||
{
|
||||
String safe = payload(text);
|
||||
Integer limit = switch(field)
|
||||
{
|
||||
case "inputJson" -> 4000;
|
||||
case "resultJson" -> 64000;
|
||||
case "title" -> 60;
|
||||
default -> null;
|
||||
};
|
||||
if("title".equals(field))
|
||||
{
|
||||
safe = safe.strip().split("\\R", 2)[0].replaceAll("\\s+", " ").strip();
|
||||
}
|
||||
return (limit != null && safe.length() > limit ? safe.substring(0, limit - 1) + "…" : safe);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Return live content to the caller without losing backend-generated defaults.
|
||||
*******************************************************************************/
|
||||
public void restore(QRecord returned, QRecord original)
|
||||
{
|
||||
for(String field : PAYLOAD_FIELDS)
|
||||
{
|
||||
if(original.getValues().containsKey(field))
|
||||
{
|
||||
returned.setValue(field, original.getValue(field));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Remember only content columns, merging later writes without stale bookkeeping.
|
||||
*******************************************************************************/
|
||||
public synchronized void saved(String table, QRecord original, Serializable id)
|
||||
{
|
||||
if(flushing || closed || PAYLOAD_FIELDS.stream().noneMatch(original.getValues()::containsKey))
|
||||
{
|
||||
return;
|
||||
}
|
||||
QRecord content = pending.computeIfAbsent(List.of(table, String.valueOf(id)), key -> new QRecord().withValue("id", id));
|
||||
for(String field : PAYLOAD_FIELDS)
|
||||
{
|
||||
if(original.getValues().containsKey(field))
|
||||
{
|
||||
content.setValue(field, original.getValue(field));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Flush only redacted copies. A crash/failure leaves value-free placeholders.
|
||||
** Redacted proposals cannot later submit token strings to an application field.
|
||||
*******************************************************************************/
|
||||
public void flush(AssistantStore store) throws QException
|
||||
{
|
||||
Map<List<String>, QRecord> writes;
|
||||
Boolean complete;
|
||||
synchronized(this)
|
||||
{
|
||||
complete = workers == 0;
|
||||
flushing = complete;
|
||||
writes = new LinkedHashMap<>(pending);
|
||||
pending.clear();
|
||||
}
|
||||
for(var entry : writes.entrySet())
|
||||
{
|
||||
QRecord record = entry.getValue();
|
||||
String table = entry.getKey().getFirst();
|
||||
if(AssistantPendingAction.TABLE_NAME.equals(table) && record.getValueString("paramsJson") != null)
|
||||
{
|
||||
Boolean changed = !complete || !record.getValueString("paramsJson").equals(payload(record.getValueString("paramsJson")));
|
||||
record.setValue("status", changed ? AssistantPendingAction.STATUS_REJECTED : AssistantPendingAction.STATUS_PENDING);
|
||||
if(changed)
|
||||
{
|
||||
record.setValue("resultSummary", "Classified values were removed. Make this change in the application.");
|
||||
}
|
||||
}
|
||||
store.updateFields(table, record);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A timed-out tool can finish after the engine; retain its log protection.
|
||||
*******************************************************************************/
|
||||
public synchronized Boolean retainWorker()
|
||||
{
|
||||
if(closed)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
workers++;
|
||||
return (true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Release a completed worker.
|
||||
*******************************************************************************/
|
||||
public synchronized void releaseWorker()
|
||||
{
|
||||
workers--;
|
||||
releaseIfDone();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Clear classified values only after the engine and all tool workers finish.
|
||||
*******************************************************************************/
|
||||
private void releaseIfDone()
|
||||
{
|
||||
if(closed && workers == 0)
|
||||
{
|
||||
released = true;
|
||||
QLogger.unregisterRedactor(this);
|
||||
replacements.clear();
|
||||
recordTokens.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Always release raw data and unregister, including failed/cancelled turns.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public synchronized void close()
|
||||
{
|
||||
closed = true;
|
||||
pending.clear();
|
||||
releaseIfDone();
|
||||
detach();
|
||||
}
|
||||
}
|
||||
+24
-4
@@ -22,6 +22,7 @@ import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
import com.kingsrook.qbits.assistant.AssistantRuntime;
|
||||
import com.kingsrook.qbits.assistant.engine.AbandonedTurns;
|
||||
import com.kingsrook.qbits.assistant.engine.MessageParts;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantConversation;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantMessage;
|
||||
@@ -41,7 +42,10 @@ import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
** - deletes conversations not active for conversationRetentionDays, with
|
||||
** their messages, turns, events, tool calls and feedback;
|
||||
** - nulls assistantToolCall.resultJson older than toolResultRetentionDays
|
||||
** (the audit row stays).
|
||||
** (the audit row stays);
|
||||
** - finalizes abandoned runs (still RUNNING past the hard stream limit, e.g.
|
||||
** after a pod restart nobody looked at) as interrupted;
|
||||
** - deletes client error reports older than toolResultRetentionDays.
|
||||
** It works on the rows the running session can see: run on a schedule (the
|
||||
** scheduler's session) or by an admin holding the owner all-access key.
|
||||
*******************************************************************************/
|
||||
@@ -68,6 +72,8 @@ public class AssistantRetentionStep implements BackendStep
|
||||
output.addValue("toolResultsCleared", result.toolResultsCleared());
|
||||
output.addValue("turnEventsDeleted", result.turnEventsDeleted());
|
||||
output.addValue("messagesStripped", result.messagesStripped());
|
||||
output.addValue("turnsFinalized", result.turnsFinalized());
|
||||
output.addValue("clientErrorsDeleted", result.clientErrorsDeleted());
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +81,7 @@ public class AssistantRetentionStep implements BackendStep
|
||||
/*******************************************************************************
|
||||
** What was purged.
|
||||
*******************************************************************************/
|
||||
public record Result(int conversationsDeleted, int toolResultsCleared, int turnEventsDeleted, int messagesStripped)
|
||||
public record Result(int conversationsDeleted, int toolResultsCleared, int turnEventsDeleted, int messagesStripped, int turnsFinalized, int clientErrorsDeleted)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -160,9 +166,23 @@ public class AssistantRetentionStep implements BackendStep
|
||||
}
|
||||
}
|
||||
|
||||
int turnsFinalized = AbandonedTurns.sweep(config, store, now);
|
||||
|
||||
int clientErrorsDeleted = 0;
|
||||
while(true)
|
||||
{
|
||||
int count = store.deleteClientErrorsBefore(toolResultCutoff, BATCH);
|
||||
clientErrorsDeleted += count;
|
||||
if(count < BATCH)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
LOG.info("Assistant retention ran", logPair("conversationsDeleted", deleted), logPair("toolResultsCleared", cleared),
|
||||
logPair("turnEventsDeleted", eventsDeleted), logPair("messagesStripped", messagesStripped));
|
||||
return (new Result(deleted, cleared, eventsDeleted, messagesStripped));
|
||||
logPair("turnEventsDeleted", eventsDeleted), logPair("messagesStripped", messagesStripped), logPair("turnsFinalized", turnsFinalized),
|
||||
logPair("clientErrorsDeleted", clientErrorsDeleted));
|
||||
return (new Result(deleted, cleared, eventsDeleted, messagesStripped, turnsFinalized, clientErrorsDeleted));
|
||||
}
|
||||
|
||||
}
|
||||
+17
-3
@@ -64,11 +64,21 @@ public class EmbeddingService
|
||||
** including failures, and updates the existing daily budget rollups.
|
||||
*******************************************************************************/
|
||||
public List<float[]> embed(Integer modelId, List<String> texts, DataClassification classification, String tables, Integer conversationId, Integer turnId) throws QException
|
||||
{
|
||||
return (embed(modelId, texts, classification, tables, conversationId, turnId, UsageRecorder.PURPOSE_EMBED));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Connection diagnostics use TEST accounting with the same embedding guards.
|
||||
*******************************************************************************/
|
||||
public List<float[]> embed(Integer modelId, List<String> texts, DataClassification classification, String tables, Integer conversationId, Integer turnId, String purpose) throws QException
|
||||
{
|
||||
List<float[]> vectors = new ArrayList<>();
|
||||
for(Integer offset = 0; offset < texts.size(); offset += 64)
|
||||
{
|
||||
vectors.addAll(batch(modelId, texts.subList(offset, Math.min(offset + 64, texts.size())), classification, tables, conversationId, turnId));
|
||||
vectors.addAll(batch(modelId, texts.subList(offset, Math.min(offset + 64, texts.size())), classification, tables, conversationId, turnId, purpose));
|
||||
}
|
||||
return (vectors);
|
||||
}
|
||||
@@ -104,7 +114,7 @@ public class EmbeddingService
|
||||
/*******************************************************************************
|
||||
** Never downgrade an embedding call into a different vector space.
|
||||
*******************************************************************************/
|
||||
private List<float[]> batch(Integer modelId, List<String> texts, DataClassification classification, String tables, Integer conversationId, Integer turnId) throws QException
|
||||
private List<float[]> batch(Integer modelId, List<String> texts, DataClassification classification, String tables, Integer conversationId, Integer turnId, String purpose) throws QException
|
||||
{
|
||||
String userId = AssistantSecurity.userId(QContext.getQSession());
|
||||
ScheduledTurn.checkCost(runtime, conversationId, turnId);
|
||||
@@ -140,6 +150,10 @@ public class EmbeddingService
|
||||
ModelProviderException error = null;
|
||||
try
|
||||
{
|
||||
if(Thread.currentThread().isInterrupted())
|
||||
{
|
||||
throw (new ModelProviderException(ModelProviderException.Kind.TIMEOUT, null, "Embedding request interrupted."));
|
||||
}
|
||||
EmbeddingResponse embedded = active.provider().embed(new EmbeddingRequest(active.model(), texts));
|
||||
response = new ModelResponse().withModel(active.model()).withRequestId(embedded.requestId()).withUsage(embedded.usage());
|
||||
if(embedded.vectors().size() != texts.size())
|
||||
@@ -160,7 +174,7 @@ public class EmbeddingService
|
||||
gate.release();
|
||||
}
|
||||
var pricing = runtime.getProviderService().pricing(active, active.model());
|
||||
runtime.getStore().insert(UsageRecorder.row(new UsageRecorder.Call(userId, conversationId, turnId, active, UsageRecorder.PURPOSE_EMBED),
|
||||
runtime.getStore().insert(UsageRecorder.row(new UsageRecorder.Call(userId, conversationId, turnId, active, purpose),
|
||||
response, error, (System.nanoTime() - started) / 1_000_000, pricing));
|
||||
var usage = response == null ? (error == null ? null : error.getPartialUsage()) : response.getUsage();
|
||||
if(usage != null)
|
||||
|
||||
@@ -25,6 +25,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.assistant.AssistantRuntime;
|
||||
import com.kingsrook.qbits.assistant.persistence.TranscriptRedactor;
|
||||
import com.kingsrook.qbits.assistant.engine.PolicyService;
|
||||
import com.kingsrook.qbits.assistant.engine.TurnEngine;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantRagSource;
|
||||
@@ -213,6 +214,11 @@ public class KnowledgeTool
|
||||
title = title.substring(0, Math.min(300, title.length()));
|
||||
String snippet = chunks.get(hit.chunkIndex());
|
||||
snippet = snippet.substring(0, Math.min(1500, snippet.length()));
|
||||
if(TranscriptRedactor.current() != null)
|
||||
{
|
||||
TranscriptRedactor.current().variant(hit.table(), hit.field(), hit.pk(), snippet);
|
||||
TranscriptRedactor.current().variant(hit.table(), source.getTitleFieldName(), hit.pk(), title);
|
||||
}
|
||||
records.add(Map.of("table", hit.table(), "recordId", hit.pk(), "title", title, "snippet", snippet, "score", hit.score(), "href", href));
|
||||
result.getRecordRefs().add(new AiRecordRef().withTableName(hit.table()).withId(hit.pk()).withLabel(title).withHref(href));
|
||||
result.getRedactions().addAll(read.getResult().getRedactions());
|
||||
|
||||
@@ -27,6 +27,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import com.kingsrook.qbits.assistant.persistence.TranscriptRedactor;
|
||||
import com.kingsrook.qbits.assistant.access.DataAccessMode;
|
||||
import com.kingsrook.qbits.assistant.access.DataAccessService;
|
||||
import com.kingsrook.qbits.assistant.engine.PolicyService;
|
||||
@@ -222,6 +223,7 @@ public class UiApiToolbox
|
||||
private Integer httpStatus;
|
||||
private String auditHref;
|
||||
private Integer pendingActionId;
|
||||
private boolean failed;
|
||||
|
||||
|
||||
|
||||
@@ -250,6 +252,17 @@ public class UiApiToolbox
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the tool failed unexpectedly (an exception, not a refusal or a
|
||||
** bad input): the user is told a step failed.
|
||||
*******************************************************************************/
|
||||
public boolean isFailed()
|
||||
{
|
||||
return (failed);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for result
|
||||
*******************************************************************************/
|
||||
@@ -482,6 +495,7 @@ public class UiApiToolbox
|
||||
}
|
||||
catch(QException | RuntimeException e)
|
||||
{
|
||||
outcome.failed = true;
|
||||
result.withOutcome(AiToolOutcome.ERROR).withSummary("Failed").withModelContent("The tool failed: " + shortText(e.getMessage()));
|
||||
}
|
||||
finally
|
||||
@@ -1445,7 +1459,12 @@ public class UiApiToolbox
|
||||
{
|
||||
text = AiValueMasker.mask(text, field.getType());
|
||||
}
|
||||
rs.put(name, sanitize(text, context, redactions));
|
||||
String visible = sanitize(text, context, redactions);
|
||||
if(TranscriptRedactor.current() != null)
|
||||
{
|
||||
TranscriptRedactor.current().observe(table.getName(), name, (Serializable) raw.get(table.getPrimaryKeyField()), entry.getValue(), visible);
|
||||
}
|
||||
rs.put(name, visible);
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
@@ -1534,6 +1553,10 @@ public class UiApiToolbox
|
||||
return ("");
|
||||
}
|
||||
String line = text.replaceAll("\\s+", " ").strip();
|
||||
if(line.length() > 300 && TranscriptRedactor.current() != null)
|
||||
{
|
||||
TranscriptRedactor.current().truncated(line, line.substring(0, 299));
|
||||
}
|
||||
return (line.length() <= 300 ? line : line.substring(0, 299) + "…");
|
||||
}
|
||||
|
||||
|
||||
+5
@@ -23,6 +23,7 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.assistant.persistence.TranscriptRedactor;
|
||||
import com.kingsrook.qbits.assistant.access.DataAccessService;
|
||||
import com.kingsrook.qbits.assistant.engine.PolicyService;
|
||||
import com.kingsrook.qbits.assistant.spi.ModelToolDefinition;
|
||||
@@ -823,6 +824,10 @@ public class BulkLoadTools
|
||||
{
|
||||
return ("");
|
||||
}
|
||||
if(text.length() > 300 && TranscriptRedactor.current() != null)
|
||||
{
|
||||
TranscriptRedactor.current().truncated(text, text.substring(0, 297));
|
||||
}
|
||||
return (text.length() > 300 ? text.substring(0, 297) + "..." : text);
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -83,7 +83,7 @@ public class HeaderedUiApiInvoker extends UiApiInvoker
|
||||
{
|
||||
if(!RESERVED_HEADERS.contains(name.toLowerCase(Locale.ROOT)))
|
||||
{
|
||||
builder.header(name, value);
|
||||
builder.setHeader(name, value);
|
||||
}
|
||||
});
|
||||
return (builder.build());
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+31
-3
@@ -345,6 +345,11 @@ class AssistantDefaultsChangelogTest extends BaseAssistantTest
|
||||
{
|
||||
for(String sql : statements(ddl))
|
||||
{
|
||||
if(sql.startsWith("DO $$"))
|
||||
{
|
||||
// the foreign-key block is PL/pgSQL (Postgres only); SchemaForeignKeyPostgresTest runs it on real Postgres
|
||||
continue;
|
||||
}
|
||||
Matcher m = Pattern.compile("^(CREATE TABLE|ALTER TABLE) (assistant_[a-z_]+)\\b(.*)", Pattern.DOTALL).matcher(sql);
|
||||
if(m.find() && DDL_TABLES.contains(m.group(2)) && (m.group(1).startsWith("CREATE") || m.group(3).strip().startsWith("ADD COLUMN")))
|
||||
{
|
||||
@@ -420,7 +425,8 @@ class AssistantDefaultsChangelogTest extends BaseAssistantTest
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SQL statements, without comment lines, split at a ';' that ends a line.
|
||||
** SQL statements, without comment lines, split at a ';' that ends a line;
|
||||
** a DO $$ ... $$ block stays one statement.
|
||||
*******************************************************************************/
|
||||
static List<String> statements(String sql)
|
||||
{
|
||||
@@ -433,14 +439,36 @@ class AssistantDefaultsChangelogTest extends BaseAssistantTest
|
||||
text.append(stripped).append("\n");
|
||||
}
|
||||
}
|
||||
List<String> rs = new ArrayList<>();
|
||||
List<String> rs = new ArrayList<>();
|
||||
StringBuilder block = null;
|
||||
for(String statement : text.toString().split(";\\s*\\n"))
|
||||
{
|
||||
String stripped = statement.strip();
|
||||
if(block == null && stripped.startsWith("DO $$") && !stripped.endsWith("$$"))
|
||||
{
|
||||
// a dollar-quoted block has its own semicolons: keep it whole up to its closing $$
|
||||
block = new StringBuilder(stripped);
|
||||
continue;
|
||||
}
|
||||
if(block != null)
|
||||
{
|
||||
block.append(";\n").append(stripped);
|
||||
if(stripped.endsWith("$$"))
|
||||
{
|
||||
rs.add(block.toString());
|
||||
block = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if(!statement.isBlank())
|
||||
{
|
||||
rs.add(statement.strip().replaceAll(";$", ""));
|
||||
rs.add(stripped.replaceAll(";$", ""));
|
||||
}
|
||||
}
|
||||
if(block != null)
|
||||
{
|
||||
rs.add(block.toString());
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -90,8 +90,8 @@ class AssistantQBitProducerTest extends BaseAssistantTest
|
||||
// the nav: the home plus the main tables; processes are actions //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
assertThat(qInstance.getApp(AssistantMetaDataBuilder.APP_NAME).getChildren()).extracting(c -> c.getName())
|
||||
.containsExactly("assistant_scheduled_task", "assistant_rag_source", "assistant_budget", "assistant_provider", "assistant_profile", "assistant_skill", "assistant_data_access", "assistant_conversation", "assistant_model_price", "assistant_policy");
|
||||
assertThat(qInstance.getApp(AssistantMetaDataBuilder.APP_NAME).getWidgets()).containsExactly("assistantHome", "assistantAccess");
|
||||
.containsExactly("assistant_scheduled_task", "assistant_rag_source", "assistant_budget", "assistant_provider", "assistant_profile", "assistant_skill", "assistant_data_access", "assistant_conversation", "assistant_client_error", "assistant_model_price", "assistant_policy");
|
||||
assertThat(qInstance.getApp(AssistantMetaDataBuilder.APP_NAME).getWidgets()).containsExactly("assistantHealth", "assistantHome", "assistantAccess");
|
||||
assertThat(qInstance.getProcess(AssistantMetaDataBuilder.RETENTION_PROCESS_NAME).getTableName()).isEqualTo("assistant_conversation");
|
||||
assertThat(qInstance.getProcess(AssistantMetaDataBuilder.RETENTION_PROCESS_NAME)).isNotNull();
|
||||
assertThat(qInstance.getProcess(AssistantMetaDataBuilder.RETENTION_PROCESS_NAME).getSchedule()).isNull();
|
||||
|
||||
+38
-11
@@ -26,11 +26,13 @@ import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantHelp;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
|
||||
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
|
||||
import com.kingsrook.qqq.backend.core.actions.help.HelpTargetCatalog;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.testing.HelpBundleAssertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@@ -69,6 +71,7 @@ class AssistantTablesGuardrailTest extends BaseAssistantTest
|
||||
Map.entry("assistant_tool_call", "runtime: every tool call"),
|
||||
Map.entry("assistant_pending_action", "runtime: a proposed change"),
|
||||
Map.entry("assistant_feedback", "user content: a rating"),
|
||||
Map.entry("assistant_client_error", "runtime: the panel reports an error it hit"),
|
||||
Map.entry("assistant_usage", "runtime: every model call"),
|
||||
Map.entry("assistant_usage_daily", "runtime: after every answer"),
|
||||
Map.entry("assistant_data_audit", "runtime: every model call carrying data"));
|
||||
@@ -121,21 +124,45 @@ class AssistantTablesGuardrailTest extends BaseAssistantTest
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table and the app have bundle help that says how rows get created.
|
||||
** Every owned target has valid bundle help, even on a host without help storage.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyTableHasEmptyStateHelp() throws Exception
|
||||
void everyOwnedTargetHasBundleHelp()
|
||||
{
|
||||
assertThat(AssistantHelp.entries()).allSatisfy((key, entry) -> assertThat(entry.path("summary").asText().length()).as(key).isLessThanOrEqualTo(120));
|
||||
assertThat(qInstance.getTable("helpContent")).isNull();
|
||||
var bundles = HelpBundles.of(qInstance).getBundles();
|
||||
assertThat(bundles).hasSize(1);
|
||||
assertThat(bundles.get(0).getId()).isEqualTo("qbit-assistant");
|
||||
HelpBundleAssertions.forInstance(qInstance)
|
||||
.bundles("help/qbit-assistant.yaml")
|
||||
// Core-generated bulk actions belong to core's bundle, not this QBit.
|
||||
.owning(target -> target.owner() instanceof QProcessMetaData process
|
||||
? qInstance.getTable("assistant_conversation").getSourceQBitName().equals(process.getSourceQBitName())
|
||||
: target.name() != null && target.name().startsWith("assistant"))
|
||||
.assertComplete();
|
||||
// RC.7's gate requires tables, fields, apps and processes; also cover our selectors and widgets.
|
||||
for(var target : HelpTargetCatalog.collect(qInstance))
|
||||
{
|
||||
if(List.of("widget", "possibleValueSource").contains(target.kind()) && target.name().startsWith("assistant"))
|
||||
{
|
||||
assertThat(bundles.get(0).getEntries()).as(target.key())
|
||||
.anyMatch(entry -> entry.getKey().equals(target.key()) && entry.getSummary() != null && !entry.getSummary().isBlank());
|
||||
}
|
||||
}
|
||||
for(String table : assistantTables())
|
||||
{
|
||||
List<QHelpContent> help = qInstance.getTable(table).getHelpContent() == null ? null : qInstance.getTable(table).getHelpContent().get(AssistantHelp.SLOT);
|
||||
assertThat(help).as(table + " table help").isNotEmpty();
|
||||
assertThat(help.get(0).getContent()).as(table + " help says how rows get created").contains("How rows get created");
|
||||
assertThat(bundles.get(0).getEntries())
|
||||
.anySatisfy(entry ->
|
||||
{
|
||||
assertThat(entry.getKey()).isEqualTo("table:" + table + ";slot:help");
|
||||
assertThat(entry.getContent()).isNotBlank();
|
||||
});
|
||||
}
|
||||
assertThat(qInstance.getApp(AssistantMetaDataBuilder.APP_NAME).getHelpContent()).containsKey(AssistantHelp.SLOT);
|
||||
assertThat(AssistantHelp.entries().keySet()).allMatch(key -> !key.startsWith("table:") || qInstance.getTable(key.substring(6, key.indexOf(';'))) != null,
|
||||
"help names only real tables");
|
||||
assertThat(bundles.get(0).getEntries()).anySatisfy(entry ->
|
||||
{
|
||||
assertThat(entry.getKey()).isEqualTo("app:" + AssistantMetaDataBuilder.APP_NAME + ";slot:help");
|
||||
assertThat(entry.getContent()).isNotBlank();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant;
|
||||
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.DriverManager;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute the payload column definitions and upgrade statements on H2. Other
|
||||
** schema constraints have their own Postgres guardrail; this checks expansion.
|
||||
*******************************************************************************/
|
||||
class TranscriptSchemaTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Both fresh installs and upgraded columns accept expanded token sequences.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void redactedColumnsAcceptExpandedTokens() throws Exception
|
||||
{
|
||||
Map<String, List<String>> columns = Map.of(
|
||||
"assistant_turn", List.of("stop_reason"),
|
||||
"assistant_tool_call", List.of("tool_call_id", "result_summary"),
|
||||
"assistant_feedback", List.of("reason"),
|
||||
"assistant_usage", List.of("request_id"),
|
||||
"assistant_pending_action", List.of("tool_call_id", "summary", "result_summary"),
|
||||
"assistant_data_audit", List.of("reason"),
|
||||
"assistant_budget_override", List.of("reason"),
|
||||
"assistant_scheduled_run", List.of("summary", "error"));
|
||||
String ddl = Files.readString(Path.of("..", "docs", "schema-postgres.sql"));
|
||||
String tokens = "hmac:v1:test:0123456789abcdefghijklmnopqrstuvwxyzABCDEFG".repeat(100);
|
||||
try(var connection = DriverManager.getConnection("jdbc:h2:mem:transcript" + System.nanoTime() + ";MODE=PostgreSQL");
|
||||
var statement = connection.createStatement())
|
||||
{
|
||||
for(var table : columns.entrySet())
|
||||
{
|
||||
var create = Pattern.compile("CREATE TABLE " + table.getKey() + "\\s*\\((.*?)\\n\\);", Pattern.DOTALL).matcher(ddl);
|
||||
assertThat(create.find()).as(table.getKey()).isTrue();
|
||||
for(String column : table.getValue())
|
||||
{
|
||||
var definition = Pattern.compile("(?m)^ " + column + " (TEXT|VARCHAR\\(\\d+\\))").matcher(create.group(1));
|
||||
assertThat(definition.find()).as(column).isTrue();
|
||||
statement.execute("DROP TABLE IF EXISTS " + table.getKey());
|
||||
statement.execute("CREATE TABLE " + table.getKey() + " (" + column + " " + definition.group(1) + ")");
|
||||
try(var insert = connection.prepareStatement("INSERT INTO " + table.getKey() + " VALUES (?)"))
|
||||
{
|
||||
insert.setString(1, tokens);
|
||||
assertThat(insert.executeUpdate()).isEqualTo(1);
|
||||
}
|
||||
String migration = "ALTER TABLE " + table.getKey() + " ALTER COLUMN " + column + " SET DATA TYPE TEXT";
|
||||
if(!"result_summary".equals(column) && !("assistant_pending_action".equals(table.getKey()) && "summary".equals(column)))
|
||||
{
|
||||
assertThat(ddl).contains(migration + ";");
|
||||
statement.execute("DELETE FROM " + table.getKey());
|
||||
statement.execute("ALTER TABLE " + table.getKey() + " ALTER COLUMN " + column + " SET DATA TYPE VARCHAR(64)");
|
||||
statement.execute(migration);
|
||||
statement.execute(migration);
|
||||
try(var insert = connection.prepareStatement("INSERT INTO " + table.getKey() + " VALUES (?)"))
|
||||
{
|
||||
insert.setString(1, tokens);
|
||||
assertThat(insert.executeUpdate()).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
try(var result = statement.executeQuery("SELECT " + column + " FROM " + table.getKey()))
|
||||
{
|
||||
assertThat(result.next()).isTrue();
|
||||
assertThat(result.getString(1)).isEqualTo(tokens);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+109
-9
@@ -30,9 +30,13 @@ import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantPolicy;
|
||||
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
|
||||
import com.kingsrook.qbits.assistant.spi.ModelPart;
|
||||
import com.kingsrook.qbits.assistant.spi.ModelResponse;
|
||||
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
|
||||
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
|
||||
import com.kingsrook.qbits.assistant.testing.UiApiTestServer;
|
||||
@@ -175,7 +179,11 @@ class AssistantRouteProviderTest extends BaseAssistantTest
|
||||
assertThat((Map<String, Object>) config.get("features")).containsEntry("writes", false);
|
||||
|
||||
assertThat(json(send("GET", "/config", NO_ACCESS, null))).containsEntry("enabled", false).containsEntry("reason", "NO_PERMISSION");
|
||||
assertThat(send("GET", "/conversations", NO_ACCESS, null).statusCode()).isEqualTo(403);
|
||||
HttpResponse<String> denied = send("GET", "/conversations", NO_ACCESS, null);
|
||||
assertThat(denied.statusCode()).isEqualTo(403);
|
||||
assertThat(json(denied)).containsEntry("code", "NO_PERMISSION")
|
||||
.containsEntry("message", json(denied).get("error"))
|
||||
.containsEntry("traceId", denied.headers().firstValue("X-QQQ-Trace-Id").orElseThrow());
|
||||
|
||||
setKillSwitch(true);
|
||||
assertThat(json(send("GET", "/config", EDITOR, null))).containsEntry("enabled", false).containsEntry("reason", "KILL_SWITCH");
|
||||
@@ -213,6 +221,9 @@ class AssistantRouteProviderTest extends BaseAssistantTest
|
||||
HttpResponse<String> foreign = send("GET", "/conversations/" + id, OTHER_EDITOR, null);
|
||||
assertThat(foreign.statusCode()).isEqualTo(404);
|
||||
assertThat(foreign.body()).doesNotContain("Posts");
|
||||
assertThat(json(foreign)).containsEntry("code", "NOT_FOUND")
|
||||
.containsEntry("message", json(foreign).get("error"))
|
||||
.containsEntry("traceId", foreign.headers().firstValue("X-QQQ-Trace-Id").orElseThrow());
|
||||
assertThat(send("PATCH", "/conversations/" + id, OTHER_EDITOR, "{\"title\":\"x\"}").statusCode()).isEqualTo(404);
|
||||
assertThat(send("DELETE", "/conversations/" + id, OTHER_EDITOR, null).statusCode()).isEqualTo(404);
|
||||
assertThat(send("POST", "/conversations/" + id + "/turns", OTHER_EDITOR, "{\"text\":\"hi\"}").statusCode()).isEqualTo(404);
|
||||
@@ -234,7 +245,11 @@ class AssistantRouteProviderTest extends BaseAssistantTest
|
||||
HttpRequest form = HttpRequest.newBuilder(URI.create(base + "/conversations")).header("Cookie", "sessionId=" + EDITOR)
|
||||
.header("Content-Type", "application/x-www-form-urlencoded").POST(HttpRequest.BodyPublishers.ofString("title=x")).build();
|
||||
assertThat(client.send(form, HttpResponse.BodyHandlers.ofString()).statusCode()).isEqualTo(400);
|
||||
assertThat(send("GET", "/conversations/abc", EDITOR, null).statusCode()).isEqualTo(400);
|
||||
HttpResponse<String> invalid = send("GET", "/conversations/abc", EDITOR, null);
|
||||
assertThat(invalid.statusCode()).isEqualTo(400);
|
||||
assertThat(json(invalid)).containsEntry("code", "BAD_REQUEST")
|
||||
.containsEntry("message", json(invalid).get("error"))
|
||||
.containsEntry("traceId", invalid.headers().firstValue("X-QQQ-Trace-Id").orElseThrow());
|
||||
assertThat(send("POST", "/conversations/" + id + "/turns", EDITOR, "{}").statusCode()).isEqualTo(400);
|
||||
|
||||
assertThat(send("DELETE", "/conversations/" + id, EDITOR, null).statusCode()).isEqualTo(204);
|
||||
@@ -314,7 +329,7 @@ class AssistantRouteProviderTest extends BaseAssistantTest
|
||||
assertThat(send("POST", "/messages/" + messages.get(0).get("id") + "/feedback", EDITOR, "{\"rating\":1}").statusCode()).isEqualTo(400);
|
||||
assertThat(send("POST", "/messages/" + msgId + "/feedback", OTHER_EDITOR, "{\"rating\":-1}").statusCode()).isEqualTo(404);
|
||||
|
||||
assertThat(send("POST", "/turns/" + turnId + "/cancel", EDITOR, "{}").statusCode()).isEqualTo(409);
|
||||
assertThat(send("POST", "/turns/" + turnId + "/cancel", EDITOR, "{}").statusCode()).isEqualTo(200);
|
||||
}
|
||||
|
||||
|
||||
@@ -351,15 +366,10 @@ class AssistantRouteProviderTest extends BaseAssistantTest
|
||||
}
|
||||
assertThat(turnId).isNotNull();
|
||||
|
||||
///////////////////////////////////////////////////////////////
|
||||
// a second turn while one runs: refused (concurrency = 1) //
|
||||
///////////////////////////////////////////////////////////////
|
||||
assertThat(send("POST", "/conversations/" + id + "/turns", EDITOR, "{\"text\":\"again\"}").statusCode()).isEqualTo(429);
|
||||
|
||||
Thread.sleep(500);
|
||||
assertThat(send("POST", "/turns/" + turnId + "/cancel", OTHER_EDITOR, "{}").statusCode()).isEqualTo(404);
|
||||
HttpResponse<String> cancel = send("POST", "/turns/" + turnId + "/cancel", EDITOR, "{}");
|
||||
assertThat(cancel.statusCode()).isEqualTo(202);
|
||||
assertThat(cancel.statusCode()).isEqualTo(200);
|
||||
assertThat(json(cancel)).containsEntry("status", "CANCELLING");
|
||||
|
||||
List<Frame> frames = frames(future.get().body());
|
||||
@@ -371,6 +381,96 @@ class AssistantRouteProviderTest extends BaseAssistantTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A disconnected POST keeps running; active discovery and replay can attach
|
||||
** again while the answer is blocked, then receive the terminal frame.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void testDisconnectRefreshAndReattach() throws Exception
|
||||
{
|
||||
CountDownLatch entered = new CountDownLatch(1);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
provider.thenToolUses("I am checking the count before answering. ", List.<Object[]>of(new Object[] { "count_records", Map.of("table", "blog_post") }))
|
||||
.thenRespond(request ->
|
||||
{
|
||||
entered.countDown();
|
||||
try
|
||||
{
|
||||
if(!release.await(5, TimeUnit.SECONDS))
|
||||
{
|
||||
throw (new IllegalStateException("test did not release provider"));
|
||||
}
|
||||
}
|
||||
catch(InterruptedException e)
|
||||
{
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
return (new ModelResponse().withParts(List.of(ModelPart.text("Finished after reconnect."))).withStopReason(ModelResponse.STOP_END_TURN));
|
||||
});
|
||||
Integer conversation = (Integer) json(send("POST", "/conversations", EDITOR, "{}")).get("id");
|
||||
HttpRequest request = HttpRequest.newBuilder(URI.create(base + "/conversations/" + conversation + "/turns"))
|
||||
.header("Cookie", "sessionId=" + EDITOR).header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString("{\"text\":\"count posts\"}")).build();
|
||||
int lastSeq = 0;
|
||||
try
|
||||
{
|
||||
HttpResponse<InputStream> response = client.send(request, HttpResponse.BodyHandlers.ofInputStream());
|
||||
assertThat(response.statusCode()).isEqualTo(200);
|
||||
try(BufferedReader reader = new BufferedReader(new InputStreamReader(response.body(), StandardCharsets.UTF_8)))
|
||||
{
|
||||
String event = null;
|
||||
String line;
|
||||
while((line = reader.readLine()) != null)
|
||||
{
|
||||
if(line.startsWith("id: "))
|
||||
{
|
||||
lastSeq = Integer.parseInt(line.substring(4));
|
||||
}
|
||||
if(line.startsWith("event: "))
|
||||
{
|
||||
event = line.substring(7);
|
||||
}
|
||||
if(line.isEmpty() && "text.delta".equals(event))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
assertThat(lastSeq).isPositive();
|
||||
assertThat(entered.await(5, TimeUnit.SECONDS)).isTrue();
|
||||
HttpResponse<String> activeResponse = send("GET", "/turns/active", EDITOR, null);
|
||||
assertThat(activeResponse.statusCode()).isEqualTo(200);
|
||||
List<Map<String, Object>> active = (List<Map<String, Object>>) json(activeResponse).get("turns");
|
||||
assertThat(active).hasSize(1);
|
||||
Map<String, Object> turn = active.getFirst();
|
||||
assertThat(turn).containsEntry("live", true).containsEntry("phase", "thinking").containsEntry("conversationId", conversation)
|
||||
.containsKeys("startedAt", "heartbeatAt", "elapsedMs", "concurrencyMode");
|
||||
assertThat(json(send("GET", "/turns/active", OTHER_EDITOR, null)).get("turns")).isEqualTo(List.of());
|
||||
assertThat(send("GET", "/turns/active", NO_ACCESS, null).statusCode()).isEqualTo(403);
|
||||
HttpRequest attach = HttpRequest.newBuilder(URI.create(base + "/turns/" + turn.get("turnId") + "/events?after=0"))
|
||||
.header("Cookie", "sessionId=" + EDITOR).GET().build();
|
||||
HttpResponse<InputStream> attached = client.send(attach, HttpResponse.BodyHandlers.ofInputStream());
|
||||
release.countDown();
|
||||
List<Frame> followed = frames(attached.body());
|
||||
assertThat(followed.getFirst().event()).isEqualTo("message.start");
|
||||
assertThat(followed.getFirst().data()).containsKey("startedAt");
|
||||
assertThat(followed.getLast().event()).isEqualTo("message.end");
|
||||
assertThat(followed.getLast().data()).containsEntry("status", "COMPLETE");
|
||||
HttpRequest replay = HttpRequest.newBuilder(URI.create(base + "/turns/" + turn.get("turnId") + "/events?after=" + lastSeq))
|
||||
.header("Cookie", "sessionId=" + EDITOR).GET().build();
|
||||
List<Frame> rest = frames(client.send(replay, HttpResponse.BodyHandlers.ofInputStream()).body());
|
||||
assertThat(Integer.parseInt(rest.getFirst().id())).isEqualTo(lastSeq + 1);
|
||||
assertThat(rest.getLast().event()).isEqualTo("message.end");
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.countDown();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
/*
|
||||
* 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.api;
|
||||
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
import com.kingsrook.qbits.assistant.engine.ClientErrors;
|
||||
import com.kingsrook.qbits.assistant.health.AssistantHealthChecks;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantClientError;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantConversation;
|
||||
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
|
||||
import com.kingsrook.qbits.assistant.processes.AssistantRetentionStep;
|
||||
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
|
||||
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
|
||||
import com.kingsrook.qbits.assistant.testing.UiApiTestServer;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthChecksMetaData;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthStatus;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.EDITOR;
|
||||
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.OTHER_EDITOR;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** POST /assistant/client-errors: bounds and scrubbing, owned ids only, the
|
||||
** per-user rate limit (202 accepted:false), owner security, the HTTP
|
||||
** contract, the health check and retention.
|
||||
*******************************************************************************/
|
||||
class ClientErrorsTest extends BaseAssistantTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** A small limit and warn threshold keep the tests short.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected void customizeConfig(AssistantQBitConfig config)
|
||||
{
|
||||
config.withClientErrorsPerUserPerMinute(3).withClientErrorWarnPerHour(2);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The API bound to a user's own session (in QContext).
|
||||
*******************************************************************************/
|
||||
private AssistantApi as(String userId)
|
||||
{
|
||||
QSession session = AssistantTestFixture.ownerSession(userId, config);
|
||||
QContext.init(qInstance, session);
|
||||
return (new AssistantApi(config.getRuntime(), session));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The user's stored reports.
|
||||
*******************************************************************************/
|
||||
private List<AssistantClientError> rows(String userId) throws Exception
|
||||
{
|
||||
as(userId);
|
||||
return (new AssistantStore().query(AssistantClientError.TABLE_NAME, new QQueryFilter(), AssistantClientError.class));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every field is bounded and the free text scrubbed; ids are kept only when
|
||||
** they are the user's own.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void storesBoundedScrubbedReports() throws Exception
|
||||
{
|
||||
AssistantApi api = as(EDITOR);
|
||||
AssistantConversation mine = config.getRuntime().getStore().createConversation(EDITOR, "mine", null);
|
||||
as(OTHER_EDITOR);
|
||||
AssistantConversation other = config.getRuntime().getStore().createConversation(OTHER_EDITOR, "other", null);
|
||||
api = as(EDITOR);
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("scope", "assistant-render" + "x".repeat(100));
|
||||
body.put("message", "Cannot read properties of undefined (reading 'length') for jane.doe@example.com card 4111 1111 1111 1111 " + "y".repeat(800));
|
||||
body.put("errorName", "TypeError");
|
||||
body.put("stack", "at render (https://app.example.com/_next/chunk.js?token=abc:1:2)\n" + "z".repeat(5000));
|
||||
body.put("componentStack", "in RecordsRender\n in AssistantPart key 9f8e7d6c5b4a3928170655e4d3c2b1a0AbCdEfQx");
|
||||
body.put("requestId", "req-123");
|
||||
body.put("route", "https://app.example.com/app/orders/12345678?filter=secret#top");
|
||||
body.put("conversationId", mine.getId());
|
||||
body.put("messageId", 999_999);
|
||||
body.put("turnId", "12");
|
||||
body.put("partType", "tool");
|
||||
body.put("renderType", "records");
|
||||
body.put("appVersion", "1.2.3");
|
||||
body.put("occurredAt", "2026-10-15T12:00:00Z");
|
||||
body.put("unknownField", "ignored");
|
||||
assertThat(api.reportClientError(body)).isEqualTo(Map.of("accepted", true));
|
||||
|
||||
body.put("conversationId", other.getId());
|
||||
body.put("route", "javascript:alert(1)");
|
||||
body.put("occurredAt", "not a date");
|
||||
assertThat(as(EDITOR).reportClientError(body)).containsEntry("accepted", true);
|
||||
|
||||
List<AssistantClientError> rows = rows(EDITOR);
|
||||
assertThat(rows).hasSize(2);
|
||||
AssistantClientError row = rows.get(0);
|
||||
assertThat(row.getUserId()).isEqualTo(EDITOR);
|
||||
assertThat(row.getScope()).hasSize(ClientErrors.SCOPE_MAX).startsWith("assistant-render");
|
||||
assertThat(row.getMessage()).hasSize(ClientErrors.MESSAGE_MAX).startsWith("Cannot read properties of undefined (reading 'length') for [email] card [")
|
||||
.doesNotContain("jane.doe").doesNotContain("4111");
|
||||
assertThat(ClientErrors.text("order 12345678 for 555 123 4567, version 1.2.3", 100)).isEqualTo("order [number] for [number], version 1.2.3");
|
||||
assertThat(row.getStack()).hasSize(ClientErrors.STACK_MAX).contains("chunk.js?[query]").doesNotContain("token=abc");
|
||||
assertThat(row.getComponentStack()).contains("[token]").doesNotContain("9f8e7d6c");
|
||||
assertThat(row.getRoute()).isEqualTo("/app/orders/[number]");
|
||||
assertThat(row.getConversationId()).isEqualTo(mine.getId());
|
||||
assertThat(row.getMessageId()).isNull();
|
||||
assertThat(row.getTurnId()).isNull();
|
||||
assertThat(row.getPartType()).isEqualTo("tool");
|
||||
assertThat(row.getRenderType()).isEqualTo("records");
|
||||
assertThat(row.getAppVersion()).isEqualTo("1.2.3");
|
||||
assertThat(row.getOccurredAt()).isEqualTo(Instant.parse("2026-10-15T12:00:00Z"));
|
||||
|
||||
AssistantClientError second = rows.get(1);
|
||||
assertThat(second.getConversationId()).as("another user's conversation is not linked").isNull();
|
||||
assertThat(second.getRoute()).isNull();
|
||||
assertThat(second.getOccurredAt()).isNull();
|
||||
|
||||
///////////////////////////////////////////////////////////
|
||||
// owner-locked: another user never sees these reports //
|
||||
///////////////////////////////////////////////////////////
|
||||
assertThat(rows(OTHER_EDITOR)).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** scope and message are required; everything else is optional.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void requiresScopeAndMessage()
|
||||
{
|
||||
AssistantApi api = as(EDITOR);
|
||||
assertThatThrownBy(() -> api.reportClientError(null)).isInstanceOf(AssistantApiException.class).extracting("status").isEqualTo(400);
|
||||
assertThatThrownBy(() -> api.reportClientError(Map.of("message", "x"))).extracting("status").isEqualTo(400);
|
||||
assertThatThrownBy(() -> api.reportClientError(Map.of("scope", "page", "message", " "))).extracting("status").isEqualTo(400);
|
||||
assertThatThrownBy(() -> api.reportClientError(Map.of("scope", 7, "message", "x"))).extracting("status").isEqualTo(400);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Over the per-user limit: still acknowledged (accepted:false), nothing
|
||||
** stored; other users have their own limit.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void rateLimitedPerUser() throws Exception
|
||||
{
|
||||
Map<String, Object> body = Map.of("scope", "page", "message", "boom");
|
||||
for(int i = 0; i < 3; i++)
|
||||
{
|
||||
assertThat(as(EDITOR).reportClientError(body)).containsEntry("accepted", true);
|
||||
}
|
||||
assertThat(as(EDITOR).reportClientError(body)).isEqualTo(Map.of("accepted", false));
|
||||
assertThat(as(OTHER_EDITOR).reportClientError(body)).containsEntry("accepted", true);
|
||||
assertThat(rows(EDITOR)).hasSize(3);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The health check is UP below the threshold and DEGRADED at it.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void healthCheckWarnsOnManyReports() throws Exception
|
||||
{
|
||||
assertThat(HealthChecksMetaData.ofOrWithNew(qInstance).getChecks()).anySatisfy(check -> assertThat(check.getName()).isEqualTo(AssistantHealthChecks.CLIENT_ERRORS));
|
||||
as(EDITOR);
|
||||
assertThat(AssistantHealthChecks.clientErrors(config.getRuntime()).getStatus()).isEqualTo(HealthStatus.UP);
|
||||
as(EDITOR).reportClientError(Map.of("scope", "page", "message", "one"));
|
||||
as(EDITOR).reportClientError(Map.of("scope", "page", "message", "two"));
|
||||
as(EDITOR);
|
||||
assertThat(AssistantHealthChecks.clientErrors(config.getRuntime()).getStatus()).isEqualTo(HealthStatus.DEGRADED);
|
||||
assertThat(AssistantHealthChecks.clientErrors(null).getStatus()).isEqualTo(HealthStatus.UNKNOWN);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Retention deletes reports older than the tool result retention.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void retentionDeletesOldReports() throws Exception
|
||||
{
|
||||
as(EDITOR).reportClientError(Map.of("scope", "page", "message", "old"));
|
||||
as(EDITOR);
|
||||
AssistantRetentionStep.Result kept = AssistantRetentionStep.purge(config, config.getRuntime().getStore(), Instant.now());
|
||||
assertThat(kept.clientErrorsDeleted()).isZero();
|
||||
AssistantRetentionStep.Result result = AssistantRetentionStep.purge(config, config.getRuntime().getStore(), Instant.now().plus(Duration.ofDays(config.getToolResultRetentionDays() + 1)));
|
||||
assertThat(result.clientErrorsDeleted()).isEqualTo(1);
|
||||
assertThat(rows(EDITOR)).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Over HTTP: 202 {accepted}, a non-object body or a missing field is a 400;
|
||||
** the table is in the Assistant nav. (The session comes from the standard
|
||||
** UI API authentication; the fixture's MOCK module signs anyone in.)
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void httpContract() throws Exception
|
||||
{
|
||||
QContext.clear();
|
||||
String base = UiApiTestServer.serve(qInstance, config) + AssistantRouteProvider.BASE_PATH;
|
||||
HttpClient client = HttpClient.newHttpClient();
|
||||
|
||||
HttpResponse<String> accepted = client.send(HttpRequest.newBuilder(URI.create(base + "/client-errors")).header("Cookie", "sessionId=" + EDITOR)
|
||||
.header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString("{\"scope\":\"assistant-part\",\"message\":\"Cannot read properties of undefined\"}")).build(),
|
||||
HttpResponse.BodyHandlers.ofString());
|
||||
assertThat(accepted.statusCode()).isEqualTo(202);
|
||||
assertThat(JsonUtils.toObject(accepted.body(), new TypeReference<Map<String, Object>>() {})).isEqualTo(Map.of("accepted", true));
|
||||
|
||||
HttpResponse<String> notObject = client.send(HttpRequest.newBuilder(URI.create(base + "/client-errors")).header("Cookie", "sessionId=" + EDITOR)
|
||||
.header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString("[1,2]")).build(), HttpResponse.BodyHandlers.ofString());
|
||||
assertThat(notObject.statusCode()).isEqualTo(400);
|
||||
|
||||
HttpResponse<String> missing = client.send(HttpRequest.newBuilder(URI.create(base + "/client-errors")).header("Cookie", "sessionId=" + EDITOR)
|
||||
.header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString("{\"scope\":\"page\"}")).build(), HttpResponse.BodyHandlers.ofString());
|
||||
assertThat(missing.statusCode()).isEqualTo(400);
|
||||
|
||||
assertThat(rows(EDITOR)).hasSize(1);
|
||||
assertThat(AssistantMetaDataBuilder.NAV_TABLES).contains(AssistantClientError.TABLE_NAME);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* 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.api;
|
||||
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
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 org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Terminal events must be drained even when closure races an empty poll.
|
||||
*******************************************************************************/
|
||||
class TurnStreamsTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Reproduce the poll/close boundary without relying on thread timing.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void drainsTerminalEventsAfterEmptyPoll() throws Exception
|
||||
{
|
||||
TurnEventLog log = new TurnEventLog()
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Append just after a timed-out poll, before the reader checks closure.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<TurnEvent> after(int afterSeq, long timeoutMillis) throws InterruptedException
|
||||
{
|
||||
if(size() == 0)
|
||||
{
|
||||
append(TurnEvent.ERROR, Map.of("code", "TIMEOUT"));
|
||||
append(TurnEvent.MESSAGE_END, Map.of("status", "ERROR"));
|
||||
return (List.of());
|
||||
}
|
||||
return (super.after(afterSeq, timeoutMillis));
|
||||
}
|
||||
};
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
TurnStreams.writeLive(output, log, 0);
|
||||
assertThat(output.toString(StandardCharsets.UTF_8)).contains("event: error", "TIMEOUT", "event: message.end");
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -65,8 +65,17 @@ class AssistantSpecsContractTest extends BaseAssistantTest
|
||||
|
||||
assertThat(generated).contains("/qqq/v1/assistant/conversations/{id}/turns").contains("text/event-stream").contains("/qqq/v1/assistant/actions/{id}/confirm")
|
||||
.contains("AssistantTurnEventV1").contains("/qqq/v1/assistant/access");
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
// The standard error schema retains error/code and adds message/traceId/fieldErrors. //
|
||||
// The two 204 responses never had bodies; their old refs pointed to schema "null". //
|
||||
// Keep the release baseline and accept only these documented migration corrections. //
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
List<String> acceptedChanges = List.of(
|
||||
"$ref changed from #/components/schemas/AssistantErrorV1 to #/components/schemas/BasicErrorResponseV1",
|
||||
"DELETE /qqq/v1/assistant/conversations/{id} response 204: content type removed: application/json",
|
||||
"POST /qqq/v1/assistant/messages/{id}/feedback response 204: content type removed: application/json");
|
||||
List<String> problems = UiApiContract.check(generated, Files.readString(COMMITTED, StandardCharsets.UTF_8),
|
||||
Files.exists(BASELINE) ? Files.readString(BASELINE, StandardCharsets.UTF_8) : null, 1, "./mvnw test -pl qbit-assistant -Dtest=AssistantSpecsContractTest -Dassistant.regenerateOpenApi=true");
|
||||
Files.exists(BASELINE) ? Files.readString(BASELINE, StandardCharsets.UTF_8) : null, 1, "./mvnw test -pl qbit-assistant -Dtest=AssistantSpecsContractTest -Dassistant.regenerateOpenApi=true", acceptedChanges);
|
||||
assertThat(problems).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
+1021
File diff suppressed because it is too large.
Load diff
+1
-2
@@ -151,8 +151,7 @@ class EngineComponentsTest extends BaseAssistantTest
|
||||
|
||||
RateLimiter limiter = new RateLimiter(clock);
|
||||
assertThat(limiter.tryAcquire("u", 2, 1)).isNull();
|
||||
assertThat(limiter.tryAcquire("u", 2, 1)).contains("in progress");
|
||||
limiter.release("u");
|
||||
|
||||
assertThat(limiter.tryAcquire("u", 2, 1)).isNull();
|
||||
limiter.release("u");
|
||||
assertThat(limiter.tryAcquire("u", 2, 1)).contains("last minute");
|
||||
|
||||
+347
@@ -0,0 +1,347 @@
|
||||
/*
|
||||
* 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.engine;
|
||||
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
import com.kingsrook.qbits.assistant.api.AssistantApi;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantConversation;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantMessage;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantTurn;
|
||||
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
|
||||
import com.kingsrook.qbits.assistant.processes.AssistantRetentionStep;
|
||||
import com.kingsrook.qbits.assistant.provider.fake.ScriptedModelProvider;
|
||||
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.ModelRequest;
|
||||
import com.kingsrook.qbits.assistant.spi.StreamHandle;
|
||||
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
|
||||
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
|
||||
import com.kingsrook.qbits.assistant.testing.TurnRunner;
|
||||
import com.kingsrook.qbits.assistant.uiapi.UiApiCall;
|
||||
import com.kingsrook.qbits.assistant.uiapi.UiApiGateway;
|
||||
import com.kingsrook.qbits.assistant.uiapi.UiApiResult;
|
||||
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;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.EDITOR;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fail soft: tool failures and timeouts become ERROR (is_error) results plus
|
||||
** an error notice; any exception in the loop still ends the turn (ERROR,
|
||||
** message.end, terminal row, unregistered); abandoned runs and stuck
|
||||
** messages are finalized when read or swept.
|
||||
*******************************************************************************/
|
||||
class FailSoftTurnsTest extends BaseAssistantTest
|
||||
{
|
||||
private static final String INTERNAL_DETAIL = "INTERNAL-DETAIL-7731";
|
||||
|
||||
/** what the UI API gateway does: THROW (an unexpected exception) or SLEEP (hang) */
|
||||
private final AtomicReference<String> gatewayMode = new AtomicReference<>("THROW");
|
||||
|
||||
/** whether the provider's stream() throws */
|
||||
private volatile boolean providerThrows = false;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A gateway that fails or hangs, a short tool limit, and a provider whose
|
||||
** stream() can throw outright (not a ModelProviderException).
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected void customizeConfig(AssistantQBitConfig config)
|
||||
{
|
||||
config.withToolCallTimeoutSeconds(1);
|
||||
config.withUiApiGateway(new UiApiGateway()
|
||||
{
|
||||
@Override
|
||||
public boolean isInvocable(String method, String pathTemplate)
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public UiApiResult invoke(UiApiCall call) throws QException
|
||||
{
|
||||
if("SLEEP".equals(gatewayMode.get()))
|
||||
{
|
||||
try
|
||||
{
|
||||
Thread.sleep(30_000);
|
||||
}
|
||||
catch(InterruptedException e)
|
||||
{
|
||||
Thread.currentThread().interrupt();
|
||||
throw (new QException("interrupted"));
|
||||
}
|
||||
}
|
||||
throw (new IllegalStateException(INTERNAL_DETAIL));
|
||||
}
|
||||
});
|
||||
provider = new ScriptedModelProvider()
|
||||
{
|
||||
@Override
|
||||
public StreamHandle stream(ModelRequest request, ModelEventListener listener)
|
||||
{
|
||||
if(providerThrows)
|
||||
{
|
||||
throw (new IllegalStateException(INTERNAL_DETAIL));
|
||||
}
|
||||
return (super.stream(request, listener));
|
||||
}
|
||||
};
|
||||
config.withModelProvider(provider);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The tool results the model got back in its second request.
|
||||
*******************************************************************************/
|
||||
private List<ModelPart> toolResultsSentBack()
|
||||
{
|
||||
ModelRequest second = provider.getRequests().get(1);
|
||||
ModelMessage results = second.getMessages().get(second.getMessages().size() - 1);
|
||||
return (results.getParts());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A tool that throws: an ERROR is_error result for the model, an ERROR tool
|
||||
** part and an error notice for the user, no internal detail in either.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void toolExceptionBecomesErrorPart() throws Exception
|
||||
{
|
||||
provider.thenToolUse("count_records", input("table", "blog_post", "filter", filter("AND"))).thenText("Sorry, I could not count them.");
|
||||
TurnRunner.Outcome outcome = runner.run(EDITOR, "how many posts?");
|
||||
|
||||
assertThat(outcome.end()).containsEntry("status", "COMPLETE");
|
||||
assertThat(outcome.events(TurnEvent.TOOL_RESULT)).singleElement().satisfies(result -> assertThat(result).containsEntry("outcome", "ERROR").containsEntry("summary", "Failed"));
|
||||
assertThat(outcome.events(TurnEvent.NOTICE)).anySatisfy(notice -> assertThat(notice).containsEntry("level", "error"));
|
||||
assertThat(toolResultsSentBack()).singleElement().satisfies(part -> assertThat(part.getIsError()).isTrue());
|
||||
|
||||
List<Map<String, Object>> parts = assistantParts(outcome.turnId());
|
||||
assertThat(parts).anySatisfy(part -> assertThat(part).containsEntry("type", "tool").containsEntry("outcome", "ERROR"));
|
||||
assertThat(parts).anySatisfy(part -> assertThat(part).containsEntry("type", "notice").containsEntry("level", "error"));
|
||||
assertThat(String.valueOf(parts)).doesNotContain(INTERNAL_DETAIL);
|
||||
outcome.events().forEach(event -> assertThat(event.toJson()).doesNotContain(INTERNAL_DETAIL));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A tool that hangs past toolCallTimeoutSeconds gets an ERROR (is_error)
|
||||
** result; the turn goes on and completes.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void toolTimeoutBecomesErrorResult() throws Exception
|
||||
{
|
||||
gatewayMode.set("SLEEP");
|
||||
provider.thenToolUse("count_records", input("table", "blog_post", "filter", filter("AND"))).thenText("That took too long.");
|
||||
long started = System.currentTimeMillis();
|
||||
TurnRunner.Outcome outcome = runner.run(EDITOR, "how many posts?");
|
||||
|
||||
assertThat(System.currentTimeMillis() - started).isLessThan(20_000);
|
||||
assertThat(outcome.end()).containsEntry("status", "COMPLETE");
|
||||
assertThat(outcome.events(TurnEvent.TOOL_RESULT)).singleElement().satisfies(result -> assertThat(result).containsEntry("outcome", "ERROR").containsEntry("summary", "Timed out"));
|
||||
assertThat(outcome.events(TurnEvent.NOTICE)).anySatisfy(notice -> assertThat(notice).containsEntry("level", "error"));
|
||||
assertThat(toolResultsSentBack()).singleElement().satisfies(part ->
|
||||
{
|
||||
assertThat(part.getIsError()).isTrue();
|
||||
assertThat(part.getText()).contains("did not finish in time");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The turn's own limit: status ERROR, an error event with code TIMEOUT,
|
||||
** then message.end.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void turnTimeoutEndsWithTimeoutError() throws Exception
|
||||
{
|
||||
config.setMaxTurnSeconds(1);
|
||||
provider.withDelay(Duration.ofSeconds(3)).thenText("too late");
|
||||
TurnRunner.Outcome outcome = runner.run(EDITOR, "slow");
|
||||
assertThat(outcome.end()).containsEntry("status", "ERROR");
|
||||
assertThat(outcome.events(TurnEvent.ERROR)).singleElement().satisfies(error -> assertThat(error).containsEntry("code", "TIMEOUT"));
|
||||
List<TurnEvent> events = outcome.events();
|
||||
assertThat(events.getLast().getType()).isEqualTo(TurnEvent.MESSAGE_END);
|
||||
assertThat(turnRow(outcome.turnId()).getStatus()).isEqualTo("ERROR");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An unexpected exception anywhere in the loop: ERROR, message.end, the turn
|
||||
** row and the message finalized, the run unregistered, and the next
|
||||
** question is not locked out.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void loopExceptionStillEndsTheTurn() throws Exception
|
||||
{
|
||||
providerThrows = true;
|
||||
TurnRunner.Outcome failed = runner.run(EDITOR, "hello");
|
||||
assertThat(failed.end()).containsEntry("status", "ERROR");
|
||||
assertThat(failed.errorCode()).isEqualTo("INTERNAL");
|
||||
assertThat(failed.events().getLast().getType()).isEqualTo(TurnEvent.MESSAGE_END);
|
||||
assertThat(String.valueOf(failed.events())).doesNotContain(INTERNAL_DETAIL);
|
||||
assertThat(runner.runtime().getRunningTurn(failed.turnId())).isNull();
|
||||
|
||||
AssistantTurn row = turnRow(failed.turnId());
|
||||
assertThat(row.getStatus()).isEqualTo("ERROR");
|
||||
assertThat(row.getErrorCode()).isEqualTo("INTERNAL");
|
||||
assertThat(row.getEndedAt()).isNotNull();
|
||||
assertThat(message(row.getAssistantMessageId()).getStatus()).isEqualTo("ERROR");
|
||||
assertThat(assistantParts(failed.turnId())).anySatisfy(part -> assertThat(part).containsEntry("type", "notice").containsEntry("level", "error"));
|
||||
|
||||
providerThrows = false;
|
||||
provider.thenText("Fine now.");
|
||||
assertThat(runner.run(EDITOR, failed.conversationId(), "again", null).end()).containsEntry("status", "COMPLETE");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Conversation detail repairs a message left STREAMING: by a run that
|
||||
** already ended (its status), and by an abandoned run (reaped, ERROR).
|
||||
** Legacy parts come back normalized.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void conversationDetailRepairsStuckMessages() throws Exception
|
||||
{
|
||||
QSession session = AssistantTestFixture.ownerSession(EDITOR, config);
|
||||
QContext.init(qInstance, session);
|
||||
AssistantStore store = config.getRuntime().getStore();
|
||||
AssistantApi api = new AssistantApi(config.getRuntime(), session);
|
||||
AssistantConversation conversation = store.createConversation(EDITOR, "stuck", null);
|
||||
|
||||
AssistantTurn ended = store.insert(new AssistantTurn().withConversationId(conversation.getId()).withUserId(EDITOR).withStatus("COMPLETE").withStartedAt(Instant.now()));
|
||||
store.insert(new AssistantMessage().withConversationId(conversation.getId()).withUserId(EDITOR).withRole("ASSISTANT").withStatus("STREAMING").withSeq(2)
|
||||
.withTurnId(ended.getId()).withPartsJson("[{\"type\":\"tool\",\"name\":\"query_records\",\"render\":{\"type\":\"records\",\"tableName\":\"t\"}}]"));
|
||||
|
||||
AssistantTurn abandoned = store.insert(new AssistantTurn().withConversationId(conversation.getId()).withUserId(EDITOR).withStatus("RUNNING")
|
||||
.withStartedAt(Instant.now().minus(Duration.ofHours(1))).withHeartbeatAt(Instant.now().minus(Duration.ofHours(1))));
|
||||
AssistantMessage stuck = store.insert(new AssistantMessage().withConversationId(conversation.getId()).withUserId(EDITOR).withRole("ASSISTANT").withStatus("STREAMING").withSeq(4)
|
||||
.withTurnId(abandoned.getId()).withPartsJson("[{\"type\":\"text\",\"text\":\"Partial\"}]"));
|
||||
store.updateFields(AssistantTurn.TABLE_NAME, new QRecord().withValue("id", abandoned.getId()).withValue("assistantMessageId", stuck.getId()));
|
||||
|
||||
AssistantMessage orphan = store.insert(new AssistantMessage().withConversationId(conversation.getId()).withUserId(EDITOR).withRole("ASSISTANT").withStatus("STREAMING").withSeq(6)
|
||||
.withPartsJson("[]"));
|
||||
|
||||
List<Map<String, Object>> messages = (List<Map<String, Object>>) api.getConversation(conversation.getId(), null, null).get("messages");
|
||||
assertThat(messages).extracting(m -> m.get("status")).containsExactly("COMPLETE", "ERROR", "ERROR");
|
||||
Map<String, Object> render = (Map<String, Object>) ((List<Map<String, Object>>) messages.get(0).get("parts")).get(0).get("render");
|
||||
assertThat(render).containsEntry("rows", List.of()).containsEntry("columns", List.of());
|
||||
assertThat(String.valueOf(messages.get(1).get("parts"))).contains(AbandonedTurns.INTERRUPTED_TEXT);
|
||||
assertThat(String.valueOf(messages.get(2).get("parts"))).contains(AbandonedTurns.UNFINISHED_TEXT);
|
||||
|
||||
assertThat(store.getTurn(EDITOR, abandoned.getId()).getStatus()).isEqualTo("ERROR");
|
||||
assertThat(store.getMessage(EDITOR, orphan.getId()).getStatus()).isEqualTo("ERROR");
|
||||
assertThat(api.activeTurns().get("turns")).isEqualTo(List.of());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The retention process finalizes runs nobody read again (e.g. after a pod
|
||||
** restart), with their messages and a terminal replay.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void retentionSweepsAbandonedRuns() throws Exception
|
||||
{
|
||||
QSession session = AssistantTestFixture.ownerSession(EDITOR, config);
|
||||
QContext.init(qInstance, session);
|
||||
AssistantStore store = config.getRuntime().getStore();
|
||||
AssistantConversation conversation = store.createConversation(EDITOR, "restarted", null);
|
||||
AssistantMessage message = store.insert(new AssistantMessage().withConversationId(conversation.getId()).withUserId(EDITOR).withRole("ASSISTANT").withStatus("STREAMING").withSeq(2)
|
||||
.withPartsJson("[]"));
|
||||
AssistantTurn old = store.insert(new AssistantTurn().withConversationId(conversation.getId()).withUserId(EDITOR).withStatus("RUNNING")
|
||||
.withAssistantMessageId(message.getId()).withStartedAt(Instant.now().minus(Duration.ofHours(2))));
|
||||
AssistantTurn fresh = store.insert(new AssistantTurn().withConversationId(conversation.getId()).withUserId(EDITOR).withStatus("RUNNING")
|
||||
.withStartedAt(Instant.now()).withHeartbeatAt(Instant.now()));
|
||||
|
||||
AssistantRetentionStep.Result result = AssistantRetentionStep.purge(config, store, Instant.now());
|
||||
assertThat(result.turnsFinalized()).isEqualTo(1);
|
||||
assertThat(store.getTurn(EDITOR, old.getId()).getStatus()).isEqualTo("ERROR");
|
||||
assertThat(store.getTurn(EDITOR, fresh.getId()).getStatus()).isEqualTo("RUNNING");
|
||||
assertThat(store.getMessage(EDITOR, message.getId()).getStatus()).isEqualTo("ERROR");
|
||||
assertThat(store.listEvents(EDITOR, old.getId(), 0, 10)).extracting(e -> e.getEventType()).containsExactly(TurnEvent.ERROR, TurnEvent.MESSAGE_END);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The turn row, read as its owner.
|
||||
*******************************************************************************/
|
||||
private AssistantTurn turnRow(Integer turnId) throws Exception
|
||||
{
|
||||
QContext.init(qInstance, AssistantTestFixture.ownerSession(EDITOR, config));
|
||||
try
|
||||
{
|
||||
return (config.getRuntime().getStore().getTurn(EDITOR, turnId));
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A message, read as its owner.
|
||||
*******************************************************************************/
|
||||
private AssistantMessage message(Integer messageId) throws Exception
|
||||
{
|
||||
QContext.init(qInstance, AssistantTestFixture.ownerSession(EDITOR, config));
|
||||
try
|
||||
{
|
||||
return (config.getRuntime().getStore().getMessage(EDITOR, messageId));
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The stored parts of a turn's assistant message.
|
||||
*******************************************************************************/
|
||||
private List<Map<String, Object>> assistantParts(Integer turnId) throws Exception
|
||||
{
|
||||
return (MessageParts.fromJson(message(turnRow(turnId).getAssistantMessageId()).getPartsJson()).list());
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -133,7 +133,7 @@ class PhiRoutingTest extends BaseAssistantTest
|
||||
assertThat(outcome.toolCalls()).singleElement().satisfies(call ->
|
||||
{
|
||||
assertThat(call.getOutcome()).isEqualTo("OK");
|
||||
assertThat(call.getResultJson()).contains("PHI_DIAGNOSIS_SENTINEL");
|
||||
assertThat(call.getResultJson()).doesNotContain("PHI_DIAGNOSIS_SENTINEL").contains("changed:v1");
|
||||
});
|
||||
assertThat(provider.getRequests()).hasSize(1);
|
||||
assertThat(audits()).singleElement().satisfies(row ->
|
||||
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
/*
|
||||
* 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.engine;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Records renders keep their (empty) lists in stored parts and stream events,
|
||||
** and parts stored without them are completed when read.
|
||||
*******************************************************************************/
|
||||
class RenderShapesTest
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** A query that matched nothing.
|
||||
*******************************************************************************/
|
||||
private static Map<String, Object> emptyRecordsPart()
|
||||
{
|
||||
Map<String, Object> render = new LinkedHashMap<>();
|
||||
render.put("type", "records");
|
||||
render.put("tableName", "db_perf_finding");
|
||||
render.put("tableLabel", "Findings");
|
||||
render.put("columns", new ArrayList<>());
|
||||
render.put("rows", new ArrayList<>());
|
||||
render.put("totalCount", 0);
|
||||
render.put("truncated", false);
|
||||
render.put("openHref", "/app/db_perf_finding");
|
||||
Map<String, Object> part = new LinkedHashMap<>();
|
||||
part.put("type", "tool");
|
||||
part.put("name", "query_records");
|
||||
part.put("render", render);
|
||||
return (part);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void emptyListsSurviveSerialization()
|
||||
{
|
||||
String json = RenderShapes.toJson(List.of(emptyRecordsPart()));
|
||||
assertThat(json).contains("\"columns\":[]").contains("\"rows\":[]");
|
||||
assertThat(new TurnEvent(1, "tool.result", emptyRecordsPart()).toJson()).contains("\"rows\":[]").contains("\"columns\":[]");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The exact shape stored on kof22 before the fix (lists omitted).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void partsStoredWithoutListsAreCompletedWhenRead()
|
||||
{
|
||||
String stored = "[{\"type\":\"tool\",\"name\":\"query_records\",\"outcome\":\"OK\",\"render\":{\"type\":\"records\",\"tableName\":\"db_perf_finding\",\"tableLabel\":\"Findings\",\"openHref\":\"/app/db_perf_finding\",\"totalCount\":0,\"truncated\":false}},"
|
||||
+ "{\"type\":\"tool\",\"name\":\"history\",\"render\":{\"type\":\"history\",\"entries\":[{\"message\":\"Edited\"}]}}]";
|
||||
List<Map<String, Object>> parts = MessageParts.fromJson(stored).list();
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> records = (Map<String, Object>) parts.get(0).get("render");
|
||||
assertThat(records.get("rows")).isEqualTo(List.of());
|
||||
assertThat(records.get("columns")).isEqualTo(List.of());
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> history = (Map<String, Object>) parts.get(1).get("render");
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> entry = ((List<Map<String, Object>>) history.get("entries")).get(0);
|
||||
assertThat(entry.get("details")).isEqualTo(List.of());
|
||||
|
||||
Map<String, Object> event = TurnEvent.fromJson(3, "tool.result", "{\"render\":{\"type\":\"records\",\"tableLabel\":\"Findings\"}}").getData();
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> eventRender = (Map<String, Object>) event.get("render");
|
||||
assertThat(eventRender.get("rows")).isEqualTo(List.of());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One render through the normalizer, as a stored part would be read.
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Map<String, Object> renderOf(String renderJson)
|
||||
{
|
||||
List<Map<String, Object>> parts = MessageParts.fromJson("[{\"type\":\"tool\",\"name\":\"t\",\"render\":" + renderJson + "}]").list();
|
||||
assertThat(parts).hasSize(1);
|
||||
return ((Map<String, Object>) parts.get(0).get("render"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every known render type gets its required lists and safe defaults.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void everyRenderTypeIsNormalized()
|
||||
{
|
||||
Map<String, Object> records = renderOf("{\"type\":\"records\",\"tableName\":\"t\",\"columns\":[{\"name\":\"a\"},\"junk\",{\"label\":\"no name\"}],\"rows\":[{\"id\":1},7],\"totalCount\":\"3\",\"truncated\":\"yes\"}");
|
||||
assertThat(records).containsEntry("tableLabel", "t").containsEntry("totalCount", 3L).containsEntry("truncated", false);
|
||||
assertThat((List<Map<String, Object>>) records.get("columns")).containsExactly(Map.of("name", "a", "label", "a"));
|
||||
assertThat((List<Map<String, Object>>) records.get("rows")).hasSize(1).first().satisfies(row -> assertThat(row.get("values")).isEqualTo(Map.of()));
|
||||
|
||||
Map<String, Object> record = renderOf("{\"type\":\"record\",\"tableName\":\"t\",\"fields\":[{\"name\":\"n\",\"value\":5},{\"value\":\"orphan\"}]}");
|
||||
assertThat((List<Map<String, Object>>) record.get("fields")).containsExactly(Map.of("name", "n", "label", "n", "value", "5"));
|
||||
|
||||
Map<String, Object> count = renderOf("{\"type\":\"count\",\"tableName\":\"t\",\"count\":\"many\"}");
|
||||
assertThat(count).containsEntry("tableLabel", "t").containsEntry("count", null);
|
||||
|
||||
Map<String, Object> widget = renderOf("{\"type\":\"widget\",\"widgetName\":\"w\",\"data\":\"bad\"}");
|
||||
assertThat(widget).containsEntry("label", "w").containsEntry("data", Map.of());
|
||||
|
||||
Map<String, Object> history = renderOf("{\"type\":\"history\",\"entries\":[{\"message\":\"x\",\"details\":\"bad\"},3]}");
|
||||
assertThat((List<Map<String, Object>>) history.get("entries")).containsExactly(Map.of("message", "x", "details", List.of()));
|
||||
|
||||
Map<String, Object> pending = renderOf("{\"type\":\"pending_action\",\"actionId\":\"12\"}");
|
||||
assertThat(pending).containsEntry("actionId", 12L).containsEntry("summary", "").containsEntry("changes", List.of());
|
||||
|
||||
assertThat(renderOf("{\"type\":\"bulk_load_dry_run\",\"okCount\":1}")).containsEntry("errors", List.of());
|
||||
assertThat(renderOf("{\"type\":\"records\"}")).containsEntry("rows", List.of()).containsEntry("columns", List.of()).containsEntry("tableLabel", "");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Unknown render and part types pass through untouched.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void unknownTypesPassThrough()
|
||||
{
|
||||
assertThat(renderOf("{\"type\":\"sparkline\",\"points\":\"whatever\"}")).isEqualTo(Map.of("type", "sparkline", "points", "whatever"));
|
||||
List<Map<String, Object>> parts = MessageParts.fromJson("[{\"type\":\"future_part\",\"x\":1}]").list();
|
||||
assertThat(parts).containsExactly(Map.of("type", "future_part", "x", 1));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What cannot be repaired becomes the error notice; nothing throws.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void unrepairableRendersBecomeNotices()
|
||||
{
|
||||
List<Map<String, Object>> parts = MessageParts.fromJson("[{\"type\":\"tool\",\"name\":\"t\",\"outcome\":\"OK\",\"render\":{\"type\":\"pending_action\"}},"
|
||||
+ "{\"type\":\"tool\",\"name\":\"u\",\"render\":\"not an object\"},"
|
||||
+ "42, {\"no\":\"type\"}, {\"type\":\"presentation\"},"
|
||||
+ "{\"type\":\"navigate\",\"href\":\"https://evil.example\"}, {\"type\":\"notice\",\"level\":\"panic\"}]").list();
|
||||
Map<String, Object> notice = RenderShapes.undisplayableNotice();
|
||||
assertThat(parts).containsExactly(
|
||||
Map.of("type", "tool", "name", "t", "outcome", "OK", "summary", ""), notice,
|
||||
Map.of("type", "tool", "name", "u", "outcome", "ERROR", "summary", ""), notice,
|
||||
notice, notice, notice,
|
||||
Map.of("type", "notice", "level", "info", "text", ""));
|
||||
assertThat(notice).containsEntry("level", "error").containsEntry("text", RenderShapes.UNDISPLAYABLE_TEXT);
|
||||
assertThat(MessageParts.fromJson("{\"not\":\"a list\"}").list()).isEmpty();
|
||||
assertThat(RenderShapes.parts(null)).isEmpty();
|
||||
assertThat(RenderShapes.render(null)).isNull();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Presentations: blocks keep their lists; a presentation with nothing to
|
||||
** show is replaced (in parts and in the presentation event).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void presentationsAreNormalized()
|
||||
{
|
||||
Map<String, Object> presentation = RenderShapes.presentation(Map.of("summary", "Sales", "blocks", List.of(
|
||||
Map.of("kind", "table", "columns", List.of(Map.of("key", "c0"), "junk"), "rows", List.of(Map.of("cells", "bad"))),
|
||||
Map.of("kind", "chart"), Map.of("kind", "metrics"), Map.of("no", "kind"), Map.of("kind", "future"))));
|
||||
assertThat(presentation).containsEntry("type", "presentation").containsEntry("markdown", "Sales");
|
||||
List<Map<String, Object>> blocks = (List<Map<String, Object>>) presentation.get("blocks");
|
||||
assertThat(blocks).hasSize(4);
|
||||
assertThat(blocks.get(0)).containsEntry("title", "").containsEntry("columns", List.of(Map.of("key", "c0", "label", "c0")))
|
||||
.containsEntry("rows", List.of(Map.of("cells", Map.of(), "display", Map.of())));
|
||||
assertThat(blocks.get(1)).containsEntry("chartType", "bar").containsEntry("points", List.of());
|
||||
assertThat(blocks.get(2)).containsEntry("items", List.of());
|
||||
assertThat(blocks.get(3)).isEqualTo(Map.of("kind", "future"));
|
||||
|
||||
assertThat(RenderShapes.presentation(Map.of("blocks", List.of()))).isNull();
|
||||
TurnEvent event = new TurnEvent(4, TurnEvent.PRESENTATION, Map.of("part", Map.of("type", "presentation")));
|
||||
assertThat(event.getData().get("part")).isEqualTo(RenderShapes.undisplayableNotice());
|
||||
TurnEvent toolResult = TurnEvent.fromJson(5, TurnEvent.TOOL_RESULT, "{\"name\":\"t\",\"render\":{\"type\":\"pending_action\"}}");
|
||||
assertThat(toolResult.getData()).doesNotContainKey("render").containsEntry("name", "t");
|
||||
assertThat(TurnEvent.fromJson(6, TurnEvent.TOOL_RESULT, "not json").getData()).containsOnlyKeys("seq");
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -119,7 +119,7 @@ class RobustnessTest extends BaseAssistantTest
|
||||
// idempotent: a second look adds nothing //
|
||||
////////////////////////////////////////////////////////
|
||||
assertThat(api.turnEvents(turn.getId(), 0).persisted()).hasSize(4);
|
||||
assertThatThrownBy(() -> api.cancelTurn(turn.getId())).isInstanceOfSatisfying(AssistantApiException.class, e -> assertThat(e.getStatus()).isEqualTo(409));
|
||||
assertThat(api.cancelTurn(turn.getId())).containsEntry("status", "ERROR");
|
||||
|
||||
assertThat(api.turnEvents(young.getId(), 0).finished()).isFalse();
|
||||
assertThatThrownBy(() -> api.deleteConversation(conversation.getId())).isInstanceOfSatisfying(AssistantApiException.class, e -> assertThat(e.getStatus()).isEqualTo(409));
|
||||
|
||||
+1
-1
@@ -676,7 +676,7 @@ class RoutingFallbackTest extends BaseAssistantTest
|
||||
assertThat(script("a").getRequests()).isEmpty();
|
||||
assertThat(store().getProvider(a.getProviderId()).getHealthState()).isEqualTo("DOWN");
|
||||
String html = AssistantHomeView.render(AssistantHomeData.load(config.getRuntime()), "");
|
||||
assertThat(html).contains("DOWN", "Down until", "0 fallbacks");
|
||||
assertThat(html).contains("0 fallbacks").doesNotContain("Down until", "Last test failed");
|
||||
}
|
||||
|
||||
|
||||
|
||||
+476
@@ -0,0 +1,476 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.engine;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
import com.kingsrook.qbits.assistant.api.AssistantApi;
|
||||
import com.kingsrook.qbits.assistant.classification.ConfiguredClassificationSource;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantDataAccess;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantDataAudit;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProvider;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantScheduledRun;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantTurn;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantTurnEvent;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantUsage;
|
||||
import com.kingsrook.qbits.assistant.persistence.TranscriptRedactor;
|
||||
import com.kingsrook.qbits.assistant.spi.AssistantModelProvider;
|
||||
import com.kingsrook.qbits.assistant.spi.AssistantProviderType;
|
||||
import com.kingsrook.qbits.assistant.spi.ModelPart;
|
||||
import com.kingsrook.qbits.assistant.spi.ModelProviderException;
|
||||
import com.kingsrook.qbits.assistant.spi.ModelResponse;
|
||||
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
|
||||
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.ClassifiedValueKeySource;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.ClassifiedValueKeySourceMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.fieldaccess.ClassifiedValueTokens;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.audits.QAuditRules;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.core.LogEvent;
|
||||
import org.apache.logging.log4j.core.Logger;
|
||||
import org.apache.logging.log4j.core.appender.AbstractAppender;
|
||||
import org.apache.logging.log4j.core.config.Property;
|
||||
import org.apache.logging.log4j.core.layout.PatternLayout;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Secured-data egress canaries: inspect actual backend rows, core audits and logs,
|
||||
** while independently proving that the eligible model still received the values.
|
||||
*******************************************************************************/
|
||||
@Tag("redteam")
|
||||
class TranscriptRedactionTest extends BaseAssistantTest
|
||||
{
|
||||
private final String phi = "CANARY-PHI-" + UUID.randomUUID();
|
||||
private final String pii = "CANARY-PII-" + UUID.randomUUID();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Enable the fixture table and a local eligible provider.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected void customizeConfig(AssistantQBitConfig config)
|
||||
{
|
||||
config.withDataClassification("patient", DataClassification.PHI).withProviderType(new PatientProviderType());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Real core metadata, including table fallback, drives the redactor and core audits.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void seed() throws Exception
|
||||
{
|
||||
admin();
|
||||
var table = qInstance.getTable("patient");
|
||||
table.withSupplementalMetaData(new AiTableMetaData().withClassification(DataClassification.PHI));
|
||||
table.getField("email").withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PII));
|
||||
table.getField("id").withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PUBLIC));
|
||||
table.getField("status").withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PUBLIC));
|
||||
table.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD));
|
||||
config.withClassificationSource(new ConfiguredClassificationSource());
|
||||
assertThat(config.getClassificationSource().ofTable("patient")).isEqualTo(DataClassification.PHI);
|
||||
new UpdateAction().execute(new UpdateInput("patient").withRecord(new QRecord().withValue("id", 1).withValue("email", pii).withValue("diagnosis", phi)));
|
||||
config.getRuntime().getStore().insert(new AssistantProvider().withName("Eligible local provider").withProviderType("patient-test")
|
||||
.withModel("test-model").withLocation("LOCAL").withHipaaEligible(true).withEnabled(true).withIsDefault(true));
|
||||
config.getRuntime().getStore().insert(new AssistantDataAccess().withObjectType("TABLE").withObjectName("patient").withMode("READ_WRITE"));
|
||||
config.getRuntime().getDataAccessService().invalidate();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Scan raw backend storage as an administrator, not just decoded transcript APIs.
|
||||
*******************************************************************************/
|
||||
private void admin()
|
||||
{
|
||||
QContext.init(qInstance, AssistantTestFixture.ownerSession(AssistantTestFixture.ADMIN, config));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every five-character window is sufficient to detect any leaked substring > 4.
|
||||
*******************************************************************************/
|
||||
private void assertNoCanaries(String text)
|
||||
{
|
||||
for(String canary : List.of(phi, pii))
|
||||
{
|
||||
for(int i = 0; i <= canary.length() - 5; i++)
|
||||
{
|
||||
assertThat(text).doesNotContain(canary.substring(i, i + 5));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Query every registered assistant table and all core audit tables, without a row cap.
|
||||
*******************************************************************************/
|
||||
private void scanStorage() throws Exception
|
||||
{
|
||||
admin();
|
||||
for(String table : qInstance.getTables().keySet())
|
||||
{
|
||||
if(table.startsWith("assistant_") || table.toLowerCase().startsWith("audit"))
|
||||
{
|
||||
var rows = new QueryAction().execute(new QueryInput(table).withFilter(new QQueryFilter())).getRecords();
|
||||
assertNoCanaries(JsonUtils.toJson(rows));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Query, get, input filters, presentation, split deltas, proposals and errors.
|
||||
** Log capture includes exception detail and a provider thread without QContext.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void canariesNeverReachStorageOrLogs() throws Exception
|
||||
{
|
||||
List<String> logs = new CopyOnWriteArrayList<>();
|
||||
AbstractAppender appender = new AbstractAppender("transcript-canary", null, PatternLayout.createDefaultLayout(), false, Property.EMPTY_ARRAY)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Retain the emitted log representation, including any exception.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void append(LogEvent event)
|
||||
{
|
||||
logs.add(event.getMessage().getFormattedMessage() + (event.getThrown() == null ? "" : event.getThrown().toString()));
|
||||
}
|
||||
};
|
||||
appender.start();
|
||||
Logger logger = (Logger) LogManager.getRootLogger();
|
||||
logger.addAppender(appender);
|
||||
try
|
||||
{
|
||||
provider.thenToolUse("query_records", Map.of("table", "patient"))
|
||||
.thenToolUse("get_record", Map.of("table", "patient", "id", "1"))
|
||||
.thenRespond(request ->
|
||||
{
|
||||
assertThat(JsonUtils.toJson(request)).contains(phi, pii);
|
||||
QLogger.getLogger(TranscriptRedactionTest.class).warn("Provider echoed " + phi + " " + pii, new IllegalStateException(phi + " " + pii));
|
||||
return (new ModelResponse().withParts(List.of(ModelPart.toolUse("present", "present_results", Map.of("summary", phi + " " + pii))))
|
||||
.withStopReason(ModelResponse.STOP_TOOL_USE));
|
||||
})
|
||||
.thenToolUse("query_records", Map.of("table", "patient", "filter", filter("AND", "email", "EQUALS", List.of(pii))))
|
||||
.thenToolUse("update_record", Map.of("table", "patient", "id", "1", "values", Map.of("diagnosis", phi, "email", pii)))
|
||||
.thenText("x".repeat(43) + phi + " " + pii);
|
||||
var outcome = runner.run(AssistantTestFixture.ADMIN, phi + " " + pii);
|
||||
assertThat(outcome.errorCode()).isNull();
|
||||
assertThat(outcome.text()).contains(phi, pii);
|
||||
assertThat(outcome.toolCalls()).hasSize(5).allSatisfy(call -> assertThat(call.getOutcome()).isEqualTo("OK"));
|
||||
admin();
|
||||
var store = config.getRuntime().getStore();
|
||||
var proposals = store.query(AssistantPendingAction.TABLE_NAME, new QQueryFilter(), AssistantPendingAction.class);
|
||||
assertThat(proposals).singleElement().satisfies(action ->
|
||||
{
|
||||
assertThat(action.getParamsJson()).contains("changed:v1");
|
||||
assertThat(action.getStatus()).isEqualTo(AssistantPendingAction.STATUS_REJECTED);
|
||||
});
|
||||
assertThatThrownBy(() -> new AssistantApi(config.getRuntime(), QContext.getQSession()).confirmAction(proposals.getFirst().getId()))
|
||||
.hasMessageContaining("rejected");
|
||||
var events = store.query(AssistantTurnEvent.TABLE_NAME, new QQueryFilter(), AssistantTurnEvent.class);
|
||||
assertThat(events).isNotEmpty();
|
||||
assertThat(events.stream().map(AssistantTurnEvent::getDataJson).reduce("", String::concat)).contains("changed:v1");
|
||||
scanStorage();
|
||||
|
||||
provider.thenToolUse("get_record", Map.of("table", "patient", "id", "1"))
|
||||
.thenError(ModelProviderException.Kind.INVALID_REQUEST, phi + " " + pii);
|
||||
assertThat(runner.run(AssistantTestFixture.ADMIN, "Read patients again").errorCode()).isEqualTo("PROVIDER");
|
||||
scanStorage();
|
||||
assertThat(logs).isNotEmpty();
|
||||
assertNoCanaries(String.join("\n", logs));
|
||||
assertThat(QLogger.redact(phi)).as("turn hook released").isEqualTo(phi);
|
||||
}
|
||||
finally
|
||||
{
|
||||
logger.removeAppender(appender);
|
||||
appender.stop();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Content written before discovery never reaches storage, even transiently.
|
||||
** Includes the audit reason, scheduled summary, bulk log and redactions payloads.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void provisionalPayloadsAndNoResultCriteria() throws Exception
|
||||
{
|
||||
admin();
|
||||
var store = config.getRuntime().getStore();
|
||||
try(TranscriptRedactor redactor = new TranscriptRedactor(qInstance, config.getClassificationSource()))
|
||||
{
|
||||
redactor.attach();
|
||||
var audit = store.insert(new AssistantDataAudit().withUserId(AssistantTestFixture.ADMIN).withReason(phi + " " + pii));
|
||||
store.insert(new AssistantScheduledRun().withUserId(AssistantTestFixture.ADMIN).withTaskId(1).withScheduledFor(Instant.now()).withSummary(phi));
|
||||
store.insert(new AssistantToolCall().withUserId(AssistantTestFixture.ADMIN).withToolName("bulk_load_preview")
|
||||
.withInputJson(JsonUtils.toJson(Map.of("email", pii))).withResultJson(phi).withRedactionsJson(JsonUtils.toJson(List.of(phi, pii))));
|
||||
scanStorage();
|
||||
redactor.input(Map.of("table", "patient", "filter", Map.of("subFilters", List.of(filter("AND", "email", "EQUALS", List.of(pii))))));
|
||||
redactor.observe("patient", "diagnosis", 1, phi, phi);
|
||||
store.updateFields(AssistantDataAudit.TABLE_NAME, new QRecord().withValue("id", audit.getId()).withValue("reason", pii + " " + phi));
|
||||
redactor.flush(store);
|
||||
scanStorage();
|
||||
}
|
||||
provider.thenToolUse("query_records", Map.of("table", "patient", "filter", filter("AND", "email", "EQUALS", List.of(pii + "-absent"))))
|
||||
.thenText(pii + "-absent");
|
||||
runner.run(AssistantTestFixture.ADMIN, "Find missing patient");
|
||||
scanStorage();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Metadata must not reject or clip tokens after the SQL columns are widened.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void expandedTokensSurviveMetadataValidation() throws Exception
|
||||
{
|
||||
admin();
|
||||
var store = config.getRuntime().getStore();
|
||||
String raw = phi.repeat(250);
|
||||
try(TranscriptRedactor redactor = new TranscriptRedactor(qInstance, config.getClassificationSource()))
|
||||
{
|
||||
redactor.attach();
|
||||
redactor.observe("patient", "diagnosis", 1, phi, phi);
|
||||
var audit = store.insert(new AssistantDataAudit().withUserId(AssistantTestFixture.ADMIN).withReason(raw));
|
||||
var turn = store.insert(new AssistantTurn().withUserId(AssistantTestFixture.ADMIN).withStatus("RUNNING").withStopReason(raw));
|
||||
var call = store.insert(new AssistantToolCall().withUserId(AssistantTestFixture.ADMIN).withToolCallId(raw).withResultSummary(raw));
|
||||
var usage = store.insert(new AssistantUsage().withUserId(AssistantTestFixture.ADMIN).withRequestId(raw));
|
||||
redactor.flush(store);
|
||||
String expected = "changed:v1".repeat(250);
|
||||
assertThat(store.getTurn(AssistantTestFixture.ADMIN, turn.getId()).getStopReason()).isEqualTo(expected);
|
||||
for(var check : List.of(Map.entry(audit, "reason"), Map.entry(call, "toolCallId"), Map.entry(call, "resultSummary"), Map.entry(usage, "requestId")))
|
||||
{
|
||||
var row = new QueryAction().execute(new QueryInput(check.getKey().tableName())
|
||||
.withFilter(new QQueryFilter().withCriteria("id", com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator.EQUALS,
|
||||
check.getKey().toQRecord().getValue("id")))).getRecords().getFirst();
|
||||
assertThat(row.getValueString(check.getValue())).isEqualTo(expected);
|
||||
}
|
||||
}
|
||||
scanStorage();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A late tool retains the log hook; incomplete/cancelled turns keep placeholders
|
||||
** and cannot turn a provisional pending payload into an executable empty write.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void lateWorkerKeepsProtectionAndFailsClosed() throws Exception
|
||||
{
|
||||
admin();
|
||||
var store = config.getRuntime().getStore();
|
||||
TranscriptRedactor redactor = new TranscriptRedactor(qInstance, config.getClassificationSource());
|
||||
try
|
||||
{
|
||||
redactor.attach();
|
||||
redactor.observe("patient", "diagnosis", 1, phi, phi);
|
||||
assertThat(redactor.retainWorker()).isTrue();
|
||||
var action = store.insert(new AssistantPendingAction().withUserId(AssistantTestFixture.ADMIN)
|
||||
.withStatus(AssistantPendingAction.STATUS_PENDING).withParamsJson(JsonUtils.toJson(Map.of("diagnosis", phi))));
|
||||
assertThat(store.getPendingAction(AssistantTestFixture.ADMIN, action.getId()).getStatus()).isEqualTo("PREPARING");
|
||||
redactor.flush(store);
|
||||
assertThat(store.getPendingAction(AssistantTestFixture.ADMIN, action.getId()).getStatus()).isEqualTo(AssistantPendingAction.STATUS_REJECTED);
|
||||
redactor.close();
|
||||
assertThat(redactor.retainWorker()).isFalse();
|
||||
assertThat(QLogger.redact(phi)).isEqualTo("changed:v1");
|
||||
redactor.releaseWorker();
|
||||
assertThat(QLogger.redact(phi)).isEqualTo(phi);
|
||||
scanStorage();
|
||||
}
|
||||
finally
|
||||
{
|
||||
redactor.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Equal values keep distinct record bindings; canonical decimals and escaped text
|
||||
** redact exactly and case-sensitively, with no mutation of model-facing objects.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void recordBindingsAndCanonicalVariants() throws Exception
|
||||
{
|
||||
admin();
|
||||
qInstance.withSupplementalMetaData(new ClassifiedValueKeySourceMetaData().withKeySourceInstance(new ClassifiedValueKeySource()
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Deterministic test key, never a production secret.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Optional<KeyAndId> instanceKey()
|
||||
{
|
||||
return (Optional.of(new KeyAndId(new byte[32], "test")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** No per-record keys in this instance-key test.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Optional<KeyAndId> keyForRecordField(String table, String field, Serializable pk, boolean create)
|
||||
{
|
||||
return (Optional.empty());
|
||||
}
|
||||
}));
|
||||
var table = qInstance.getTable("patient");
|
||||
try(TranscriptRedactor redactor = new TranscriptRedactor(qInstance, config.getClassificationSource()))
|
||||
{
|
||||
redactor.observe("patient", "diagnosis", 1, phi, phi);
|
||||
redactor.observe("patient", "diagnosis", 2, phi, phi);
|
||||
String first = ClassifiedValueTokens.forRecordField(table, table.getField("diagnosis"), 1, phi);
|
||||
String second = ClassifiedValueTokens.forRecordField(table, table.getField("diagnosis"), 2, phi);
|
||||
assertThat(first).isNotEqualTo(second);
|
||||
String stored = redactor.payload(JsonUtils.toJson(Map.of("table", "patient", "records", List.of(
|
||||
Map.of("id", 1, "values", Map.of("diagnosis", phi)), Map.of("id", 2, "values", Map.of("diagnosis", phi))))));
|
||||
assertThat(stored).contains(first, second).doesNotContain(phi);
|
||||
assertThat(ClassifiedValueTokens.verifyRecordField(table, table.getField("diagnosis"), 1, phi, first)).isTrue();
|
||||
redactor.observe("patient", "diagnosis", 3, new BigDecimal("123.4500"), "123.4500");
|
||||
String numeric = redactor.redact("123.4500 and 123.45");
|
||||
assertThat(numeric).doesNotContain("123.45").contains("hmac:v1:test:");
|
||||
assertThat(redactor.redact(phi.toLowerCase())).isEqualTo(phi.toLowerCase());
|
||||
redactor.variant("patient", "diagnosis", "1", phi.substring(0, 25));
|
||||
assertThat(redactor.redact(phi.substring(0, 25))).isEqualTo(first);
|
||||
redactor.truncated("Detail: " + pii, "Detail: " + pii.substring(0, 20));
|
||||
redactor.observe("patient", "email", 1, pii, pii);
|
||||
redactor.truncated("Detail: " + pii, "Detail: " + pii.substring(0, 20));
|
||||
assertThat(redactor.redact("Detail: " + pii.substring(0, 20))).doesNotContain("CANARY");
|
||||
assertThat(redactor.redact(first)).isEqualTo(first);
|
||||
redactor.observe("patient", "diagnosis", 4, "quote\"line\n", "quote\"line\n");
|
||||
assertThat(redactor.payload(JsonUtils.toJson(Map.of("text", "quote\"line\n")))).doesNotContain("quote");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** No cloud dependency: both rows use the recording provider.
|
||||
*******************************************************************************/
|
||||
class PatientProviderType implements AssistantProviderType
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Fixture adapter.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getType()
|
||||
{
|
||||
return ("patient-test");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fixture adapter.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getLabel()
|
||||
{
|
||||
return ("Test");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fixture adapter.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<String> getModels()
|
||||
{
|
||||
return (List.of("test-model"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fixture adapter.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getDefaultApiKeyEnvName()
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fixture adapter.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getDefaultBaseUrl()
|
||||
{
|
||||
return ("http://localhost");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fixture adapter.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public AssistantModelProvider create(String apiKey, String baseUrl)
|
||||
{
|
||||
return (provider);
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -141,6 +141,27 @@ class TurnEngineTest extends BaseAssistantTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The panel reloads the conversation as soon as message.end arrives, so the
|
||||
** question and the answer must already be stored then, not after it.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testMessagesAreStoredBeforeMessageEnd() throws Exception
|
||||
{
|
||||
provider.thenText("Four posts are published.");
|
||||
|
||||
TurnRunner.Outcome outcome = runner.run(EDITOR, "how many published posts?");
|
||||
assertThat(outcome.end()).containsEntry("status", "COMPLETE");
|
||||
|
||||
List<AssistantMessage> stored = runner.messagesAtEnd();
|
||||
assertThat(stored).hasSize(2);
|
||||
assertThat(stored).extracting(AssistantMessage::getPartsJson).allSatisfy(parts -> assertThat(parts).isNotEqualTo("[]"));
|
||||
assertThat(stored.get(0).getPartsJson()).contains("how many published posts?");
|
||||
assertThat(stored.get(1).getPartsJson()).contains("Four posts are published.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Parallel tool calls: all results in ONE user message, in order.
|
||||
*******************************************************************************/
|
||||
|
||||
+818
@@ -0,0 +1,818 @@
|
||||
/*
|
||||
* 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.math.BigDecimal;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
import com.kingsrook.qbits.assistant.engine.ProviderHealth;
|
||||
import com.kingsrook.qbits.assistant.engine.ProviderService;
|
||||
import com.kingsrook.qbits.assistant.engine.UsageRecorder;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantBudget;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantModel;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantPolicy;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProfile;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProvider;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantUsage;
|
||||
import com.kingsrook.qbits.assistant.provider.fake.ScriptedModelProvider;
|
||||
import com.kingsrook.qbits.assistant.spi.AssistantModelProvider;
|
||||
import com.kingsrook.qbits.assistant.spi.AssistantProviderType;
|
||||
import com.kingsrook.qbits.assistant.spi.EmbeddingRequest;
|
||||
import com.kingsrook.qbits.assistant.spi.EmbeddingResponse;
|
||||
import com.kingsrook.qbits.assistant.spi.HealthResult;
|
||||
import com.kingsrook.qbits.assistant.spi.ModelProviderException;
|
||||
import com.kingsrook.qbits.assistant.spi.ModelUsage;
|
||||
import com.kingsrook.qbits.assistant.spi.ProviderSettings;
|
||||
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
|
||||
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.health.HealthCategory;
|
||||
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.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.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
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.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QUser;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Registration, deadlines, billing and secret safety through the memory backend.
|
||||
*******************************************************************************/
|
||||
class AssistantHealthChecksTest extends BaseAssistantTest
|
||||
{
|
||||
private static final String SECRET = "S3NT1NEL-secret";
|
||||
private final FakeType type = new FakeType();
|
||||
private Boolean healthy = true;
|
||||
private Boolean failProbe = false;
|
||||
private Boolean failCreate = false;
|
||||
private Boolean failEmbedding = false;
|
||||
private Integer probes = 0;
|
||||
private Integer embeddings = 0;
|
||||
private ProviderSettings lastSettings;
|
||||
private CountDownLatch probeWait;
|
||||
private final CountDownLatch interrupted = new CountDownLatch(1);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve a poisoned secret through the normal key-reference mechanism.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected void customizeConfig(AssistantQBitConfig config)
|
||||
{
|
||||
provider = new EmbeddingProvider();
|
||||
config.withModelProvider(provider).withProviderType(type).withAllowedApiKeyEnvNames(Set.of("HEALTH_TEST_KEY"))
|
||||
.withSecretResolver(name -> SECRET);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An adapter with no network and controllable provider failures.
|
||||
*******************************************************************************/
|
||||
class FakeType implements AssistantProviderType
|
||||
{
|
||||
/***************************************************************************
|
||||
** Fake provider contract.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public String getType()
|
||||
{
|
||||
return ("health-fake");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Fake provider contract.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public String getLabel()
|
||||
{
|
||||
return ("Health fake");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Fake provider contract.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public List<String> getModels()
|
||||
{
|
||||
return (List.of("model"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Fake provider contract.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public String getDefaultApiKeyEnvName()
|
||||
{
|
||||
return ("HEALTH_TEST_KEY");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Fake provider contract.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public String getDefaultBaseUrl()
|
||||
{
|
||||
return ("https://health.invalid");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Fake provider contract.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public String getDefaultLocation(String kind)
|
||||
{
|
||||
return ("LOCAL");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Reuse the existing scripted completion fake.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public AssistantModelProvider create(String key, String url)
|
||||
{
|
||||
assertThat(key).isEqualTo(SECRET);
|
||||
if(failCreate)
|
||||
{
|
||||
throw (new IllegalStateException(SECRET));
|
||||
}
|
||||
return (provider);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Verify bounded settings and exercise hostile exception messages.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public HealthResult checkHealth(ProviderSettings settings)
|
||||
{
|
||||
probes++;
|
||||
lastSettings = settings;
|
||||
assertThat(settings.getApiKey()).isEqualTo(SECRET);
|
||||
if(failProbe)
|
||||
{
|
||||
throw (new IllegalStateException(SECRET));
|
||||
}
|
||||
if(probeWait != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
probeWait.await();
|
||||
}
|
||||
catch(InterruptedException e)
|
||||
{
|
||||
interrupted.countDown();
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
return (new HealthResult(healthy, 7L, SECRET));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Keep embedding tests on the existing scripted-provider fixture as well.
|
||||
*******************************************************************************/
|
||||
class EmbeddingProvider extends ScriptedModelProvider
|
||||
{
|
||||
/***************************************************************************
|
||||
** Minimal embedding result; no external service is contacted.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public EmbeddingResponse embed(EmbeddingRequest request) throws ModelProviderException
|
||||
{
|
||||
embeddings++;
|
||||
if(failEmbedding)
|
||||
{
|
||||
throw (new ModelProviderException(ModelProviderException.Kind.NETWORK, null, SECRET));
|
||||
}
|
||||
return (new EmbeddingResponse(List.of(new float[] { 1, 0 }), new ModelUsage().withInputTokens(2L), "fake"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set up real settings records in the existing admin fixture session.
|
||||
*******************************************************************************/
|
||||
private AssistantProvider row() throws Exception
|
||||
{
|
||||
QContext.init(qInstance, AssistantTestFixture.sessionFor(AssistantTestFixture.ADMIN));
|
||||
return (config.getRuntime().getStore().insert(new AssistantProvider().withName("Health test")
|
||||
.withProviderType(type.getType()).withEnabled(true)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Enabled chat model with a stable integer id.
|
||||
*******************************************************************************/
|
||||
private AssistantModel model(AssistantProvider row) throws Exception
|
||||
{
|
||||
return (config.getRuntime().getStore().insert(new AssistantModel().withProviderId(row.getId()).withModelId("model").withEnabled(true)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Exercise the producer-installed suppliers, not a separately constructed check.
|
||||
*******************************************************************************/
|
||||
private HealthCheckMetaData check(String name) throws Exception
|
||||
{
|
||||
return (HealthChecksMetaData.ofOrWithNew(qInstance).resolveAll(qInstance).stream()
|
||||
.filter(check -> name.equals(check.getName())).findFirst().orElseThrow());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Raw outcomes must be clean; core's sanitize method is package-private.
|
||||
*******************************************************************************/
|
||||
private HealthCheckOutcome run(HealthCheckMetaData check) throws Exception
|
||||
{
|
||||
HealthCheckOutcome outcome = check.getCheckInstance().run(new HealthCheckInput().withQInstance(qInstance).withDeadline(Instant.now().plusSeconds(5)));
|
||||
assertThat(outcome.getMessage()).doesNotContain(SECRET, "health.invalid", "Connection test.", "\n");
|
||||
assertThat(outcome.getDetails().toJson()).doesNotContain(SECRET, "health.invalid", "Connection test.");
|
||||
return (outcome);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Both suppliers are installed by production, refresh enabled sets and use AI.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void registration() throws Exception
|
||||
{
|
||||
AssistantProvider local = row();
|
||||
AssistantProvider cloud = config.getRuntime().getStore().insert(new AssistantProvider().withName("Cloud")
|
||||
.withProviderType(type.getType()).withEnabled(true).withLocation("CLOUD"));
|
||||
AssistantModel model = model(local);
|
||||
model(cloud);
|
||||
config.getRuntime().getStore().insert(new AssistantProvider().withName("Disabled").withProviderType(type.getType()).withEnabled(false));
|
||||
config.getRuntime().getStore().insert(new AssistantModel().withProviderId(local.getId()).withModelId("disabled").withEnabled(false));
|
||||
var registry = HealthChecksMetaData.ofOrWithNew(qInstance);
|
||||
AssistantHealthChecks.register(qInstance);
|
||||
assertThat(registry.getSuppliers()).hasSize(2).allSatisfy(supplier -> assertThat(supplier.getComponent()).isEqualTo("assistant"));
|
||||
assertThat(registry.resolveAll(qInstance)).hasSize(5).allSatisfy(check ->
|
||||
{
|
||||
assertThat(check.getKind()).isEqualTo(HealthCheckKind.DEPENDENCY);
|
||||
assertThat(check.getCategory()).isEqualTo(HealthCategory.AI);
|
||||
assertThat(check.getComponent()).isEqualTo("assistant");
|
||||
assertThat(check.getName()).matches("[a-z0-9]+([._-][a-z0-9]+)*");
|
||||
});
|
||||
assertThat(check("assistant.provider." + local.getId()).getIntervalSeconds()).isEqualTo(60);
|
||||
assertThat(check("assistant.provider." + cloud.getId()).getIntervalSeconds()).isEqualTo(300);
|
||||
var modelCheck = check("assistant.model." + model.getId());
|
||||
assertThat(modelCheck.getIntervalSeconds()).isEqualTo(21600);
|
||||
assertThat(modelCheck.getTimeoutMs()).isEqualTo(30000);
|
||||
assertThat(check("assistant.provider." + local.getId()).getTimeoutMs()).isEqualTo(10000);
|
||||
config.getRuntime().getStore().updateFields(AssistantModel.TABLE_NAME, new QRecord().withValue("id", model.getId()).withValue("enabled", false));
|
||||
assertThat(registry.resolveAll(qInstance)).hasSize(4);
|
||||
assertThat(run(modelCheck).getStatus()).isEqualTo(HealthStatus.UNKNOWN);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** HealthResult messages and thrown adapter messages never enter outcomes.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void providerStatesAndNoSecrets() throws Exception
|
||||
{
|
||||
AssistantProvider row = row();
|
||||
var check = check("assistant.provider." + row.getId());
|
||||
assertThat(run(check).getStatus()).isEqualTo(HealthStatus.UP);
|
||||
assertThat(lastSettings.getRequestTimeoutSeconds()).isLessThanOrEqualTo(5);
|
||||
healthy = false;
|
||||
assertThat(run(check).getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
failProbe = true;
|
||||
assertThat(run(check).getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
failProbe = false;
|
||||
healthy = null;
|
||||
assertThat(run(check).getMessage()).isEqualTo("health probe not supported");
|
||||
for(String state : List.of("UP", "DOWN", "DEGRADED"))
|
||||
{
|
||||
config.getRuntime().getStore().updateFields(AssistantProvider.TABLE_NAME, new QRecord().withValue("id", row.getId())
|
||||
.withValue("healthState", state).withValue("healthCheckedAt", Instant.now().minusSeconds(30)));
|
||||
assertThat(run(check).getStatus()).isEqualTo(HealthStatus.valueOf(state));
|
||||
}
|
||||
for(Instant time : List.of(Instant.now().minusSeconds(600), Instant.now().plusSeconds(30)))
|
||||
{
|
||||
config.getRuntime().getStore().updateFields(AssistantProvider.TABLE_NAME, new QRecord().withValue("id", row.getId()).withValue("healthCheckedAt", time));
|
||||
assertThat(run(check).getStatus()).isEqualTo(HealthStatus.UNKNOWN);
|
||||
}
|
||||
config.getRuntime().getStore().updateFields(AssistantProvider.TABLE_NAME, new QRecord().withValue("id", row.getId()).withValue("enabled", false));
|
||||
assertThat(run(check).getStatus()).isEqualTo(HealthStatus.UNKNOWN);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Missing adapter/configuration is safe and does not dispatch a completion.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void missingAdapterAndSecret() throws Exception
|
||||
{
|
||||
AssistantProvider row = row();
|
||||
var check = check("assistant.provider." + row.getId());
|
||||
config.withSecretResolver(name -> null);
|
||||
assertThat(run(check).getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
config.getProviderTypes().remove(type.getType());
|
||||
assertThat(run(check).getStatus()).isEqualTo(HealthStatus.UNKNOWN);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Successful and failed completions use TEST and preserve manual test history.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void modelAccountingAndNoSecrets() throws Exception
|
||||
{
|
||||
AssistantModel model = model(row());
|
||||
Instant previous = Instant.now().minusSeconds(1000);
|
||||
config.getRuntime().getStore().updateFields(AssistantModel.TABLE_NAME, new QRecord().withValue("id", model.getId())
|
||||
.withValue("lastTestAt", previous).withValue("lastTestStatus", "OK"));
|
||||
var check = check("assistant.model." + model.getId());
|
||||
provider.thenText(SECRET);
|
||||
assertThat(run(check).getStatus()).isEqualTo(HealthStatus.UP);
|
||||
assertThat(provider.getRequests().getFirst().getMaxTokens()).isEqualTo(32);
|
||||
assertThat(probes).isZero();
|
||||
provider.thenError(ModelProviderException.Kind.NETWORK, SECRET);
|
||||
assertThat(run(check).getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
var usage = config.getRuntime().getStore().queryRecords(AssistantUsage.TABLE_NAME, new QQueryFilter(), 10);
|
||||
assertThat(usage).hasSize(2).allSatisfy(record -> assertThat(record.getValueString("purpose")).isEqualTo(UsageRecorder.PURPOSE_TEST));
|
||||
assertThat(config.getRuntime().getStore().listModels().getFirst().getLastTestAt()).isEqualTo(previous);
|
||||
failCreate = true;
|
||||
assertThat(run(check).getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Monthly cap skips even on-demand checks, before any billable request.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void modelCostCapAndDisabledProvider() throws Exception
|
||||
{
|
||||
AssistantProvider row = row();
|
||||
AssistantModel model = model(row);
|
||||
var check = check("assistant.model." + model.getId());
|
||||
config.getRuntime().getStore().updateFields(AssistantProvider.TABLE_NAME, new QRecord().withValue("id", row.getId()).withValue("monthlyCostCapUsd", BigDecimal.ZERO));
|
||||
var outcome = check.getCheckInstance().run(new HealthCheckInput().withQInstance(qInstance).withDeadline(Instant.now().plusSeconds(5)).withOnDemand(true));
|
||||
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.UNKNOWN);
|
||||
assertThat(outcome.getMessage()).isEqualTo("skipped: cost cap");
|
||||
assertThat(provider.getRequests()).isEmpty();
|
||||
assertThat(config.getRuntime().getStore().queryRecords(AssistantUsage.TABLE_NAME, new QQueryFilter(), 10)).isEmpty();
|
||||
config.getRuntime().getStore().updateFields(AssistantProvider.TABLE_NAME, new QRecord().withValue("id", row.getId()).withValue("enabled", false));
|
||||
assertThat(run(check).getStatus()).isEqualTo(HealthStatus.UNKNOWN);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The real runner identity records only its own usage and sees shared caps.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void automatedSessionAccountingAndSharedCap() throws Exception
|
||||
{
|
||||
AssistantProvider row = row();
|
||||
AssistantModel model = model(row);
|
||||
var check = check("assistant.model." + model.getId());
|
||||
QContext.init(qInstance, new QSession().withUser(new QUser().withIdReference("health_runner"))
|
||||
.withSecurityKeyValues(Map.of()).withPermissions("health.read", "health.write"));
|
||||
assertThat(run(check).getStatus()).isEqualTo(HealthStatus.UP);
|
||||
assertThat(QContext.getQSession().getSecurityKeyValues()).containsOnlyKeys(config.getOwnerSecurityKeyType());
|
||||
assertThat(QContext.getQSession().hasPermission("assistant.admin")).isFalse();
|
||||
var usage = config.getRuntime().getStore().queryRecords(AssistantUsage.TABLE_NAME, new QQueryFilter(), 10);
|
||||
assertThat(usage).hasSize(1);
|
||||
assertThat(usage.getFirst().getValueString("userId")).isEqualTo("health_runner");
|
||||
var healthSession = QContext.getQSession();
|
||||
QContext.init(qInstance, AssistantTestFixture.ownerSession(AssistantTestFixture.ADMIN, config));
|
||||
config.getRuntime().getStore().insert(new AssistantUsage().withUserId(AssistantTestFixture.EDITOR).withProviderId(row.getId())
|
||||
.withCostMicros(1_000_000L).withCreateDate(Instant.now()).withPurpose("TURN").withStatus("OK"));
|
||||
config.getRuntime().getStore().updateFields(AssistantProvider.TABLE_NAME, new QRecord().withValue("id", row.getId()).withValue("monthlyCostCapUsd", BigDecimal.ONE));
|
||||
QContext.init(qInstance, healthSession);
|
||||
assertThat(config.getRuntime().getProviderService().monthToDateCostMicros(row.getId())).isZero();
|
||||
assertThat(run(check).getMessage()).isEqualTo("skipped: cost cap");
|
||||
assertThat(provider.getRequests()).hasSize(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The kill switch is read fresh before every scheduled model call.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void modelKillSwitch() throws Exception
|
||||
{
|
||||
var check = check("assistant.model." + model(row()).getId());
|
||||
config.getRuntime().getStore().insert(new AssistantPolicy().withName("Stop").withKillSwitch(true));
|
||||
assertThat(run(check).getMessage()).isEqualTo("skipped: kill switch");
|
||||
assertThat(provider.getRequests()).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Embedding models reuse ConnectionTest's embedding path, never chat.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void embeddingAccountingAndNoSecrets() throws Exception
|
||||
{
|
||||
AssistantProvider row = row();
|
||||
AssistantModel model = config.getRuntime().getStore().insert(new AssistantModel().withProviderId(row.getId()).withModelId("embedding")
|
||||
.withModelKind("EMBEDDING").withEnabled(true));
|
||||
var check = check("assistant.model." + model.getId());
|
||||
assertThat(run(check).getStatus()).isEqualTo(HealthStatus.UP);
|
||||
failEmbedding = true;
|
||||
assertThat(run(check).getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
assertThat(embeddings).isEqualTo(2);
|
||||
assertThat(provider.getRequests()).isEmpty();
|
||||
assertThat(config.getRuntime().getStore().queryRecords(AssistantUsage.TABLE_NAME, new QQueryFilter(), 10))
|
||||
.hasSize(2).allSatisfy(record -> assertThat(record.getValueString("purpose")).isEqualTo("TEST"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Expired input performs no work; a stuck existing probe is interrupted.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void providerDeadline() throws Exception
|
||||
{
|
||||
var check = check("assistant.provider." + row().getId());
|
||||
assertThat(check.getCheckInstance().run(new HealthCheckInput().withQInstance(qInstance).withDeadline(Instant.now().minusSeconds(1))).getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
assertThat(probes).isZero();
|
||||
probeWait = new CountDownLatch(1);
|
||||
assertTimeoutPreemptively(Duration.ofSeconds(3), () ->
|
||||
{
|
||||
QContext.init(qInstance, AssistantTestFixture.sessionFor(AssistantTestFixture.ADMIN));
|
||||
try
|
||||
{
|
||||
var outcome = check.getCheckInstance().run(new HealthCheckInput().withQInstance(qInstance).withDeadline(Instant.now().plusMillis(200)));
|
||||
assertThat(outcome.getMessage()).isEqualTo("Health check timed out");
|
||||
assertThat(interrupted.await(1, TimeUnit.SECONDS)).isTrue();
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A slow completion cannot outlive the caller's deadline.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void modelDeadline() throws Exception
|
||||
{
|
||||
var check = check("assistant.model." + model(row()).getId());
|
||||
provider.withDelay(Duration.ofSeconds(10)).thenText("OK");
|
||||
assertTimeoutPreemptively(Duration.ofSeconds(3), () ->
|
||||
{
|
||||
QContext.init(qInstance, AssistantTestFixture.sessionFor(AssistantTestFixture.ADMIN));
|
||||
try
|
||||
{
|
||||
var outcome = check.getCheckInstance().run(new HealthCheckInput().withQInstance(qInstance).withDeadline(Instant.now().plusMillis(300)));
|
||||
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Drive the real runner cache without its scheduler or health-table writes.
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("unchecked")
|
||||
private HealthRunner cached(String name, HealthCheckOutcome outcome, Instant at) throws Exception
|
||||
{
|
||||
HealthRunner runner = new HealthRunner(qInstance, "test", Clock.fixed(at, ZoneOffset.UTC));
|
||||
var cache = HealthRunner.class.getDeclaredMethod("cacheOutcome", String.class, HealthCheckOutcome.class);
|
||||
cache.setAccessible(true);
|
||||
cache.invoke(runner, name, outcome);
|
||||
var runners = HealthRunner.class.getDeclaredField("RUNNERS");
|
||||
runners.setAccessible(true);
|
||||
((Map<QInstance, HealthRunner>) runners.get(null)).put(qInstance, runner);
|
||||
return (runner);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fresh central results avoid duplicate local probes; stale/unknown fall back.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void breakerUsesCentralResults() throws Exception
|
||||
{
|
||||
AssistantProvider row = row();
|
||||
var active = new ProviderService.Active(row, type.getType(), "model", provider);
|
||||
ProviderHealth breaker = new ProviderHealth(config.getRuntime());
|
||||
String name = "assistant.provider." + row.getId();
|
||||
for(HealthStatus status : List.of(HealthStatus.UP, HealthStatus.DEGRADED, HealthStatus.DOWN))
|
||||
{
|
||||
try(var central = cached(name, new HealthCheckOutcome().withStatus(status), Instant.now().minusSeconds(1)))
|
||||
{
|
||||
if(status == HealthStatus.DOWN)
|
||||
{
|
||||
assertThatThrownBy(() -> breaker.beforeCall(active)).isInstanceOf(ModelProviderException.class);
|
||||
}
|
||||
else
|
||||
{
|
||||
breaker.beforeCall(active);
|
||||
}
|
||||
assertThat(probes).isZero();
|
||||
}
|
||||
}
|
||||
try(var central = cached(name, HealthCheckOutcome.down("Down"), Instant.now().minusSeconds(60)))
|
||||
{
|
||||
breaker.beforeCall(active);
|
||||
assertThat(probes).isEqualTo(1);
|
||||
}
|
||||
config.getRuntime().getStore().updateFields(AssistantProvider.TABLE_NAME, new QRecord().withValue("id", row.getId()).withValue("healthCheckedAt", null));
|
||||
try(var central = cached(name, HealthCheckOutcome.unknown("Unsupported"), Instant.now()))
|
||||
{
|
||||
breaker.beforeCall(active);
|
||||
assertThat(probes).isEqualTo(2);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The replacement card is generic and links to filtered central health.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void centralHealthCard() throws Exception
|
||||
{
|
||||
AssistantProvider row = row();
|
||||
assertThat(qInstance.getWidget(AssistantHealthWidget.NAME).getType()).isEqualTo(WidgetType.STATISTICS.getType());
|
||||
var renderer = new AssistantHealthWidget();
|
||||
StatisticsData data = (StatisticsData) renderer.render(new RenderWidgetInput()).getWidgetData();
|
||||
assertThat(data.getCount()).isEqualTo("Not checked yet");
|
||||
assertThat(data.getCountContext()).contains("Provider checks run every 1 to 5 minutes");
|
||||
assertThat(data.getCountURL()).startsWith("/app/").contains("health_check", "assistant");
|
||||
try(var central = cached("assistant.provider." + row.getId(), HealthCheckOutcome.up("Reachable"), Instant.now()))
|
||||
{
|
||||
var cache = HealthRunner.class.getDeclaredMethod("cacheOutcome", String.class, HealthCheckOutcome.class);
|
||||
cache.setAccessible(true);
|
||||
cache.invoke(central, AssistantHealthChecks.CLIENT_ERRORS, HealthCheckOutcome.up("0 client errors reported in the last hour"));
|
||||
data = (StatisticsData) renderer.render(new RenderWidgetInput()).getWidgetData();
|
||||
assertThat(data.getCount()).isEqualTo("Healthy");
|
||||
|
||||
//////////////////////////////////////////////////////////////////
|
||||
// many client errors in the last hour show on the card as well //
|
||||
//////////////////////////////////////////////////////////////////
|
||||
cache.invoke(central, AssistantHealthChecks.CLIENT_ERRORS, HealthCheckOutcome.degraded("25 client errors reported in the last hour"));
|
||||
data = (StatisticsData) renderer.render(new RenderWidgetInput()).getWidgetData();
|
||||
assertThat(data.getCount()).isEqualTo("Degraded");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A second enabled provider, not the default.
|
||||
*******************************************************************************/
|
||||
private AssistantProvider optionalRow(AssistantProvider defaultRow) throws Exception
|
||||
{
|
||||
config.getRuntime().getStore().updateFields(AssistantProvider.TABLE_NAME, new QRecord().withValue("id", defaultRow.getId()).withValue("isDefault", true).withValue("name", "Claude"));
|
||||
AssistantProvider optional = config.getRuntime().getStore().insert(new AssistantProvider().withName("lore (local GPU)")
|
||||
.withProviderType(type.getType()).withEnabled(true).withIsDefault(false));
|
||||
config.getRuntime().getProviderService().invalidate();
|
||||
return (optional);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An unreachable optional provider is DEGRADED and names the default; the
|
||||
** default stays DOWN, and the breaker still refuses the optional provider.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void optionalProviderIsDegradedDefaultIsDown() throws Exception
|
||||
{
|
||||
AssistantProvider defaultRow = row();
|
||||
AssistantProvider lore = optionalRow(defaultRow);
|
||||
healthy = false;
|
||||
|
||||
HealthCheckOutcome outcome = run(check("assistant.provider." + lore.getId()));
|
||||
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DEGRADED);
|
||||
assertThat(outcome.getMessage()).isEqualTo("Provider 'lore (local GPU)' is unreachable; it is optional (not the default and not the only route), so requests use 'Claude'");
|
||||
assertThat(outcome.getDetails().getValues()).containsEntry("reachable", false).containsEntry("optional", true).containsEntry("providerId", lore.getId());
|
||||
assertThat(ProviderHealth.isUnreachable(outcome)).isTrue();
|
||||
|
||||
HealthCheckOutcome required = run(check("assistant.provider." + defaultRow.getId()));
|
||||
assertThat(required.getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
assertThat(required.getDetails().getValues()).containsEntry("required", "default provider").containsEntry("reachable", false);
|
||||
|
||||
healthy = true;
|
||||
assertThat(run(check("assistant.provider." + lore.getId())).getStatus()).isEqualTo(HealthStatus.UP);
|
||||
assertThat(ProviderHealth.isUnreachable(HealthCheckOutcome.degraded("Slow"))).isFalse();
|
||||
assertThat(ProviderHealth.isUnreachable(null)).isFalse();
|
||||
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// a fresh DEGRADED-but-unreachable central result opens the breaker //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
var active = new ProviderService.Active(lore, type.getType(), "model", provider);
|
||||
try(var central = cached("assistant.provider." + lore.getId(), outcome, Instant.now().minusSeconds(1)))
|
||||
{
|
||||
assertThatThrownBy(() -> new ProviderHealth(config.getRuntime()).beforeCall(active)).isInstanceOf(ModelProviderException.class);
|
||||
assertThat(probes).isEqualTo(3);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Model checks follow their provider: DEGRADED when optional.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void optionalProviderModelIsDegraded() throws Exception
|
||||
{
|
||||
AssistantProvider defaultRow = row();
|
||||
AssistantProvider lore = optionalRow(defaultRow);
|
||||
AssistantModel model = model(lore);
|
||||
provider.thenError(ModelProviderException.Kind.NETWORK, SECRET);
|
||||
HealthCheckOutcome outcome = run(check("assistant.model." + model.getId()));
|
||||
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DEGRADED);
|
||||
assertThat(outcome.getMessage()).startsWith("Model connection failed; provider 'lore (local GPU)' is optional").endsWith("so requests use 'Claude'");
|
||||
assertThat(outcome.getDetails().getValues()).doesNotContainKey("reachable").containsEntry("modelId", model.getId());
|
||||
|
||||
AssistantModel defaultModel = model(defaultRow);
|
||||
provider.thenError(ModelProviderException.Kind.NETWORK, SECRET);
|
||||
assertThat(run(check("assistant.model." + defaultModel.getId())).getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A provider that is the only route for routing or a budget downgrade stays
|
||||
** DOWN; an explicit fallback on another provider makes it optional again.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void soleRouteStaysDown() throws Exception
|
||||
{
|
||||
AssistantProvider defaultRow = row();
|
||||
config.getRuntime().getStore().updateFields(AssistantProvider.TABLE_NAME, new QRecord().withValue("id", defaultRow.getId()).withValue("location", "CLOUD"));
|
||||
AssistantProvider lore = optionalRow(defaultRow);
|
||||
AssistantModel loreModel = model(lore);
|
||||
AssistantModel defaultModel = model(defaultRow);
|
||||
healthy = false;
|
||||
var check = check("assistant.provider." + lore.getId());
|
||||
assertThat(run(check).getStatus()).isEqualTo(HealthStatus.DEGRADED);
|
||||
|
||||
AssistantProfile profile = config.getRuntime().getStore().insert(new AssistantProfile().withName("routing").withDisplayName("Assistant").withEnabled(true)
|
||||
.withRoutingMode("HEURISTIC").withSimpleModelId(loreModel.getId()).withComplexModelId(defaultModel.getId()));
|
||||
HealthCheckOutcome outcome = run(check);
|
||||
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
assertThat(outcome.getDetails().getValues()).containsEntry("required", "only route for a configured model");
|
||||
|
||||
config.getRuntime().getStore().updateFields(AssistantModel.TABLE_NAME, new QRecord().withValue("id", loreModel.getId()).withValue("fallbackModelId", defaultModel.getId()));
|
||||
assertThat(run(check).getStatus()).isEqualTo(HealthStatus.DEGRADED);
|
||||
|
||||
config.getRuntime().getStore().updateFields(AssistantProvider.TABLE_NAME, new QRecord().withValue("id", defaultRow.getId()).withValue("setupState", AssistantProvider.SETUP_STATE_SETTING_UP));
|
||||
config.getRuntime().getProviderService().invalidate();
|
||||
assertThat(run(check).getStatus()).as("lore is the only usable provider left, hence the default").isEqualTo(HealthStatus.DOWN);
|
||||
config.getRuntime().getStore().updateFields(AssistantProvider.TABLE_NAME, new QRecord().withValue("id", defaultRow.getId()).withValue("setupState", AssistantProvider.SETUP_STATE_ACTIVE));
|
||||
config.getRuntime().getProviderService().invalidate();
|
||||
|
||||
config.getRuntime().getStore().updateFields(AssistantModel.TABLE_NAME, new QRecord().withValue("id", loreModel.getId()).withValue("fallbackModelId", null));
|
||||
config.getRuntime().getStore().updateFields(AssistantProfile.TABLE_NAME, new QRecord().withValue("id", profile.getId()).withValue("routingMode", "OFF"));
|
||||
assertThat(run(check).getStatus()).isEqualTo(HealthStatus.DEGRADED);
|
||||
|
||||
config.getRuntime().getStore().insert(new AssistantBudget().withName("Downgrade").withScopeType("INSTANCE").withPeriod("MONTH").withMetric("COST_USD")
|
||||
.withLimitValue(BigDecimal.ONE).withEnabled(true).withDowngradeModelId(loreModel.getId()));
|
||||
assertThat(run(check).getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Another local provider of the same type is the automatic fallback.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void localPoolIsAFallback() throws Exception
|
||||
{
|
||||
AssistantProvider defaultRow = row();
|
||||
AssistantProvider lore = optionalRow(defaultRow);
|
||||
AssistantModel loreModel = model(lore);
|
||||
model(defaultRow);
|
||||
config.getRuntime().getStore().insert(new AssistantProfile().withName("routing").withDisplayName("Assistant").withEnabled(true)
|
||||
.withRoutingMode("HEURISTIC").withSimpleModelId(loreModel.getId()).withComplexModelId(loreModel.getId()));
|
||||
healthy = false;
|
||||
assertThat(run(check("assistant.provider." + lore.getId())).getStatus()).isEqualTo(HealthStatus.DEGRADED);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setting up: no checks registered, no routing, and an already resolved check
|
||||
** reports UP without probing.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void settingUpSkipsChecksAndRouting() throws Exception
|
||||
{
|
||||
AssistantProvider defaultRow = row();
|
||||
AssistantProvider lore = optionalRow(defaultRow);
|
||||
AssistantModel model = model(lore);
|
||||
assertThat(lore.getSetupState()).isEqualTo(AssistantProvider.SETUP_STATE_ACTIVE);
|
||||
var providerCheck = check("assistant.provider." + lore.getId());
|
||||
var modelCheck = check("assistant.model." + model.getId());
|
||||
|
||||
config.getRuntime().getStore().updateFields(AssistantProvider.TABLE_NAME, new QRecord().withValue("id", lore.getId()).withValue("setupState", AssistantProvider.SETUP_STATE_SETTING_UP));
|
||||
config.getRuntime().getProviderService().invalidate();
|
||||
assertThat(HealthChecksMetaData.ofOrWithNew(qInstance).resolveAll(qInstance)).extracting(HealthCheckMetaData::getName)
|
||||
.doesNotContain("assistant.provider." + lore.getId(), "assistant.model." + model.getId())
|
||||
.contains("assistant.provider." + defaultRow.getId());
|
||||
Integer before = probes;
|
||||
assertThat(run(providerCheck).getMessage()).isEqualTo(AssistantHealthChecks.NOT_SET_UP);
|
||||
assertThat(run(modelCheck).getStatus()).isEqualTo(HealthStatus.UP);
|
||||
assertThat(probes).isEqualTo(before);
|
||||
assertThat(provider.getRequests()).isEmpty();
|
||||
|
||||
assertThat(config.getRuntime().getProviderService().resolveModel(model.getId(), false)).isNull();
|
||||
assertThatThrownBy(() -> new ProviderHealth(config.getRuntime()).beforeCall(new ProviderService.Active(lore, type.getType(), "model", provider)))
|
||||
.hasMessageContaining("not set up yet");
|
||||
|
||||
config.getRuntime().getStore().updateFields(AssistantProvider.TABLE_NAME, new QRecord().withValue("id", lore.getId()).withValue("isDefault", true));
|
||||
config.getRuntime().getProviderService().invalidate();
|
||||
assertThat(config.getRuntime().getProviderService().activeRow().getId()).as("a default that is setting up is skipped").isEqualTo(defaultRow.getId());
|
||||
|
||||
assertThatThrownBy(() -> config.getRuntime().getStore().insert(new AssistantProvider().withName("Bad").withProviderType(type.getType()).withEnabled(true).withSetupState("BOGUS")))
|
||||
.hasMessageContaining("Setup state must be Active or Setting up.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** If routes cannot be read the failure stays DOWN; other statuses pass through.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void optionalPassThrough() throws Exception
|
||||
{
|
||||
assertThat(AssistantHealthChecks.optional(config.getRuntime(), null, null, false)).isNull();
|
||||
HealthCheckOutcome up = HealthCheckOutcome.up("fine");
|
||||
assertThat(AssistantHealthChecks.optional(config.getRuntime(), 1, up, false)).isSameAs(up);
|
||||
assertThat(AssistantHealthChecks.optional(config.getRuntime(), null, HealthCheckOutcome.down("x"), true).getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
assertThat(AssistantHealthChecks.optional(null, 1, HealthCheckOutcome.down("x"), false).getStatus()).isEqualTo(HealthStatus.DOWN);
|
||||
}
|
||||
}
|
||||
+513
@@ -0,0 +1,513 @@
|
||||
/*
|
||||
* 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.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import com.kingsrook.qbits.assistant.AssistantRuntime;
|
||||
import com.kingsrook.qbits.assistant.api.AssistantApi;
|
||||
import com.kingsrook.qbits.assistant.engine.RunningTurn;
|
||||
import com.kingsrook.qbits.assistant.engine.TurnEvent;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantConversation;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantTurn;
|
||||
import com.kingsrook.qbits.assistant.provider.fake.ScriptedModelProvider;
|
||||
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.StreamHandle;
|
||||
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
|
||||
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
|
||||
import com.kingsrook.qbits.assistant.uiapi.CoreUiApiGateway;
|
||||
import com.kingsrook.qbits.assistant.uiapi.UiApiCall;
|
||||
import com.kingsrook.qbits.assistant.uiapi.UiApiResult;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
|
||||
import com.kingsrook.qqq.backend.core.jobs.DurableJobsMetaData;
|
||||
import com.kingsrook.qqq.backend.core.jobs.DurableJobsMetaDataProvider;
|
||||
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.JobProgressSnapshot;
|
||||
import com.kingsrook.qqq.backend.core.jobs.runtime.DurableJobsRuntime;
|
||||
import com.kingsrook.qqq.backend.core.jobs.store.DurableJobStore;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.EDITOR;
|
||||
import static com.kingsrook.qqq.backend.core.jobs.DurableJobsTables.TABLE_JOB;
|
||||
import static com.kingsrook.qqq.backend.core.jobs.DurableJobsTables.TABLE_LOG;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Real Jobs tray rows, progress and cancellation for assistant answers.
|
||||
*******************************************************************************/
|
||||
class JobTrackingRunTrackerTest extends BaseAssistantTest
|
||||
{
|
||||
private static final String PROMPT = "PRIVATE-PROMPT-JOBS-CANARY";
|
||||
private static final String ANSWER = "PRIVATE-ANSWER-JOBS-CANARY";
|
||||
|
||||
private final CountDownLatch toolEntered = new CountDownLatch(1);
|
||||
private final CountDownLatch toolRelease = new CountDownLatch(1);
|
||||
private DurableJobStore jobs;
|
||||
private AssistantApi api;
|
||||
private ControlledProvider controlled;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add jobs only to this fixture, leaving runtime startup explicit per test.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void initializeJobs() throws Exception
|
||||
{
|
||||
new DurableJobsMetaDataProvider().defineAll(qInstance, AssistantTestFixture.BACKEND, null);
|
||||
DurableJobsMetaData settings = new DurableJobsMetaData().withWorkerThreads(1).withLeaseTtlSeconds(3)
|
||||
.withReaperIntervalSeconds(1).withPollIntervalMillis(50).withPodId("test-pod");
|
||||
qInstance.withSupplementalMetaData(settings);
|
||||
new QInstanceEnricher(qInstance).enrich();
|
||||
jobs = new DurableJobStore(settings);
|
||||
|
||||
config.getRuntime().close();
|
||||
config.withUiApiGateway(new CoreUiApiGateway(config::getUiApiBaseUrl, qInstance)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Hold the real count call so the tool phase can be inspected in storage.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public UiApiResult invoke(UiApiCall call) throws QException
|
||||
{
|
||||
if("/table/{tableName}/count".equals(call.getPathTemplate()))
|
||||
{
|
||||
toolEntered.countDown();
|
||||
try
|
||||
{
|
||||
assertThat(toolRelease.await(15, TimeUnit.SECONDS)).as("tool released").isTrue();
|
||||
}
|
||||
catch(InterruptedException e)
|
||||
{
|
||||
Thread.currentThread().interrupt();
|
||||
throw (new QException("Test tool interrupted", e));
|
||||
}
|
||||
}
|
||||
return (super.invoke(call));
|
||||
}
|
||||
});
|
||||
config.setRuntime(new AssistantRuntime(qInstance, config));
|
||||
var session = AssistantTestFixture.ownerSession(EDITOR, config);
|
||||
QContext.init(qInstance, session);
|
||||
api = new AssistantApi(config.getRuntime(), session);
|
||||
assertThat(config.getRunTracker()).isExactlyInstanceOf(JobTrackingRunTracker.class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Release every gate before draining both runtimes, even after an assertion.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void stopJobs()
|
||||
{
|
||||
toolRelease.countDown();
|
||||
if(controlled != null)
|
||||
{
|
||||
controlled.handles.forEach(handle -> handle.release.countDown());
|
||||
}
|
||||
config.getRuntime().close();
|
||||
DurableJobsRuntime.stop(qInstance, Duration.ofSeconds(5));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A single linked owner job progresses through thinking, tool and writing.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void answerTracksProgressAndCompletes() throws Exception
|
||||
{
|
||||
assertThat(DurableJobsRuntime.start(qInstance)).isNotNull();
|
||||
ControlledHandle thinking = new ControlledHandle(new ModelResponse().withParts(List.of(
|
||||
ModelPart.toolUse("count-1", "count_records", input("table", "blog_post", "filter", filter("AND")))))
|
||||
.withStopReason(ModelResponse.STOP_TOOL_USE), null, false);
|
||||
ControlledHandle writing = answer(false);
|
||||
useProvider(thinking, writing);
|
||||
Integer conversationId = (Integer) api.createConversation(Map.of()).get("id");
|
||||
RunningTurn run = api.startTurn(conversationId, Map.of("text", PROMPT));
|
||||
entered(thinking);
|
||||
QRecord job = onlyJob();
|
||||
Integer jobId = job.getValueInteger("id");
|
||||
assertThat(job.getValueString("processName")).isEqualTo("assistant.turn");
|
||||
assertThat(job.getValueString("label")).isEqualTo("Assistant answer");
|
||||
assertThat(job.getValueString("userId")).isEqualTo(EDITOR);
|
||||
assertThat(job.getValueString("tableName")).isEqualTo(AssistantConversation.TABLE_NAME);
|
||||
assertThat(JsonUtils.toObject(job.getValueString("sourceJson"), Map.class)).containsEntry("recordId", conversationId);
|
||||
assertPhase(jobId, "thinking", null);
|
||||
|
||||
thinking.release.countDown();
|
||||
assertThat(toolEntered.await(15, TimeUnit.SECONDS)).as("tool entered").isTrue();
|
||||
assertPhase(jobId, "tool", "Counting records");
|
||||
toolRelease.countDown();
|
||||
entered(writing);
|
||||
assertPhase(jobId, "writing", null);
|
||||
writing.release.countDown();
|
||||
|
||||
assertThat(completed(run).getStatus()).isEqualTo("COMPLETE");
|
||||
job = onlyJob();
|
||||
assertThat(job.getValueInteger("id")).isEqualTo(jobId);
|
||||
assertThat(job.getValueString("status")).isEqualTo("COMPLETED");
|
||||
assertThat(job.getValueString("progressMessage")).isEqualTo("Answered.");
|
||||
assertThat(job.getValueString("statusReason")).isEqualTo("Answered.");
|
||||
assertThat(run.getLog().after(0, 1)).filteredOn(event -> TurnEvent.TOOL_RESULT.equals(event.getType()))
|
||||
.singleElement().satisfies(event -> assertThat(event.getData()).containsEntry("outcome", "OK"));
|
||||
assertPrivateTextAbsent();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The runtime's periodic tick observes the same row change as Jobs tray Stop.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void jobsTrayStopCancelsAnswer() throws Exception
|
||||
{
|
||||
DurableJobsRuntime.start(qInstance);
|
||||
ControlledHandle writing = answer(false);
|
||||
useProvider(writing);
|
||||
RunningTurn run = startTurn();
|
||||
entered(writing);
|
||||
Integer jobId = onlyJob().getValueInteger("id");
|
||||
jobs.updateJob(jobId, Map.of("status", "CANCELLING"));
|
||||
|
||||
assertThat(completed(run).getStatus()).isEqualTo("CANCELLED");
|
||||
assertThat(writing.isCancelled()).isTrue();
|
||||
assertThat(onlyJob().getValueString("status")).isEqualTo("CANCELLED");
|
||||
assertThat(onlyJob().getValueString("progressMessage")).isEqualTo("Stopped.");
|
||||
assertPrivateTextAbsent();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Even partial output and provider errors containing private text stay out.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void failedAnswerHasOnlyFixedJobMessages() throws Exception
|
||||
{
|
||||
DurableJobsRuntime.start(qInstance);
|
||||
ControlledHandle writing = answer(true);
|
||||
useProvider(writing);
|
||||
RunningTurn run = startTurn();
|
||||
entered(writing);
|
||||
writing.release.countDown();
|
||||
|
||||
assertThat(completed(run).getStatus()).isEqualTo("ERROR");
|
||||
QRecord job = onlyJob();
|
||||
assertThat(job.getValueString("status")).isEqualTo("FAILED");
|
||||
assertThat(job.getValueString("progressMessage")).isEqualTo("The answer did not finish.");
|
||||
assertThat(job.getValueString("statusReason")).isEqualTo("The answer did not finish.");
|
||||
assertThat(jobs.query(TABLE_LOG, new QQueryFilter())).isNotEmpty();
|
||||
assertPrivateTextAbsent();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Hosts without jobs keep ordinary answers and safe null tracker callbacks.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void absentRuntimeDoesNotTrackOrDisruptAnswer() throws Exception
|
||||
{
|
||||
assertThat(DurableJobsRuntime.get(qInstance)).isNull();
|
||||
AssistantRunTracker tracker = config.getRunTracker();
|
||||
assertThat(tracker.start(EDITOR, 1, 1, "ignored")).isNull();
|
||||
tracker.progress(null, "thinking", PROMPT);
|
||||
tracker.onCancel(null, () ->
|
||||
{
|
||||
throw (new AssertionError("No job to cancel"));
|
||||
});
|
||||
tracker.finish(null, "COMPLETE", ANSWER);
|
||||
provider.thenText(ANSWER);
|
||||
RunningTurn run = startTurn();
|
||||
assertThat(completed(run).getStatus()).isEqualTo("COMPLETE");
|
||||
assertThat(jobs.query(TABLE_JOB, new QQueryFilter())).isEmpty();
|
||||
assertThat(jobs.query(TABLE_LOG, new QQueryFilter())).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Core mutates phase objects; every new job must start with fresh phases.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void jobsHaveIndependentPhaseState() throws Exception
|
||||
{
|
||||
DurableJobsRuntime.start(qInstance);
|
||||
AssistantRunTracker tracker = config.getRunTracker();
|
||||
Integer conversationId = (Integer) api.createConversation(Map.of()).get("id");
|
||||
String first = tracker.start(EDITOR, 1, conversationId, PROMPT);
|
||||
tracker.progress(first, "writing", ANSWER);
|
||||
String second = tracker.start(EDITOR, 2, conversationId, PROMPT);
|
||||
QRecord secondJob = jobs.getJob(Integer.valueOf(second));
|
||||
JobProgressSnapshot snapshot = JsonUtils.toObject(secondJob.getValueString("progressJson"), JobProgressSnapshot.class);
|
||||
assertThat(snapshot.getPhases()).extracting(JobPhase::getStatus)
|
||||
.containsExactly(JobPhase.Status.PENDING, JobPhase.Status.PENDING, JobPhase.Status.PENDING);
|
||||
tracker.progress(second, "thinking", PROMPT);
|
||||
tracker.progress(first, "unknown", PROMPT);
|
||||
tracker.progress("missing", "tool", PROMPT);
|
||||
assertPhase(Integer.valueOf(first), "writing", null);
|
||||
assertPhase(Integer.valueOf(second), "thinking", null);
|
||||
tracker.finish(first, "COMPLETE", ANSWER);
|
||||
tracker.finish(second, "COMPLETE", ANSWER);
|
||||
assertPrivateTextAbsent();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Terminal mapping is fixed, including unknown and absent assistant statuses.
|
||||
*******************************************************************************/
|
||||
@ParameterizedTest
|
||||
@CsvSource({ "COMPLETE,COMPLETED,Answered.", "CANCELLED,CANCELLED,Stopped.",
|
||||
"ERROR,FAILED,The answer did not finish.", "unknown,FAILED,The answer did not finish.",
|
||||
",FAILED,The answer did not finish." })
|
||||
void terminalMapping(String status, JobStatus expected, String message)
|
||||
{
|
||||
assertThat(JobTrackingRunTracker.jobStatus(status)).isEqualTo(expected);
|
||||
assertThat(JobTrackingRunTracker.finishMessage(status)).isEqualTo(message);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Use independently controlled model calls without changing the tracker.
|
||||
*******************************************************************************/
|
||||
private void useProvider(ControlledHandle... handles)
|
||||
{
|
||||
controlled = new ControlledProvider(List.of(handles));
|
||||
config.withModelProvider(controlled);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Partial text is emitted before this response is released or fails.
|
||||
*******************************************************************************/
|
||||
private ControlledHandle answer(boolean fail)
|
||||
{
|
||||
return (new ControlledHandle(new ModelResponse().withParts(List.of(ModelPart.text(ANSWER)))
|
||||
.withStopReason(ModelResponse.STOP_END_TURN), ANSWER, fail));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Create a conversation and answer as the fixture's editor.
|
||||
*******************************************************************************/
|
||||
private RunningTurn startTurn() throws Exception
|
||||
{
|
||||
Integer conversationId = (Integer) api.createConversation(Map.of()).get("id");
|
||||
return (api.startTurn(conversationId, Map.of("text", PROMPT)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Wait until a model call has emitted its optional text and is blocked.
|
||||
*******************************************************************************/
|
||||
private void entered(ControlledHandle handle) throws InterruptedException
|
||||
{
|
||||
assertThat(handle.entered.await(15, TimeUnit.SECONDS)).as("model call entered").isTrue();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Check persisted progress while the answer is still running.
|
||||
*******************************************************************************/
|
||||
private void assertPhase(Integer jobId, String phase, String message) throws QException
|
||||
{
|
||||
QRecord job = jobs.getJob(jobId);
|
||||
assertThat(job.getValueString("status")).isEqualTo("RUNNING");
|
||||
assertThat(job.getValueString("currentPhase")).isEqualTo(phase);
|
||||
assertThat(job.getValueString("progressMessage")).isEqualTo(message);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Assert uniqueness across all jobs, not just those matching this turn.
|
||||
*******************************************************************************/
|
||||
private QRecord onlyJob() throws QException
|
||||
{
|
||||
List<QRecord> rows = jobs.query(TABLE_JOB, new QQueryFilter());
|
||||
assertThat(rows).hasSize(1);
|
||||
return (rows.getFirst());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Inspect every persisted job and log field for both private canaries.
|
||||
*******************************************************************************/
|
||||
private void assertPrivateTextAbsent() throws QException
|
||||
{
|
||||
for(String table : List.of(TABLE_JOB, TABLE_LOG))
|
||||
{
|
||||
assertThat(jobs.query(table, new QQueryFilter())).allSatisfy(row ->
|
||||
assertThat(JsonUtils.toJson(row)).doesNotContain(PROMPT, ANSWER));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Wait for finalization and cleanup before reading the assistant turn.
|
||||
*******************************************************************************/
|
||||
private AssistantTurn completed(RunningTurn run) throws Exception
|
||||
{
|
||||
await(() -> config.getRuntime().getRunningTurn(run.getTurnId()) == null);
|
||||
assertThat(run.getLog().isClosed()).isTrue();
|
||||
return (config.getRuntime().getStore().getTurn(EDITOR, run.getTurnId()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bound polling by observable completion rather than fixed timing guesses.
|
||||
*******************************************************************************/
|
||||
private static void await(Callable<Boolean> condition) throws Exception
|
||||
{
|
||||
long deadline = System.nanoTime() + Duration.ofSeconds(15).toNanos();
|
||||
while(!condition.call() && System.nanoTime() < deadline)
|
||||
{
|
||||
Thread.sleep(10);
|
||||
}
|
||||
assertThat(condition.call()).as("condition reached within fifteen seconds").isTrue();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Script model responses with separate gates for thinking and writing.
|
||||
*******************************************************************************/
|
||||
private static class ControlledProvider extends ScriptedModelProvider
|
||||
{
|
||||
private final List<ControlledHandle> handles;
|
||||
private final AtomicInteger next = new AtomicInteger();
|
||||
|
||||
/*******************************************************************************
|
||||
** Retain the scripted handles for teardown and assertions.
|
||||
*******************************************************************************/
|
||||
ControlledProvider(List<ControlledHandle> handles)
|
||||
{
|
||||
this.handles = handles;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Publish partial text, then let the engine wait on the selected handle.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public StreamHandle stream(ModelRequest request, ModelEventListener listener)
|
||||
{
|
||||
ControlledHandle handle = handles.get(next.getAndIncrement());
|
||||
if(handle.text != null)
|
||||
{
|
||||
listener.onTextDelta(handle.text);
|
||||
}
|
||||
return (handle);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A bounded, cancellable model call that can fail after streaming text.
|
||||
*******************************************************************************/
|
||||
private static class ControlledHandle implements StreamHandle
|
||||
{
|
||||
private final ModelResponse response;
|
||||
private final String text;
|
||||
private final boolean fail;
|
||||
private final CountDownLatch entered = new CountDownLatch(1);
|
||||
private final CountDownLatch release = new CountDownLatch(1);
|
||||
private volatile boolean cancelled;
|
||||
|
||||
/*******************************************************************************
|
||||
** Set the eventual response and optional partial-output failure.
|
||||
*******************************************************************************/
|
||||
ControlledHandle(ModelResponse response, String text, boolean fail)
|
||||
{
|
||||
this.response = response;
|
||||
this.text = text;
|
||||
this.fail = fail;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Stop unblocks the engine without test intervention.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void cancel()
|
||||
{
|
||||
cancelled = true;
|
||||
release.countDown();
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Report whether the real cancellation callback reached the provider.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean isCancelled()
|
||||
{
|
||||
return (cancelled);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Wait until the test or the Jobs tray releases this model call.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ModelResponse await(Duration timeout) throws ModelProviderException, InterruptedException
|
||||
{
|
||||
entered.countDown();
|
||||
if(!release.await(timeout.toMillis(), TimeUnit.MILLISECONDS))
|
||||
{
|
||||
throw (new ModelProviderException(ModelProviderException.Kind.TIMEOUT, null, "Test provider timed out"));
|
||||
}
|
||||
if(cancelled)
|
||||
{
|
||||
return (new ModelResponse().withStopReason(ModelResponse.STOP_CANCELLED));
|
||||
}
|
||||
if(fail)
|
||||
{
|
||||
throw (new ModelProviderException(ModelProviderException.Kind.OTHER, null, PROMPT + " " + ANSWER));
|
||||
}
|
||||
return (response);
|
||||
}
|
||||
}
|
||||
}
|
||||
+390
@@ -0,0 +1,390 @@
|
||||
/*
|
||||
* 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.persistence;
|
||||
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
import org.testcontainers.utility.DockerImageName;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Guardrail for the reference DDL (docs/schema-postgres.sql) on a real
|
||||
** PostgreSQL: every relation the qbit declares (a table possible-value source
|
||||
** on an assistant_ field, or a join between assistant_ tables), plus the
|
||||
** relations only the code knows, has a foreign key with the intended ON DELETE
|
||||
** rule, and every foreign key column leads an index. A new relation without a
|
||||
** foreign key fails here unless it is added to EXEMPT with a reason.
|
||||
*******************************************************************************/
|
||||
@Testcontainers(disabledWithoutDocker = true)
|
||||
class SchemaForeignKeyPostgresTest extends BaseAssistantTest
|
||||
{
|
||||
@Container
|
||||
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>(DockerImageName.parse("pgvector/pgvector:pg16").asCompatibleSubstituteFor("postgres"));
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// relations the meta-data does not declare (no possible-value source) //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
private static final List<Relation> CODE_ONLY = List.of(
|
||||
new Relation("assistant_data_audit", "turn_id", "assistant_turn"),
|
||||
new Relation("assistant_data_audit", "provider_id", "assistant_provider"),
|
||||
new Relation("assistant_budget_usage", "provider_id", "assistant_provider"));
|
||||
|
||||
//////////////////////////////////////////////////////////////////
|
||||
// relations that deliberately have no foreign key, with reason //
|
||||
//////////////////////////////////////////////////////////////////
|
||||
private static final Map<Relation, String> EXEMPT = Map.of(
|
||||
new Relation("assistant_budget_usage", "usage_id", "assistant_usage"),
|
||||
"budget accounting projection: it must outlive its usage row so budgets keep counting the spend");
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// the intended ON DELETE rule (pg confdeltype: a NO ACTION, c CASCADE, //
|
||||
// n SET NULL) - see the foreign key block in docs/schema-postgres.sql //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
private static final Map<Relation, String> RULES = new LinkedHashMap<>();
|
||||
|
||||
static
|
||||
{
|
||||
rule("assistant_message", "conversation_id", "assistant_conversation", "c");
|
||||
rule("assistant_message", "parent_message_id", "assistant_message", "n");
|
||||
rule("assistant_message", "turn_id", "assistant_turn", "n");
|
||||
rule("assistant_turn", "conversation_id", "assistant_conversation", "c");
|
||||
rule("assistant_turn", "user_message_id", "assistant_message", "n");
|
||||
rule("assistant_turn", "assistant_message_id", "assistant_message", "n");
|
||||
rule("assistant_turn_event", "turn_id", "assistant_turn", "c");
|
||||
rule("assistant_tool_call", "conversation_id", "assistant_conversation", "c");
|
||||
rule("assistant_tool_call", "turn_id", "assistant_turn", "c");
|
||||
rule("assistant_tool_call", "pending_action_id", "assistant_pending_action", "n");
|
||||
rule("assistant_feedback", "conversation_id", "assistant_conversation", "c");
|
||||
rule("assistant_feedback", "message_id", "assistant_message", "c");
|
||||
rule("assistant_client_error", "conversation_id", "assistant_conversation", "n");
|
||||
rule("assistant_client_error", "message_id", "assistant_message", "n");
|
||||
rule("assistant_client_error", "turn_id", "assistant_turn", "n");
|
||||
rule("assistant_pending_action", "conversation_id", "assistant_conversation", "c");
|
||||
rule("assistant_pending_action", "turn_id", "assistant_turn", "n");
|
||||
rule("assistant_data_audit", "conversation_id", "assistant_conversation", "n");
|
||||
rule("assistant_data_audit", "turn_id", "assistant_turn", "n");
|
||||
rule("assistant_data_audit", "provider_id", "assistant_provider", "a");
|
||||
rule("assistant_usage", "conversation_id", "assistant_conversation", "n");
|
||||
rule("assistant_usage", "turn_id", "assistant_turn", "n");
|
||||
rule("assistant_usage", "provider_id", "assistant_provider", "a");
|
||||
rule("assistant_budget_usage", "provider_id", "assistant_provider", "a");
|
||||
rule("assistant_profile_skill", "profile_id", "assistant_profile", "c");
|
||||
rule("assistant_profile_skill", "skill_id", "assistant_skill", "c");
|
||||
rule("assistant_model", "provider_id", "assistant_provider", "c");
|
||||
rule("assistant_scheduled_run", "conversation_id", "assistant_conversation", "n");
|
||||
rule("assistant_scheduled_run", "turn_id", "assistant_turn", "n");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
record Relation(String child, String column, String parent)
|
||||
{
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return (child + "." + column + " -> " + parent);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void rule(String child, String column, String parent, String rule)
|
||||
{
|
||||
RULES.put(new Relation(child, column, parent), rule);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply the whole reference DDL, then its foreign key block a second time
|
||||
** (it must be re-runnable on an existing database).
|
||||
*******************************************************************************/
|
||||
@BeforeAll
|
||||
static void applyDdl() throws Exception
|
||||
{
|
||||
String ddl = Files.readString(Path.of("..", "docs", "schema-postgres.sql"));
|
||||
try(Connection connection = connect(); Statement statement = connection.createStatement())
|
||||
{
|
||||
statement.execute(ddl);
|
||||
int start = ddl.indexOf("-- Foreign keys (2026-10-14)");
|
||||
if(start >= 0)
|
||||
{
|
||||
statement.execute(ddl.substring(start));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every declared or code-only relation has a foreign key, unless exempt.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyRelationHasAForeignKey() throws Exception
|
||||
{
|
||||
Set<Relation> relations = new LinkedHashSet<>(declaredRelations());
|
||||
assertThat(relations).as("declared relations found in the meta-data").hasSizeGreaterThan(20);
|
||||
relations.addAll(CODE_ONLY);
|
||||
|
||||
Map<Relation, String> foreignKeys = foreignKeys();
|
||||
List<String> missing = new ArrayList<>();
|
||||
for(Relation relation : relations)
|
||||
{
|
||||
if(!foreignKeys.containsKey(relation) && !EXEMPT.containsKey(relation))
|
||||
{
|
||||
missing.add(relation.toString());
|
||||
}
|
||||
}
|
||||
assertThat(missing).as("relations without a foreign key (add one to docs/schema-postgres.sql, or to EXEMPT with a reason)").isEmpty();
|
||||
|
||||
for(Relation exempt : EXEMPT.keySet())
|
||||
{
|
||||
assertThat(foreignKeys).as("exempt relation " + exempt + " must stay without a foreign key").doesNotContainKey(exempt);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The ON DELETE rules match how the qbit deletes.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void deleteRulesMatchTheDeletePaths() throws Exception
|
||||
{
|
||||
Map<Relation, String> foreignKeys = foreignKeys();
|
||||
for(Map.Entry<Relation, String> entry : RULES.entrySet())
|
||||
{
|
||||
assertThat(foreignKeys.get(entry.getKey())).as(entry.getKey().toString()).isEqualTo(entry.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every foreign key column is the first column of some index.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyForeignKeyColumnLeadsAnIndex() throws Exception
|
||||
{
|
||||
List<String> unindexed = new ArrayList<>();
|
||||
try(Connection connection = connect(); Statement statement = connection.createStatement();
|
||||
ResultSet rs = statement.executeQuery("""
|
||||
SELECT c.conrelid::regclass::text, a.attname
|
||||
FROM pg_constraint c
|
||||
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = c.conkey[1]
|
||||
WHERE c.contype = 'f' AND c.connamespace = 'public'::regnamespace
|
||||
AND NOT EXISTS (SELECT 1 FROM pg_index i WHERE i.indrelid = c.conrelid AND i.indkey[0] = c.conkey[1])
|
||||
"""))
|
||||
{
|
||||
while(rs.next())
|
||||
{
|
||||
unindexed.add(rs.getString(1) + "." + rs.getString(2));
|
||||
}
|
||||
}
|
||||
assertThat(unindexed).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** AssistantStore.deleteConversation's delete order works under the foreign
|
||||
** keys; usage (cost accounting) survives with its pointers nulled. A plain
|
||||
** delete of a conversation (admin screen) cascades through the
|
||||
** message <-> turn cycle and keeps usage and the data audit.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void conversationDeletesWorkUnderTheForeignKeys() throws Exception
|
||||
{
|
||||
try(Connection connection = connect(); Statement statement = connection.createStatement())
|
||||
{
|
||||
connection.setAutoCommit(false);
|
||||
for(int c : new int[] { 1, 2 })
|
||||
{
|
||||
int m = c * 10;
|
||||
statement.execute("INSERT INTO assistant_provider (id, name, provider_type) VALUES (" + c + ", 'p" + c + "', 'anthropic')");
|
||||
statement.execute("INSERT INTO assistant_conversation (id, user_id) VALUES (" + c + ", 'u')");
|
||||
statement.execute("INSERT INTO assistant_message (id, conversation_id, user_id, role) VALUES (" + m + ", " + c + ", 'u', 'USER')");
|
||||
statement.execute("INSERT INTO assistant_turn (id, conversation_id, user_id, user_message_id, provider_id) VALUES (" + c + ", " + c + ", 'u', " + m + ", NULL)");
|
||||
statement.execute("INSERT INTO assistant_message (id, conversation_id, user_id, role, turn_id, parent_message_id) VALUES (" + (m + 1) + ", " + c + ", 'u', 'ASSISTANT', " + c + ", " + m + ")");
|
||||
statement.execute("UPDATE assistant_message SET turn_id = " + c + " WHERE id = " + m);
|
||||
statement.execute("UPDATE assistant_turn SET assistant_message_id = " + (m + 1) + " WHERE id = " + c);
|
||||
statement.execute("INSERT INTO assistant_turn_event (turn_id, user_id) VALUES (" + c + ", 'u')");
|
||||
statement.execute("INSERT INTO assistant_pending_action (id, conversation_id, turn_id, user_id) VALUES (" + c + ", " + c + ", " + c + ", 'u')");
|
||||
statement.execute("INSERT INTO assistant_tool_call (conversation_id, turn_id, pending_action_id, user_id) VALUES (" + c + ", " + c + ", " + c + ", 'u')");
|
||||
statement.execute("INSERT INTO assistant_feedback (conversation_id, message_id, user_id) VALUES (" + c + ", " + (m + 1) + ", 'u')");
|
||||
statement.execute("INSERT INTO assistant_data_audit (id, conversation_id, turn_id, provider_id, user_id) VALUES (" + c + ", " + c + ", " + c + ", " + c + ", 'u')");
|
||||
statement.execute("INSERT INTO assistant_usage (id, conversation_id, turn_id, provider_id, user_id) VALUES (" + c + ", " + c + ", " + c + ", " + c + ", 'u')");
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////
|
||||
// conversation 1: AssistantStore.deleteConversation order //
|
||||
////////////////////////////////////////////////////////////
|
||||
statement.execute("DELETE FROM assistant_turn_event WHERE turn_id IN (SELECT id FROM assistant_turn WHERE conversation_id = 1)");
|
||||
for(String table : List.of("assistant_tool_call", "assistant_feedback", "assistant_message", "assistant_turn", "assistant_data_audit", "assistant_pending_action"))
|
||||
{
|
||||
statement.execute("DELETE FROM " + table + " WHERE conversation_id = 1");
|
||||
}
|
||||
statement.execute("DELETE FROM assistant_conversation WHERE id = 1");
|
||||
assertThat(queryString(statement, "SELECT conversation_id || '/' || turn_id FROM assistant_usage WHERE id = 1")).as("usage kept, pointers nulled").isNull();
|
||||
assertThat(queryString(statement, "SELECT provider_id::text FROM assistant_usage WHERE id = 1")).isEqualTo("1");
|
||||
|
||||
//////////////////////////////////////////////
|
||||
// conversation 2: a plain conversation delete //
|
||||
//////////////////////////////////////////////
|
||||
statement.execute("DELETE FROM assistant_conversation WHERE id = 2");
|
||||
for(String table : List.of("assistant_message", "assistant_turn", "assistant_tool_call", "assistant_feedback", "assistant_pending_action"))
|
||||
{
|
||||
assertThat(queryString(statement, "SELECT COUNT(*)::text FROM " + table + " WHERE conversation_id = 2")).as(table).isEqualTo("0");
|
||||
}
|
||||
assertThat(queryString(statement, "SELECT COUNT(*)::text FROM assistant_turn_event")).isEqualTo("0");
|
||||
assertThat(queryString(statement, "SELECT (conversation_id IS NULL AND turn_id IS NULL)::text FROM assistant_data_audit WHERE id = 2")).as("data audit kept").isEqualTo("true");
|
||||
assertThat(queryString(statement, "SELECT (conversation_id IS NULL AND turn_id IS NULL)::text FROM assistant_usage WHERE id = 2")).as("usage kept").isEqualTo("true");
|
||||
connection.rollback();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Possible-value-source fields and joins between assistant_ tables.
|
||||
*******************************************************************************/
|
||||
private List<Relation> declaredRelations()
|
||||
{
|
||||
List<Relation> relations = new ArrayList<>();
|
||||
for(QTableMetaData table : qInstance.getTables().values())
|
||||
{
|
||||
if(!table.getName().startsWith("assistant_"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for(QFieldMetaData field : table.getFields().values())
|
||||
{
|
||||
QPossibleValueSource pvs = field.getPossibleValueSourceName() == null ? null : qInstance.getPossibleValueSource(field.getPossibleValueSourceName());
|
||||
if(pvs != null && pvs.getType() == QPossibleValueSourceType.TABLE && pvs.getTableName().startsWith("assistant_"))
|
||||
{
|
||||
relations.add(new Relation(table.getName(), snake(field.getName()), pvs.getTableName()));
|
||||
}
|
||||
}
|
||||
}
|
||||
for(QJoinMetaData join : qInstance.getJoins().values())
|
||||
{
|
||||
if(!join.getLeftTable().startsWith("assistant_") || !join.getRightTable().startsWith("assistant_"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for(JoinOn on : join.getJoinOns())
|
||||
{
|
||||
if("id".equals(on.getLeftField()))
|
||||
{
|
||||
relations.add(new Relation(join.getRightTable(), snake(on.getRightField()), join.getLeftTable()));
|
||||
}
|
||||
else if("id".equals(on.getRightField()))
|
||||
{
|
||||
relations.add(new Relation(join.getLeftTable(), snake(on.getLeftField()), join.getRightTable()));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (relations);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Single-column foreign keys in the database, with their delete rule.
|
||||
*******************************************************************************/
|
||||
private static Map<Relation, String> foreignKeys() throws Exception
|
||||
{
|
||||
Map<Relation, String> foreignKeys = new LinkedHashMap<>();
|
||||
try(Connection connection = connect(); Statement statement = connection.createStatement();
|
||||
ResultSet rs = statement.executeQuery("""
|
||||
SELECT c.conrelid::regclass::text, a.attname, c.confrelid::regclass::text, c.confdeltype
|
||||
FROM pg_constraint c
|
||||
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = c.conkey[1]
|
||||
WHERE c.contype = 'f' AND array_length(c.conkey, 1) = 1 AND c.connamespace = 'public'::regnamespace
|
||||
"""))
|
||||
{
|
||||
while(rs.next())
|
||||
{
|
||||
foreignKeys.put(new Relation(rs.getString(1), rs.getString(2), rs.getString(3)), rs.getString(4));
|
||||
}
|
||||
}
|
||||
return (foreignKeys);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String queryString(Statement statement, String sql) throws Exception
|
||||
{
|
||||
try(ResultSet rs = statement.executeQuery(sql))
|
||||
{
|
||||
return (rs.next() ? rs.getString(1) : null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String snake(String camel)
|
||||
{
|
||||
return (camel.replaceAll("([A-Z])", "_$1").toLowerCase());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Connection connect() throws Exception
|
||||
{
|
||||
return (DriverManager.getConnection(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword()));
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
import com.kingsrook.qbits.assistant.engine.PolicyService;
|
||||
import com.kingsrook.qbits.assistant.engine.TurnClassification;
|
||||
@@ -38,6 +39,7 @@ import com.kingsrook.qbits.assistant.spi.AssistantProviderType;
|
||||
import com.kingsrook.qbits.assistant.spi.EmbeddingRequest;
|
||||
import com.kingsrook.qbits.assistant.spi.EmbeddingResponse;
|
||||
import com.kingsrook.qbits.assistant.spi.ModelPart;
|
||||
import com.kingsrook.qbits.assistant.spi.ModelResponse;
|
||||
import com.kingsrook.qbits.assistant.spi.ModelUsage;
|
||||
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
|
||||
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
|
||||
@@ -50,12 +52,17 @@ import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
|
||||
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.DataClassification;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -242,6 +249,49 @@ class RagTest extends BaseAssistantTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Indexed PHI is re-read live; snippets reach the eligible model but never
|
||||
** assistant rows or audit values.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
@Tag("redteam")
|
||||
void classifiedKnowledgeCanary() throws Exception
|
||||
{
|
||||
String phi = "CANARY-PHI-" + UUID.randomUUID();
|
||||
String pii = "CANARY-PII-" + UUID.randomUUID();
|
||||
var table = qInstance.getTable("blog_post");
|
||||
table.getField("content").withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PHI));
|
||||
table.getField("title").withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PII));
|
||||
new UpdateAction().execute(new UpdateInput("blog_post").withRecord(new QRecord().withValue("id", 1)
|
||||
.withValue("title", pii).withValue("content", phi)));
|
||||
config.getRuntime().getStore().updateFields(AssistantProvider.TABLE_NAME, new QRecord().withValue("id", embeddingProvider.getId())
|
||||
.withValue("hipaaEligible", true).withValue("isDefault", true));
|
||||
assertThat(config.getRuntime().getRagService().index(source).errors()).isZero();
|
||||
embeddings.thenToolUse("search_knowledge", Map.of("query", "Drain upgrade verify", "limit", 8)).thenRespond(request ->
|
||||
{
|
||||
assertThat(JsonUtils.toJson(request)).contains(phi, pii);
|
||||
return (new ModelResponse().withParts(List.of(ModelPart.text(phi + " " + pii))).withStopReason(ModelResponse.STOP_END_TURN));
|
||||
});
|
||||
assertThat(runner.run(AssistantTestFixture.ADMIN, "Search knowledge").errorCode()).isNull();
|
||||
as(AssistantTestFixture.ADMIN);
|
||||
for(String name : qInstance.getTables().keySet())
|
||||
{
|
||||
if(name.startsWith("assistant_") || name.toLowerCase().startsWith("audit"))
|
||||
{
|
||||
String rows = JsonUtils.toJson(new QueryAction().execute(new QueryInput(name)).getRecords());
|
||||
for(String marker : List.of(phi, pii))
|
||||
{
|
||||
for(int i = 0; i <= marker.length() - 5; i++)
|
||||
{
|
||||
assertThat(rows).doesNotContain(marker.substring(i, i + 5));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Unchanged content performs no embedding; disappeared records are removed.
|
||||
*******************************************************************************/
|
||||
|
||||
+34
@@ -32,6 +32,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.CyclicBarrier;
|
||||
import java.util.concurrent.Executors;
|
||||
@@ -56,7 +57,9 @@ import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
|
||||
import com.kingsrook.qbits.assistant.testing.UiApiTestServer;
|
||||
import com.kingsrook.qbits.assistant.uiapi.UiApiCall;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
@@ -64,12 +67,14 @@ import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutp
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.implementations.MockAuthenticationModule;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
@@ -213,6 +218,35 @@ class ScheduledTasksTest extends BaseAssistantTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Scheduled summaries are derived from the redacted stored message, not live SSE.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void scheduledSummaryCanary() throws Exception
|
||||
{
|
||||
String marker = "CANARY-PII-" + UUID.randomUUID();
|
||||
qInstance.getTable("page").getField("title").withSupplementalMetaData(new AiFieldMetaData().withClassification(DataClassification.PII));
|
||||
new UpdateAction().execute(new UpdateInput("page").withRecord(new QRecord().withValue("id", 1).withValue("title", marker)));
|
||||
provider.thenToolUse("get_record", Map.of("table", "page", "id", "1")).thenText(marker);
|
||||
AssistantScheduledRun run = scheduledRunner().run(task(), false);
|
||||
assertThat(run.getStatus()).as(run.getError()).isEqualTo("OK");
|
||||
assertThat(run.getSummary()).isEqualTo("changed:v1");
|
||||
assertThat(JsonUtils.toJson(provider.getRequests().getLast())).contains(marker);
|
||||
for(String table : qInstance.getTables().keySet())
|
||||
{
|
||||
if(table.startsWith("assistant_") || table.toLowerCase().startsWith("audit"))
|
||||
{
|
||||
String rows = JsonUtils.toJson(new QueryAction().execute(new QueryInput(table)).getRecords());
|
||||
for(int i = 0; i <= marker.length() - 5; i++)
|
||||
{
|
||||
assertThat(rows).doesNotContain(marker.substring(i, i + 5));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every tool HTTP call carries both the designated UUID and client channel.
|
||||
*******************************************************************************/
|
||||
|
||||
+1
-1
@@ -561,7 +561,7 @@ class AssistantSettingsTest extends BaseAssistantTest
|
||||
assertThat(data.getMonthCostMicros()).isZero();
|
||||
|
||||
String html = render();
|
||||
assertThat(html).contains("Answering").contains("Fake").contains("In use").contains("Last test passed").contains("…wxyz")
|
||||
assertThat(html).contains("Configuration and usage").contains("Fake").contains("In use").doesNotContain("Answering", "Last test passed").contains("…wxyz")
|
||||
.contains("of $50.00 cap").contains("list_tables").contains("Model call #").contains("25% of 4 requests").doesNotContain(KEY).doesNotContain("<table");
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -99,6 +99,7 @@ public class AssistantTestFixture
|
||||
*******************************************************************************/
|
||||
public static QInstance build(AssistantQBitConfig config) throws Exception
|
||||
{
|
||||
config.setRegisterShutdownHook(false);
|
||||
MemoryRecordStore.fullReset();
|
||||
QContext.clear();
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import com.kingsrook.qbits.assistant.AssistantRuntime;
|
||||
import com.kingsrook.qbits.assistant.api.AssistantApi;
|
||||
import com.kingsrook.qbits.assistant.engine.RunningTurn;
|
||||
import com.kingsrook.qbits.assistant.engine.TurnEvent;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantMessage;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
|
||||
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
@@ -47,6 +48,8 @@ public class TurnRunner
|
||||
private final QInstance qInstance;
|
||||
private final AssistantQBitConfig config;
|
||||
|
||||
private List<AssistantMessage> messagesAtEnd = List.of();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -169,11 +172,15 @@ public class TurnRunner
|
||||
{
|
||||
List<TurnEvent> batch = running.getLog().after(last, 1000);
|
||||
events.addAll(batch);
|
||||
if(batch.stream().anyMatch(e -> TurnEvent.MESSAGE_END.equals(e.getType())))
|
||||
{
|
||||
messagesAtEnd = messagesOf(userId, convId);
|
||||
}
|
||||
if(!batch.isEmpty())
|
||||
{
|
||||
last = batch.get(batch.size() - 1).getSeq();
|
||||
}
|
||||
if(batch.isEmpty() && running.getLog().isClosed())
|
||||
if(batch.isEmpty() && running.getLog().isClosed() && running.getLog().size() <= last)
|
||||
{
|
||||
break;
|
||||
}
|
||||
@@ -194,6 +201,36 @@ public class TurnRunner
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The conversation's stored messages, read the moment the run's message.end
|
||||
** was seen: what the panel finds when it reloads the conversation at the end
|
||||
** of the stream.
|
||||
*******************************************************************************/
|
||||
public List<AssistantMessage> messagesAtEnd()
|
||||
{
|
||||
return (messagesAtEnd);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The stored messages of a conversation (read as the user).
|
||||
*******************************************************************************/
|
||||
private List<AssistantMessage> messagesOf(String userId, Integer conversationId) throws Exception
|
||||
{
|
||||
QContext.init(qInstance, AssistantTestFixture.ownerSession(userId, config));
|
||||
try
|
||||
{
|
||||
return (new AssistantStore().listMessages(userId, conversationId, null, 50));
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The audited tool calls of a turn (read as the user).
|
||||
*******************************************************************************/
|
||||
|
||||
Reference in new issue
Block a user