Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15938e07e1
|
||
|
|
b2c97d1000 | ||
|
|
ae8e394a7b | ||
|
|
1bae6e26ba
|
||
|
|
80d529e741
|
||
|
|
b50bc4a5c7
|
||
|
|
6a1bac6a9e | ||
|
|
9c3af04a91
|
||
|
|
0bfa4e4d01 | ||
|
|
3d288ddfe9
|
||
|
|
6d6b4696a5 | ||
|
|
ba8e7a6562
|
||
|
|
c751fc055c
|
||
|
|
50898a927d
|
||
|
|
f60009f595
|
||
|
|
ffa7e25bf4
|
||
|
|
00861af42c
|
||
|
|
3b56c93da7 | ||
|
|
a5c38d74a8
|
||
|
|
e8beb7444a
|
||
|
|
fdf8d72367
|
||
|
|
8aac6cf92d
|
||
|
|
6c36917beb
|
||
|
|
befb90a48a | ||
|
|
bca3ea9d83 | ||
|
|
b49e4f4ee0 |
No files matched your search
@@ -1,7 +1,7 @@
|
||||
version: 2.1
|
||||
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.6.8
|
||||
qqq-orb: kingsrook/qqq-orb@0.6.9
|
||||
|
||||
commands:
|
||||
configure-package-install:
|
||||
|
||||
@@ -1,5 +1,53 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased - Transcript redaction
|
||||
|
||||
- Tokenize classified PII/PHI in stored messages, tool inputs/results, replay events, pending actions and derived summaries using QQQ secured-data tokens. Preserve eligible model requests and live SSE.
|
||||
- Stage content until turn completion, redact split deltas and classify criteria even when no records match; protect assistant logs with a scoped core redactor.
|
||||
- Reject proposals whose parameters were redacted; prevent confirmation before turn completion.
|
||||
- Read core table-level classification metadata and add secured-data canary tests for storage, audits, logs and in-memory model access.
|
||||
- Preserve durable-turn watchdog, supersede, shutdown and restart recovery with turn-scoped transcript redaction; widen token-bearing columns and entity limits, with H2-safe upgrade SQL.
|
||||
- Require QQQ 4.1.0-RC.7 (qqq#1182); retain the default audit-history opt-in and avoid duplicated routing headers against current core. RC.7 is released.
|
||||
## Unreleased - Foreign keys
|
||||
|
||||
- Reference DDL: add the missing foreign keys (conversation tree, usage, data audit,
|
||||
budget accounting, profile skills) with orphan quarantine and their indexes; make
|
||||
`assistant_model.provider_id` `ON DELETE CASCADE`. A Postgres guardrail test fails
|
||||
any declared relation without a foreign key.
|
||||
|
||||
## Unreleased - Durable turns
|
||||
|
||||
- Renew user-owned run leases with heartbeats, poll cross-replica Stop, enforce
|
||||
hard deadlines, and finalize active runs during graceful shutdown. Recover
|
||||
abandoned runs on replay, active discovery, or the next question.
|
||||
- Default to superseding a prior answer in the same conversation. Accept explicit
|
||||
concurrent questions; the per-user cap supersedes the oldest run instead of
|
||||
refusing another question. Per-minute limits and budgets still apply.
|
||||
- Add `GET /qqq/v1/assistant/turns/active`, `turn.phase` progress and `startedAt`
|
||||
on `message.start`. Cancel is idempotent and returns HTTP 200.
|
||||
- Add the optional `AssistantRunTracker` Jobs tray SPI (no-op by default),
|
||||
pending QQQ core job tracking (#1210).
|
||||
- Add the durable-turn schema upgrade; apply it before deploying this runtime.
|
||||
A partial unique index fences duplicate terminal events from concurrent reapers.
|
||||
- UI API extension `assistant` 1.2.0 (additive): `turns/active`, the optional
|
||||
`concurrency` on a new turn, 503 `SHUTTING_DOWN`, `turn.phase`,
|
||||
`message.start.startedAt`, cancelReason `SUPERSEDED`. The 429 response stays
|
||||
documented for budgets and per-minute limits; the "answer already in progress"
|
||||
refusal is no longer returned. Cancel keeps 202 documented for older replicas.
|
||||
|
||||
## Unreleased - Starter defaults
|
||||
|
||||
- Every seed changeset's rollback is empty: a seeded row cannot be told apart from a host's own row with the same name, and Liquibase also rolls back changesets it marked ran, so a name-based undo would delete host data.
|
||||
|
||||
- Fix the defaults changelog: every precondition is a portable `sql-check` (the formatted-SQL `table-exists` form failed to parse); the policy, budget and prices are seeded only into empty tables and the profile only when no default exists, so an existing host never changes on deploy. The test now runs the changelog through Liquibase 4.33, including an already configured host and a missing table.
|
||||
- The Setup card prices the default model the way calls are costed (registry, price rows, configured prices); "Add rows for every table" reads fresh data-access rows.
|
||||
|
||||
- Ship `db/liquibase/qbit-assistant/assistant-defaults.changelog.sql`: insert-only starter rows for the policy, a default profile, four starter skills offered through it, a monthly instance budget (warn, downgrade, stop), current Anthropic prices and a disabled example scheduled task.
|
||||
- Add the Setup card to the Assistant home: every configuration table with its state (set up, needs setup, using defaults, optional) and the next step.
|
||||
- Add the `assistant_data_access` table action "Add rows for every table", which writes the default access out as editable rows.
|
||||
- Add `help/qbit-assistant.yaml` (QQQ help-bundle format) for the app and every assistant table, saying what each table is for and how its rows get created; applied by `AssistantHelp` until core help bundles ship in a release.
|
||||
- Guardrail tests: every table is used at runtime, seeded or explained, and has empty-state help; the changelog seeds a fresh schema and the seeded rows change the assistant's behavior.
|
||||
|
||||
## Unreleased - X1/Y1
|
||||
|
||||
- Add default-off `withRecordHistory(true)` host opt-in: remove only `audit*` on RC.6, or invoke core #1193 audit-read/write-deny support when available. Preserve user permissions and all other channel denials; kof22 does not opt in.
|
||||
|
||||
@@ -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
|
||||
@@ -173,6 +192,15 @@ The key never goes in config, tables or logs. It lives in the application's secr
|
||||
|
||||
The provider's `toString` never contains the key. Its errors carry only the HTTP status and the API error type.
|
||||
|
||||
### Starter defaults
|
||||
|
||||
Include `db/liquibase/qbit-assistant/assistant-defaults.changelog.sql` (shipped in
|
||||
the jar) after your assistant DDL. A new installation then has a policy row, a
|
||||
default profile with four starter skills, a $50/month instance budget, current
|
||||
Anthropic prices and a disabled example scheduled task. Every row is insert-only,
|
||||
so administrators can edit them freely. The home's **Setup** card shows what is
|
||||
set up and the next step for the rest (usually: add a provider).
|
||||
|
||||
## Provider settings (the Providers table)
|
||||
|
||||
Admins set up providers on the **Providers** screen (`assistant_provider`), a standard QQQ table:
|
||||
|
||||
+100
@@ -1,5 +1,105 @@
|
||||
# Upgrading qbit-assistant
|
||||
|
||||
## 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.
|
||||
|
||||
Hosts may wire `AssistantRunTracker` for Jobs tray publication, progress, Stop,
|
||||
and terminal notification. It is a no-op by default and all callbacks are
|
||||
isolated from turn execution. Tracking ids are local to the owning runtime;
|
||||
reaping an already dead owner does not invent another tracking job. Hosts should
|
||||
associate their durable tracking row with the supplied turn id. The SPI is a
|
||||
stand-in for QQQ #1210.
|
||||
|
||||
## Starter defaults, setup card and table help
|
||||
|
||||
The jar now ships `db/liquibase/qbit-assistant/assistant-defaults.changelog.sql`.
|
||||
Include it from the host's master changelog **after** the changesets that create
|
||||
the `assistant_*` tables:
|
||||
|
||||
```yaml
|
||||
- include: { file: db/liquibase/qbit-assistant/assistant-defaults.changelog.sql }
|
||||
```
|
||||
|
||||
It is insert-only and never changes a host that already configured the
|
||||
assistant: the policy, the **$50/month instance budget** (warn 80%, downgrade
|
||||
100%, stop 120%) and the Anthropic list prices are seeded only into empty tables,
|
||||
and the `default` profile only when no default profile exists. Price rows take
|
||||
precedence over `AssistantQBitConfig.withPricing`, which is why they are seeded
|
||||
only when there are none. The four starter skills and a disabled example
|
||||
scheduled task are always added (by name); on a host with its own default
|
||||
profile the skills are not linked to it, and the Setup card says so. Requires
|
||||
Liquibase 4.x or later (preconditions are `sql-check`s).
|
||||
|
||||
No DDL changes. New: the Assistant home's Setup card, the `assistant_data_access`
|
||||
table action **Add rows for every table** (`assistantDataAccessAddDefaults`,
|
||||
admin only) and bundle help on every assistant table (`help/qbit-assistant.yaml`).
|
||||
|
||||
## X1/Y1: channel wall and audit attribution
|
||||
|
||||
Requires QQQ 4.1.0-RC.6. Every assistant table, process, report and widget is
|
||||
|
||||
@@ -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.
|
||||
+145
-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,139 @@ CREATE INDEX assistant_scheduled_run_owner ON assistant_scheduled_run(user_id, s
|
||||
|
||||
-- X1/Y1: encoded audit filters can exceed the old 500-character record links.
|
||||
ALTER TABLE assistant_tool_call ALTER COLUMN audit_href TYPE TEXT;
|
||||
|
||||
|
||||
-- Durable turns: apply this additive block before deploying the new runtime.
|
||||
-- Like earlier additions, fresh installs run the CREATEs followed by ALTERs.
|
||||
ALTER TABLE assistant_turn ADD COLUMN heartbeat_at TIMESTAMP;
|
||||
ALTER TABLE assistant_turn ADD COLUMN lease_owner VARCHAR(100);
|
||||
ALTER TABLE assistant_turn ADD COLUMN cancel_requested_at TIMESTAMP;
|
||||
ALTER TABLE assistant_turn ADD COLUMN phase VARCHAR(64);
|
||||
ALTER TABLE assistant_turn ADD COLUMN concurrency_mode VARCHAR(16);
|
||||
CREATE INDEX assistant_turn_user_status ON assistant_turn (user_id, status);
|
||||
|
||||
-- Fence concurrent orphan reapers at the database boundary. A turn ends once.
|
||||
-- Check for pre-existing duplicate message.end rows before applying this index.
|
||||
CREATE UNIQUE INDEX assistant_turn_event_terminal ON assistant_turn_event (turn_id)
|
||||
WHERE event_type = 'message.end';
|
||||
|
||||
-- Index audit (2026-10-14): lookup, retention and FK paths with no leading index.
|
||||
-- ScheduledTurn.cost reads usage by turn on every scheduled run.
|
||||
CREATE INDEX IF NOT EXISTS assistant_usage_turn ON assistant_usage (turn_id);
|
||||
-- Retention: deleteTurnEventsBefore, clearToolResultsBefore and listConversationsInactiveSince filter by date.
|
||||
CREATE INDEX IF NOT EXISTS assistant_turn_event_create_date ON assistant_turn_event (create_date);
|
||||
CREATE INDEX IF NOT EXISTS assistant_tool_call_create_date ON assistant_tool_call (create_date);
|
||||
CREATE INDEX IF NOT EXISTS assistant_conversation_modify_date ON assistant_conversation (modify_date);
|
||||
-- ON DELETE SET NULL children: deleting a conversation or turn would scan scheduled runs without these.
|
||||
CREATE INDEX IF NOT EXISTS assistant_scheduled_run_conversation ON assistant_scheduled_run (conversation_id);
|
||||
CREATE INDEX IF NOT EXISTS assistant_scheduled_run_turn ON assistant_scheduled_run (turn_id);
|
||||
|
||||
-- Foreign keys (2026-10-14). Run on new and existing databases; every step is guarded
|
||||
-- and re-runnable. The rules follow how the qbit deletes:
|
||||
-- * AssistantStore.deleteConversation (also the retention purge) deletes turn events,
|
||||
-- tool calls, feedback, messages, turns, data audit and pending actions, then the
|
||||
-- conversation. The conversation's own detail rows CASCADE, so an admin delete of a
|
||||
-- conversation cleans up the same way.
|
||||
-- * The message <-> turn cycle (turn.user_message_id / assistant_message_id and
|
||||
-- message.turn_id) and the other optional pointers are SET NULL. Rows are inserted
|
||||
-- parent first, so nothing is deferred.
|
||||
-- * assistant_usage keeps its cost rows when a conversation goes (SET NULL), and the
|
||||
-- data audit trail is never cascaded into (SET NULL).
|
||||
-- * Provider references from usage, budget accounting and the data audit are NO ACTION
|
||||
-- (like assistant_turn.provider_id): disable a provider with history, do not delete
|
||||
-- it. A provider's model rows are its own (CASCADE).
|
||||
-- * assistant_budget_usage.usage_id has no foreign key on purpose: the projection
|
||||
-- outlives its usage row so budgets keep counting the spend.
|
||||
-- Rows that already point at a missing parent are recorded in qqq_fk_quarantine first,
|
||||
-- then nulled (nullable columns) or deleted with the full row recorded (NOT NULL).
|
||||
CREATE INDEX IF NOT EXISTS assistant_message_parent_message ON assistant_message (parent_message_id);
|
||||
CREATE INDEX IF NOT EXISTS assistant_message_turn ON assistant_message (turn_id);
|
||||
CREATE INDEX IF NOT EXISTS assistant_turn_user_message ON assistant_turn (user_message_id);
|
||||
CREATE INDEX IF NOT EXISTS assistant_turn_assistant_message ON assistant_turn (assistant_message_id);
|
||||
CREATE INDEX IF NOT EXISTS assistant_turn_provider ON assistant_turn (provider_id);
|
||||
CREATE INDEX IF NOT EXISTS assistant_tool_call_pending_action ON assistant_tool_call (pending_action_id);
|
||||
CREATE INDEX IF NOT EXISTS assistant_feedback_message ON assistant_feedback (message_id);
|
||||
CREATE INDEX IF NOT EXISTS assistant_pending_action_turn ON assistant_pending_action (turn_id);
|
||||
CREATE INDEX IF NOT EXISTS assistant_data_audit_provider ON assistant_data_audit (provider_id);
|
||||
CREATE INDEX IF NOT EXISTS assistant_budget_usage_provider ON assistant_budget_usage (provider_id);
|
||||
CREATE INDEX IF NOT EXISTS assistant_profile_skill_skill ON assistant_profile_skill (skill_id);
|
||||
CREATE INDEX IF NOT EXISTS assistant_budget_downgrade_model ON assistant_budget (downgrade_model_id);
|
||||
CREATE INDEX IF NOT EXISTS assistant_conversation_scheduled_task ON assistant_conversation (scheduled_task_id);
|
||||
CREATE INDEX IF NOT EXISTS assistant_model_fallback_model ON assistant_model (fallback_model_id);
|
||||
CREATE INDEX IF NOT EXISTS assistant_profile_simple_model ON assistant_profile (simple_model_id);
|
||||
CREATE INDEX IF NOT EXISTS assistant_profile_complex_model ON assistant_profile (complex_model_id);
|
||||
CREATE INDEX IF NOT EXISTS assistant_profile_classifier_model ON assistant_profile (classifier_model_id);
|
||||
CREATE INDEX IF NOT EXISTS assistant_rag_source_embedding_model ON assistant_rag_source (embedding_model_id);
|
||||
CREATE INDEX IF NOT EXISTS assistant_scheduled_task_profile ON assistant_scheduled_task (profile_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qqq_fk_quarantine (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, table_name VARCHAR(100) NOT NULL, column_name VARCHAR(100) NOT NULL, row_id VARCHAR(100), missing_parent_id VARCHAR(100), action VARCHAR(20) NOT NULL, row_data TEXT, changeset VARCHAR(200), quarantined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
r RECORD;
|
||||
old TEXT;
|
||||
BEGIN
|
||||
FOR r IN SELECT * FROM (VALUES
|
||||
('assistant_message', 'conversation_id', 'assistant_conversation', 'CASCADE', TRUE),
|
||||
('assistant_message', 'parent_message_id', 'assistant_message', 'SET NULL', TRUE),
|
||||
('assistant_message', 'turn_id', 'assistant_turn', 'SET NULL', TRUE),
|
||||
('assistant_turn', 'conversation_id', 'assistant_conversation', 'CASCADE', TRUE),
|
||||
('assistant_turn', 'user_message_id', 'assistant_message', 'SET NULL', TRUE),
|
||||
('assistant_turn', 'assistant_message_id', 'assistant_message', 'SET NULL', TRUE),
|
||||
('assistant_turn_event', 'turn_id', 'assistant_turn', 'CASCADE', TRUE),
|
||||
('assistant_tool_call', 'conversation_id', 'assistant_conversation', 'CASCADE', TRUE),
|
||||
('assistant_tool_call', 'turn_id', 'assistant_turn', 'CASCADE', TRUE),
|
||||
('assistant_tool_call', 'pending_action_id', 'assistant_pending_action', 'SET NULL', TRUE),
|
||||
('assistant_feedback', 'conversation_id', 'assistant_conversation', 'CASCADE', TRUE),
|
||||
('assistant_feedback', 'message_id', 'assistant_message', 'CASCADE', TRUE),
|
||||
('assistant_pending_action', 'conversation_id', 'assistant_conversation', 'CASCADE', TRUE),
|
||||
('assistant_pending_action', 'turn_id', 'assistant_turn', 'SET NULL', TRUE),
|
||||
('assistant_data_audit', 'conversation_id', 'assistant_conversation', 'SET NULL', TRUE),
|
||||
('assistant_data_audit', 'turn_id', 'assistant_turn', 'SET NULL', TRUE),
|
||||
('assistant_usage', 'conversation_id', 'assistant_conversation', 'SET NULL', TRUE),
|
||||
('assistant_usage', 'turn_id', 'assistant_turn', 'SET NULL', TRUE),
|
||||
('assistant_usage', 'provider_id', 'assistant_provider', 'NO ACTION', TRUE),
|
||||
('assistant_data_audit', 'provider_id', 'assistant_provider', 'NO ACTION', TRUE),
|
||||
('assistant_budget_usage', 'provider_id', 'assistant_provider', 'NO ACTION', TRUE),
|
||||
('assistant_profile_skill', 'profile_id', 'assistant_profile', 'CASCADE', FALSE),
|
||||
('assistant_profile_skill', 'skill_id', 'assistant_skill', 'CASCADE', FALSE)
|
||||
) AS v(child, col, parent, rule, nullable)
|
||||
LOOP
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema = current_schema() AND LOWER(constraint_name) = 'fk_' || r.child || '_' || r.col) THEN
|
||||
IF r.nullable THEN
|
||||
EXECUTE format('INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, changeset) '
|
||||
|| 'SELECT %L, %L, c.id::text, c.%I::text, ''NULLED'', ''qbit-assistant 2026-10-14 foreign keys'' FROM %I c '
|
||||
|| 'WHERE c.%I IS NOT NULL AND NOT EXISTS (SELECT 1 FROM %I p WHERE p.id = c.%I)', r.child, r.col, r.col, r.child, r.col, r.parent, r.col);
|
||||
EXECUTE format('UPDATE %I c SET %I = NULL WHERE c.%I IS NOT NULL AND NOT EXISTS (SELECT 1 FROM %I p WHERE p.id = c.%I)', r.child, r.col, r.col, r.parent, r.col);
|
||||
ELSE
|
||||
EXECUTE format('INSERT INTO qqq_fk_quarantine (table_name, column_name, row_id, missing_parent_id, action, row_data, changeset) '
|
||||
|| 'SELECT %L, %L, c.id::text, c.%I::text, ''DELETED'', row_to_json(c)::text, ''qbit-assistant 2026-10-14 foreign keys'' FROM %I c '
|
||||
|| 'WHERE NOT EXISTS (SELECT 1 FROM %I p WHERE p.id = c.%I)', r.child, r.col, r.col, r.child, r.parent, r.col);
|
||||
EXECUTE format('DELETE FROM %I c WHERE NOT EXISTS (SELECT 1 FROM %I p WHERE p.id = c.%I)', r.child, r.parent, r.col);
|
||||
END IF;
|
||||
EXECUTE format('ALTER TABLE %I ADD CONSTRAINT %I FOREIGN KEY (%I) REFERENCES %I (id) ON DELETE %s', r.child, 'fk_' || r.child || '_' || r.col, r.col, r.parent, r.rule);
|
||||
END IF;
|
||||
END LOOP;
|
||||
|
||||
-- assistant_model.provider_id: the inline NO ACTION key becomes CASCADE, named as on kof22.
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema = current_schema() AND LOWER(constraint_name) = 'fk_assistant_model_provider') THEN
|
||||
FOR old IN SELECT conname FROM pg_constraint
|
||||
WHERE contype = 'f' AND conrelid = 'assistant_model'::regclass AND confrelid = 'assistant_provider'::regclass
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE assistant_model DROP CONSTRAINT %I', old);
|
||||
END LOOP;
|
||||
ALTER TABLE assistant_model ADD CONSTRAINT fk_assistant_model_provider FOREIGN KEY (provider_id) REFERENCES assistant_provider (id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Transcript redaction: tokens can expand beyond the original payload bounds.
|
||||
-- Apply to existing databases as well; SET DATA TYPE is also valid in H2.
|
||||
ALTER TABLE assistant_turn ALTER COLUMN stop_reason SET DATA TYPE TEXT;
|
||||
ALTER TABLE assistant_tool_call ALTER COLUMN tool_call_id SET DATA TYPE TEXT;
|
||||
ALTER TABLE assistant_feedback ALTER COLUMN reason SET DATA TYPE TEXT;
|
||||
ALTER TABLE assistant_usage ALTER COLUMN request_id SET DATA TYPE TEXT;
|
||||
ALTER TABLE assistant_pending_action ALTER COLUMN tool_call_id SET DATA TYPE TEXT;
|
||||
ALTER TABLE assistant_data_audit ALTER COLUMN reason SET DATA TYPE TEXT;
|
||||
ALTER TABLE assistant_budget_override ALTER COLUMN reason SET DATA TYPE TEXT;
|
||||
ALTER TABLE assistant_scheduled_run ALTER COLUMN summary SET DATA TYPE TEXT;
|
||||
ALTER TABLE assistant_scheduled_run ALTER COLUMN error SET DATA TYPE TEXT;
|
||||
@@ -215,7 +215,8 @@
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<qqq.snapshot.version>4.1.0-RC.6</qqq.snapshot.version>
|
||||
<!-- RC.7 carries qqq#1182 classified-value tokens and log redaction. Keep the PR draft until published. -->
|
||||
<qqq.snapshot.version>4.1.0-RC.7</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
|
||||
@@ -73,6 +73,39 @@
|
||||
<artifactId>archunit-junit5</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- AssistantDefaultsChangelogTest runs the shipped seed changelog against H2 (PostgreSQL mode). -->
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<version>2.3.232</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- ...through real Liquibase (the version kof22 runs), so preconditions and bookkeeping are exercised. -->
|
||||
<dependency>
|
||||
<groupId>org.liquibase</groupId>
|
||||
<artifactId>liquibase-core</artifactId>
|
||||
<version>4.33.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- the reference DDL guardrail (SchemaForeignKeyPostgresTest) -->
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>1.21.4</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<version>1.21.4</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<version>42.7.14</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
+247
-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,7 @@ 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.rag.VectorStore;
|
||||
import com.kingsrook.qbits.assistant.spi.AssistantModelProvider;
|
||||
import com.kingsrook.qbits.assistant.spi.AssistantProviderType;
|
||||
@@ -44,7 +46,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 +100,20 @@ public class AssistantQBitConfig implements QBitConfig
|
||||
private Integer maxParallelToolCalls = 4;
|
||||
private Integer maxRowsToModel = 50;
|
||||
private Integer maxCellChars = 500;
|
||||
private Integer turnHeartbeatSeconds = 5;
|
||||
private Integer turnLeaseSeconds = 45;
|
||||
private Integer turnForceFinalizeGraceSeconds = 20;
|
||||
private Integer shutdownGraceSeconds = 8;
|
||||
private String defaultConcurrencyMode = "SUPERSEDE";
|
||||
private Boolean registerShutdownHook = true;
|
||||
private AssistantRunTracker runTracker = AssistantRunTracker.NOOP;
|
||||
|
||||
private Integer maxTurnSeconds = 300;
|
||||
private Integer maxUserMessageChars = 8000;
|
||||
private Integer historyMessageLimit = 40;
|
||||
private AssistantBudgets budgets = new AssistantBudgets();
|
||||
private Integer maxTurnsPerUserPerMinute = 10;
|
||||
private Integer maxConcurrentTurnsPerUser = 1;
|
||||
private Integer maxConcurrentTurnsPerUser = 3;
|
||||
private Integer conversationRetentionDays = 90;
|
||||
private Integer toolResultRetentionDays = 30;
|
||||
private Integer approvalRetentionDays = 365;
|
||||
@@ -206,6 +215,23 @@ public class AssistantQBitConfig implements QBitConfig
|
||||
requirePositive(errors, "maxRowsToModel", maxRowsToModel);
|
||||
requirePositive(errors, "maxCellChars", maxCellChars);
|
||||
requirePositive(errors, "maxTurnSeconds", maxTurnSeconds);
|
||||
requirePositive(errors, "turnHeartbeatSeconds", turnHeartbeatSeconds);
|
||||
requirePositive(errors, "turnLeaseSeconds", turnLeaseSeconds);
|
||||
requirePositive(errors, "turnForceFinalizeGraceSeconds", turnForceFinalizeGraceSeconds);
|
||||
requirePositive(errors, "shutdownGraceSeconds", shutdownGraceSeconds);
|
||||
if(turnHeartbeatSeconds != null && turnLeaseSeconds != null && turnLeaseSeconds < 2 * turnHeartbeatSeconds)
|
||||
{
|
||||
errors.add("turnLeaseSeconds must be at least twice turnHeartbeatSeconds.");
|
||||
}
|
||||
if(!"SUPERSEDE".equals(defaultConcurrencyMode) && !"CONCURRENT".equals(defaultConcurrencyMode))
|
||||
{
|
||||
errors.add("defaultConcurrencyMode must be SUPERSEDE or CONCURRENT.");
|
||||
}
|
||||
if(turnHeartbeatSeconds != null && turnLeaseSeconds != null && turnHeartbeatSeconds >= turnLeaseSeconds)
|
||||
{
|
||||
errors.add("turnHeartbeatSeconds must be less than turnLeaseSeconds.");
|
||||
}
|
||||
|
||||
requirePositive(errors, "maxUserMessageChars", maxUserMessageChars);
|
||||
requirePositive(errors, "historyMessageLimit", historyMessageLimit);
|
||||
requirePositive(errors, "maxTurnsPerUserPerMinute", maxTurnsPerUserPerMinute);
|
||||
@@ -2008,4 +2034,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);
|
||||
}
|
||||
|
||||
}
|
||||
+8
-1
@@ -26,10 +26,12 @@ import com.kingsrook.qbits.assistant.developer.AssistantDeveloperPanels;
|
||||
import com.kingsrook.qbits.assistant.home.AssistantAccessWidget;
|
||||
import com.kingsrook.qbits.assistant.home.AssistantHome;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantChannelWall;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantHelp;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantInstanceMetaData;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantRecordScreens;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantBudget;
|
||||
import com.kingsrook.qbits.assistant.processes.DataAccessDefaultsProcess;
|
||||
import com.kingsrook.qbits.assistant.processes.DiscoverModelsProcess;
|
||||
import com.kingsrook.qbits.assistant.processes.KnowledgeIndexProcess;
|
||||
import com.kingsrook.qbits.assistant.processes.ScheduledTaskProcess;
|
||||
@@ -180,7 +182,7 @@ public class AssistantQBitProducer implements QBitMetaDataProducer<AssistantQBit
|
||||
QProcessMetaData testConnection = TestConnectionProcess.process();
|
||||
testConnection.setSourceQBitName(qBitMetaData.getName());
|
||||
qInstance.addProcess(testConnection);
|
||||
for(QProcessMetaData process : List.of(DiscoverModelsProcess.process(), TestModelConnectionProcess.process()))
|
||||
for(QProcessMetaData process : List.of(DiscoverModelsProcess.process(), TestModelConnectionProcess.process(), DataAccessDefaultsProcess.process()))
|
||||
{
|
||||
process.setSourceQBitName(qBitMetaData.getName());
|
||||
qInstance.addProcess(process);
|
||||
@@ -202,12 +204,17 @@ public class AssistantQBitProducer implements QBitMetaDataProducer<AssistantQBit
|
||||
|
||||
QAppMetaData app = builder.buildAdminApp(tables);
|
||||
qInstance.addApp(app);
|
||||
AssistantHelp.apply(qInstance);
|
||||
|
||||
AssistantChannelWall.register(qInstance);
|
||||
if(Boolean.TRUE.equals(qBitConfig.getRecordHistory()))
|
||||
{
|
||||
enableRecordHistory(ClientChannelsMetaData.ofOrWithNew(qInstance));
|
||||
}
|
||||
else
|
||||
{
|
||||
ClientChannelsMetaData.ofOrWithNew(qInstance).withDeniedPattern("assistant", "audit*");
|
||||
}
|
||||
postProduceActions(new MetaDataProducerMultiOutput(), qInstance);
|
||||
return (new MetaDataProducerMultiOutput());
|
||||
}
|
||||
|
||||
@@ -17,8 +17,11 @@
|
||||
package com.kingsrook.qbits.assistant;
|
||||
|
||||
|
||||
import java.net.InetAddress;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
@@ -50,6 +53,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 +66,11 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
*******************************************************************************/
|
||||
public class AssistantRuntime
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(AssistantRuntime.class);
|
||||
private final String instanceId = instanceId();
|
||||
private volatile boolean shuttingDown;
|
||||
private Thread shutdownHook;
|
||||
|
||||
private final ScheduledTurn scheduledTurn = new ScheduledTurn();
|
||||
|
||||
private final RagService ragService;
|
||||
@@ -103,12 +112,22 @@ public class AssistantRuntime
|
||||
**
|
||||
*******************************************************************************/
|
||||
public AssistantRuntime(QInstance qInstance, AssistantQBitConfig config, Clock clock)
|
||||
{
|
||||
this(qInstance, config, clock, new AssistantStore(config));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit persistence collaborator for embedded runtimes and fault tests.
|
||||
*******************************************************************************/
|
||||
public AssistantRuntime(QInstance qInstance, AssistantQBitConfig config, Clock clock, AssistantStore store)
|
||||
{
|
||||
AssistantChannelWall.validate(qInstance);
|
||||
this.qInstance = qInstance;
|
||||
this.config = config;
|
||||
this.clock = clock;
|
||||
this.store = new AssistantStore(config);
|
||||
this.store = store;
|
||||
this.policyService = new PolicyService(config, store, clock);
|
||||
this.providerService = new ProviderService(config, store, clock);
|
||||
this.budgetService = new BudgetService(store, providerService, clock);
|
||||
@@ -157,6 +176,21 @@ 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()))
|
||||
{
|
||||
shutdownHook = new Thread(() ->
|
||||
{
|
||||
try
|
||||
{
|
||||
shutdown(Duration.ofSeconds(config.getShutdownGraceSeconds()));
|
||||
}
|
||||
catch(Throwable ignored)
|
||||
{
|
||||
// Shutdown must never fail the JVM hook.
|
||||
}
|
||||
}, "assistant-shutdown-" + instanceId);
|
||||
Runtime.getRuntime().addShutdownHook(shutdownHook);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -312,8 +346,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 +384,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 +404,7 @@ public class AssistantRuntime
|
||||
*******************************************************************************/
|
||||
public void close()
|
||||
{
|
||||
runningTurns.values().forEach(t -> t.cancel("SHUTDOWN"));
|
||||
executor.shutdownNow();
|
||||
shutdown(Duration.ZERO);
|
||||
}
|
||||
|
||||
|
||||
@@ -504,4 +552,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;
|
||||
@@ -45,17 +48,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 +71,8 @@ import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials;
|
||||
*******************************************************************************/
|
||||
public class AssistantApi
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(AssistantApi.class);
|
||||
|
||||
public static final int TITLE_MAX_CHARS = 60;
|
||||
|
||||
private final AssistantRuntime runtime;
|
||||
@@ -289,6 +297,19 @@ public class AssistantApi
|
||||
public RunningTurn startTurn(Integer conversationId, Map<String, Object> body) throws QException, AssistantApiException
|
||||
{
|
||||
requireAccess();
|
||||
if(runtime.isShuttingDown())
|
||||
{
|
||||
throw (new AssistantApiException(503, "SHUTTING_DOWN", "The server is restarting. Try again in a moment."));
|
||||
}
|
||||
String concurrency = config.getDefaultConcurrencyMode();
|
||||
if(body.containsKey("concurrency"))
|
||||
{
|
||||
if(!(body.get("concurrency") instanceof String mode) || !("supersede".equalsIgnoreCase(mode) || "concurrent".equalsIgnoreCase(mode)))
|
||||
{
|
||||
throw (new AssistantApiException(400, "concurrency must be supersede or concurrent."));
|
||||
}
|
||||
concurrency = mode.toUpperCase(Locale.ROOT);
|
||||
}
|
||||
AssistantConversation conversation = requireConversation(conversationId);
|
||||
|
||||
if(!(body.get("text") instanceof String text) || !StringUtils.hasContent(text))
|
||||
@@ -324,30 +345,43 @@ public class AssistantApi
|
||||
throw (new AssistantApiException(429, "BUDGET", budget.message()));
|
||||
}
|
||||
|
||||
long running = 0;
|
||||
for(AssistantTurn other : store.listRunningTurns(userId))
|
||||
{
|
||||
if(isRunning(reapIfOrphaned(other)))
|
||||
{
|
||||
running++;
|
||||
}
|
||||
}
|
||||
if(running >= config.getMaxConcurrentTurnsPerUser())
|
||||
{
|
||||
throw (new AssistantApiException(429, "RATE_LIMIT", "You already have an assistant answer in progress. Wait for it to finish or stop it."));
|
||||
}
|
||||
|
||||
String limited = runtime.getRateLimiter().tryAcquire(userId, config.getMaxTurnsPerUserPerMinute(), config.getMaxConcurrentTurnsPerUser());
|
||||
if(limited != null)
|
||||
{
|
||||
throw (new AssistantApiException(429, "RATE_LIMIT", limited));
|
||||
}
|
||||
|
||||
List<AssistantTurn> remaining = new ArrayList<>();
|
||||
for(AssistantTurn other : store.listRunningTurns(userId))
|
||||
{
|
||||
other = reapIfOrphaned(other);
|
||||
if(!isRunning(other) || "SUPERSEDED".equals(other.getCancelReason()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if("SUPERSEDE".equals(concurrency) && conversationId.equals(other.getConversationId()))
|
||||
{
|
||||
requestStop(other, "SUPERSEDED");
|
||||
}
|
||||
else
|
||||
{
|
||||
remaining.add(other);
|
||||
}
|
||||
}
|
||||
remaining.sort(Comparator.comparing(AssistantTurn::getStartedAt, Comparator.nullsFirst(Comparator.naturalOrder()))
|
||||
.thenComparing(AssistantTurn::getId));
|
||||
while(remaining.size() >= config.getMaxConcurrentTurnsPerUser())
|
||||
{
|
||||
requestStop(remaining.removeFirst(), "SUPERSEDED");
|
||||
}
|
||||
|
||||
TranscriptRedactor transcript = new TranscriptRedactor(runtime.getQInstance(), config.getClassificationSource());
|
||||
transcript.attach();
|
||||
try
|
||||
{
|
||||
if(!StringUtils.hasContent(conversation.getTitle()))
|
||||
{
|
||||
String title = deriveTitle(text);
|
||||
String title = text;
|
||||
store.updateFields(AssistantConversation.TABLE_NAME, new QRecord().withValue("id", conversationId).withValue("title", title));
|
||||
conversation.setTitle(title);
|
||||
}
|
||||
@@ -361,13 +395,18 @@ public class AssistantApi
|
||||
.withSeq(seq)
|
||||
.withPartsJson(new MessageParts().addText(text).toJson()));
|
||||
|
||||
Instant startedAt = runtime.getClock().instant();
|
||||
AssistantTurn turn = store.insert(new AssistantTurn()
|
||||
.withConversationId(conversationId)
|
||||
.withUserId(userId)
|
||||
.withUserMessageId(userMessage.getId())
|
||||
.withStatus("RUNNING")
|
||||
.withModel(active.model())
|
||||
.withStartedAt(runtime.getClock().instant())
|
||||
.withStartedAt(startedAt)
|
||||
.withHeartbeatAt(startedAt)
|
||||
.withLeaseOwner(runtime.getInstanceId())
|
||||
.withPhase("starting")
|
||||
.withConcurrencyMode(concurrency)
|
||||
.withToolCallCount(0)
|
||||
.withModelCallCount(0));
|
||||
|
||||
@@ -385,17 +424,49 @@ public class AssistantApi
|
||||
store.updateFields(AssistantTurn.TABLE_NAME, new QRecord().withValue("id", turn.getId()).withValue("assistantMessageId", assistantMessage.getId()));
|
||||
turn.setAssistantMessageId(assistantMessage.getId());
|
||||
|
||||
RunningTurn runningTurn = runtime.register(new RunningTurn(turn.getId(), userId));
|
||||
RunningTurn runningTurn = new RunningTurn(turn.getId(), userId);
|
||||
TurnEngine engine = new TurnEngine(runtime, session, userId, conversation, userMessage, assistantMessage, turn, text, pageContext, policy, runningTurn,
|
||||
budget.softExceeded() ? budget.message() : null).withCredentials(credentials);
|
||||
runtime.getExecutor().submit(engine);
|
||||
budget.softExceeded() ? budget.message() : null).withCredentials(credentials).withTranscript(transcript);
|
||||
try
|
||||
{
|
||||
if(config.getRunTracker() != null)
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////
|
||||
// never the conversation title: it is derived from the prompt //
|
||||
///////////////////////////////////////////////////////////////////
|
||||
runningTurn.setTrackingId(config.getRunTracker().start(userId, turn.getId(), conversationId, "Assistant answer"));
|
||||
config.getRunTracker().onCancel(runningTurn.getTrackingId(), () -> runningTurn.cancel("USER"));
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Assistant run tracker start failed", logPair("turnId", turn.getId()));
|
||||
}
|
||||
runtime.register(runningTurn);
|
||||
if(!runningTurn.isFinalized())
|
||||
{
|
||||
try
|
||||
{
|
||||
runtime.getExecutor().submit(engine);
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
runningTurn.forceFinalize("SHUTDOWN");
|
||||
throw (e);
|
||||
}
|
||||
}
|
||||
return (runningTurn);
|
||||
}
|
||||
catch(QException | RuntimeException e)
|
||||
{
|
||||
transcript.close();
|
||||
runtime.getRateLimiter().release(userId);
|
||||
throw (e);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TranscriptRedactor.detach();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -443,22 +514,94 @@ public class AssistantApi
|
||||
{
|
||||
requireAccess();
|
||||
AssistantTurn turn = requireTurn(turnId);
|
||||
if(runtime.getRunningTurn(turnId) == null)
|
||||
if(isRunning(turn))
|
||||
{
|
||||
turn = requestStop(turn, "USER");
|
||||
}
|
||||
return (Map.of("status", turn.getStatus()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Durable Stop, shared by the API and superseding another question.
|
||||
*******************************************************************************/
|
||||
private AssistantTurn requestStop(AssistantTurn turn, String reason) throws QException
|
||||
{
|
||||
synchronized(store)
|
||||
{
|
||||
turn = store.getTurn(userId, turn.getId());
|
||||
if(!isRunning(turn))
|
||||
{
|
||||
return (turn);
|
||||
}
|
||||
RunningTurn live = runtime.getRunningTurn(turn.getId());
|
||||
if(live == null && orphaned(turn))
|
||||
{
|
||||
return (reap(turn, reason));
|
||||
}
|
||||
Instant now = runtime.getClock().instant();
|
||||
if(!store.updateRunningTurn(userId, new QRecord().withValue("id", turn.getId())
|
||||
.withValue("status", "CANCELLING").withValue("cancelReason", reason).withValue("cancelRequestedAt", now)))
|
||||
{
|
||||
return (store.getTurn(userId, turn.getId()));
|
||||
}
|
||||
turn.setStatus("CANCELLING");
|
||||
turn.setCancelReason(reason);
|
||||
turn.setCancelRequestedAt(now);
|
||||
if(live != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
live.cancel(reason);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Assistant stream cancellation failed; watchdog will finalize");
|
||||
}
|
||||
}
|
||||
return (turn);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** GET /turns/active: the caller's runs, newest first, after orphan recovery.
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> activeTurns() throws QException, AssistantApiException
|
||||
{
|
||||
requireAccess();
|
||||
List<AssistantTurn> turns = new ArrayList<>();
|
||||
for(AssistantTurn turn : store.listRunningTurns(userId))
|
||||
{
|
||||
turn = reapIfOrphaned(turn);
|
||||
if(isRunning(turn))
|
||||
{
|
||||
turns.add(turn);
|
||||
}
|
||||
}
|
||||
if(!isRunning(turn))
|
||||
turns.sort(Comparator.comparing(AssistantTurn::getStartedAt, Comparator.nullsFirst(Comparator.naturalOrder()))
|
||||
.thenComparing(AssistantTurn::getId).reversed());
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(AssistantTurn turn : turns)
|
||||
{
|
||||
throw (new AssistantApiException(409, "This answer has already finished."));
|
||||
RunningTurn live = runtime.getRunningTurn(turn.getId());
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("turnId", turn.getId());
|
||||
row.put("conversationId", turn.getConversationId());
|
||||
row.put("assistantMessageId", turn.getAssistantMessageId());
|
||||
row.put("userMessageId", turn.getUserMessageId());
|
||||
row.put("status", turn.getStatus());
|
||||
row.put("phase", live == null ? turn.getPhase() : live.getPhase());
|
||||
row.put("startedAt", turn.getStartedAt() == null ? null : turn.getStartedAt().toString());
|
||||
row.put("heartbeatAt", turn.getHeartbeatAt() == null ? null : turn.getHeartbeatAt().toString());
|
||||
row.put("elapsedMs", turn.getStartedAt() == null ? 0 : Math.max(0, Duration.between(turn.getStartedAt(), runtime.getClock().instant()).toMillis()));
|
||||
row.put("live", live != null);
|
||||
row.put("concurrencyMode", turn.getConcurrencyMode());
|
||||
rows.add(row);
|
||||
}
|
||||
|
||||
store.updateFields(AssistantTurn.TABLE_NAME, new QRecord().withValue("id", turnId).withValue("status", "CANCELLING"));
|
||||
RunningTurn live = runtime.getRunningTurn(turnId);
|
||||
if(live != null && userId.equals(live.getUserId()))
|
||||
{
|
||||
live.cancel("USER");
|
||||
}
|
||||
return (Map.of("status", "CANCELLING"));
|
||||
return (Map.of("turns", rows));
|
||||
}
|
||||
|
||||
|
||||
@@ -474,60 +617,106 @@ public class AssistantApi
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A turn whose replica died: it still says RUNNING / CANCELLING, no turn
|
||||
** of this runtime owns it, and it started longer ago than maxTurnSeconds
|
||||
** + 60 s (a live engine always ends itself by then). On first sight it is
|
||||
** finished as ERROR: the assistant message gets status ERROR and a
|
||||
** notice, and synthetic error (INTERNAL) + message.end events are
|
||||
** persisted, so replays end. Returns the (possibly updated) turn.
|
||||
** A remote run loses its lease, exceeds the absolute deadline, or has a
|
||||
** cancellation request that its owner did not acknowledge in time.
|
||||
*******************************************************************************/
|
||||
private boolean orphaned(AssistantTurn turn)
|
||||
{
|
||||
Instant now = runtime.getClock().instant();
|
||||
Instant heartbeat = turn.getHeartbeatAt() == null ? turn.getStartedAt() : turn.getHeartbeatAt();
|
||||
return ((heartbeat != null && heartbeat.isBefore(now.minusSeconds(config.getTurnLeaseSeconds())))
|
||||
|| (turn.getStartedAt() != null && turn.getStartedAt().isBefore(now.minusSeconds(maxStreamSeconds())))
|
||||
|| ("CANCELLING".equals(turn.getStatus()) && turn.getCancelRequestedAt() != null
|
||||
&& turn.getCancelRequestedAt().isBefore(now.minusSeconds(config.getTurnLeaseSeconds()))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Recover an abandoned run and leave a terminal replay for refreshes.
|
||||
*******************************************************************************/
|
||||
AssistantTurn reapIfOrphaned(AssistantTurn turn) throws QException
|
||||
{
|
||||
if(!isRunning(turn) || runtime.getRunningTurn(turn.getId()) != null || turn.getStartedAt() == null)
|
||||
synchronized(store)
|
||||
{
|
||||
return (turn);
|
||||
}
|
||||
Instant stale = runtime.getClock().instant().minusSeconds(config.getMaxTurnSeconds() + 60L);
|
||||
if(!turn.getStartedAt().isBefore(stale))
|
||||
{
|
||||
return (turn);
|
||||
}
|
||||
|
||||
String text = "This answer was interrupted (the server restarted). Ask again.";
|
||||
if(turn.getAssistantMessageId() != null)
|
||||
{
|
||||
AssistantMessage message = store.getMessage(userId, turn.getAssistantMessageId());
|
||||
if(message != null)
|
||||
if(!isRunning(turn) || runtime.getRunningTurn(turn.getId()) != null || !orphaned(turn))
|
||||
{
|
||||
store.updateFields(AssistantMessage.TABLE_NAME, new QRecord()
|
||||
.withValue("id", message.getId())
|
||||
.withValue("status", "ERROR")
|
||||
.withValue("partsJson", MessageParts.fromJson(message.getPartsJson()).addNotice("error", text).toJson()));
|
||||
return (turn);
|
||||
}
|
||||
String reason = "CANCELLING".equals(turn.getStatus()) && turn.getCancelRequestedAt() != null
|
||||
&& turn.getCancelRequestedAt().isBefore(runtime.getClock().instant().minusSeconds(config.getTurnLeaseSeconds()))
|
||||
? (turn.getCancelReason() == null ? "USER" : turn.getCancelReason()) : null;
|
||||
return (reap(turn, reason));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Re-read before recovery; never overwrite an already terminal row or
|
||||
** append another terminal event. Each persistence step is independent.
|
||||
*******************************************************************************/
|
||||
private AssistantTurn reap(AssistantTurn turn, String cancelReason) throws QException
|
||||
{
|
||||
AssistantTurn current = store.getTurn(userId, turn.getId());
|
||||
if(!isRunning(current))
|
||||
{
|
||||
return (current);
|
||||
}
|
||||
if(!orphaned(current))
|
||||
{
|
||||
return (cancelReason == null ? current : requestStop(current, cancelReason));
|
||||
}
|
||||
// Recovery only appends fixed notices to already redacted stored parts (or
|
||||
// crash placeholders). It never reloads model/tool values or raw user text.
|
||||
String status = cancelReason == null ? "ERROR" : "CANCELLED";
|
||||
String text = cancelReason == null ? "This answer was interrupted (the server restarted). Ask again."
|
||||
: ("SUPERSEDED".equals(cancelReason) ? TurnEngine.SUPERSEDED_TEXT : "Stopped by you.");
|
||||
if(current.getAssistantMessageId() != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
AssistantMessage message = store.getMessage(userId, current.getAssistantMessageId());
|
||||
if(message != null)
|
||||
{
|
||||
store.updateFields(AssistantMessage.TABLE_NAME, new QRecord().withValue("id", message.getId()).withValue("status", status)
|
||||
.withValue("partsJson", MessageParts.fromJson(message.getPartsJson()).addNotice(cancelReason == null ? "error" : "info", text).toJson()));
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Could not persist recovered assistant message");
|
||||
}
|
||||
}
|
||||
|
||||
int seq = store.lastEventSeq(userId, turn.getId());
|
||||
store.insertEvent(turn.getId(), userId, seq + 1, TurnEvent.ERROR, new TurnEvent(seq + 1, TurnEvent.ERROR, Map.of("code", "INTERNAL", "message", text)).toJson());
|
||||
Map<String, Object> end = new java.util.LinkedHashMap<>();
|
||||
if(turn.getAssistantMessageId() != null)
|
||||
try
|
||||
{
|
||||
end.put("messageId", turn.getAssistantMessageId());
|
||||
int seq = store.lastEventSeq(userId, current.getId());
|
||||
List<AssistantTurnEvent> last = store.listEvents(userId, current.getId(), Math.max(0, seq - 1), 1);
|
||||
if(last.isEmpty() || !TurnEvent.MESSAGE_END.equals(last.getFirst().getEventType()))
|
||||
{
|
||||
if(cancelReason == null)
|
||||
{
|
||||
seq++;
|
||||
store.insertEvent(current.getId(), userId, seq, TurnEvent.ERROR,
|
||||
new TurnEvent(seq, TurnEvent.ERROR, Map.of("code", "INTERNAL", "message", text)).toJson());
|
||||
}
|
||||
seq++;
|
||||
Map<String, Object> end = new LinkedHashMap<>();
|
||||
end.put("messageId", current.getAssistantMessageId());
|
||||
end.put("status", status);
|
||||
end.put("stopReason", cancelReason == null ? "interrupted" : "cancelled");
|
||||
store.insertEvent(current.getId(), userId, seq, TurnEvent.MESSAGE_END, new TurnEvent(seq, TurnEvent.MESSAGE_END, end).toJson());
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Could not persist recovered assistant events");
|
||||
}
|
||||
end.put("status", "ERROR");
|
||||
end.put("stopReason", "interrupted");
|
||||
store.insertEvent(turn.getId(), userId, seq + 2, TurnEvent.MESSAGE_END, new TurnEvent(seq + 2, TurnEvent.MESSAGE_END, end).toJson());
|
||||
|
||||
Instant now = runtime.getClock().instant();
|
||||
store.updateFields(AssistantTurn.TABLE_NAME, new QRecord()
|
||||
.withValue("id", turn.getId())
|
||||
.withValue("status", "ERROR")
|
||||
.withValue("errorCode", "INTERNAL")
|
||||
.withValue("stopReason", "interrupted")
|
||||
.withValue("endedAt", now));
|
||||
turn.setStatus("ERROR");
|
||||
turn.setErrorCode("INTERNAL");
|
||||
turn.setEndedAt(now);
|
||||
return (turn);
|
||||
store.updateRunningTurn(userId, new QRecord().withValue("id", current.getId()).withValue("status", status)
|
||||
.withValue("errorCode", cancelReason == null ? "INTERNAL" : null).withValue("cancelReason", cancelReason)
|
||||
.withValue("stopReason", cancelReason == null ? "interrupted" : "cancelled").withValue("phase", "done").withValue("endedAt", now));
|
||||
return (store.getTurn(userId, current.getId()));
|
||||
}
|
||||
|
||||
|
||||
@@ -591,6 +780,12 @@ public class AssistantApi
|
||||
throw (new AssistantApiException(409, "This action is already " + action.getStatus().toLowerCase() + "."));
|
||||
}
|
||||
|
||||
AssistantTurn proposingTurn = store.getTurn(userId, action.getTurnId());
|
||||
if(proposingTurn != null && isRunning(proposingTurn))
|
||||
{
|
||||
throw (new AssistantApiException(409, "Wait for the assistant to finish before confirming this action."));
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// claim it first, so a double click cannot run the write twice //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
@@ -786,6 +981,16 @@ public class AssistantApi
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Hard upper bound for remote stream polling and stale-run recovery.
|
||||
*******************************************************************************/
|
||||
public long maxStreamSeconds()
|
||||
{
|
||||
return (config.getMaxTurnSeconds() + (long) config.getTurnForceFinalizeGraceSeconds() + config.getTurnLeaseSeconds());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The user may use the panel: enabled, signed in, assistant.hasAccess.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -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;
|
||||
|
||||
+86
-5
@@ -61,7 +61,7 @@ import io.javalin.http.HttpStatus;
|
||||
public final class AssistantSpecs
|
||||
{
|
||||
public static final String EXTENSION_NAME = "assistant";
|
||||
public static final String EXTENSION_VERSION = "1.1.0";
|
||||
public static final String EXTENSION_VERSION = "1.2.0";
|
||||
public static final String PREFIX = "/assistant";
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@ public final class AssistantSpecs
|
||||
.withSpec(StartTurnSpec.class)
|
||||
.withSpec(TurnEventsSpec.class)
|
||||
.withSpec(CancelTurnSpec.class)
|
||||
.withSpec(ActiveTurnsSpec.class)
|
||||
.withSpec(FeedbackSpec.class)
|
||||
.withSpec(GetActionSpec.class)
|
||||
.withSpec(ConfirmActionSpec.class)
|
||||
@@ -218,9 +219,14 @@ public final class AssistantSpecs
|
||||
"href", string("After a confirm: the written record's /app link.").withNullable(true)));
|
||||
rs.put("AssistantAccessResponseV1", object("Explain access: per table, the user's UI permissions, the assistant's data access, and each tool's verdict.",
|
||||
"tables", array(freeObject("table, label, dataAccess, dataAccessSource, ui {visible, read, insert, edit, delete}, operations [{tool, operation, allowed, reason}]."), null)));
|
||||
rs.put("AssistantTurnEventV1", object("One server-sent event of a turn (event: <type>, id: <seq>, data: this object). Types: message.start, text.delta, tool.start, tool.result, ui.navigate, notice, usage, message.end, error, ping.",
|
||||
rs.put("AssistantActiveTurnsResponseV1", object("The caller's active runs, newest first; stale leases are reaped.",
|
||||
"turns", array(object("An active assistant run.", "turnId", integer(null), "conversationId", integer(null),
|
||||
"assistantMessageId", integer(null), "userMessageId", integer(null), "status", string("RUNNING or CANCELLING."),
|
||||
"phase", string(null), "startedAt", string("ISO-8601."), "heartbeatAt", string("ISO-8601."),
|
||||
"elapsedMs", integer(null), "live", bool("Running on this replica."), "concurrencyMode", string("SUPERSEDE or CONCURRENT.")), null)));
|
||||
rs.put("AssistantTurnEventV1", object("One server-sent event of a turn (event: <type>, id: <seq>, data: this object). Types: message.start, turn.phase, text.delta, tool.start, tool.result, ui.navigate, notice, usage, message.end, error, ping.",
|
||||
"seq", integer("Sequence number; resume with ?after=<seq>."), "type", string("The event type (also the SSE event name)."),
|
||||
"data", freeObject("The event's fields, by type.")));
|
||||
"data", freeObject("The event's fields, by type. message.start includes startedAt (ISO-8601). turn.phase includes phase (thinking, tool, writing), optional toolName and label.")));
|
||||
rs.put("AssistantErrorV1", object("An error.", "error", string("A message for the user."), "code", string("BUDGET, RATE_LIMIT, NOT_CONFIGURED, KILL_SWITCH, DISABLED, NO_PERMISSION...").withNullable(true)));
|
||||
return (rs);
|
||||
}
|
||||
@@ -562,6 +568,67 @@ public final class AssistantSpecs
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** GET /assistant/turns/active
|
||||
*******************************************************************************/
|
||||
public static class ActiveTurnsSpec extends AssistantJsonSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** HTTP method.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected HttpMethod method()
|
||||
{
|
||||
return (HttpMethod.GET);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Route path.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String path()
|
||||
{
|
||||
return (PREFIX + "/turns/active");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Operation summary.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String summary()
|
||||
{
|
||||
return ("Find the user's active assistant runs");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Response schema.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("AssistantActiveTurnsResponseV1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Runs in the invoker's authenticated user session.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(AssistantApi api, JsonInput input) throws Exception
|
||||
{
|
||||
return (api.activeTurns());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** POST /assistant/conversations
|
||||
*******************************************************************************/
|
||||
@@ -844,7 +911,7 @@ public final class AssistantSpecs
|
||||
@Override
|
||||
protected Schema defineRequestBodySchema()
|
||||
{
|
||||
return (object("The user's text and the page context hint.", "text", string("The user's message."), "pageContext", freeObject("Where the user is in the app.")));
|
||||
return (object("The user's text and the page context hint.", "text", string("The user's message."), "pageContext", freeObject("Where the user is in the app."), "concurrency", string("supersede or concurrent (case-insensitive); defaults to the host configuration.")));
|
||||
}
|
||||
|
||||
|
||||
@@ -1038,10 +1105,24 @@ public final class AssistantSpecs
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Older replicas can still return 202 during a rolling upgrade. New
|
||||
** replicas return 200 for both active and already finished turns.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<BasicResponse> defineAdditionalBasicResponses()
|
||||
{
|
||||
List<BasicResponse> responses = new java.util.ArrayList<>(super.defineAdditionalBasicResponses());
|
||||
responses.add(new BasicResponse(HttpStatus.ACCEPTED, "Legacy replica: cancellation accepted", "AssistantStatusResponseV1"));
|
||||
return (responses);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
protected HttpStatus successStatus()
|
||||
{
|
||||
return (HttpStatus.ACCEPTED);
|
||||
return (HttpStatus.OK);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+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)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
}
|
||||
+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;
|
||||
}
|
||||
|
||||
}
|
||||
+399
-119
@@ -44,6 +44,7 @@ import com.kingsrook.qbits.assistant.model.AssistantScheduledTask;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantTurn;
|
||||
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
|
||||
import com.kingsrook.qbits.assistant.persistence.TranscriptRedactor;
|
||||
import com.kingsrook.qbits.assistant.scheduled.ScheduledTurn;
|
||||
import com.kingsrook.qbits.assistant.spi.AssistantModelProvider;
|
||||
import com.kingsrook.qbits.assistant.spi.CacheHint;
|
||||
@@ -102,10 +103,11 @@ public class TurnEngine implements Runnable
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(TurnEngine.class);
|
||||
|
||||
private static final int MAX_INPUT_JSON_CHARS = 4000;
|
||||
public static final String SUPERSEDED_TEXT = "Stopped because you asked something else.";
|
||||
public static final String TIMEOUT_TEXT = "The assistant took too long to answer, so it was stopped. Ask again.";
|
||||
public static final String RESTART_TEXT = "The server restarted while answering. Ask again.";
|
||||
|
||||
private static final AiRedactor INPUT_REDACTOR = new AiRedactor(List.of());
|
||||
private static final int MAX_RESULT_JSON_CHARS = 64_000;
|
||||
private static final int TEXT_FLUSH_CHARS = 48;
|
||||
private static final long TEXT_FLUSH_MILLIS = 120;
|
||||
|
||||
@@ -151,6 +153,8 @@ public class TurnEngine implements Runnable
|
||||
|
||||
private volatile boolean finished = false;
|
||||
private UiApiCredentials credentials;
|
||||
private volatile TranscriptRedactor transcript;
|
||||
private boolean engineRunning;
|
||||
|
||||
|
||||
|
||||
@@ -175,6 +179,10 @@ public class TurnEngine implements Runnable
|
||||
this.policy = policy;
|
||||
this.running = running;
|
||||
this.softBudgetNotice = softBudgetNotice;
|
||||
this.startedAt = turn.getStartedAt() == null ? runtime.getClock().instant() : turn.getStartedAt();
|
||||
this.deadline = startedAt.plusSeconds(config.getMaxTurnSeconds());
|
||||
running.setStartedAt(startedAt);
|
||||
running.setForceFinalizer(this::forceFinish);
|
||||
}
|
||||
|
||||
|
||||
@@ -192,14 +200,43 @@ public class TurnEngine implements Runnable
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Continue the API's provisional transcript scope on the engine thread.
|
||||
*******************************************************************************/
|
||||
public TurnEngine withTranscript(TranscriptRedactor transcript)
|
||||
{
|
||||
this.transcript = transcript;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Finalization may run before submission or on a watchdog/shutdown thread.
|
||||
** Bind the same turn scope before any of those paths writes transcript content.
|
||||
*******************************************************************************/
|
||||
private synchronized void attachTranscript()
|
||||
{
|
||||
if(transcript == null)
|
||||
{
|
||||
transcript = new TranscriptRedactor(runtime.getQInstance(), config.getClassificationSource());
|
||||
}
|
||||
transcript.attach();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run the turn to completion. Never throws.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run()
|
||||
{
|
||||
startedAt = runtime.getClock().instant();
|
||||
deadline = startedAt.plusSeconds(config.getMaxTurnSeconds());
|
||||
if(running.isFinalized())
|
||||
{
|
||||
return;
|
||||
}
|
||||
running.setEngineThread(Thread.currentThread());
|
||||
|
||||
String status = "COMPLETE";
|
||||
String stopReason = null;
|
||||
@@ -209,6 +246,16 @@ public class TurnEngine implements Runnable
|
||||
try
|
||||
{
|
||||
QContext.init(runtime.getQInstance(), session);
|
||||
synchronized(this)
|
||||
{
|
||||
if(running.isFinalized())
|
||||
{
|
||||
return;
|
||||
}
|
||||
engineRunning = true;
|
||||
attachTranscript();
|
||||
}
|
||||
startWatchdog();
|
||||
scheduledTask = runtime.getScheduledTurn().task(conversation.getId());
|
||||
if(conversation.getScheduledTaskId() != null && scheduledTask == null)
|
||||
{
|
||||
@@ -218,9 +265,9 @@ public class TurnEngine implements Runnable
|
||||
ScheduledTurn.checkCost(runtime, conversation.getId(), turn.getId());
|
||||
classification = new TurnClassification(config.getClassificationSource(), conversation, pageContext);
|
||||
store.saveClassification(userId, conversation.getId(), turn.getId(), classification);
|
||||
startKillSwitchWatcher();
|
||||
|
||||
emit(TurnEvent.MESSAGE_START, map("turnId", turn.getId(), "conversationId", conversation.getId(), "userMessageId", userMessage.getId(), "messageId", assistantMessage.getId()));
|
||||
|
||||
emit(TurnEvent.MESSAGE_START, map("turnId", turn.getId(), "conversationId", conversation.getId(), "userMessageId", userMessage.getId(), "messageId", assistantMessage.getId(), "startedAt", startedAt.toString()));
|
||||
if(softBudgetNotice != null)
|
||||
{
|
||||
notice("warning", softBudgetNotice);
|
||||
@@ -232,6 +279,7 @@ public class TurnEngine implements Runnable
|
||||
ModelRouter.Route route;
|
||||
try
|
||||
{
|
||||
phase("thinking", null);
|
||||
route = router.route(session, conversation, turn, userText, pageContext, policy, classification, running, deadline, text -> notice("warning", text));
|
||||
}
|
||||
finally
|
||||
@@ -420,44 +468,88 @@ public class TurnEngine implements Runnable
|
||||
}
|
||||
finally
|
||||
{
|
||||
finished = true;
|
||||
finish(status, stopReason, errorCode, errorText);
|
||||
QContext.clear();
|
||||
// Clear interruption while persisting the terminal state.
|
||||
boolean interrupted = Thread.interrupted();
|
||||
if(running.isCancelled())
|
||||
{
|
||||
Stop stop = cancelledStop();
|
||||
status = stop.status();
|
||||
stopReason = stop.stopReason();
|
||||
errorCode = stop.errorCode();
|
||||
errorText = stop.message();
|
||||
}
|
||||
try
|
||||
{
|
||||
finish(status, stopReason, errorCode, errorText);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if(transcript != null)
|
||||
{
|
||||
transcript.close();
|
||||
}
|
||||
if(interrupted)
|
||||
{
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
QContext.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Poll the kill switch while the turn runs (with the user's own session),
|
||||
** so a flip cancels the turn - and every other turn here - even while it
|
||||
** waits on the model.
|
||||
** Renew the lease, poll cancellation and the kill switch with the user's
|
||||
** own session, and enforce a hard deadline even if a provider is wedged.
|
||||
*******************************************************************************/
|
||||
private void startKillSwitchWatcher()
|
||||
private void startWatchdog()
|
||||
{
|
||||
long pollMillis = config.getKillSwitchPollMillis() == null ? 2000 : config.getKillSwitchPollMillis();
|
||||
Thread.ofVirtual().name("assistant-killswitch-" + turn.getId()).start(() ->
|
||||
long heartbeatMillis = config.getTurnHeartbeatSeconds() * 1000L;
|
||||
long pollMillis = Math.min(heartbeatMillis, config.getKillSwitchPollMillis() == null ? 2000 : config.getKillSwitchPollMillis());
|
||||
Thread.ofVirtual().name("assistant-watchdog-" + turn.getId()).start(() ->
|
||||
{
|
||||
long lastHeartbeat = System.nanoTime();
|
||||
try
|
||||
{
|
||||
while(!finished && !running.isCancelled())
|
||||
QContext.init(runtime.getQInstance(), session);
|
||||
while(!running.isFinalized())
|
||||
{
|
||||
Thread.sleep(pollMillis);
|
||||
if(finished)
|
||||
if(running.isFinalized())
|
||||
{
|
||||
return;
|
||||
break;
|
||||
}
|
||||
QContext.init(runtime.getQInstance(), session);
|
||||
try
|
||||
{
|
||||
if(System.nanoTime() - lastHeartbeat >= heartbeatMillis * 1_000_000L)
|
||||
{
|
||||
lastHeartbeat = System.nanoTime();
|
||||
heartbeat();
|
||||
}
|
||||
if(runtime.getPolicyService().isKillSwitchOn())
|
||||
{
|
||||
runtime.cancelAll("KILL_SWITCH");
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Assistant watchdog tick failed", hipaaSafe(e), logPair("turnId", turn.getId()));
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
// Deadlines do not depend on successful database or policy reads.
|
||||
Instant now = runtime.getClock().instant();
|
||||
if(!now.isBefore(deadline))
|
||||
{
|
||||
safely(() -> running.cancel("TIMEOUT"));
|
||||
}
|
||||
if(!now.isBefore(deadline.plusSeconds(config.getTurnForceFinalizeGraceSeconds()))
|
||||
|| running.cancelGraceElapsed(config.getTurnForceFinalizeGraceSeconds()))
|
||||
{
|
||||
Stop stop = running.isCancelled() ? cancelledStop() : new Stop("ERROR", "TIMEOUT", TIMEOUT_TEXT, "timeout");
|
||||
finish(stop.status(), stop.stopReason(), stop.errorCode(), stop.message());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -465,11 +557,63 @@ public class TurnEngine implements Runnable
|
||||
{
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Renew only an active row, then observe persisted cancellation requests.
|
||||
*******************************************************************************/
|
||||
private synchronized void heartbeat() throws QException
|
||||
{
|
||||
if(running.isFinalized())
|
||||
{
|
||||
return;
|
||||
}
|
||||
AssistantTurn row = store.getTurn(userId, turn.getId());
|
||||
if(row == null || !("RUNNING".equals(row.getStatus()) || "CANCELLING".equals(row.getStatus())))
|
||||
{
|
||||
safely(() -> running.cancel("USER"));
|
||||
finish(row == null ? "ERROR" : row.getStatus(), row == null ? "interrupted" : row.getStopReason(),
|
||||
row == null ? "INTERNAL" : row.getErrorCode(), null);
|
||||
return;
|
||||
}
|
||||
store.updateFields(AssistantTurn.TABLE_NAME, new QRecord().withValue("id", turn.getId())
|
||||
.withValue("heartbeatAt", runtime.getClock().instant()).withValue("phase", running.getPhase()));
|
||||
row = store.getTurn(userId, turn.getId());
|
||||
if(row != null && (row.getCancelRequestedAt() != null || "CANCELLING".equals(row.getStatus())))
|
||||
{
|
||||
running.cancel(row.getCancelReason() == null ? "USER" : row.getCancelReason());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Finalize from shutdown using the original authenticated user's context.
|
||||
*******************************************************************************/
|
||||
private void forceFinish(String reason)
|
||||
{
|
||||
try
|
||||
{
|
||||
QContext.init(runtime.getQInstance(), session);
|
||||
safely(() -> running.cancel(reason));
|
||||
Stop stop = "SHUTDOWN".equals(reason) ? new Stop("ERROR", "INTERRUPTED", RESTART_TEXT, "interrupted") : cancelledStop();
|
||||
finish(stop.status(), stop.stopReason(), stop.errorCode(), stop.message());
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Why a turn stops.
|
||||
*******************************************************************************/
|
||||
@@ -504,14 +648,15 @@ public class TurnEngine implements Runnable
|
||||
}
|
||||
if(!runtime.getClock().instant().isBefore(deadline))
|
||||
{
|
||||
return (new Stop("ERROR", "TIMEOUT", "The assistant ran out of time for this question.", "timeout"));
|
||||
running.cancel("TIMEOUT");
|
||||
return (cancelledStop());
|
||||
}
|
||||
try
|
||||
{
|
||||
AssistantTurn row = store.getTurn(userId, turn.getId());
|
||||
if(row != null && "CANCELLING".equals(row.getStatus()))
|
||||
{
|
||||
running.cancel("USER");
|
||||
running.cancel(row.getCancelReason() == null ? "USER" : row.getCancelReason());
|
||||
return (cancelledStop());
|
||||
}
|
||||
}
|
||||
@@ -529,6 +674,18 @@ public class TurnEngine implements Runnable
|
||||
*******************************************************************************/
|
||||
private Stop cancelledStop()
|
||||
{
|
||||
if("TIMEOUT".equals(running.getCancelReason()))
|
||||
{
|
||||
return (new Stop("ERROR", "TIMEOUT", TIMEOUT_TEXT, "timeout"));
|
||||
}
|
||||
if("SHUTDOWN".equals(running.getCancelReason()))
|
||||
{
|
||||
return (new Stop("ERROR", "INTERRUPTED", RESTART_TEXT, "interrupted"));
|
||||
}
|
||||
if("SUPERSEDED".equals(running.getCancelReason()))
|
||||
{
|
||||
return (new Stop("CANCELLED", null, SUPERSEDED_TEXT, ModelResponse.STOP_CANCELLED));
|
||||
}
|
||||
if("KILL_SWITCH".equals(running.getCancelReason()))
|
||||
{
|
||||
String reason = StringUtils.hasContent(policy.killSwitchReason()) ? " " + policy.killSwitchReason() : "";
|
||||
@@ -618,6 +775,7 @@ public class TurnEngine implements Runnable
|
||||
@Override
|
||||
public void onTextDelta(String text)
|
||||
{
|
||||
phase("writing", null);
|
||||
bufferText(text);
|
||||
}
|
||||
};
|
||||
@@ -639,6 +797,7 @@ public class TurnEngine implements Runnable
|
||||
TurnDataRouting.beforeModelCall(runtime, classification, turn, active);
|
||||
fallback.beforeCall(active);
|
||||
modelCallCount++;
|
||||
phase("thinking", null);
|
||||
StreamHandle handle = provider.stream(request.copy(), fallback.listener(listener));
|
||||
running.setCurrentHandle(handle);
|
||||
try
|
||||
@@ -701,12 +860,14 @@ public class TurnEngine implements Runnable
|
||||
for(int i = 0; i < toolUses.size(); i++)
|
||||
{
|
||||
ModelPart use = toolUses.get(i);
|
||||
transcript.input(use.getInput());
|
||||
if(i >= allowed)
|
||||
{
|
||||
limitReached = true;
|
||||
continue;
|
||||
}
|
||||
toolCallCount++;
|
||||
phase("tool", use.getToolName());
|
||||
emit(TurnEvent.TOOL_START, map("messageId", assistantMessage.getId(), "toolCallId", use.getToolUseId(), "name", use.getToolName(), "summary", ToolLabels.running(use.getToolName())));
|
||||
|
||||
if(!allowedTools.contains(use.getToolName()))
|
||||
@@ -720,8 +881,14 @@ public class TurnEngine implements Runnable
|
||||
AiToolCall call = new AiToolCall().withId(use.getToolUseId()).withName(use.getToolName()).withInput(use.getInput());
|
||||
futures.add(runtime.getExecutor().submit(() ->
|
||||
{
|
||||
if(!transcript.retainWorker())
|
||||
{
|
||||
return;
|
||||
}
|
||||
transcript.attach();
|
||||
try
|
||||
{
|
||||
QContext.init(runtime.getQInstance(), session);
|
||||
permits.acquire();
|
||||
try
|
||||
{
|
||||
@@ -783,6 +950,12 @@ public class TurnEngine implements Runnable
|
||||
results[index] = new AiToolResult().withToolCallId(call.getId()).withToolName(call.getName()).withOutcome(AiToolOutcome.ERROR)
|
||||
.withSummary("Failed").withModelContent("The tool failed unexpectedly.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
transcript.releaseWorker();
|
||||
TranscriptRedactor.detach();
|
||||
QContext.clear();
|
||||
}
|
||||
}));
|
||||
}
|
||||
persistPendingEvents();
|
||||
@@ -880,6 +1053,7 @@ public class TurnEngine implements Runnable
|
||||
|
||||
try
|
||||
{
|
||||
String inputJson = redactInput(PresentResultsTool.NAME.equals(use.getToolName()) ? (presentation ? result.getRender() : Map.of()) : use.getInput());
|
||||
store.insert(new AssistantToolCall()
|
||||
.withTurnId(turn.getId())
|
||||
.withConversationId(conversation.getId())
|
||||
@@ -887,7 +1061,7 @@ public class TurnEngine implements Runnable
|
||||
.withToolCallId(use.getToolUseId())
|
||||
.withToolName(use.getToolName())
|
||||
.withClientChannel(scheduledTask == null ? "assistant/" + turn.getId() : "assistant/scheduled/" + scheduledTask.getId())
|
||||
.withInputJson(truncate(redactInput(PresentResultsTool.NAME.equals(use.getToolName()) ? (presentation ? result.getRender() : Map.of()) : use.getInput()), MAX_INPUT_JSON_CHARS))
|
||||
.withInputJson(BulkLoadTools.NAMES.contains(use.getToolName()) ? BulkLoadTools.forLog(inputJson) : inputJson)
|
||||
.withOutcome(outcome)
|
||||
.withPermissionCheck(result.getPermissionCheck())
|
||||
.withTableName(result.getTableName())
|
||||
@@ -896,7 +1070,7 @@ public class TurnEngine implements Runnable
|
||||
.withRowCount(result.getRowCount())
|
||||
.withDurationMs(result.getDurationMs())
|
||||
.withResultSummary(summary)
|
||||
.withResultJson(truncate(BulkLoadTools.NAMES.contains(use.getToolName()) ? BulkLoadTools.forLog(result.getModelContent()) : result.getModelContent(), MAX_RESULT_JSON_CHARS))
|
||||
.withResultJson(BulkLoadTools.NAMES.contains(use.getToolName()) ? BulkLoadTools.forLog(result.getModelContent()) : result.getModelContent())
|
||||
.withRedactionsJson(result.getRedactions() == null || result.getRedactions().isEmpty() ? null : JsonUtils.toJson(result.getRedactions()))
|
||||
.withUiApiOperation(extras == null ? ("search_knowledge".equals(use.getToolName()) ? "GET /table/{tableName}/{primaryKey}" : null) : extras.getOperation())
|
||||
.withHttpStatus(extras == null ? null : extras.getHttpStatus())
|
||||
@@ -996,93 +1170,103 @@ public class TurnEngine implements Runnable
|
||||
/*******************************************************************************
|
||||
** Persist the message, turn, conversation and usage; emit the end events.
|
||||
*******************************************************************************/
|
||||
private void finish(String status, String stopReason, String errorCode, String errorText)
|
||||
private synchronized void finish(String status, String stopReason, String errorCode, String errorText)
|
||||
{
|
||||
if(!running.tryFinalize())
|
||||
{
|
||||
return;
|
||||
}
|
||||
TranscriptRedactor previousTranscript = TranscriptRedactor.current();
|
||||
try
|
||||
{
|
||||
persistClassification();
|
||||
flushText(true);
|
||||
Instant now = runtime.getClock().instant();
|
||||
|
||||
if(errorText != null)
|
||||
{
|
||||
parts.addNotice("error", errorText);
|
||||
}
|
||||
|
||||
String model = this.model != null ? this.model : (policy == null ? null : policy.mainModel());
|
||||
attachTranscript();
|
||||
AssistantTurn persistedTurn = null;
|
||||
try
|
||||
{
|
||||
store.updateFields(AssistantMessage.TABLE_NAME, new QRecord()
|
||||
.withValue("id", assistantMessage.getId())
|
||||
.withValue("status", status)
|
||||
.withValue("model", model)
|
||||
.withValue("partsJson", parts.toJson()));
|
||||
|
||||
AssistantConversation current = store.getConversation(userId, conversation.getId());
|
||||
if(current != null)
|
||||
{
|
||||
store.updateFields(AssistantConversation.TABLE_NAME, new QRecord()
|
||||
.withValue("id", current.getId())
|
||||
.withValue("lastMessageAt", now)
|
||||
.withValue("unreadCount", (current.getUnreadCount() == null ? 0 : current.getUnreadCount()) + 1)
|
||||
.withValue("totalTokensIn", nz(current.getTotalTokensIn()) + nz(usage.getInputTokens()) + nz(usage.getCacheReadTokens()) + nz(usage.getCacheWriteTokens()))
|
||||
.withValue("totalTokensOut", nz(current.getTotalTokensOut()) + nz(usage.getOutputTokens()))
|
||||
.withValue("totalCostMicros", nz(current.getTotalCostMicros()) + costMicros));
|
||||
}
|
||||
|
||||
store.addUsage(userId, LocalDate.now(runtime.getClock().withZone(ZoneOffset.UTC)), usage, costMicros, true);
|
||||
persistedTurn = store.getTurn(userId, turn.getId());
|
||||
}
|
||||
catch(QException e)
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Could not persist the end of an assistant turn", hipaaSafe(e), logPair("turnId", turn.getId()));
|
||||
LOG.warn("Could not read final assistant status", hipaaSafe(e), logPair("turnId", turn.getId()));
|
||||
}
|
||||
boolean alreadyTerminal = persistedTurn != null && !("RUNNING".equals(persistedTurn.getStatus()) || "CANCELLING".equals(persistedTurn.getStatus()));
|
||||
if(alreadyTerminal)
|
||||
{
|
||||
status = persistedTurn.getStatus();
|
||||
stopReason = persistedTurn.getStopReason();
|
||||
errorCode = persistedTurn.getErrorCode();
|
||||
}
|
||||
String finalStatus = status;
|
||||
String finalStopReason = stopReason;
|
||||
String finalErrorCode = errorCode;
|
||||
String model = this.model != null ? this.model : (policy == null ? null : policy.mainModel());
|
||||
Instant now = runtime.getClock().instant();
|
||||
safely(() -> flushText(true));
|
||||
if(errorText != null && !alreadyTerminal)
|
||||
{
|
||||
parts.addNotice("CANCELLED".equals(status) ? "info" : "error", errorText);
|
||||
}
|
||||
running.setPhase("done", null);
|
||||
if(!alreadyTerminal)
|
||||
{
|
||||
safely(() -> persistClassification());
|
||||
safely(() -> store.updateFields(AssistantMessage.TABLE_NAME, new QRecord()
|
||||
.withValue("id", assistantMessage.getId()).withValue("status", finalStatus)
|
||||
.withValue("model", model).withValue("partsJson", parts.toJson())));
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// terminal events are persisted BEFORE the turn row turns final, //
|
||||
// so a replay of a finished turn always ends with message.end //
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
emit(TurnEvent.USAGE, map("turnId", turn.getId(), "inputTokens", usage.getInputTokens(), "outputTokens", usage.getOutputTokens(),
|
||||
"cacheReadTokens", usage.getCacheReadTokens(), "cacheWriteTokens", usage.getCacheWriteTokens(), "costMicros", costMicros));
|
||||
if(errorCode != null)
|
||||
{
|
||||
emit(TurnEvent.ERROR, map("code", errorCode, "message", errorText));
|
||||
}
|
||||
Map<String, Object> end = map("messageId", assistantMessage.getId(), "status", status);
|
||||
if(stopReason != null)
|
||||
emit(TurnEvent.MESSAGE_END, map("messageId", assistantMessage.getId(), "status", status, "stopReason", stopReason));
|
||||
if(!alreadyTerminal)
|
||||
{
|
||||
end.put("stopReason", stopReason);
|
||||
safely(this::persistPendingEvents);
|
||||
safely(() -> transcript.flush(store));
|
||||
// Accounting failures cannot skip the terminal row or local cleanup.
|
||||
safely(() ->
|
||||
{
|
||||
store.updateRunningTurn(userId, new QRecord()
|
||||
.withValue("id", turn.getId())
|
||||
.withValue("status", finalStatus)
|
||||
.withValue("phase", "done")
|
||||
.withValue("stopReason", finalStopReason)
|
||||
.withValue("errorCode", finalErrorCode)
|
||||
.withValue("cancelReason", running.getCancelReason())
|
||||
.withValue("model", model)
|
||||
.withValue("providerId", active == null ? null : active.providerId())
|
||||
.withValue("routeDecision", turn.getRouteDecision())
|
||||
.withValue("routeSource", turn.getRouteSource())
|
||||
.withValue("fallbackCount", turn.getFallbackCount())
|
||||
.withValue("endedAt", now)
|
||||
.withValue("toolCallCount", toolCallCount)
|
||||
.withValue("modelCallCount", modelCallCount)
|
||||
.withValue("inputTokens", usage.getInputTokens())
|
||||
.withValue("outputTokens", usage.getOutputTokens())
|
||||
.withValue("cacheReadTokens", usage.getCacheReadTokens())
|
||||
.withValue("cacheWriteTokens", usage.getCacheWriteTokens())
|
||||
.withValue("costMicros", costMicros)
|
||||
.withValue("providerRequestIds", requestIds.isEmpty() ? null : String.join(",", requestIds)));
|
||||
});
|
||||
safely(() ->
|
||||
{
|
||||
AssistantConversation current = store.getConversation(userId, conversation.getId());
|
||||
if(current != null)
|
||||
{
|
||||
store.updateFields(AssistantConversation.TABLE_NAME, new QRecord()
|
||||
.withValue("id", current.getId())
|
||||
.withValue("lastMessageAt", now)
|
||||
.withValue("unreadCount", (current.getUnreadCount() == null ? 0 : current.getUnreadCount()) + 1)
|
||||
.withValue("totalTokensIn", nz(current.getTotalTokensIn()) + nz(usage.getInputTokens()) + nz(usage.getCacheReadTokens()) + nz(usage.getCacheWriteTokens()))
|
||||
.withValue("totalTokensOut", nz(current.getTotalTokensOut()) + nz(usage.getOutputTokens()))
|
||||
.withValue("totalCostMicros", nz(current.getTotalCostMicros()) + costMicros));
|
||||
}
|
||||
});
|
||||
safely(() -> store.addUsage(userId, LocalDate.now(runtime.getClock().withZone(ZoneOffset.UTC)), usage, costMicros, true));
|
||||
}
|
||||
emit(TurnEvent.MESSAGE_END, end);
|
||||
persistPendingEvents();
|
||||
|
||||
try
|
||||
{
|
||||
store.updateFields(AssistantTurn.TABLE_NAME, new QRecord()
|
||||
.withValue("id", turn.getId())
|
||||
.withValue("status", status)
|
||||
.withValue("stopReason", truncate(stopReason, 64))
|
||||
.withValue("errorCode", errorCode)
|
||||
.withValue("cancelReason", running.getCancelReason())
|
||||
.withValue("model", model)
|
||||
.withValue("providerId", active == null ? null : active.providerId())
|
||||
.withValue("routeDecision", turn.getRouteDecision())
|
||||
.withValue("routeSource", turn.getRouteSource())
|
||||
.withValue("fallbackCount", turn.getFallbackCount())
|
||||
.withValue("endedAt", now)
|
||||
.withValue("toolCallCount", toolCallCount)
|
||||
.withValue("modelCallCount", modelCallCount)
|
||||
.withValue("inputTokens", usage.getInputTokens())
|
||||
.withValue("outputTokens", usage.getOutputTokens())
|
||||
.withValue("cacheReadTokens", usage.getCacheReadTokens())
|
||||
.withValue("cacheWriteTokens", usage.getCacheWriteTokens())
|
||||
.withValue("costMicros", costMicros)
|
||||
.withValue("providerRequestIds", requestIds.isEmpty() ? null : String.join(",", requestIds)));
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
LOG.warn("Could not persist the end of an assistant turn", hipaaSafe(e), logPair("turnId", turn.getId()));
|
||||
}
|
||||
|
||||
LOG.info("Assistant turn finished",
|
||||
logPair("turnId", turn.getId()),
|
||||
logPair("conversationId", conversation.getId()),
|
||||
@@ -1091,6 +1275,7 @@ public class TurnEngine implements Runnable
|
||||
logPair("status", status),
|
||||
logPair("stopReason", stopReason),
|
||||
logPair("errorCode", errorCode),
|
||||
logPair("cancelReason", running.getCancelReason()),
|
||||
logPair("modelCalls", modelCallCount),
|
||||
logPair("toolCalls", toolCallCount),
|
||||
logPair("inputTokens", usage.getInputTokens()),
|
||||
@@ -1100,29 +1285,104 @@ public class TurnEngine implements Runnable
|
||||
logPair("costMicros", costMicros),
|
||||
logPair("durationMs", Duration.between(startedAt, now).toMillis()));
|
||||
}
|
||||
catch(QException | RuntimeException e)
|
||||
{
|
||||
LOG.warn("Assistant turn finish failed", hipaaSafe(e), logPair("turnId", turn.getId()));
|
||||
}
|
||||
finally
|
||||
{
|
||||
finished = true;
|
||||
running.getLog().close();
|
||||
if(scheduledTask != null)
|
||||
safely(() ->
|
||||
{
|
||||
runtime.getScheduledTurn().detach(conversation.getId(), session.getUuid());
|
||||
if(scheduledTask != null)
|
||||
{
|
||||
runtime.getScheduledTurn().detach(conversation.getId(), session.getUuid());
|
||||
}
|
||||
});
|
||||
String finalStatus = status;
|
||||
String finalText = errorText;
|
||||
try
|
||||
{
|
||||
safely(() -> config.getRunTracker().finish(running.getTrackingId(), finalStatus, finalText));
|
||||
}
|
||||
finally
|
||||
{
|
||||
runtime.unregister(turn.getId());
|
||||
runtime.getRateLimiter().release(userId);
|
||||
if(transcript != null && !engineRunning)
|
||||
{
|
||||
transcript.close();
|
||||
}
|
||||
else
|
||||
{
|
||||
// A wedged provider can still log after forced finalization. The
|
||||
// engine's finally releases its log protection when it returns.
|
||||
TranscriptRedactor.detach();
|
||||
}
|
||||
if(previousTranscript != null && previousTranscript != transcript)
|
||||
{
|
||||
previousTranscript.attach();
|
||||
}
|
||||
}
|
||||
runtime.unregister(turn.getId());
|
||||
runtime.getRateLimiter().release(userId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One independently fallible finalization operation.
|
||||
*******************************************************************************/
|
||||
@FunctionalInterface
|
||||
private interface FinishStep
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Execute the step.
|
||||
*******************************************************************************/
|
||||
void run() throws Exception;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A failure in a collaborator must not strand the run.
|
||||
*******************************************************************************/
|
||||
private void safely(FinishStep step)
|
||||
{
|
||||
try
|
||||
{
|
||||
step.run();
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Assistant lifecycle step failed", hipaaSafe(e), logPair("turnId", turn.getId()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Emit and track only phase changes, including the current tool label.
|
||||
*******************************************************************************/
|
||||
private synchronized void phase(String phase, String toolName)
|
||||
{
|
||||
if(running.isFinalized() || (phase.equals(running.getPhase()) && Objects.equals(toolName, running.getToolName())))
|
||||
{
|
||||
return;
|
||||
}
|
||||
running.setPhase(phase, toolName);
|
||||
String label = toolName == null ? phase : ToolLabels.running(toolName);
|
||||
emit(TurnEvent.TURN_PHASE, map("phase", phase, "toolName", toolName, "label", label));
|
||||
safely(() -> config.getRunTracker().progress(running.getTrackingId(), phase, label));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Buffer streamed text; emit it in small chunks (provider thread).
|
||||
*******************************************************************************/
|
||||
private void bufferText(String text)
|
||||
private synchronized void bufferText(String text)
|
||||
{
|
||||
if(running.isFinalized())
|
||||
{
|
||||
return;
|
||||
}
|
||||
synchronized(textBuffer)
|
||||
{
|
||||
textBuffer.append(text);
|
||||
@@ -1135,7 +1395,7 @@ public class TurnEngine implements Runnable
|
||||
/*******************************************************************************
|
||||
** Emit buffered text as one text.delta (when big or old enough, or forced).
|
||||
*******************************************************************************/
|
||||
private void flushText(boolean force)
|
||||
private synchronized void flushText(boolean force)
|
||||
{
|
||||
String chunk;
|
||||
synchronized(textBuffer)
|
||||
@@ -1170,8 +1430,12 @@ public class TurnEngine implements Runnable
|
||||
** Append to the live log; queue for persistence (done on the engine
|
||||
** thread, which has the QContext).
|
||||
*******************************************************************************/
|
||||
private void emit(String type, Map<String, Object> data)
|
||||
private synchronized void emit(String type, Map<String, Object> data)
|
||||
{
|
||||
if(finished)
|
||||
{
|
||||
return;
|
||||
}
|
||||
toPersist.add(running.getLog().append(type, data));
|
||||
}
|
||||
|
||||
@@ -1180,18 +1444,44 @@ public class TurnEngine implements Runnable
|
||||
/*******************************************************************************
|
||||
** Persist queued events.
|
||||
*******************************************************************************/
|
||||
private void persistPendingEvents()
|
||||
private synchronized void persistPendingEvents()
|
||||
{
|
||||
if(!running.isFinalized() || finished)
|
||||
{
|
||||
return;
|
||||
}
|
||||
List<TurnEvent> events = new ArrayList<>();
|
||||
TurnEvent event;
|
||||
while((event = toPersist.poll()) != null)
|
||||
{
|
||||
events.add(event);
|
||||
}
|
||||
StringBuilder text = new StringBuilder();
|
||||
for(TurnEvent queued : events)
|
||||
{
|
||||
if(TurnEvent.TEXT_DELTA.equals(queued.getType()))
|
||||
{
|
||||
text.append(queued.getData().get("text"));
|
||||
}
|
||||
}
|
||||
Boolean firstText = true;
|
||||
for(TurnEvent queued : events)
|
||||
{
|
||||
try
|
||||
{
|
||||
store.insertEvent(turn.getId(), userId, event.getSeq(), event.getType(), event.toJson());
|
||||
String json = queued.toJson();
|
||||
if(TurnEvent.TEXT_DELTA.equals(queued.getType()))
|
||||
{
|
||||
Map<String, Object> data = new LinkedHashMap<>(queued.getData());
|
||||
data.put("text", firstText ? text.toString() : "");
|
||||
firstText = false;
|
||||
json = JsonUtils.toJson(data);
|
||||
}
|
||||
store.insertEvent(turn.getId(), userId, queued.getSeq(), queued.getType(), json);
|
||||
}
|
||||
catch(QException e)
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Could not persist assistant turn event", hipaaSafe(e), logPair("turnId", turn.getId()), logPair("seq", event.getSeq()));
|
||||
LOG.warn("Could not persist assistant turn event", hipaaSafe(e), logPair("turnId", turn.getId()), logPair("seq", queued.getSeq()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1238,16 +1528,6 @@ public class TurnEngine implements Runnable
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String truncate(String value, int max)
|
||||
{
|
||||
return (value == null || value.length() <= max ? value : value.substring(0, max));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -29,6 +29,7 @@ import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
*******************************************************************************/
|
||||
public class TurnEvent
|
||||
{
|
||||
public static final String TURN_PHASE = "turn.phase";
|
||||
public static final String MESSAGE_START = "message.start";
|
||||
public static final String TEXT_DELTA = "text.delta";
|
||||
public static final String TOOL_START = "tool.start";
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
+13
@@ -75,6 +75,7 @@ public class AssistantHomeData
|
||||
private final List<Totals> byProvider = new ArrayList<>();
|
||||
|
||||
private Long fallbackCount = 0L;
|
||||
private List<AssistantSetupStatus.Item> setup = List.of();
|
||||
private Boolean hipaaMode;
|
||||
private Integer hipaaRetentionDays;
|
||||
private LocalDate today;
|
||||
@@ -313,6 +314,7 @@ public class AssistantHomeData
|
||||
data.providers.add(new ProviderHealth(row, type == null ? row.getProviderType() : type.getLabel(), status, (status.startsWith("Set") || status.startsWith("Not needed")), service.validate(row), active != null && active.getId().equals(row.getId()),
|
||||
providerMonth.getOrDefault(row.getId(), 0L), ProviderService.capMicros(row), service.kind(row), service.location(row), service.models(row.getId()).stream().filter(m -> Boolean.TRUE.equals(m.getEnabled())).count()));
|
||||
}
|
||||
data.setup = AssistantSetupStatus.load(runtime, data.providers);
|
||||
return (data);
|
||||
}
|
||||
|
||||
@@ -578,4 +580,15 @@ public class AssistantHomeData
|
||||
{
|
||||
return (fallbackCount);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Setup card: each configuration table, its state and the next step.
|
||||
*******************************************************************************/
|
||||
public List<AssistantSetupStatus.Item> getSetup()
|
||||
{
|
||||
return (setup);
|
||||
}
|
||||
|
||||
}
|
||||
+47
@@ -90,6 +90,7 @@ public final class AssistantHomeView
|
||||
public static String render(AssistantHomeData data, String prefix)
|
||||
{
|
||||
StringBuilder html = new StringBuilder("<div class=\"assistant-home\" style=\"" + ROOT + "\">");
|
||||
setup(html, data, prefix);
|
||||
status(html, data, prefix);
|
||||
providers(html, data, prefix);
|
||||
html.append("<div style=\"").append(GRID).append("\">");
|
||||
@@ -111,6 +112,52 @@ public final class AssistantHomeView
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Setup card: each part of the assistant, set up or not, and the next
|
||||
** step, linked to its table.
|
||||
*******************************************************************************/
|
||||
private static void setup(StringBuilder html, AssistantHomeData data, String prefix)
|
||||
{
|
||||
if(data.getSetup().isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
open(html, "Setup");
|
||||
long todo = data.getSetup().stream().filter(i -> i.state() == AssistantSetupStatus.State.TODO).count();
|
||||
html.append("<p style=\"margin:0 0 .6rem;font-size:.9rem;\">")
|
||||
.append(todo == 0 ? "Everything the assistant needs is set up." : esc(todo + (todo == 1 ? " item needs" : " items need") + " attention."))
|
||||
.append("</p><ul style=\"list-style:none;margin:0;padding:0;\">");
|
||||
for(AssistantSetupStatus.Item item : data.getSetup())
|
||||
{
|
||||
String color = switch(item.state())
|
||||
{
|
||||
case OK -> GREEN;
|
||||
case TODO -> RED;
|
||||
case DEFAULT -> AMBER;
|
||||
case OPTIONAL -> GREY;
|
||||
};
|
||||
String label = switch(item.state())
|
||||
{
|
||||
case OK -> "Set up";
|
||||
case TODO -> "Needs setup";
|
||||
case DEFAULT -> "Using defaults";
|
||||
case OPTIONAL -> "Optional";
|
||||
};
|
||||
html.append("<li style=\"").append(ROW).append("\"><div style=\"display:flex;flex-wrap:wrap;align-items:center;gap:.5rem .75rem;\">")
|
||||
.append("<strong><a style=\"").append(LINK).append("\" href=\"").append(esc(prefix + "/" + item.tableName())).append("\">").append(esc(item.label())).append("</a></strong>")
|
||||
.append(chip(color, label)).append("</div><div style=\"font-size:.9rem;margin-top:.35rem;\">").append(esc(item.detail())).append("</div>");
|
||||
if(item.nextStep() != null)
|
||||
{
|
||||
html.append("<div style=\"").append(MUTED).append("font-size:.9rem;margin-top:.2rem;\">Next: ").append(esc(item.nextStep())).append("</div>");
|
||||
}
|
||||
html.append("</li>");
|
||||
}
|
||||
html.append("</ul>");
|
||||
close(html);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The status row: is the assistant answering, with what, at what cost.
|
||||
*******************************************************************************/
|
||||
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.home;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.assistant.AssistantRuntime;
|
||||
import com.kingsrook.qbits.assistant.engine.ProviderService;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantBudget;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantDataAccess;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantModelPrice;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantPolicy;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProfile;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProfileSkill;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProvider;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantRagSource;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantScheduledTask;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantSkill;
|
||||
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
|
||||
import com.kingsrook.qbits.assistant.spi.ModelPricing;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Setup card on the Assistant home: each configuration table, whether it
|
||||
** is set up, and the next step. Only reads the QBit's own configuration
|
||||
** tables (small), as the signed-in administrator.
|
||||
*******************************************************************************/
|
||||
public final class AssistantSetupStatus
|
||||
{
|
||||
private static final int LIMIT = 1000;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** OK: working. TODO: the assistant needs it. DEFAULT: working on built-in
|
||||
** defaults (nothing to do). OPTIONAL: a feature that is off until set up.
|
||||
*******************************************************************************/
|
||||
public enum State
|
||||
{
|
||||
OK, TODO, DEFAULT, OPTIONAL
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One line of the card.
|
||||
*******************************************************************************/
|
||||
public record Item(String label, String tableName, State state, String detail, String nextStep)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private AssistantSetupStatus()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build the items. `providers` is what the home already loaded.
|
||||
*******************************************************************************/
|
||||
public static List<Item> load(AssistantRuntime runtime, List<AssistantHomeData.ProviderHealth> providers) throws QException
|
||||
{
|
||||
AssistantStore store = runtime.getStore();
|
||||
List<Item> items = new ArrayList<>();
|
||||
|
||||
///////////////
|
||||
// providers //
|
||||
///////////////
|
||||
AssistantHomeData.ProviderHealth active = providers.stream().filter(AssistantHomeData.ProviderHealth::active).findFirst().orElse(null);
|
||||
if(active != null && active.keyPresent() && active.problems().isEmpty())
|
||||
{
|
||||
items.add(new Item("Provider", AssistantProvider.TABLE_NAME, State.OK, active.row().getName() + " answers questions.", null));
|
||||
}
|
||||
else if(providers.isEmpty())
|
||||
{
|
||||
items.add(new Item("Provider", AssistantProvider.TABLE_NAME, State.TODO, "No provider yet.", "Add one with New: pick the type and model, and name the environment variable that holds the API key."));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.add(new Item("Provider", AssistantProvider.TABLE_NAME, State.TODO, "The default provider cannot be used.", "Turn one on, set it as default, make sure its key variable is set, then run Test connection."));
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// prices: resolved exactly as calls are costed (registry row, price //
|
||||
// row, then the configured price table); local models cost nothing //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
int priceRows = store.query(AssistantModelPrice.TABLE_NAME, new QQueryFilter().withLimit(LIMIT), AssistantModelPrice.class).size();
|
||||
String rowsText = priceRows + " price row" + (priceRows == 1 ? "" : "s");
|
||||
if(active == null)
|
||||
{
|
||||
items.add(new Item("Model prices", AssistantModelPrice.TABLE_NAME, State.DEFAULT, rowsText + "; checked once a provider answers.", null));
|
||||
}
|
||||
else
|
||||
{
|
||||
AssistantProvider row = active.row();
|
||||
ModelPricing price = runtime.getProviderService().pricing(new ProviderService.Active(row, row.getProviderType(), row.getModel(), null), row.getModel());
|
||||
boolean local = "LOCAL".equals(active.location());
|
||||
boolean known = price != null && (positive(price.getInputUsdPerMTok()) || positive(price.getOutputUsdPerMTok()));
|
||||
items.add(known || local
|
||||
? new Item("Model prices", AssistantModelPrice.TABLE_NAME, State.OK, local && !known ? "The default model is local and costs nothing. " + rowsText + "."
|
||||
: "The default model is priced at $" + price.getInputUsdPerMTok() + " in / $" + price.getOutputUsdPerMTok() + " out per million tokens. " + rowsText + ".", null)
|
||||
: new Item("Model prices", AssistantModelPrice.TABLE_NAME, State.TODO, "The default model (" + row.getModel() + ") has no price, so its calls cost $0 and cost budgets never trigger.",
|
||||
"Add a price row for its provider type and model."));
|
||||
}
|
||||
|
||||
////////////
|
||||
// policy //
|
||||
////////////
|
||||
List<AssistantPolicy> policies = store.query(AssistantPolicy.TABLE_NAME, new QQueryFilter().withLimit(1), AssistantPolicy.class);
|
||||
if(policies.isEmpty())
|
||||
{
|
||||
items.add(new Item("Policy", AssistantPolicy.TABLE_NAME, State.DEFAULT, "No policy row: the code configuration applies.", "Add one to control the kill switch, confirmations and tool limits here."));
|
||||
}
|
||||
else if(Boolean.TRUE.equals(policies.get(0).getKillSwitch()))
|
||||
{
|
||||
items.add(new Item("Policy", AssistantPolicy.TABLE_NAME, State.TODO, "The kill switch is on: the assistant answers nothing.", "Turn it off on the policy row when ready."));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.add(new Item("Policy", AssistantPolicy.TABLE_NAME, State.OK, "Writes " + (Boolean.FALSE.equals(policies.get(0).getWritesRequireConfirmation()) ? "do not wait" : "wait") + " for confirmation.", null));
|
||||
}
|
||||
|
||||
////////////////////////
|
||||
// profiles and skills //
|
||||
////////////////////////
|
||||
List<AssistantProfile> profiles = enabled(store.query(AssistantProfile.TABLE_NAME, new QQueryFilter().withLimit(LIMIT), AssistantProfile.class));
|
||||
boolean hasDefault = profiles.stream().anyMatch(p -> Boolean.TRUE.equals(p.getIsDefault()));
|
||||
items.add(profiles.isEmpty()
|
||||
? new Item("Profiles", AssistantProfile.TABLE_NAME, State.DEFAULT, "No profile: the built-in assistant answers, with no skills.", "Add a default profile to name the assistant, set its tone and offer skills.")
|
||||
: new Item("Profiles", AssistantProfile.TABLE_NAME, hasDefault ? State.OK : State.DEFAULT,
|
||||
profiles.size() + " profile" + (profiles.size() == 1 ? "" : "s") + (hasDefault ? ", with a default." : ", none of them the default."),
|
||||
hasDefault ? null : "Mark one as the default so it applies everywhere else."));
|
||||
|
||||
Set<Integer> linkedSkillIds = new HashSet<>();
|
||||
Set<Integer> profileIds = new HashSet<>(profiles.stream().map(AssistantProfile::getId).toList());
|
||||
for(AssistantProfileSkill link : store.query(AssistantProfileSkill.TABLE_NAME, new QQueryFilter().withLimit(LIMIT * 5), AssistantProfileSkill.class))
|
||||
{
|
||||
if(profileIds.contains(link.getProfileId()))
|
||||
{
|
||||
linkedSkillIds.add(link.getSkillId());
|
||||
}
|
||||
}
|
||||
List<AssistantSkill> skills = enabled(store.query(AssistantSkill.TABLE_NAME, new QQueryFilter().withLimit(LIMIT), AssistantSkill.class));
|
||||
long offered = skills.stream().filter(s -> linkedSkillIds.contains(s.getId())).count();
|
||||
items.add(skills.isEmpty()
|
||||
? new Item("Skills", AssistantSkill.TABLE_NAME, State.OPTIONAL, "No skills.", "Write know-how for your business terms, then link it to a profile.")
|
||||
: offered == 0
|
||||
? new Item("Skills", AssistantSkill.TABLE_NAME, State.TODO, skills.size() + " skill" + (skills.size() == 1 ? "" : "s") + ", but none is linked to a turned-on profile, so the model never sees them.", "Link them to a profile (Profile skills).")
|
||||
: new Item("Skills", AssistantSkill.TABLE_NAME, State.OK, offered + " of " + skills.size() + " skill" + (skills.size() == 1 ? " is" : "s are") + " offered through a profile.", null));
|
||||
|
||||
/////////////
|
||||
// budgets //
|
||||
/////////////
|
||||
List<AssistantBudget> budgets = store.budgets();
|
||||
items.add(budgets.isEmpty()
|
||||
? new Item("Budgets", AssistantBudget.TABLE_NAME, State.TODO, "No budget: spending is limited only by the policy's configured caps.", "Add a monthly instance budget (warn, downgrade, stop).")
|
||||
: new Item("Budgets", AssistantBudget.TABLE_NAME, State.OK, budgets.size() + " budget" + (budgets.size() == 1 ? "" : "s") + " checked before every question.", null));
|
||||
|
||||
/////////////////
|
||||
// data access //
|
||||
/////////////////
|
||||
int accessRows = store.query(AssistantDataAccess.TABLE_NAME, new QQueryFilter().withLimit(LIMIT), AssistantDataAccess.class).size();
|
||||
items.add(accessRows == 0
|
||||
? new Item("Data access", AssistantDataAccess.TABLE_NAME, State.DEFAULT, "Defaults: the assistant may read what each user can read; it proposes no changes.",
|
||||
"Run Add rows for every table to review them, then raise tables to READ_WRITE where it may propose changes.")
|
||||
: new Item("Data access", AssistantDataAccess.TABLE_NAME, State.OK, accessRows + " row" + (accessRows == 1 ? "" : "s") + "; tables without a row use the default.", null));
|
||||
|
||||
///////////////////////////////////////
|
||||
// optional: knowledge and schedules //
|
||||
///////////////////////////////////////
|
||||
long sources = enabled(store.query(AssistantRagSource.TABLE_NAME, new QQueryFilter().withLimit(LIMIT), AssistantRagSource.class)).size();
|
||||
items.add(sources == 0
|
||||
? new Item("Knowledge sources", AssistantRagSource.TABLE_NAME, State.OPTIONAL, "Off: search by meaning is not offered.", "Add a source (a readable table, its text fields and an embedding model), then index it.")
|
||||
: new Item("Knowledge sources", AssistantRagSource.TABLE_NAME, State.OK, sources + " source" + (sources == 1 ? "" : "s") + " turned on.", null));
|
||||
|
||||
List<AssistantScheduledTask> tasks = store.query(AssistantScheduledTask.TABLE_NAME, new QQueryFilter().withLimit(LIMIT), AssistantScheduledTask.class);
|
||||
long onTasks = tasks.stream().filter(t -> Boolean.TRUE.equals(t.getEnabled())).count();
|
||||
items.add(onTasks == 0
|
||||
? new Item("Scheduled tasks", AssistantScheduledTask.TABLE_NAME, State.OPTIONAL, tasks.isEmpty() ? "None." : tasks.size() + " task" + (tasks.size() == 1 ? "" : "s") + ", all turned off.",
|
||||
"Set a run-as user and an allowed credential variable on a task, then turn it on.")
|
||||
: new Item("Scheduled tasks", AssistantScheduledTask.TABLE_NAME, State.OK, onTasks + " task" + (onTasks == 1 ? "" : "s") + " turned on.", null));
|
||||
return (items);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Rows not explicitly turned off.
|
||||
*******************************************************************************/
|
||||
private static <T extends QRecordEntity> List<T> enabled(List<T> rows)
|
||||
{
|
||||
return (rows.stream().filter(row -> !Boolean.FALSE.equals(row.toQRecord().getValueBoolean("enabled"))).toList());
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static boolean positive(Double value)
|
||||
{
|
||||
return (value != null && value > 0);
|
||||
}
|
||||
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.jobs;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** STAND-IN(core=QRun-IO/qqq#1210, removeBy=RC.9): core job tracking
|
||||
** (jobs_job tracking rows) will implement this contract; hosts may wire their own.
|
||||
*******************************************************************************/
|
||||
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);
|
||||
|
||||
/*******************************************************************************
|
||||
** Publish a changed phase.
|
||||
*******************************************************************************/
|
||||
void progress(String trackingId, String phase, String message);
|
||||
|
||||
/*******************************************************************************
|
||||
** Publish the terminal outcome.
|
||||
*******************************************************************************/
|
||||
void finish(String trackingId, String status, String message);
|
||||
|
||||
/*******************************************************************************
|
||||
** Wire the host's Stop action to the owner's cancellation path.
|
||||
*******************************************************************************/
|
||||
default void onCancel(String trackingId, Runnable cancel)
|
||||
{
|
||||
}
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.metadata;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Applies help/qbit-assistant.yaml (the QQQ help-bundle format) to the
|
||||
** Assistant app and its assistant_* tables: what each table is for and how
|
||||
** its rows get created, shown on the table screen and when it is empty.
|
||||
** STAND-IN(core=#1198): replace with HelpBundles.register once the pinned
|
||||
** QQQ release includes it; the bundle file stays the same.
|
||||
*******************************************************************************/
|
||||
public final class AssistantHelp
|
||||
{
|
||||
public static final String RESOURCE = "/help/qbit-assistant.yaml";
|
||||
public static final String SLOT = "help";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private AssistantHelp()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The bundle's entries by key, validated (fails startup if malformed).
|
||||
*******************************************************************************/
|
||||
public static Map<String, JsonNode> entries() throws QException
|
||||
{
|
||||
try(InputStream stream = AssistantHelp.class.getResourceAsStream(RESOURCE))
|
||||
{
|
||||
if(stream == null)
|
||||
{
|
||||
throw (new IOException("missing resource"));
|
||||
}
|
||||
JsonNode bundle = new ObjectMapper(new YAMLFactory()).readTree(stream);
|
||||
if(!"qbit-assistant".equals(bundle.path("bundle").asText()) || !"QBIT".equals(bundle.path("layer").asText()))
|
||||
{
|
||||
throw (new IOException("invalid bundle header"));
|
||||
}
|
||||
Map<String, JsonNode> entries = new LinkedHashMap<>();
|
||||
for(JsonNode entry : bundle.path("entries"))
|
||||
{
|
||||
String key = entry.path("key").asText();
|
||||
String summary = entry.path("summary").asText();
|
||||
if(key.isBlank() || summary.isBlank() || summary.length() > 120 || entry.path("content").asText().isBlank() || entries.put(key, entry) != null)
|
||||
{
|
||||
throw (new IOException("invalid or duplicate entry: " + key));
|
||||
}
|
||||
}
|
||||
return (entries);
|
||||
}
|
||||
catch(IOException | RuntimeException e)
|
||||
{
|
||||
throw (new QException("Could not load " + RESOURCE, e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply the help to the app and every assistant_* table in the instance.
|
||||
** A host's own help on a table is kept (the bundle only fills the slot).
|
||||
*******************************************************************************/
|
||||
public static void apply(QInstance qInstance) throws QException
|
||||
{
|
||||
Map<String, JsonNode> entries = entries();
|
||||
for(QTableMetaData table : qInstance.getTables().values())
|
||||
{
|
||||
JsonNode entry = entries.get("table:" + table.getName() + ";slot:" + SLOT);
|
||||
if(entry != null && (table.getHelpContent() == null || !table.getHelpContent().containsKey(SLOT)))
|
||||
{
|
||||
table.withHelpContent(SLOT, content(entry));
|
||||
}
|
||||
}
|
||||
QAppMetaData app = qInstance.getApp(AssistantMetaDataBuilder.APP_NAME);
|
||||
JsonNode appEntry = entries.get("app:" + AssistantMetaDataBuilder.APP_NAME + ";slot:" + SLOT);
|
||||
if(app != null && appEntry != null && (app.getHelpContent() == null || !app.getHelpContent().containsKey(SLOT)))
|
||||
{
|
||||
app.withHelpContent(SLOT, content(appEntry));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Summary as the first paragraph, then the content, as markdown.
|
||||
*******************************************************************************/
|
||||
static QHelpContent content(JsonNode entry)
|
||||
{
|
||||
return (new QHelpContent().withContentAsMarkdown(entry.path("summary").asText().strip() + "\n\n" + entry.path("content").asText().strip()));
|
||||
}
|
||||
|
||||
}
|
||||
+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)
|
||||
|
||||
+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)
|
||||
|
||||
+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)
|
||||
|
||||
+93
-1
@@ -31,6 +31,7 @@ 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;
|
||||
@@ -161,7 +162,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 +179,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 +196,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 +393,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.
|
||||
*******************************************************************************/
|
||||
@@ -411,6 +441,51 @@ public class AssistantStore
|
||||
** 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)
|
||||
@@ -832,6 +907,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 +923,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();
|
||||
}
|
||||
}
|
||||
+268
@@ -0,0 +1,268 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.processes;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.assistant.AssistantRuntime;
|
||||
import com.kingsrook.qbits.assistant.access.DataAccessMode;
|
||||
import com.kingsrook.qbits.assistant.access.DataAccessService;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantDataAccess;
|
||||
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractExtractStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** "Add rows for every table": a table action on assistant_data_access that
|
||||
** writes one row per application table that has none yet, with the access
|
||||
** the assistant already gets by default (READ, or NONE where the table's AI
|
||||
** exposure is NONE). It changes no behavior on its own; it makes the defaults
|
||||
** visible and editable. Existing rows are never touched. Standard streamed
|
||||
** ETL: Review lists what will be added, Result what was added.
|
||||
*******************************************************************************/
|
||||
public final class DataAccessDefaultsProcess
|
||||
{
|
||||
public static final String NAME = "assistantDataAccessAddDefaults";
|
||||
|
||||
public static final String NOTE = "Added by 'Add rows for every table': the assistant's default for this table. The user's own permissions still apply.";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private DataAccessDefaultsProcess()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The process: a table-level action (no records need to be selected).
|
||||
*******************************************************************************/
|
||||
public static QProcessMetaData process()
|
||||
{
|
||||
Map<String, Serializable> values = new HashMap<>();
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, AssistantDataAccess.TABLE_NAME);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, AssistantDataAccess.TABLE_NAME);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, false);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE,
|
||||
"These rows will be added: one per table that has none, with the access the assistant already has by default. Existing rows are not changed.");
|
||||
QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(Extract.class, Transform.class, Load.class, values);
|
||||
process.setName(NAME);
|
||||
process.setLabel("Add rows for every table");
|
||||
process.setIcon(new QIcon("playlist_add"));
|
||||
process.setTableName(AssistantDataAccess.TABLE_NAME);
|
||||
process.setMinInputRecords(0);
|
||||
process.setPermissionRules(AssistantMetaDataBuilder.adminRules());
|
||||
return (process);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The rows that are missing: one per application table without a row.
|
||||
*******************************************************************************/
|
||||
public static List<AssistantDataAccess> missingRows(QInstance qInstance, DataAccessService access, AssistantStore store) throws QException
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// fresh rows: a row deleted on another server must not come back //
|
||||
// from this server's cache as a "default" //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
access.invalidate();
|
||||
Set<String> existing = new HashSet<>();
|
||||
for(AssistantDataAccess row : store.query(AssistantDataAccess.TABLE_NAME, new QQueryFilter().withLimit(10_000), AssistantDataAccess.class))
|
||||
{
|
||||
if(row.getObjectType() == null || AssistantDataAccess.TYPE_TABLE.equals(row.getObjectType()))
|
||||
{
|
||||
existing.add(row.getObjectName());
|
||||
}
|
||||
}
|
||||
|
||||
List<AssistantDataAccess> rs = new ArrayList<>();
|
||||
for(QTableMetaData table : qInstance.getTables().values().stream().sorted((a, b) -> a.getName().compareTo(b.getName())).toList())
|
||||
{
|
||||
if(table.getName().startsWith("assistant_") || existing.contains(table.getName()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
DataAccessMode mode = access.forTable(table.getName()).mode();
|
||||
rs.add(new AssistantDataAccess().withObjectType(AssistantDataAccess.TYPE_TABLE).withObjectName(table.getName())
|
||||
.withMode(mode.name()).withNotes(NOTE));
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The running assistant (or a clear error).
|
||||
*******************************************************************************/
|
||||
private static AssistantRuntime runtime() throws QException
|
||||
{
|
||||
AssistantRuntime runtime = AssistantRuntime.of(QContext.getQInstance());
|
||||
if(runtime == null)
|
||||
{
|
||||
throw (new QException("The assistant is not running on this server."));
|
||||
}
|
||||
return (runtime);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Extract: the missing rows, computed (no query of the selected records).
|
||||
*******************************************************************************/
|
||||
public static class Extract extends AbstractExtractStep
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Integer doCount(RunBackendStepInput input) throws QException
|
||||
{
|
||||
AssistantRuntime runtime = runtime();
|
||||
return (missingRows(QContext.getQInstance(), runtime.getDataAccessService(), runtime.getStore()).size());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
AssistantRuntime runtime = runtime();
|
||||
for(AssistantDataAccess row : missingRows(QContext.getQInstance(), runtime.getDataAccessService(), runtime.getStore()))
|
||||
{
|
||||
getRecordPipe().addRecord(row.toQRecord());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Transform: counts by mode for the review screen.
|
||||
*******************************************************************************/
|
||||
public static class Transform extends AbstractTransformStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final Map<String, ProcessSummaryLine> byMode = new HashMap<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
for(QRecord record : input.getRecords())
|
||||
{
|
||||
String mode = record.getValueString("mode");
|
||||
byMode.computeIfAbsent(mode, m -> DiscoverModelsProcess.line("NONE".equals(m) ? Status.INFO : Status.OK,
|
||||
"table will get a row with access " + m, "tables will get a row with access " + m,
|
||||
"table got a row with access " + m, "tables got a row with access " + m)).incrementCountAndAddPrimaryKey(record.getValue("objectName"));
|
||||
output.getRecords().add(record);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
byMode.values().forEach(line -> line.addSelfToListIfAnyCount(lines));
|
||||
if(lines.isEmpty())
|
||||
{
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, null, "Every table already has a row."));
|
||||
}
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Load: insert through the store (the user's own session; admin only).
|
||||
*******************************************************************************/
|
||||
public static class Load extends AbstractLoadStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ProcessSummaryLine added = DiscoverModelsProcess.line(Status.OK, "row was added", "rows were added", "row was added", "rows were added");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
AssistantRuntime runtime = runtime();
|
||||
for(QRecord record : input.getRecords())
|
||||
{
|
||||
AssistantDataAccess row = runtime.getStore().insert(new AssistantDataAccess(record));
|
||||
added.incrementCountAndAddPrimaryKey(row.getId());
|
||||
output.getRecords().add(row.toQRecord());
|
||||
}
|
||||
runtime.getDataAccessService().invalidate();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
added.addSelfToListIfAnyCount(lines);
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
@@ -1445,7 +1446,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 +1540,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());
|
||||
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
-- liquibase formatted sql
|
||||
|
||||
-- qbit-assistant starter defaults: a working assistant out of the box.
|
||||
-- Include it from the host's master changelog AFTER the changesets that create
|
||||
-- the assistant_* tables (docs/schema-postgres.sql):
|
||||
--
|
||||
-- - include: { file: db/liquibase/qbit-assistant/assistant-defaults.changelog.sql }
|
||||
--
|
||||
-- Rules for this file:
|
||||
-- * insert-only: every row is inserted only when its natural key is absent,
|
||||
-- so an administrator's edits always survive and nothing is ever updated;
|
||||
-- * rollback is empty: a seeded row cannot be told apart from a host's own
|
||||
-- row with the same name, and Liquibase also rolls back changesets it marked
|
||||
-- ran, so undoing would delete the host's data (remove seeded rows by hand);
|
||||
-- * no secrets: environment variable NAMES only, never values;
|
||||
-- * portable SQL (PostgreSQL and H2): INSERT ... SELECT ... WHERE NOT EXISTS;
|
||||
-- * every changeset has a precondition on its table, so a host that has not
|
||||
-- created a table yet simply skips it (MARK_RAN).
|
||||
-- AssistantDefaultsChangelogTest runs this file twice against a fresh schema.
|
||||
|
||||
-- changeset qbit-assistant:defaults-1
|
||||
-- preconditions onFail:MARK_RAN onError:MARK_RAN
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM assistant_policy
|
||||
-- comment: The single policy row, only when the host has none. Nulls inherit the code configuration (models, config budgets). Writes wait for the user's confirmation, PII is redacted, 50 rows per call.
|
||||
INSERT INTO assistant_policy (name, kill_switch, writes_require_confirmation, redact_pii, max_rows_per_call, create_date, modify_date)
|
||||
SELECT 'default', FALSE, TRUE, TRUE, 50, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
WHERE NOT EXISTS (SELECT 1 FROM assistant_policy);
|
||||
-- rollback empty
|
||||
|
||||
-- changeset qbit-assistant:defaults-2
|
||||
-- preconditions onFail:MARK_RAN onError:MARK_RAN
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM assistant_profile WHERE name = 'default' OR is_default = TRUE
|
||||
-- comment: The default profile (persona): used for every app and user that no more specific profile matches. Skipped when the host already has a default profile, so there is never a second default.
|
||||
INSERT INTO assistant_profile (name, display_name, icon, personality, greeting, starter_prompts, is_default, enabled, create_date, modify_date)
|
||||
SELECT 'default', 'Assistant', 'auto_awesome',
|
||||
'Be concise, friendly and plain-spoken. Lead with the answer, then the detail that supports it. When something is ambiguous, ask one short question instead of guessing. Offer a link to the list or record you used so the user can check it.',
|
||||
'Hi! I can find and count records, explain a record and its history, and open the right screen for you. What would you like to know?',
|
||||
'What can you help me with?
|
||||
Which tables can I look at?
|
||||
What changed in the last 7 days?',
|
||||
TRUE, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
WHERE NOT EXISTS (SELECT 1 FROM assistant_profile WHERE name = 'default' OR is_default = TRUE);
|
||||
-- rollback empty
|
||||
|
||||
-- changeset qbit-assistant:defaults-3
|
||||
-- preconditions onFail:MARK_RAN onError:MARK_RAN
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM assistant_skill WHERE name = 'find-and-count'
|
||||
-- comment: Starter skill. Skills are know-how text only: they never grant tools, data or permissions.
|
||||
INSERT INTO assistant_skill (name, description, instructions, enabled, trigger_keywords, create_date, modify_date)
|
||||
SELECT 'find-and-count', 'Finding, filtering and counting records.',
|
||||
'When the user asks to find, list or count records:
|
||||
1. If you do not already know the table and field names, call list_tables and describe_table first. Never guess names.
|
||||
2. For "how many" questions use count_records; for lists use query_records with a filter, not search_records.
|
||||
3. Say in one short phrase which filter you used (for example "open orders created this month").
|
||||
4. Always include a filterLinks item for the list you counted, so the user can open exactly those records.
|
||||
5. If the result was truncated, say so and offer the link instead of listing more.',
|
||||
TRUE, 'find,show,list,which,how many,count,filter,search,look up', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
WHERE NOT EXISTS (SELECT 1 FROM assistant_skill WHERE name = 'find-and-count');
|
||||
-- rollback empty
|
||||
|
||||
-- changeset qbit-assistant:defaults-4
|
||||
-- preconditions onFail:MARK_RAN onError:MARK_RAN
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM assistant_skill WHERE name = 'explain-a-record'
|
||||
-- comment: Starter skill: summarizing one record and its change history.
|
||||
INSERT INTO assistant_skill (name, description, instructions, enabled, trigger_keywords, create_date, modify_date)
|
||||
SELECT 'explain-a-record', 'Summarizing a record and who changed it.',
|
||||
'When the user asks about one record (what it is, its status, what changed, who changed it):
|
||||
1. Read it with get_record. If they ask what changed or who changed it, also call get_record_history.
|
||||
2. Summarize in plain language: what the record is, its current state, and the few fields that matter for the question. Use the field labels, not the field names.
|
||||
3. For history, list changes newest first as "date - who - field: old -> new", at most ten, and say if there are more.
|
||||
4. Link to the record.',
|
||||
TRUE, 'explain,summarize,summary,tell me about,what is,status,history,changed,who changed,audit', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
WHERE NOT EXISTS (SELECT 1 FROM assistant_skill WHERE name = 'explain-a-record');
|
||||
-- rollback empty
|
||||
|
||||
-- changeset qbit-assistant:defaults-5
|
||||
-- preconditions onFail:MARK_RAN onError:MARK_RAN
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM assistant_skill WHERE name = 'safe-changes'
|
||||
-- comment: Starter skill: proposing changes that the user confirms.
|
||||
INSERT INTO assistant_skill (name, description, instructions, enabled, trigger_keywords, create_date, modify_date)
|
||||
SELECT 'safe-changes', 'Proposing creates, updates and deletes safely.',
|
||||
'When the user asks you to create, change or delete records:
|
||||
1. If you have no create_record, update_record or delete_record tool for that table, say the assistant cannot change it and offer to open the screen where they can.
|
||||
2. Read the record first, then restate exactly what will change (field: old -> new) before proposing it.
|
||||
3. Propose one change at a time unless the user clearly asked for several. Changes wait for the user to confirm them in the panel; never say a change is done until a tool result says so.
|
||||
4. For many rows from a file, use the bulk load tools and always run bulk_load_dry_run and show its summary before bulk_load_run.',
|
||||
TRUE, 'change,update,set,edit,create,add,new,delete,remove,fix,rename,import,load', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
WHERE NOT EXISTS (SELECT 1 FROM assistant_skill WHERE name = 'safe-changes');
|
||||
-- rollback empty
|
||||
|
||||
-- changeset qbit-assistant:defaults-6
|
||||
-- preconditions onFail:MARK_RAN onError:MARK_RAN
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM assistant_skill WHERE name = 'access-questions'
|
||||
-- comment: Starter skill: explaining missing or denied data without guessing.
|
||||
INSERT INTO assistant_skill (name, description, instructions, enabled, trigger_keywords, create_date, modify_date)
|
||||
SELECT 'access-questions', 'Explaining why data is missing or not available.',
|
||||
'When the user cannot find something, or a tool returns DENIED or NOT_FOUND:
|
||||
1. Say plainly that the information is not available to them through the assistant. Do not guess what it contains and do not try other tools to get around it.
|
||||
2. Explain the likely reason in one sentence: their permissions, a filter that excludes it, or the table being off for the assistant.
|
||||
3. Suggest the next step: check the filter, open the list screen, or ask an administrator for access.',
|
||||
TRUE, 'cannot see,can''t see,missing,not found,denied,access,permission,not allowed,hidden', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
WHERE NOT EXISTS (SELECT 1 FROM assistant_skill WHERE name = 'access-questions');
|
||||
-- rollback empty
|
||||
|
||||
-- changeset qbit-assistant:defaults-7
|
||||
-- preconditions onFail:MARK_RAN onError:MARK_RAN
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM assistant_profile_skill WHERE 1 = 0
|
||||
-- comment: Offer the starter skills through the default profile (skills reach the model only through a profile). Pairs that already exist are left alone.
|
||||
INSERT INTO assistant_profile_skill (profile_id, skill_id, create_date, modify_date)
|
||||
SELECT p.id, s.id, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
FROM assistant_profile p, assistant_skill s
|
||||
WHERE p.name = 'default'
|
||||
AND s.name IN ('find-and-count', 'explain-a-record', 'safe-changes', 'access-questions')
|
||||
AND NOT EXISTS (SELECT 1 FROM assistant_profile_skill ps WHERE ps.profile_id = p.id AND ps.skill_id = s.id);
|
||||
-- rollback empty
|
||||
|
||||
-- changeset qbit-assistant:defaults-8
|
||||
-- preconditions onFail:MARK_RAN onError:MARK_RAN
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM assistant_budget
|
||||
-- comment: Only when the host has no budget at all, so an existing host's limits never change on deploy. A monthly cost budget for the whole instance: warn at 80%, switch to the cheapest allowed model (local first) at 100%, stop at 120%. Change the limit to suit; overrides raise it for a while.
|
||||
INSERT INTO assistant_budget (name, enabled, scope_type, period, metric, limit_value, warn_percent, at_limit_action, hard_ceiling_percent, notes, create_date, modify_date)
|
||||
SELECT 'Monthly spend (whole instance)', TRUE, 'INSTANCE', 'MONTH', 'COST_USD', 50, 80, 'DOWNGRADE', 120,
|
||||
'Starter budget. Warns everyone at 80% of $50 a month, switches to the cheapest allowed model at 100% (or stops when there is none), and stops at 120%. Set Downgrade Model to choose the fallback; add an override to raise it temporarily.',
|
||||
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
WHERE NOT EXISTS (SELECT 1 FROM assistant_budget);
|
||||
-- rollback empty
|
||||
|
||||
-- changeset qbit-assistant:defaults-9
|
||||
-- preconditions onFail:MARK_RAN onError:MARK_RAN
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM assistant_model_price
|
||||
-- comment: Anthropic first-party list prices, USD per million tokens (2026-10), only when the host has no price rows (rows take precedence over AssistantQBitConfig.withPricing, so a host's configured prices are never overridden on deploy). Empty cache prices use the standard multipliers (read 0.1x, 5 minute write 1.25x, 1 hour write 2x input).
|
||||
INSERT INTO assistant_model_price (provider_type, model, input_usd_per_m_tok, output_usd_per_m_tok, cache_read_usd_per_m_tok, cache_write5m_usd_per_m_tok, cache_write1h_usd_per_m_tok, notes, create_date, modify_date)
|
||||
SELECT v.provider_type, v.model, v.input_price, v.output_price, v.cache_read, v.cache_write5m, v.cache_write1h, v.notes, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
FROM (VALUES
|
||||
('anthropic', 'claude-fable-5-1', 10.00, 50.00, 0.25, 12.50, 20.00, 'Anthropic list price, 2026-10'),
|
||||
('anthropic', 'claude-fable-5', 10.00, 50.00, CAST(NULL AS NUMERIC), CAST(NULL AS NUMERIC), CAST(NULL AS NUMERIC), 'Anthropic list price, 2026-10'),
|
||||
('anthropic', 'claude-opus-5-5', 4.00, 20.00, 0.20, 5.00, 8.00, 'Anthropic list price, 2026-10'),
|
||||
('anthropic', 'claude-opus-5', 5.00, 25.00, 0.50, 6.25, 10.00, 'Anthropic list price, 2026-10'),
|
||||
('anthropic', 'claude-opus-4-8', 5.00, 25.00, 0.50, 6.25, 10.00, 'Anthropic list price, 2026-10'),
|
||||
('anthropic', 'claude-opus-4-7', 5.00, 25.00, 0.50, 6.25, 10.00, 'Anthropic list price, 2026-10'),
|
||||
('anthropic', 'claude-opus-4-6', 5.00, 25.00, 0.50, 6.25, 10.00, 'Anthropic list price, 2026-10'),
|
||||
('anthropic', 'claude-sonnet-5-5', 2.00, 10.00, 0.20, 2.50, 4.00, 'Anthropic list price, 2026-10'),
|
||||
('anthropic', 'claude-sonnet-5', 2.00, 10.00, 0.20, 2.50, 4.00, 'Anthropic list price, 2026-10'),
|
||||
('anthropic', 'claude-sonnet-4-6', 3.00, 15.00, 0.30, 3.75, 6.00, 'Anthropic list price, 2026-10'),
|
||||
('anthropic', 'claude-haiku-5-5', 0.10, 0.50, 0.01, 0.125, 0.20, 'Anthropic list price, 2026-10 (prompts up to 100K tokens)'),
|
||||
('anthropic', 'claude-haiku-4-5', 1.00, 5.00, 0.10, 1.25, 2.00, 'Anthropic list price, 2026-10')
|
||||
) AS v (provider_type, model, input_price, output_price, cache_read, cache_write5m, cache_write1h, notes)
|
||||
WHERE NOT EXISTS (SELECT 1 FROM assistant_model_price p);
|
||||
-- rollback empty
|
||||
|
||||
-- changeset qbit-assistant:defaults-10
|
||||
-- preconditions onFail:MARK_RAN onError:MARK_RAN
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM assistant_scheduled_task WHERE name = 'Example: weekly change summary'
|
||||
-- comment: A disabled example scheduled task that shows the shape of one. It never runs until an administrator sets the run-as user and an allowed credential variable and turns it on.
|
||||
INSERT INTO assistant_scheduled_task (name, enabled, prompt, run_as_user_id, credential_env_name, profile_id, cron_expression, timezone, output_mode, write_mode_requested, max_cost_usd_per_run, create_date, modify_date)
|
||||
SELECT 'Example: weekly change summary', FALSE,
|
||||
'Summarize what changed in the last 7 days in the tables I use most: counts of new and changed records per table, and anything that looks unusual. Include links to the lists.',
|
||||
'set-the-run-as-user-id', 'ASSISTANT_SCHEDULED_TOKEN', p.id, '0 0 8 ? * MON', 'UTC', 'RECORD', FALSE, 0.50,
|
||||
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
FROM (SELECT MIN(id) AS id FROM assistant_profile WHERE name = 'default') p
|
||||
WHERE NOT EXISTS (SELECT 1 FROM assistant_scheduled_task WHERE name = 'Example: weekly change summary');
|
||||
-- rollback empty
|
||||
@@ -0,0 +1,138 @@
|
||||
bundle: qbit-assistant
|
||||
version: 1
|
||||
layer: QBIT
|
||||
# Help for the Assistant app and every assistant_* table, in the QQQ help-bundle
|
||||
# format (qqq#1198). Each table entry says what the table is for and how its rows
|
||||
# get created, so an empty table explains itself. AssistantHelp applies it until
|
||||
# the core HelpBundles registry ships in a QQQ release (STAND-IN(core=#1198)).
|
||||
# Summaries stay under 120 characters (AssistantHelpTest).
|
||||
entries:
|
||||
- key: app:assistantAdmin;slot:help
|
||||
summary: "Set up and watch the in-app assistant: providers, budgets, profiles, skills, data access and usage."
|
||||
content: |
|
||||
The Setup card on the home lists every part of the assistant with its status and the next step. A new installation starts with a default profile, four starter skills, a monthly budget, a policy row, Anthropic prices and a disabled example scheduled task. Add a provider to start answering.
|
||||
|
||||
- key: table:assistant_provider;slot:help
|
||||
summary: "A model provider the assistant can call: its type, model, endpoint and the variable that holds its key."
|
||||
content: |
|
||||
**How rows get created:** an administrator adds one with New (pick the type and model, and name the environment variable that holds the API key; the key itself is never stored). The default provider answers questions; Test connection checks it and Discover models fills the model list.
|
||||
|
||||
- key: table:assistant_model;slot:help
|
||||
summary: "The models each provider offers, with what they support and their prices."
|
||||
content: |
|
||||
**How rows get created:** Discover models on a provider adds them, or an administrator adds one by hand. Budgets, fallbacks, routing and knowledge sources pick models from this list. Embedding models are used only for knowledge sources.
|
||||
|
||||
- key: table:assistant_model_price;slot:help
|
||||
summary: "List prices per provider type and model, used to work out what each call costs."
|
||||
content: |
|
||||
**How rows get created:** the qbit seeds the current Anthropic list prices; add a row for any other priced model. Every call's cost in Usage is computed from these prices, and cost budgets count that cost. A model with no price costs zero, which is right for local models.
|
||||
|
||||
- key: table:assistant_policy;slot:help
|
||||
summary: "The single policy row: kill switch, write confirmation, PII redaction, tool list and row limits."
|
||||
content: |
|
||||
**How rows get created:** the qbit seeds one `default` row. Only the first row is used. Empty values fall back to the code configuration. Turn on the kill switch to stop every answer at once.
|
||||
|
||||
- key: table:assistant_budget;slot:help
|
||||
summary: "Spending or token limits per instance, user, role, provider or model, with warn, downgrade and stop."
|
||||
content: |
|
||||
**How rows get created:** the qbit seeds a monthly budget for the whole instance ($50: warn at 80%, switch to the cheapest allowed model at 100%, stop at 120%). Add more for users, roles, providers or models. Every question is checked against every budget that applies before the model is called.
|
||||
|
||||
- key: table:assistant_budget_override;slot:help
|
||||
summary: "A temporary raise or unlimited window for one budget, with a reason and an expiry."
|
||||
content: |
|
||||
**How rows get created:** an administrator adds one when a budget is reached and work must continue. Empty means no budget is currently raised. Overrides expire on their own.
|
||||
|
||||
- key: table:assistant_budget_event;slot:help
|
||||
summary: "Each time a budget warned, downgraded or stopped someone, once per period and state."
|
||||
content: |
|
||||
**How rows get created:** written by the assistant when usage crosses a budget threshold; it also raises an alert. Empty means no budget has been reached yet.
|
||||
|
||||
- key: table:assistant_budget_usage;slot:help
|
||||
summary: "Accounting copy of every call's tokens and cost, kept for shared budgets."
|
||||
content: |
|
||||
**How rows get created:** written with every model call. Instance, provider and model budgets add up these rows. Empty means the assistant has not answered anything yet.
|
||||
|
||||
- key: table:assistant_profile;slot:help
|
||||
summary: "Who the assistant is in an app or for a role: name, tone, greeting, starter prompts and skills."
|
||||
content: |
|
||||
**How rows get created:** the qbit seeds a `default` profile that applies everywhere. Add a profile with an app name or a permission to change the assistant for that app or role; the most specific match wins. Profiles change tone and know-how only, never permissions.
|
||||
|
||||
- key: table:assistant_skill;slot:help
|
||||
summary: "Know-how text the assistant follows when a question matches its keywords or app."
|
||||
content: |
|
||||
**How rows get created:** the qbit seeds four starter skills (finding and counting, explaining a record, safe changes, access questions). Write your own for your business terms. A skill reaches the model only when it is linked to a profile and the question matches its keywords or app; it never grants tools or data.
|
||||
|
||||
- key: table:assistant_profile_skill;slot:help
|
||||
summary: "Which skills each profile offers to the model."
|
||||
content: |
|
||||
**How rows get created:** the qbit links the starter skills to the default profile. Add a row to offer a skill through another profile.
|
||||
|
||||
- key: table:assistant_data_access;slot:help
|
||||
summary: "What the assistant may do with each table or process: NONE, READ or READ and write."
|
||||
content: |
|
||||
**How rows get created:** a table with no row gets the default: READ (or NONE for sensitive tables), always limited by the user's own permissions; processes default to NONE. Use **Add rows for every table** to write those defaults out as rows you can edit, then raise a table to READ_WRITE to let the assistant propose changes there.
|
||||
|
||||
- key: table:assistant_rag_source;slot:help
|
||||
summary: "A table the assistant can search by meaning (knowledge source), with its fields and embedding model."
|
||||
content: |
|
||||
**How rows get created:** an administrator adds one: pick a table the assistant can read, its text fields and an enabled embedding model, then run Index knowledge source. The index keeps no record text; every hit is re-read as the asking user. Empty means search_knowledge is not offered.
|
||||
|
||||
- key: table:assistant_scheduled_task;slot:help
|
||||
summary: "A question the assistant answers on a schedule as a chosen user, with the result saved or sent."
|
||||
content: |
|
||||
**How rows get created:** the qbit seeds a disabled example. To use it, set the run-as user, an allowed credential variable and the schedule, then turn it on. Scheduled writes always wait for the user's confirmation in the panel.
|
||||
|
||||
- key: table:assistant_scheduled_run;slot:help
|
||||
summary: "One run of a scheduled task: when, status, cost and the conversation it produced."
|
||||
content: |
|
||||
**How rows get created:** written each time a scheduled task runs. Empty means no task has run yet; see Scheduled tasks.
|
||||
|
||||
- key: table:assistant_conversation;slot:help
|
||||
summary: "One chat between a user and the assistant, from the panel or a scheduled task."
|
||||
content: |
|
||||
**How rows get created:** created when someone asks the assistant a question. Users see only their own; administrators see all. Retention removes old ones.
|
||||
|
||||
- key: table:assistant_message;slot:help
|
||||
summary: "The messages of each conversation: questions, answers and tool results."
|
||||
content: |
|
||||
**How rows get created:** written as a conversation happens. Content is encrypted in HIPAA mode and removed by retention.
|
||||
|
||||
- key: table:assistant_turn;slot:help
|
||||
summary: "One question and its answer: status, model, tokens, cost, timing and routing."
|
||||
content: |
|
||||
**How rows get created:** written for every question the assistant answers. Use it to see failures, slow answers and costly questions.
|
||||
|
||||
- key: table:assistant_turn_event;slot:help
|
||||
summary: "The step-by-step event stream of each answer, for replay and troubleshooting."
|
||||
content: |
|
||||
**How rows get created:** written while an answer streams. Retention removes old events.
|
||||
|
||||
- key: table:assistant_tool_call;slot:help
|
||||
summary: "Every tool the assistant called: which, with what input, the result status and timing."
|
||||
content: |
|
||||
**How rows get created:** written for each tool call during an answer. Denied calls show where data access or permissions stopped the assistant.
|
||||
|
||||
- key: table:assistant_pending_action;slot:help
|
||||
summary: "A create, update or delete the assistant proposed, waiting for the user to confirm it."
|
||||
content: |
|
||||
**How rows get created:** written when the assistant proposes a change on a READ_WRITE table. It runs once, only after the user confirms it in the panel. Empty means no change has been proposed.
|
||||
|
||||
- key: table:assistant_feedback;slot:help
|
||||
summary: "Thumbs up or down and comments users gave on answers."
|
||||
content: |
|
||||
**How rows get created:** written when a user rates an answer in the panel. Empty means nobody has rated an answer yet.
|
||||
|
||||
- key: table:assistant_usage;slot:help
|
||||
summary: "One row per model call: tokens, cost, provider, model and purpose."
|
||||
content: |
|
||||
**How rows get created:** written with every model call, including connection tests. Costs come from Model prices.
|
||||
|
||||
- key: table:assistant_usage_daily;slot:help
|
||||
summary: "Daily token and cost totals per user and for the instance, for the configured limits."
|
||||
content: |
|
||||
**How rows get created:** updated after every answer. The policy's daily and monthly limits read these totals.
|
||||
|
||||
- key: table:assistant_data_audit;slot:help
|
||||
summary: "Where data went: which provider and model received which tables' data, with its classification."
|
||||
content: |
|
||||
**How rows get created:** written before each model or embedding call that carries application data. Use it to show that sensitive data went only to allowed providers.
|
||||
@@ -53,6 +53,43 @@ components:
|
||||
nullable: true
|
||||
type: "string"
|
||||
type: "object"
|
||||
AssistantActiveTurnsResponseV1:
|
||||
description: "The caller's active runs, newest first; stale leases are reaped."
|
||||
properties:
|
||||
turns:
|
||||
items:
|
||||
description: "An active assistant run."
|
||||
properties:
|
||||
turnId:
|
||||
type: "integer"
|
||||
conversationId:
|
||||
type: "integer"
|
||||
assistantMessageId:
|
||||
type: "integer"
|
||||
userMessageId:
|
||||
type: "integer"
|
||||
status:
|
||||
description: "RUNNING or CANCELLING."
|
||||
type: "string"
|
||||
phase:
|
||||
type: "string"
|
||||
startedAt:
|
||||
description: "ISO-8601."
|
||||
type: "string"
|
||||
heartbeatAt:
|
||||
description: "ISO-8601."
|
||||
type: "string"
|
||||
elapsedMs:
|
||||
type: "integer"
|
||||
live:
|
||||
description: "Running on this replica."
|
||||
type: "boolean"
|
||||
concurrencyMode:
|
||||
description: "SUPERSEDE or CONCURRENT."
|
||||
type: "string"
|
||||
type: "object"
|
||||
type: "array"
|
||||
type: "object"
|
||||
AssistantConfigResponseV1:
|
||||
description: "Whether the assistant is available to this user, and how."
|
||||
properties:
|
||||
@@ -187,8 +224,8 @@ components:
|
||||
type: "object"
|
||||
AssistantTurnEventV1:
|
||||
description: "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."
|
||||
\ this object). Types: message.start, turn.phase, text.delta, tool.start,\
|
||||
\ tool.result, ui.navigate, notice, usage, message.end, error, ping."
|
||||
properties:
|
||||
seq:
|
||||
description: "Sequence number; resume with ?after=<seq>."
|
||||
@@ -198,7 +235,9 @@ components:
|
||||
type: "string"
|
||||
data:
|
||||
additionalProperties: true
|
||||
description: "The event's fields, by type."
|
||||
description: "The event's fields, by type. message.start includes startedAt\
|
||||
\ (ISO-8601). turn.phase includes phase (thinking, tool, writing), optional\
|
||||
\ toolName and label."
|
||||
type: "object"
|
||||
type: "object"
|
||||
securitySchemes:
|
||||
@@ -212,7 +251,7 @@ info:
|
||||
description: "QQQ Assistant: conversations, streamed turns, feedback, pending actions\
|
||||
\ and data access explanations"
|
||||
title: "QQQ Middleware API extension: assistant"
|
||||
version: "1.1.0"
|
||||
version: "1.2.0"
|
||||
openapi: "3.0.3"
|
||||
paths:
|
||||
/qqq/v1/assistant/config:
|
||||
@@ -605,6 +644,10 @@ paths:
|
||||
additionalProperties: true
|
||||
description: "Where the user is in the app."
|
||||
type: "object"
|
||||
concurrency:
|
||||
description: "supersede or concurrent (case-insensitive); defaults\
|
||||
\ to the host configuration."
|
||||
type: "string"
|
||||
type: "object"
|
||||
required: true
|
||||
responses:
|
||||
@@ -754,6 +797,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AssistantErrorV1"
|
||||
description: "Disabled, not configured, or the kill switch is on"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AssistantStatusResponseV1"
|
||||
description: "Successful Response"
|
||||
409:
|
||||
content:
|
||||
application/json:
|
||||
@@ -765,7 +814,7 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AssistantStatusResponseV1"
|
||||
description: "Successful Response"
|
||||
description: "Legacy replica: cancellation accepted"
|
||||
429:
|
||||
content:
|
||||
application/json:
|
||||
@@ -776,6 +825,56 @@ paths:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
summary: "Stop a running turn"
|
||||
/qqq/v1/assistant/turns/active:
|
||||
get:
|
||||
description: "Find the user's active assistant runs"
|
||||
responses:
|
||||
400:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AssistantErrorV1"
|
||||
description: "Invalid input"
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AssistantErrorV1"
|
||||
description: "No access to the assistant"
|
||||
404:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AssistantErrorV1"
|
||||
description: "Not found (or not the user's own)"
|
||||
503:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AssistantErrorV1"
|
||||
description: "Disabled, not configured, or the kill switch is on"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AssistantActiveTurnsResponseV1"
|
||||
description: "Successful Response"
|
||||
409:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AssistantErrorV1"
|
||||
description: "Already finished or decided"
|
||||
429:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AssistantErrorV1"
|
||||
description: "Budget or rate limit"
|
||||
security:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
summary: "Find the user's active assistant runs"
|
||||
/qqq/v1/assistant/messages/{id}/feedback:
|
||||
post:
|
||||
description: "Thumbs up or down on an assistant message"
|
||||
|
||||
+505
@@ -0,0 +1,505 @@
|
||||
/*
|
||||
* 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.io.InputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
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.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
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.engine.BudgetService;
|
||||
import com.kingsrook.qbits.assistant.engine.PolicyService;
|
||||
import com.kingsrook.qbits.assistant.engine.ProfileService;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantBudget;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantBudgetUsage;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantPolicy;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProfile;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProfileSkill;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantSkill;
|
||||
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
|
||||
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
|
||||
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import liquibase.Contexts;
|
||||
import liquibase.Liquibase;
|
||||
import liquibase.database.Database;
|
||||
import liquibase.database.DatabaseFactory;
|
||||
import liquibase.database.jvm.JdbcConnection;
|
||||
import liquibase.resource.ClassLoaderResourceAccessor;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The shipped starter defaults (db/liquibase/qbit-assistant/
|
||||
** assistant-defaults.changelog.sql): they apply to a fresh schema built from
|
||||
** docs/schema-postgres.sql, re-running inserts nothing and keeps an
|
||||
** administrator's edits, and the seeded rows change what the assistant does
|
||||
** (persona, skills offered to the model, budget warnings, policy).
|
||||
*******************************************************************************/
|
||||
class AssistantDefaultsChangelogTest extends BaseAssistantTest
|
||||
{
|
||||
static final String CHANGELOG = "/db/liquibase/qbit-assistant/assistant-defaults.changelog.sql";
|
||||
|
||||
/*******************************************************************************
|
||||
** The tables the changelog seeds; each must be created by the reference DDL.
|
||||
*******************************************************************************/
|
||||
static final Set<String> SEEDED_TABLES = Set.of("assistant_policy", "assistant_profile", "assistant_skill", "assistant_profile_skill",
|
||||
"assistant_budget", "assistant_model_price", "assistant_scheduled_task");
|
||||
|
||||
private static final Set<String> DDL_TABLES = Set.of("assistant_provider", "assistant_model", "assistant_model_price", "assistant_policy",
|
||||
"assistant_profile", "assistant_skill", "assistant_profile_skill", "assistant_budget", "assistant_scheduled_task");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fresh schema, seed twice: same rows; an edit survives.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void seedsAFreshSchemaInsertOnly() throws Exception
|
||||
{
|
||||
try(Connection connection = freshSchema())
|
||||
{
|
||||
runChangelog(connection);
|
||||
assertThat(ranChangesets(connection)).hasSize(10).allSatisfy((id, type) -> assertThat(type).as(id).isEqualTo("EXECUTED"));
|
||||
Map<String, Integer> first = counts(connection);
|
||||
assertThat(first).containsEntry("assistant_policy", 1).containsEntry("assistant_profile", 1).containsEntry("assistant_skill", 4)
|
||||
.containsEntry("assistant_profile_skill", 4).containsEntry("assistant_budget", 1).containsEntry("assistant_scheduled_task", 1);
|
||||
assertThat(first.get("assistant_model_price")).isEqualTo(12);
|
||||
|
||||
try(Statement statement = connection.createStatement())
|
||||
{
|
||||
statement.execute("UPDATE assistant_budget SET limit_value = 75 WHERE name = 'Monthly spend (whole instance)'");
|
||||
statement.execute("UPDATE assistant_profile SET display_name = 'Ada' WHERE name = 'default'");
|
||||
}
|
||||
|
||||
runChangelog(connection);
|
||||
assertThat(ranChangesets(connection)).hasSize(10);
|
||||
assertThat(counts(connection)).isEqualTo(first);
|
||||
assertThat(scalar(connection, "SELECT limit_value FROM assistant_budget")).isEqualTo("75");
|
||||
assertThat(scalar(connection, "SELECT display_name FROM assistant_profile")).isEqualTo("Ada");
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM assistant_scheduled_task WHERE enabled = FALSE AND profile_id IS NOT NULL")).isEqualTo("1");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A host that already configured the assistant: its policy, default
|
||||
** profile, budget and prices are left exactly as they were (no second
|
||||
** default, no new limit, no price that overrides its configured pricing).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void neverChangesAnExistingHost() throws Exception
|
||||
{
|
||||
try(Connection connection = freshSchema(); Statement statement = connection.createStatement())
|
||||
{
|
||||
statement.execute("INSERT INTO assistant_policy (name, kill_switch) VALUES ('host', TRUE)");
|
||||
statement.execute("INSERT INTO assistant_profile (name, display_name, is_default, enabled) VALUES ('host', 'Hal', TRUE, TRUE)");
|
||||
statement.execute("INSERT INTO assistant_budget (name, scope_type, period, metric, limit_value) VALUES ('Host daily', 'USER', 'DAY', 'TOKENS', 1000)");
|
||||
statement.execute("INSERT INTO assistant_model_price (provider_type, model, input_usd_per_m_tok, output_usd_per_m_tok) VALUES ('anthropic', 'claude-sonnet-5-5', 1.00, 5.00)");
|
||||
|
||||
runChangelog(connection);
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM assistant_policy")).isEqualTo("1");
|
||||
assertThat(scalar(connection, "SELECT name FROM assistant_policy")).isEqualTo("host");
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM assistant_profile WHERE is_default = TRUE")).isEqualTo("1");
|
||||
assertThat(scalar(connection, "SELECT name FROM assistant_profile")).isEqualTo("host");
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM assistant_budget")).isEqualTo("1");
|
||||
assertThat(scalar(connection, "SELECT name FROM assistant_budget")).isEqualTo("Host daily");
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM assistant_model_price")).isEqualTo("1");
|
||||
assertThat(scalar(connection, "SELECT input_usd_per_m_tok FROM assistant_model_price")).isEqualTo("1");
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM assistant_skill")).as("starter skills are still added").isEqualTo("4");
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM assistant_profile_skill")).as("the host's profile is not changed").isEqualTo("0");
|
||||
assertThat(ranChangesets(connection)).containsEntry("defaults-1", "MARK_RAN").containsEntry("defaults-2", "MARK_RAN")
|
||||
.containsEntry("defaults-8", "MARK_RAN").containsEntry("defaults-9", "MARK_RAN");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Rolling the changelog back never deletes data: not the host's own rows
|
||||
** (changesets marked ran included), not the seeded ones.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void rollbackKeepsEveryRow() throws Exception
|
||||
{
|
||||
try(Connection connection = freshSchema(); Statement statement = connection.createStatement())
|
||||
{
|
||||
statement.execute("INSERT INTO assistant_policy (name, kill_switch) VALUES ('default', FALSE)");
|
||||
statement.execute("INSERT INTO assistant_profile (name, display_name, is_default, enabled) VALUES ('default', 'Hal', TRUE, TRUE)");
|
||||
statement.execute("INSERT INTO assistant_skill (name, instructions) VALUES ('find-and-count', 'The host wrote this.')");
|
||||
runChangelog(connection);
|
||||
Map<String, Integer> before = counts(connection);
|
||||
|
||||
Database database = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(new JdbcConnection(connection));
|
||||
new Liquibase(CHANGELOG.substring(1), new ClassLoaderResourceAccessor(), database).rollback(10, (String) null);
|
||||
|
||||
assertThat(ranChangesets(connection)).isEmpty();
|
||||
assertThat(counts(connection)).isEqualTo(before);
|
||||
assertThat(scalar(connection, "SELECT display_name FROM assistant_profile WHERE name = 'default'")).isEqualTo("Hal");
|
||||
assertThat(scalar(connection, "SELECT instructions FROM assistant_skill WHERE name = 'find-and-count'")).isEqualTo("The host wrote this.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A host without one of the tables: that changeset is marked ran, the
|
||||
** rest apply, nothing fails.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void missingTableIsSkipped() throws Exception
|
||||
{
|
||||
try(Connection connection = freshSchema(); Statement statement = connection.createStatement())
|
||||
{
|
||||
statement.execute("DROP TABLE assistant_model_price");
|
||||
runChangelog(connection);
|
||||
assertThat(ranChangesets(connection)).containsEntry("defaults-9", "MARK_RAN").containsEntry("defaults-8", "EXECUTED").containsEntry("defaults-7", "EXECUTED");
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM assistant_profile_skill")).isEqualTo("4");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every changeset guards itself: a precondition (host without the table
|
||||
** marks it ran) and an insert that skips rows that exist.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyChangesetIsGuarded() throws Exception
|
||||
{
|
||||
List<String> changesets = changesets(changelogText());
|
||||
assertThat(changesets).hasSizeGreaterThanOrEqualTo(SEEDED_TABLES.size());
|
||||
for(String changeset : changesets)
|
||||
{
|
||||
assertThat(changeset).contains("-- preconditions onFail:MARK_RAN onError:MARK_RAN").contains("-- precondition-sql-check expectedResult:0 ")
|
||||
.doesNotContain("precondition-table-exists").contains("-- rollback empty").contains("NOT EXISTS");
|
||||
}
|
||||
for(String table : SEEDED_TABLES)
|
||||
{
|
||||
assertThat(changelogText()).contains("INSERT INTO " + table + " ");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The seeded rows drive the runtime: persona, skills reach the model's
|
||||
** request, the budget warns at 80%, and the policy is read.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void seededRowsChangeBehavior() throws Exception
|
||||
{
|
||||
try(Connection connection = freshSchema())
|
||||
{
|
||||
runChangelog(connection);
|
||||
copyIntoStore(connection);
|
||||
}
|
||||
|
||||
ProfileService profiles = config.getRuntime().getProfileService();
|
||||
profiles.invalidate();
|
||||
QContext.init(qInstance, AssistantTestFixture.sessionFor(AssistantTestFixture.EDITOR));
|
||||
ProfileService.Persona persona = profiles.persona(QContext.getQSession(), "content");
|
||||
assertThat(persona.profileId()).isNotNull();
|
||||
assertThat(persona.greeting()).startsWith("Hi! I can find and count records");
|
||||
assertThat(persona.starterPrompts()).contains("What changed in the last 7 days?");
|
||||
assertThat(profiles.relevantSkills(persona.profileId(), "How many posts were published this month?", "content"))
|
||||
.extracting(AssistantSkill::getName).contains("find-and-count").doesNotContain("safe-changes");
|
||||
|
||||
///////////////////////////////////////////////////////////////
|
||||
// a real turn: the matching skill is in the model's request //
|
||||
///////////////////////////////////////////////////////////////
|
||||
provider.thenText("There are 4.");
|
||||
runner.run(AssistantTestFixture.EDITOR, "How many posts were published this month?");
|
||||
assertThat(provider.getRequestJson()).last().asString().contains("find-and-count").contains("Always include a filterLinks item").doesNotContain("safe-changes");
|
||||
|
||||
///////////////////////////////////////////
|
||||
// the starter budget warns at 80% of $50 //
|
||||
///////////////////////////////////////////
|
||||
AssistantStore store = adminStore();
|
||||
store.insert(new AssistantBudgetUsage().withUsageId(900_001).withUserId(AssistantTestFixture.EDITOR).withOccurredAt(Instant.now())
|
||||
.withCostMicros(40_000_000L).withInputTokens(0L).withOutputTokens(0L));
|
||||
BudgetService.Decision decision = config.getRuntime().getBudgetService().evaluate(AssistantTestFixture.EDITOR, null, false, false);
|
||||
assertThat(decision.stopped()).isFalse();
|
||||
assertThat(decision.notices()).singleElement().asString().contains("80%", "Monthly spend (whole instance)");
|
||||
|
||||
////////////////////////////////////////////////
|
||||
// the seeded policy: writes wait, 50 rows max //
|
||||
////////////////////////////////////////////////
|
||||
PolicyService.EffectivePolicy policy = config.getRuntime().getPolicyService().current(true);
|
||||
assertThat(policy.killSwitch()).isFalse();
|
||||
assertThat(policy.tools().writesRequireConfirmation()).isTrue();
|
||||
assertThat(policy.tools().maxRowsPerCall()).isEqualTo(50);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Copy the seeded profile, skills, links, budget and policy into the
|
||||
** test instance's store (ids remapped).
|
||||
*******************************************************************************/
|
||||
private void copyIntoStore(Connection connection) throws Exception
|
||||
{
|
||||
AssistantStore store = adminStore();
|
||||
Map<Integer, Integer> profileIds = new HashMap<>();
|
||||
Map<Integer, Integer> skillIds = new HashMap<>();
|
||||
try(Statement statement = connection.createStatement())
|
||||
{
|
||||
try(ResultSet rs = statement.executeQuery("SELECT * FROM assistant_profile"))
|
||||
{
|
||||
while(rs.next())
|
||||
{
|
||||
AssistantProfile row = store.insert(new AssistantProfile().withName(rs.getString("name")).withDisplayName(rs.getString("display_name"))
|
||||
.withIcon(rs.getString("icon")).withPersonality(rs.getString("personality")).withGreeting(rs.getString("greeting"))
|
||||
.withStarterPrompts(rs.getString("starter_prompts")).withIsDefault(rs.getBoolean("is_default")).withEnabled(rs.getBoolean("enabled")));
|
||||
profileIds.put(rs.getInt("id"), row.getId());
|
||||
}
|
||||
}
|
||||
try(ResultSet rs = statement.executeQuery("SELECT * FROM assistant_skill"))
|
||||
{
|
||||
while(rs.next())
|
||||
{
|
||||
AssistantSkill row = store.insert(new AssistantSkill().withName(rs.getString("name")).withDescription(rs.getString("description"))
|
||||
.withInstructions(rs.getString("instructions")).withEnabled(rs.getBoolean("enabled")).withTriggerKeywords(rs.getString("trigger_keywords")));
|
||||
skillIds.put(rs.getInt("id"), row.getId());
|
||||
}
|
||||
}
|
||||
try(ResultSet rs = statement.executeQuery("SELECT * FROM assistant_profile_skill"))
|
||||
{
|
||||
while(rs.next())
|
||||
{
|
||||
store.insert(new AssistantProfileSkill().withProfileId(profileIds.get(rs.getInt("profile_id"))).withSkillId(skillIds.get(rs.getInt("skill_id"))));
|
||||
}
|
||||
}
|
||||
try(ResultSet rs = statement.executeQuery("SELECT * FROM assistant_budget"))
|
||||
{
|
||||
while(rs.next())
|
||||
{
|
||||
store.insert(new AssistantBudget().withName(rs.getString("name")).withEnabled(rs.getBoolean("enabled")).withScopeType(rs.getString("scope_type"))
|
||||
.withPeriod(rs.getString("period")).withMetric(rs.getString("metric")).withLimitValue(rs.getBigDecimal("limit_value"))
|
||||
.withWarnPercent(rs.getInt("warn_percent")).withAtLimitAction(rs.getString("at_limit_action")).withHardCeilingPercent(rs.getInt("hard_ceiling_percent")));
|
||||
}
|
||||
}
|
||||
try(ResultSet rs = statement.executeQuery("SELECT * FROM assistant_policy"))
|
||||
{
|
||||
while(rs.next())
|
||||
{
|
||||
store.insert(new AssistantPolicy().withName(rs.getString("name")).withKillSwitch(rs.getBoolean("kill_switch"))
|
||||
.withWritesRequireConfirmation(rs.getBoolean("writes_require_confirmation")).withRedactPii(rs.getBoolean("redact_pii"))
|
||||
.withMaxRowsPerCall(rs.getInt("max_rows_per_call")));
|
||||
}
|
||||
}
|
||||
}
|
||||
assertThat(new BigDecimal("50")).isEqualByComparingTo(store.budgets().get(0).getLimitValue());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private AssistantStore adminStore()
|
||||
{
|
||||
QContext.init(qInstance, AssistantTestFixture.ownerSession(AssistantTestFixture.ADMIN, config));
|
||||
return (config.getRuntime().getStore());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A fresh H2 (PostgreSQL mode) with the seeded tables from the reference DDL.
|
||||
*******************************************************************************/
|
||||
static Connection freshSchema() throws Exception
|
||||
{
|
||||
Connection connection = DriverManager.getConnection("jdbc:h2:mem:defaults" + System.nanoTime() + ";MODE=PostgreSQL;DEFAULT_NULL_ORDERING=HIGH");
|
||||
String ddl = Files.readString(Path.of("..", "docs", "schema-postgres.sql"), StandardCharsets.UTF_8);
|
||||
try(Statement statement = connection.createStatement())
|
||||
{
|
||||
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")))
|
||||
{
|
||||
statement.execute(sql.replace("TIMESTAMPTZ", "TIMESTAMP WITH TIME ZONE"));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (connection);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run the changelog through Liquibase, as a host does.
|
||||
*******************************************************************************/
|
||||
static void runChangelog(Connection connection) throws Exception
|
||||
{
|
||||
Database database = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(new JdbcConnection(connection));
|
||||
new Liquibase(CHANGELOG.substring(1), new ClassLoaderResourceAccessor(), database).update(new Contexts());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** changeset id -> EXECTYPE (EXECUTED, MARK_RAN, ...).
|
||||
*******************************************************************************/
|
||||
static Map<String, String> ranChangesets(Connection connection) throws Exception
|
||||
{
|
||||
Map<String, String> rs = new HashMap<>();
|
||||
try(Statement statement = connection.createStatement(); ResultSet result = statement.executeQuery("SELECT id, exectype FROM databasechangelog"))
|
||||
{
|
||||
while(result.next())
|
||||
{
|
||||
rs.put(result.getString(1), result.getString(2));
|
||||
}
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static String changelogText() throws Exception
|
||||
{
|
||||
try(InputStream stream = AssistantDefaultsChangelogTest.class.getResourceAsStream(CHANGELOG))
|
||||
{
|
||||
assertThat(stream).as(CHANGELOG + " is on the classpath").isNotNull();
|
||||
return (new String(stream.readAllBytes(), StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The text of each changeset (from its "-- changeset" line).
|
||||
*******************************************************************************/
|
||||
static List<String> changesets(String changelog)
|
||||
{
|
||||
List<String> rs = new ArrayList<>();
|
||||
String[] parts = changelog.split("(?m)^(?=-- changeset )");
|
||||
for(String part : parts)
|
||||
{
|
||||
if(part.startsWith("-- changeset "))
|
||||
{
|
||||
rs.add(part);
|
||||
}
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SQL statements, without comment lines, split at a ';' that ends a line;
|
||||
** a DO $$ ... $$ block stays one statement.
|
||||
*******************************************************************************/
|
||||
static List<String> statements(String sql)
|
||||
{
|
||||
StringBuilder text = new StringBuilder();
|
||||
for(String line : sql.split("\\R"))
|
||||
{
|
||||
String stripped = line.replaceAll("\\s+--.*$", "");
|
||||
if(!line.strip().startsWith("--"))
|
||||
{
|
||||
text.append(stripped).append("\n");
|
||||
}
|
||||
}
|
||||
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(stripped.replaceAll(";$", ""));
|
||||
}
|
||||
}
|
||||
if(block != null)
|
||||
{
|
||||
rs.add(block.toString());
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Map<String, Integer> counts(Connection connection) throws Exception
|
||||
{
|
||||
Map<String, Integer> rs = new HashMap<>();
|
||||
for(String table : SEEDED_TABLES)
|
||||
{
|
||||
rs.put(table, Integer.valueOf(scalar(connection, "SELECT COUNT(*) FROM " + table)));
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String scalar(Connection connection, String sql) throws Exception
|
||||
{
|
||||
try(Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery(sql))
|
||||
{
|
||||
rs.next();
|
||||
BigDecimal number = rs.getObject(1) instanceof BigDecimal bd ? bd.stripTrailingZeros() : null;
|
||||
return (number != null ? number.toPlainString() : rs.getString(1));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
* 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.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
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.model.metadata.tables.QTableMetaData;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Guardrail (owner requirement 2026-10-14: "all tables should be populated and
|
||||
** the qbit should be fully using them"). For every assistant_* table:
|
||||
** - runtime code (not just its model, metadata or home screen) reads or
|
||||
** writes it, so no table exists only on paper;
|
||||
** - it is either seeded by the shipped defaults changelog, or listed here
|
||||
** with why it starts empty;
|
||||
** - it has bundle help that says what it is for and how its rows get
|
||||
** created (shown on the table screen and when it is empty).
|
||||
** A new table fails this test until it is wired, seeded or explained.
|
||||
*******************************************************************************/
|
||||
class AssistantTablesGuardrailTest extends BaseAssistantTest
|
||||
{
|
||||
private static final Path MAIN = Path.of("src", "main", "java", "com", "kingsrook", "qbits", "assistant");
|
||||
|
||||
/*******************************************************************************
|
||||
** Tables that legitimately start empty, and where their rows come from.
|
||||
*******************************************************************************/
|
||||
static final Map<String, String> STARTS_EMPTY = Map.ofEntries(
|
||||
Map.entry("assistant_provider", "host connection config: needs the host's key variable (kof22 seeds its providers)"),
|
||||
Map.entry("assistant_model", "Discover models on a provider, or the host"),
|
||||
Map.entry("assistant_rag_source", "host-specific: a host table plus an embedding model"),
|
||||
Map.entry("assistant_data_access", "defaults apply without rows; 'Add rows for every table' writes them out"),
|
||||
Map.entry("assistant_budget_override", "an administrator, when a budget is reached"),
|
||||
Map.entry("assistant_budget_event", "runtime: a budget threshold is crossed"),
|
||||
Map.entry("assistant_budget_usage", "runtime: every model call"),
|
||||
Map.entry("assistant_scheduled_run", "runtime: a scheduled task runs"),
|
||||
Map.entry("assistant_conversation", "user content: someone asks a question"),
|
||||
Map.entry("assistant_message", "user content: the conversation"),
|
||||
Map.entry("assistant_turn", "runtime: every answer"),
|
||||
Map.entry("assistant_turn_event", "runtime: every answer's event stream"),
|
||||
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_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"));
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table is used by runtime code.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyTableIsUsedAtRuntime() throws Exception
|
||||
{
|
||||
Map<String, String> entityByTable = entityByTable();
|
||||
List<String> runtimeSource;
|
||||
try(Stream<Path> files = Files.walk(MAIN))
|
||||
{
|
||||
runtimeSource = files.filter(p -> p.toString().endsWith(".java"))
|
||||
.filter(p -> !p.startsWith(MAIN.resolve("model")) && !p.startsWith(MAIN.resolve("metadata")) && !p.startsWith(MAIN.resolve("home")))
|
||||
.map(AssistantTablesGuardrailTest::read).toList();
|
||||
}
|
||||
for(String table : assistantTables())
|
||||
{
|
||||
String entity = entityByTable.get(table);
|
||||
assertThat(entity).as("entity class for " + table).isNotNull();
|
||||
Pattern use = Pattern.compile("\\b" + entity + "(\\.TABLE_NAME|\\.class|\\(|::)");
|
||||
assertThat(runtimeSource.stream().anyMatch(source -> use.matcher(source).find()))
|
||||
.as(table + " (" + entity + ") must be read or written by runtime code, not only declared").isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table is seeded or explained.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyTableIsSeededOrExplained() throws Exception
|
||||
{
|
||||
String changelog = AssistantDefaultsChangelogTest.changelogText();
|
||||
for(String table : assistantTables())
|
||||
{
|
||||
boolean seeded = AssistantDefaultsChangelogTest.SEEDED_TABLES.contains(table);
|
||||
assertThat(seeded || STARTS_EMPTY.containsKey(table)).as(table + " must be seeded by the defaults changelog or listed in STARTS_EMPTY").isTrue();
|
||||
assertThat(seeded && STARTS_EMPTY.containsKey(table)).as(table + " cannot be both seeded and listed as starting empty").isFalse();
|
||||
assertThat(changelog.contains("INSERT INTO " + table + " ")).as(table + " seeded iff the changelog inserts it").isEqualTo(seeded);
|
||||
}
|
||||
assertThat(STARTS_EMPTY.keySet()).allMatch(table -> qInstance.getTable(table) != null, "STARTS_EMPTY names only real tables");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table and the app have bundle help that says how rows get created.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyTableHasEmptyStateHelp() throws Exception
|
||||
{
|
||||
assertThat(AssistantHelp.entries()).allSatisfy((key, entry) -> assertThat(entry.path("summary").asText().length()).as(key).isLessThanOrEqualTo(120));
|
||||
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(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");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private List<String> assistantTables()
|
||||
{
|
||||
List<String> tables = qInstance.getTables().values().stream().map(QTableMetaData::getName).filter(n -> n.startsWith("assistant_")).sorted().toList();
|
||||
assertThat(tables).hasSizeGreaterThan(20);
|
||||
return (tables);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** model/X.java declares TABLE_NAME = "x".
|
||||
*******************************************************************************/
|
||||
private static Map<String, String> entityByTable() throws Exception
|
||||
{
|
||||
Map<String, String> rs = new HashMap<>();
|
||||
Pattern p = Pattern.compile("TABLE_NAME\\s*=\\s*\"([a-z_]+)\"");
|
||||
try(Stream<Path> files = Files.list(MAIN.resolve("model")))
|
||||
{
|
||||
for(Path file : files.toList())
|
||||
{
|
||||
Matcher m = p.matcher(read(file));
|
||||
if(m.find())
|
||||
{
|
||||
rs.put(m.group(1), file.getFileName().toString().replace(".java", ""));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String read(Path path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (Files.readString(path, StandardCharsets.UTF_8));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new IllegalStateException(e));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+96
-7
@@ -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;
|
||||
@@ -314,7 +318,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 +355,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 +370,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,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");
|
||||
}
|
||||
}
|
||||
+985
@@ -0,0 +1,985 @@
|
||||
/*
|
||||
* 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.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
import com.kingsrook.qbits.assistant.AssistantRuntime;
|
||||
import com.kingsrook.qbits.assistant.api.AssistantApi;
|
||||
import com.kingsrook.qbits.assistant.api.AssistantApiException;
|
||||
import com.kingsrook.qbits.assistant.jobs.AssistantRunTracker;
|
||||
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.persistence.TranscriptRedactor;
|
||||
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.ModelRequest;
|
||||
import com.kingsrook.qbits.assistant.spi.ModelResponse;
|
||||
import com.kingsrook.qbits.assistant.spi.ModelUsage;
|
||||
import com.kingsrook.qbits.assistant.spi.StreamHandle;
|
||||
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
|
||||
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
|
||||
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.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.EDITOR;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Durable run lifecycle, using latches instead of provider timing guesses.
|
||||
*******************************************************************************/
|
||||
class DurableTurnsTest extends BaseAssistantTest
|
||||
{
|
||||
private static final String CLASSIFIED_TEXT = "CLASSIFIED-LIFECYCLE-CANARY";
|
||||
|
||||
private QSession session;
|
||||
private AssistantApi api;
|
||||
private AssistantStore store;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Short, valid lease settings keep watchdog tests bounded.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected void customizeConfig(AssistantQBitConfig config)
|
||||
{
|
||||
config.withTurnHeartbeatSeconds(1).withTurnLeaseSeconds(3).withTurnForceFinalizeGraceSeconds(1)
|
||||
.withMaxTurnSeconds(10).withKillSwitchPollMillis(50);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every operation uses the user's own fixture session.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void atomicBackend()
|
||||
{
|
||||
config.getRuntime().close();
|
||||
config.setRuntime(new AssistantRuntime(qInstance, config, Clock.systemUTC(), new AtomicCallsStore(config)));
|
||||
userContext();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Establish the caller's context, also after a test replaces the runtime.
|
||||
*******************************************************************************/
|
||||
private void userContext()
|
||||
{
|
||||
session = AssistantTestFixture.ownerSession(EDITOR, config);
|
||||
QContext.init(qInstance, session);
|
||||
api = new AssistantApi(config.getRuntime(), session);
|
||||
store = config.getRuntime().getStore();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Terminal paths on other threads flush the same provisional transcript scope.
|
||||
** Live text stays intact; stored user/message/replay content has only tokens.
|
||||
*******************************************************************************/
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = { "SUPERSEDED", "TIMEOUT", "SHUTDOWN" })
|
||||
void terminalPathsRedactClassifiedContent(String reason) throws Exception
|
||||
{
|
||||
config.withDataClassification("blog_post", "title", DataClassification.PII);
|
||||
ControlledProvider controlled = controlled(true);
|
||||
controlled.classifiedText = CLASSIFIED_TEXT;
|
||||
if("TIMEOUT".equals(reason))
|
||||
{
|
||||
config.setMaxTurnSeconds(1);
|
||||
}
|
||||
Integer conversation = conversation();
|
||||
RunningTurn run = api.startTurn(conversation, Map.of("text", CLASSIFIED_TEXT));
|
||||
try
|
||||
{
|
||||
controlled.awaitCalls(1);
|
||||
if("SHUTDOWN".equals(reason))
|
||||
{
|
||||
config.getRuntime().shutdown(Duration.ZERO);
|
||||
QContext.init(qInstance, session);
|
||||
}
|
||||
else if("SUPERSEDED".equals(reason))
|
||||
{
|
||||
api.startTurn(conversation, Map.of("text", "next question"));
|
||||
}
|
||||
AssistantTurn row = completed(run);
|
||||
assertThat(row.getCancelReason()).isEqualTo(reason);
|
||||
assertThat(store.getMessage(EDITOR, row.getUserMessageId()).getPartsJson()).contains("changed:v1").doesNotContain(CLASSIFIED_TEXT);
|
||||
assertThat(store.getMessage(EDITOR, row.getAssistantMessageId()).getPartsJson()).contains("changed:v1").doesNotContain(CLASSIFIED_TEXT);
|
||||
assertThat(api.turnEvents(run.getTurnId(), 0).persisted()).anySatisfy(event -> assertThat(event.toJson()).contains("changed:v1"))
|
||||
.allSatisfy(event -> assertThat(event.toJson()).doesNotContain(CLASSIFIED_TEXT));
|
||||
assertThat(run.getLog().after(0, 1)).anySatisfy(event -> assertThat(event.toJson()).contains(CLASSIFIED_TEXT));
|
||||
assertThat(TranscriptRedactor.current()).isNull();
|
||||
assertThat(QLogger.redact(CLASSIFIED_TEXT)).isEqualTo("changed:v1");
|
||||
}
|
||||
finally
|
||||
{
|
||||
controlled.release();
|
||||
}
|
||||
await(() -> CLASSIFIED_TEXT.equals(QLogger.redact(CLASSIFIED_TEXT)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A rejected submission still persists its terminal notice and releases scope.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void rejectedSubmissionFinalizesTranscript() throws Exception
|
||||
{
|
||||
Integer conversation = conversation();
|
||||
config.getRuntime().getExecutor().shutdown();
|
||||
assertThatThrownBy(() -> api.startTurn(conversation, Map.of("text", "Submission failed")))
|
||||
.isInstanceOf(java.util.concurrent.RejectedExecutionException.class);
|
||||
QContext.init(qInstance, session);
|
||||
assertThat(store.listRunningTurns(EDITOR)).isEmpty();
|
||||
assertThat(store.listMessages(EDITOR, conversation, null, 100)).anySatisfy(message ->
|
||||
assertThat(message.getPartsJson()).contains(TurnEngine.RESTART_TEXT));
|
||||
assertThat(TranscriptRedactor.current()).isNull();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Completion closes the lease, emits progress, and frees the next question.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void completion() throws Exception
|
||||
{
|
||||
provider.thenText("Done").thenText("Again");
|
||||
Integer conversation = conversation();
|
||||
RunningTurn first = api.startTurn(conversation, Map.of("text", "hello"));
|
||||
AssistantTurn row = completed(first);
|
||||
assertThat(row.getStatus()).isEqualTo("COMPLETE");
|
||||
assertThat(row.getPhase()).isEqualTo("done");
|
||||
assertThat(row.getEndedAt()).isNotNull();
|
||||
assertThat(row.getHeartbeatAt()).isAfterOrEqualTo(row.getStartedAt());
|
||||
assertThat(row.getLeaseOwner()).isEqualTo(config.getRuntime().getInstanceId()).hasSizeLessThanOrEqualTo(100);
|
||||
assertThat(row.getConcurrencyMode()).isEqualTo("SUPERSEDE");
|
||||
assertThat(store.listRunningTurns(EDITOR)).isEmpty();
|
||||
assertThat(store.updateRunningTurn(EDITOR, new QRecord().withValue("id", first.getTurnId()).withValue("status", "CANCELLING"))).isFalse();
|
||||
assertThat(first.getLog().after(0, 1).getFirst().getData()).containsEntry("startedAt", row.getStartedAt().toString());
|
||||
assertThat(first.getLog().after(0, 1)).filteredOn(e -> TurnEvent.TURN_PHASE.equals(e.getType()))
|
||||
.extracting(e -> e.getData().get("phase")).containsExactly("thinking", "writing");
|
||||
assertThat(completed(api.startTurn(conversation, Map.of("text", "again"))).getStatus()).isEqualTo("COMPLETE");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A throwing post-processing collaborator cannot strand a streamed answer.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void accountingFailureStillFinalizes() throws Exception
|
||||
{
|
||||
AssistantStore failing = new AssistantStore(config)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Inject a failure after streaming has finished.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void addUsage(String userId, LocalDate date, ModelUsage usage, long costMicros, boolean countTurn)
|
||||
{
|
||||
throw (new IllegalStateException("accounting unavailable"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Conversation totals fail independently of the turn update.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void updateFields(String table, QRecord record) throws QException
|
||||
{
|
||||
if(AssistantConversation.TABLE_NAME.equals(table) && record.getValue("totalTokensIn") != null)
|
||||
{
|
||||
throw (new IllegalStateException("totals unavailable"));
|
||||
}
|
||||
super.updateFields(table, record);
|
||||
}
|
||||
};
|
||||
config.getRuntime().close();
|
||||
AssistantRuntime runtime = new AssistantRuntime(qInstance, config, Clock.systemUTC())
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Substitute only the engine/API persistence collaborator.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public AssistantStore getStore()
|
||||
{
|
||||
return (failing);
|
||||
}
|
||||
};
|
||||
config.setRuntime(runtime);
|
||||
userContext();
|
||||
provider.thenText("A streamed answer").thenText("Next answer");
|
||||
Integer conversation = conversation();
|
||||
RunningTurn run = api.startTurn(conversation, Map.of("text", "hello"));
|
||||
assertThat(completed(run).getStatus()).isEqualTo("COMPLETE");
|
||||
assertThat(api.turnEvents(run.getTurnId(), 0).persisted()).extracting(TurnEvent::getType).contains(TurnEvent.TEXT_DELTA, TurnEvent.MESSAGE_END);
|
||||
assertThat(completed(api.startTurn(conversation, Map.of("text", "next"))).getStatus()).isEqualTo("COMPLETE");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit concurrent mode permits overlap within and across conversations.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void concurrentQuestions() throws Exception
|
||||
{
|
||||
ControlledProvider controlled = controlled(false);
|
||||
Integer firstConversation = conversation();
|
||||
List<RunningTurn> runs = new ArrayList<>();
|
||||
runs.add(api.startTurn(firstConversation, Map.of("text", "one", "concurrency", "CoNcUrReNt")));
|
||||
controlled.awaitCalls(1);
|
||||
runs.add(api.startTurn(firstConversation, Map.of("text", "two", "concurrency", "concurrent")));
|
||||
controlled.awaitCalls(2);
|
||||
runs.add(api.startTurn(conversation(), Map.of("text", "three", "concurrency", "concurrent")));
|
||||
controlled.awaitCalls(3);
|
||||
assertThat(active()).hasSize(3);
|
||||
controlled.release();
|
||||
for(RunningTurn run : runs)
|
||||
{
|
||||
assertThat(completed(run).getStatus()).isEqualTo("COMPLETE");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Default supersede frees the slot immediately and records the explanation.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void supersedeAndSoftCap() throws Exception
|
||||
{
|
||||
ControlledProvider controlled = controlled(false);
|
||||
Integer conversation = conversation();
|
||||
RunningTurn first = api.startTurn(conversation, Map.of("text", "one"));
|
||||
controlled.awaitCalls(1);
|
||||
RunningTurn second = api.startTurn(conversation, Map.of("text", "two"));
|
||||
controlled.awaitCalls(2);
|
||||
AssistantTurn superseded = completed(first);
|
||||
assertThat(superseded.getStatus()).isEqualTo("CANCELLED");
|
||||
assertThat(superseded.getCancelReason()).isEqualTo("SUPERSEDED");
|
||||
assertThat(store.getMessage(EDITOR, superseded.getAssistantMessageId()).getPartsJson()).contains(TurnEngine.SUPERSEDED_TEXT);
|
||||
config.setMaxConcurrentTurnsPerUser(2);
|
||||
RunningTurn third = api.startTurn(conversation(), Map.of("text", "three", "concurrency", "concurrent"));
|
||||
controlled.awaitCalls(3);
|
||||
RunningTurn fourth = api.startTurn(conversation(), Map.of("text", "four", "concurrency", "concurrent"));
|
||||
controlled.awaitCalls(4);
|
||||
assertThat(completed(second).getCancelReason()).isEqualTo("SUPERSEDED");
|
||||
controlled.release();
|
||||
assertThat(completed(third).getStatus()).isEqualTo("COMPLETE");
|
||||
assertThat(completed(fourth).getStatus()).isEqualTo("COMPLETE");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A provider ignoring both cancel and interruption is forcibly finalized.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void wedgedProviderTimeout() throws Exception
|
||||
{
|
||||
ControlledProvider controlled = controlled(true);
|
||||
controlled.emitText = false;
|
||||
RecordingTracker tracker = new RecordingTracker();
|
||||
config.setRunTracker(tracker);
|
||||
config.setMaxTurnSeconds(1);
|
||||
RunningTurn run = api.startTurn(conversation(), Map.of("text", "wedged"));
|
||||
try
|
||||
{
|
||||
controlled.awaitCalls(1);
|
||||
AssistantTurn row = completed(run);
|
||||
assertThat(row.getStatus()).isEqualTo("ERROR");
|
||||
assertThat(row.getErrorCode()).isEqualTo("TIMEOUT");
|
||||
assertThat(row.getCancelReason()).isEqualTo("TIMEOUT");
|
||||
assertThat(store.getMessage(EDITOR, row.getAssistantMessageId()).getPartsJson()).contains(TurnEngine.TIMEOUT_TEXT);
|
||||
assertThat(controlled.handles.getFirst().cancelled).isTrue();
|
||||
assertThat(tracker.finishes.get()).isEqualTo(1);
|
||||
int events = run.getLog().after(0, 1).size();
|
||||
controlled.release();
|
||||
await(() -> controlled.handles.getFirst().returned);
|
||||
assertThat(run.getLog().after(0, 1)).hasSize(events);
|
||||
assertThat(tracker.finishes.get()).isEqualTo(1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
controlled.release();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stop has its own bounded force path even when the provider ignores it.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void stopWedgedProvider() throws Exception
|
||||
{
|
||||
ControlledProvider controlled = controlled(true);
|
||||
RunningTurn run = api.startTurn(conversation(), Map.of("text", "wait"));
|
||||
try
|
||||
{
|
||||
controlled.awaitCalls(1);
|
||||
assertThat(api.cancelTurn(run.getTurnId())).containsEntry("status", "CANCELLING");
|
||||
AssistantTurn row = completed(run);
|
||||
assertThat(row.getStatus()).isEqualTo("CANCELLED");
|
||||
assertThat(row.getCancelReason()).isEqualTo("USER");
|
||||
}
|
||||
finally
|
||||
{
|
||||
controlled.release();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A failed heartbeat tick does not stop cancellation polling on later ticks.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void heartbeatFailureDoesNotKillWatchdog() throws Exception
|
||||
{
|
||||
AtomicInteger heartbeats = new AtomicInteger();
|
||||
AssistantStore failingOnce = new AssistantStore(config)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Fail only the first renewal, then behave like the real store.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void updateFields(String table, QRecord record) throws QException
|
||||
{
|
||||
if(AssistantTurn.TABLE_NAME.equals(table) && record.getValue("heartbeatAt") != null && heartbeats.incrementAndGet() == 1)
|
||||
{
|
||||
throw (new QException("temporary heartbeat failure"));
|
||||
}
|
||||
super.updateFields(table, record);
|
||||
}
|
||||
};
|
||||
config.getRuntime().close();
|
||||
config.setRuntime(new AssistantRuntime(qInstance, config)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** A transient persistence failure local to the owner.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public AssistantStore getStore()
|
||||
{
|
||||
return (failingOnce);
|
||||
}
|
||||
});
|
||||
userContext();
|
||||
ControlledProvider controlled = controlled(false);
|
||||
RunningTurn run = api.startTurn(conversation(), Map.of("text", "wait"));
|
||||
controlled.awaitCalls(1);
|
||||
AssistantRuntime other = new AssistantRuntime(qInstance, config);
|
||||
try
|
||||
{
|
||||
new AssistantApi(other, session).cancelTurn(run.getTurnId());
|
||||
assertThat(completed(run).getStatus()).isEqualTo("CANCELLED");
|
||||
assertThat(heartbeats.get()).isGreaterThanOrEqualTo(2);
|
||||
}
|
||||
finally
|
||||
{
|
||||
controlled.release();
|
||||
other.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stale leases, old cancel requests and absolute deadlines release remotely.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void orphanRecoveryAndIdempotentStop() throws Exception
|
||||
{
|
||||
AssistantTurn stale = remote(Instant.now().minusSeconds(5));
|
||||
store.updateFields(AssistantMessage.TABLE_NAME, new QRecord().withValue("id", stale.getAssistantMessageId())
|
||||
.withValue("partsJson", new MessageParts().addText("changed:v1").toJson()));
|
||||
AssistantTurn fresh = remote(Instant.now());
|
||||
AssistantTurn deadline = remote(Instant.now());
|
||||
store.updateFields(AssistantTurn.TABLE_NAME, new QRecord().withValue("id", deadline.getId()).withValue("startedAt", Instant.now().minusSeconds(30)));
|
||||
assertThat(active()).extracting(r -> r.get("turnId")).containsExactly(fresh.getId());
|
||||
assertThat(store.getTurn(EDITOR, stale.getId()).getStatus()).isEqualTo("ERROR");
|
||||
assertThat(store.getMessage(EDITOR, stale.getAssistantMessageId()).getPartsJson()).contains("changed:v1", "server restarted");
|
||||
assertThat(store.getTurn(EDITOR, deadline.getId()).getStatus()).isEqualTo("ERROR");
|
||||
assertThat(api.turnEvents(stale.getId(), 0).persisted()).extracting(TurnEvent::getType).containsExactly(TurnEvent.ERROR, TurnEvent.MESSAGE_END);
|
||||
assertThat(api.turnEvents(stale.getId(), 0).persisted()).hasSize(2);
|
||||
assertThat(api.cancelTurn(stale.getId())).containsEntry("status", "ERROR");
|
||||
assertThat(api.cancelTurn(fresh.getId())).containsEntry("status", "CANCELLING");
|
||||
assertThat(store.getTurn(EDITOR, fresh.getId()).getCancelRequestedAt()).isNotNull();
|
||||
store.updateFields(AssistantTurn.TABLE_NAME, new QRecord().withValue("id", fresh.getId()).withValue("cancelRequestedAt", Instant.now().minusSeconds(5)));
|
||||
assertThat(active()).isEmpty();
|
||||
assertThat(store.getTurn(EDITOR, fresh.getId()).getStatus()).isEqualTo("CANCELLED");
|
||||
AssistantTurn stopped = remote(Instant.now().minusSeconds(5));
|
||||
assertThat(api.cancelTurn(stopped.getId())).containsEntry("status", "CANCELLED");
|
||||
assertThat(api.cancelTurn(stopped.getId())).containsEntry("status", "CANCELLED");
|
||||
provider.thenText("Recovered");
|
||||
assertThat(completed(api.startTurn(conversation(), Map.of("text", "next"))).getStatus()).isEqualTo("COMPLETE");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Two replicas reaping the same row produce only one terminal frame.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void simultaneousReapers() throws Exception
|
||||
{
|
||||
AssistantTurn stale = remote(Instant.now().minusSeconds(5));
|
||||
AssistantRuntime other = new AssistantRuntime(qInstance, config);
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
try(var executor = java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor())
|
||||
{
|
||||
List<java.util.concurrent.Future<?>> futures = new ArrayList<>();
|
||||
for(AssistantRuntime runtime : List.of(config.getRuntime(), other))
|
||||
{
|
||||
futures.add(executor.submit(() ->
|
||||
{
|
||||
start.await();
|
||||
QContext.init(qInstance, session);
|
||||
try
|
||||
{
|
||||
new AssistantApi(runtime, session).activeTurns();
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
return (null);
|
||||
}));
|
||||
}
|
||||
start.countDown();
|
||||
for(var future : futures)
|
||||
{
|
||||
future.get(5, java.util.concurrent.TimeUnit.SECONDS);
|
||||
}
|
||||
List<TurnEvent> events = api.turnEvents(stale.getId(), 0).persisted();
|
||||
assertThat(events).filteredOn(TurnEvent::isTerminal).hasSize(1);
|
||||
assertThat(store.getTurn(EDITOR, stale.getId()).getStatus()).isEqualTo("ERROR");
|
||||
}
|
||||
finally
|
||||
{
|
||||
other.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A terminal outcome written elsewhere wins over the local engine's finish.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void remoteTerminalOutcomeIsPreserved() throws Exception
|
||||
{
|
||||
ControlledProvider controlled = controlled(false);
|
||||
RunningTurn run = api.startTurn(conversation(), Map.of("text", "wait"));
|
||||
controlled.awaitCalls(1);
|
||||
store.insertEvent(run.getTurnId(), EDITOR, 50, TurnEvent.MESSAGE_END,
|
||||
new TurnEvent(50, TurnEvent.MESSAGE_END, Map.of("status", "ERROR", "stopReason", "interrupted")).toJson());
|
||||
store.updateFields(AssistantTurn.TABLE_NAME, new QRecord().withValue("id", run.getTurnId()).withValue("status", "ERROR")
|
||||
.withValue("phase", "done").withValue("errorCode", "INTERNAL").withValue("stopReason", "interrupted").withValue("endedAt", Instant.now()));
|
||||
controlled.release();
|
||||
assertThat(completed(run).getStatus()).isEqualTo("ERROR");
|
||||
assertThat(api.turnEvents(run.getTurnId(), 0).persisted()).filteredOn(TurnEvent::isTerminal).hasSize(1);
|
||||
assertThat(run.getLog().after(0, 1)).last().extracting(e -> e.getData().get("status")).isEqualTo("ERROR");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configuration rejects invalid lease timing and concurrency modes.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void configurationValidation()
|
||||
{
|
||||
config.withTurnHeartbeatSeconds(5).withTurnLeaseSeconds(4).withDefaultConcurrencyMode("invalid");
|
||||
List<String> errors = new ArrayList<>();
|
||||
config.validate(qInstance, errors);
|
||||
assertThat(errors).contains("turnHeartbeatSeconds must be less than turnLeaseSeconds.", "defaultConcurrencyMode must be SUPERSEDE or CONCURRENT.");
|
||||
config.withRunTracker(null);
|
||||
assertThat(config.getRunTracker()).isSameAs(AssistantRunTracker.NOOP);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A different runtime writes Stop; the owner sees it within one heartbeat.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void crossPodStopAndHeartbeat() throws Exception
|
||||
{
|
||||
ControlledProvider controlled = controlled(false);
|
||||
RunningTurn run = api.startTurn(conversation(), Map.of("text", "wait"));
|
||||
controlled.awaitCalls(1);
|
||||
AssistantRuntime remote = new AssistantRuntime(qInstance, config);
|
||||
try
|
||||
{
|
||||
assertThat(remote.getInstanceId()).isNotEqualTo(config.getRuntime().getInstanceId());
|
||||
AssistantApi otherPod = new AssistantApi(remote, session);
|
||||
assertThat(((List<?>) otherPod.activeTurns().get("turns"))).hasSize(1);
|
||||
assertThat(active().getFirst()).containsEntry("live", true).containsEntry("phase", "writing").containsKey("elapsedMs");
|
||||
assertThat(otherPod.cancelTurn(run.getTurnId())).containsEntry("status", "CANCELLING");
|
||||
AssistantTurn row = completed(run);
|
||||
assertThat(row.getStatus()).isEqualTo("CANCELLED");
|
||||
assertThat(row.getCancelReason()).isEqualTo("USER");
|
||||
assertThat(row.getHeartbeatAt()).isAfter(row.getStartedAt());
|
||||
assertThat(otherPod.cancelTurn(run.getTurnId())).containsEntry("status", "CANCELLED");
|
||||
}
|
||||
finally
|
||||
{
|
||||
controlled.release();
|
||||
remote.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Shutdown force-finalizes a wedged engine and refuses new inserts with 503.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void rollingDeploy() throws Exception
|
||||
{
|
||||
ControlledProvider controlled = controlled(true);
|
||||
RecordingTracker tracker = new RecordingTracker();
|
||||
config.setRunTracker(tracker);
|
||||
Integer conversation = conversation();
|
||||
RunningTurn run = api.startTurn(conversation, Map.of("text", "wait"));
|
||||
try
|
||||
{
|
||||
controlled.awaitCalls(1);
|
||||
config.getRuntime().shutdown(Duration.ZERO);
|
||||
QContext.init(qInstance, session);
|
||||
AssistantTurn row = completed(run);
|
||||
assertThat(row.getStatus()).isEqualTo("ERROR");
|
||||
assertThat(row.getErrorCode()).isEqualTo("INTERRUPTED");
|
||||
assertThat(store.getMessage(EDITOR, row.getAssistantMessageId()).getPartsJson()).contains(TurnEngine.RESTART_TEXT);
|
||||
assertThat(tracker.finishes.get()).isEqualTo(1);
|
||||
int messages = store.listMessages(EDITOR, conversation, null, 100).size();
|
||||
assertThatThrownBy(() -> api.startTurn(conversation, Map.of("text", "retry")))
|
||||
.isInstanceOfSatisfying(AssistantApiException.class, e ->
|
||||
{
|
||||
assertThat(e.getStatus()).isEqualTo(503);
|
||||
assertThat(e.getCode()).isEqualTo("SHUTTING_DOWN");
|
||||
});
|
||||
assertThat(store.listMessages(EDITOR, conversation, null, 100)).hasSize(messages);
|
||||
}
|
||||
finally
|
||||
{
|
||||
controlled.release();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Graceful cancellation also uses the restart outcome, not a user cancel.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void gracefulShutdown() throws Exception
|
||||
{
|
||||
ControlledProvider controlled = controlled(false);
|
||||
RunningTurn run = api.startTurn(conversation(), Map.of("text", "wait"));
|
||||
controlled.awaitCalls(1);
|
||||
config.getRuntime().shutdown(Duration.ofSeconds(1));
|
||||
QContext.init(qInstance, session);
|
||||
assertThat(completed(run).getErrorCode()).isEqualTo("INTERRUPTED");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Tracker progress is phase-based; its Stop callback cancels the local run.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void trackerAndTrackerFailures() throws Exception
|
||||
{
|
||||
ControlledProvider controlled = controlled(false);
|
||||
RecordingTracker tracker = new RecordingTracker();
|
||||
config.setRunTracker(tracker);
|
||||
RunningTurn run = api.startTurn(conversation(), Map.of("text", "wait"));
|
||||
controlled.awaitCalls(1);
|
||||
await(() -> tracker.cancel != null);
|
||||
tracker.cancel.run();
|
||||
assertThat(completed(run).getStatus()).isEqualTo("CANCELLED");
|
||||
assertThat(tracker.starts.get()).isEqualTo(1);
|
||||
assertThat(tracker.phases).containsExactly("thinking", "writing");
|
||||
assertThat(tracker.finishes.get()).isEqualTo(1);
|
||||
assertThat(tracker.label).isEqualTo("Assistant answer");
|
||||
tracker.fail = true;
|
||||
config.withModelProvider(provider);
|
||||
config.getRuntime().getProviderService().invalidate();
|
||||
provider.thenText("Still works");
|
||||
RunningTurn throwing = api.startTurn(conversation(), Map.of("text", "hello"));
|
||||
assertThat(completed(throwing).getStatus()).isEqualTo("COMPLETE");
|
||||
assertThat(tracker.finishes.get()).isEqualTo(2);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build a remote persisted turn with an assistant message to recover.
|
||||
*******************************************************************************/
|
||||
private AssistantTurn remote(Instant heartbeat) throws Exception
|
||||
{
|
||||
Integer conversation = conversation();
|
||||
AssistantMessage message = store.insert(new AssistantMessage().withConversationId(conversation).withUserId(EDITOR)
|
||||
.withRole("ASSISTANT").withStatus("STREAMING").withSeq(1).withPartsJson("[]"));
|
||||
return (store.insert(new AssistantTurn().withConversationId(conversation).withUserId(EDITOR).withAssistantMessageId(message.getId())
|
||||
.withStatus("RUNNING").withStartedAt(heartbeat).withHeartbeatAt(heartbeat).withLeaseOwner("dead-pod").withPhase("thinking")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Create a user-owned conversation.
|
||||
*******************************************************************************/
|
||||
private Integer conversation() throws Exception
|
||||
{
|
||||
return ((Integer) api.createConversation(Map.of()).get("id"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read the active-run response.
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<Map<String, Object>> active() throws Exception
|
||||
{
|
||||
return ((List<Map<String, Object>>) api.activeTurns().get("turns"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Wait for both persisted completion and local cleanup, then check replay.
|
||||
*******************************************************************************/
|
||||
private AssistantTurn completed(RunningTurn run) throws Exception
|
||||
{
|
||||
await(() -> config.getRuntime().getRunningTurn(run.getTurnId()) == null);
|
||||
assertThat(run.getLog().isClosed()).isTrue();
|
||||
assertThat(run.getLog().after(0, 1)).filteredOn(TurnEvent::isTerminal).hasSize(1);
|
||||
AssistantTurn row = store.getTurn(EDITOR, run.getTurnId());
|
||||
assertThat(row.getStatus()).isNotIn("RUNNING", "CANCELLING");
|
||||
assertThat(api.turnEvents(run.getTurnId(), 0).persisted()).last().extracting(TurnEvent::getType).isEqualTo(TurnEvent.MESSAGE_END);
|
||||
return (row);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Install a deterministic provider before the first call resolves it.
|
||||
*******************************************************************************/
|
||||
private ControlledProvider controlled(boolean stubborn)
|
||||
{
|
||||
ControlledProvider result = new ControlledProvider(stubborn);
|
||||
config.withModelProvider(result);
|
||||
return (result);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Short polling with a hard bound, without timing-sensitive fixed waits.
|
||||
*******************************************************************************/
|
||||
private static void await(BooleanSupplier condition) throws InterruptedException
|
||||
{
|
||||
long until = System.nanoTime() + Duration.ofSeconds(6).toNanos();
|
||||
while(!condition.getAsBoolean() && System.nanoTime() < until)
|
||||
{
|
||||
Thread.sleep(5);
|
||||
}
|
||||
assertThat(condition.getAsBoolean()).as("condition reached within six seconds").isTrue();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** MemoryRecordStore iterates its synchronized maps without holding their
|
||||
** lock. Serialize individual backend calls, as in RobustnessTest, so the
|
||||
** fixture models atomic database operations while turns still overlap.
|
||||
*******************************************************************************/
|
||||
private static class AtomicCallsStore extends AssistantStore
|
||||
{
|
||||
private static final Object BACKEND_LOCK = new Object();
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve encryption and other configured persistence behavior.
|
||||
*******************************************************************************/
|
||||
AtomicCallsStore(AssistantQBitConfig config)
|
||||
{
|
||||
super(config);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Make a memory query atomic with respect to insert/update operations.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public <T extends QRecordEntity> List<T> query(String table, QQueryFilter filter, Class<T> type) throws QException
|
||||
{
|
||||
synchronized(BACKEND_LOCK)
|
||||
{
|
||||
return (super.query(table, filter, type));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Make an insert atomic with respect to memory queries.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public <T extends QRecordEntity> T insert(T entity) throws QException
|
||||
{
|
||||
synchronized(BACKEND_LOCK)
|
||||
{
|
||||
return (super.insert(entity));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Make an update atomic with respect to memory queries.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void updateFields(String table, QRecord record) throws QException
|
||||
{
|
||||
synchronized(BACKEND_LOCK)
|
||||
{
|
||||
super.updateFields(table, record);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Streams partial text, then blocks until explicitly released.
|
||||
*******************************************************************************/
|
||||
private static class ControlledProvider extends ScriptedModelProvider
|
||||
{
|
||||
private final boolean stubborn;
|
||||
private String classifiedText;
|
||||
private final List<ControlledHandle> handles = new CopyOnWriteArrayList<>();
|
||||
private boolean emitText = true;
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure whether cancel and interruption are deliberately ignored.
|
||||
*******************************************************************************/
|
||||
ControlledProvider(boolean stubborn)
|
||||
{
|
||||
this.stubborn = stubborn;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Start a controlled stream.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public StreamHandle stream(ModelRequest request, ModelEventListener listener)
|
||||
{
|
||||
ControlledHandle handle = new ControlledHandle(stubborn);
|
||||
if(classifiedText != null)
|
||||
{
|
||||
TranscriptRedactor.current().observe("blog_post", "title", 1, classifiedText, classifiedText);
|
||||
listener.onTextDelta(classifiedText);
|
||||
}
|
||||
if(emitText)
|
||||
{
|
||||
listener.onTextDelta("A partial answer that has already begun streaming to the user. ");
|
||||
}
|
||||
handles.add(handle);
|
||||
return (handle);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Await a known number of started calls.
|
||||
*******************************************************************************/
|
||||
void awaitCalls(int count) throws InterruptedException
|
||||
{
|
||||
await(() -> handles.size() >= count);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Always release test threads, including deliberately wedged ones.
|
||||
*******************************************************************************/
|
||||
void release()
|
||||
{
|
||||
handles.forEach(h -> h.release.countDown());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A model that can deliberately violate the provider timeout contract.
|
||||
*******************************************************************************/
|
||||
private static class ControlledHandle implements StreamHandle
|
||||
{
|
||||
private final boolean stubborn;
|
||||
private final CountDownLatch release = new CountDownLatch(1);
|
||||
private volatile boolean cancelled;
|
||||
private volatile boolean returned;
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure cancellation behavior.
|
||||
*******************************************************************************/
|
||||
ControlledHandle(boolean stubborn)
|
||||
{
|
||||
this.stubborn = stubborn;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Abort unless this is the wedged-provider test.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void cancel()
|
||||
{
|
||||
cancelled = true;
|
||||
if(!stubborn)
|
||||
{
|
||||
release.countDown();
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Observe cancellation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean isCancelled()
|
||||
{
|
||||
return (cancelled);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Block deterministically; stubborn mode ignores timeouts and interrupts.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ModelResponse await(Duration timeout) throws InterruptedException
|
||||
{
|
||||
while(release.getCount() != 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
release.await();
|
||||
}
|
||||
catch(InterruptedException e)
|
||||
{
|
||||
if(!stubborn)
|
||||
{
|
||||
throw (e);
|
||||
}
|
||||
}
|
||||
}
|
||||
returned = true;
|
||||
return (new ModelResponse().withParts(List.of(ModelPart.text("Done")))
|
||||
.withStopReason(cancelled ? ModelResponse.STOP_CANCELLED : ModelResponse.STOP_END_TURN));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Records the host job-tray contract and can throw from every callback.
|
||||
*******************************************************************************/
|
||||
private static class RecordingTracker implements AssistantRunTracker
|
||||
{
|
||||
private final AtomicInteger starts = new AtomicInteger();
|
||||
private final AtomicInteger finishes = new AtomicInteger();
|
||||
private final List<String> phases = new CopyOnWriteArrayList<>();
|
||||
private volatile Runnable cancel;
|
||||
private String label;
|
||||
private boolean fail;
|
||||
|
||||
/*******************************************************************************
|
||||
** Record publication.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String start(String userId, Integer turnId, Integer conversationId, String label)
|
||||
{
|
||||
starts.incrementAndGet();
|
||||
this.label = label;
|
||||
if(fail)
|
||||
{
|
||||
throw (new IllegalStateException("tracker start failed"));
|
||||
}
|
||||
return ("job-" + turnId);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Record a phase transition.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void progress(String trackingId, String phase, String message)
|
||||
{
|
||||
phases.add(phase);
|
||||
if(fail)
|
||||
{
|
||||
throw (new IllegalStateException("tracker progress failed"));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Record terminal notification exactly once.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void finish(String trackingId, String status, String message)
|
||||
{
|
||||
finishes.incrementAndGet();
|
||||
if(fail)
|
||||
{
|
||||
throw (new IllegalStateException("tracker finish failed"));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Retain the host's Stop callback.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void onCancel(String trackingId, Runnable cancel)
|
||||
{
|
||||
this.cancel = cancel;
|
||||
}
|
||||
}
|
||||
}
|
||||
+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");
|
||||
|
||||
+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 ->
|
||||
|
||||
+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));
|
||||
|
||||
+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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.home;
|
||||
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import com.kingsrook.qbits.assistant.access.DataAccessMode;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantBudget;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantDataAccess;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantModelPrice;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProfile;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProfileSkill;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProvider;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantSkill;
|
||||
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
|
||||
import com.kingsrook.qbits.assistant.processes.DataAccessDefaultsProcess;
|
||||
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
|
||||
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The home's Setup card and the "Add rows for every table" action.
|
||||
*******************************************************************************/
|
||||
class AssistantSetupTest extends BaseAssistantTest
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** An empty installation says what to do next; filling the tables flips
|
||||
** each item to set up.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void setupCardTracksTheTables() throws Exception
|
||||
{
|
||||
Map<String, AssistantSetupStatus.Item> items = items();
|
||||
assertThat(items.keySet()).containsExactly("Provider", "Model prices", "Policy", "Profiles", "Skills", "Budgets", "Data access", "Knowledge sources", "Scheduled tasks");
|
||||
assertThat(items.get("Model prices").state()).as("no provider yet").isEqualTo(AssistantSetupStatus.State.DEFAULT);
|
||||
assertThat(items.get("Budgets").state()).isEqualTo(AssistantSetupStatus.State.TODO);
|
||||
assertThat(items.get("Budgets").nextStep()).contains("monthly instance budget");
|
||||
assertThat(items.get("Profiles").state()).isEqualTo(AssistantSetupStatus.State.DEFAULT);
|
||||
assertThat(items.get("Data access").state()).isEqualTo(AssistantSetupStatus.State.DEFAULT);
|
||||
assertThat(items.get("Data access").nextStep()).contains("Add rows for every table");
|
||||
assertThat(items.get("Knowledge sources").state()).isEqualTo(AssistantSetupStatus.State.OPTIONAL);
|
||||
|
||||
AssistantStore store = adminStore();
|
||||
AssistantProfile profile = store.insert(new AssistantProfile().withName("default").withDisplayName("Assistant").withIsDefault(true).withEnabled(true));
|
||||
AssistantSkill skill = store.insert(new AssistantSkill().withName("terms").withInstructions("Our terms.").withEnabled(true));
|
||||
assertThat(items().get("Skills").state()).as("a skill no profile offers is flagged").isEqualTo(AssistantSetupStatus.State.TODO);
|
||||
store.insert(new AssistantProfileSkill().withProfileId(profile.getId()).withSkillId(skill.getId()));
|
||||
store.insert(new AssistantBudget().withName("Monthly").withScopeType("INSTANCE").withPeriod("MONTH").withMetric("COST_USD").withLimitValue(BigDecimal.TEN));
|
||||
|
||||
items = items();
|
||||
assertThat(items.get("Profiles").state()).isEqualTo(AssistantSetupStatus.State.OK);
|
||||
assertThat(items.get("Skills").state()).isEqualTo(AssistantSetupStatus.State.OK);
|
||||
assertThat(items.get("Budgets").state()).isEqualTo(AssistantSetupStatus.State.OK);
|
||||
|
||||
String html = AssistantHomeView.render(AssistantHomeData.load(config.getRuntime()), "/app/assistantAdmin");
|
||||
assertThat(html).contains(">Setup</h3>").contains("href=\"/app/assistantAdmin/assistant_model_price\"").contains("Needs setup").contains("Next: ");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Prices resolve as calls are costed: a model priced only by the code
|
||||
** configuration is set up; an unpriced cloud model needs a price row; a
|
||||
** local model costs nothing.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void priceCheckUsesTheRuntimePricing() throws Exception
|
||||
{
|
||||
AssistantStore store = adminStore();
|
||||
assertThat(priceItem("anthropic", "claude-sonnet-5-5", "CLOUD").state()).as("config-priced").isEqualTo(AssistantSetupStatus.State.OK);
|
||||
AssistantSetupStatus.Item unpriced = priceItem("anthropic", "mystery-model-1", "CLOUD");
|
||||
assertThat(unpriced.state()).isEqualTo(AssistantSetupStatus.State.TODO);
|
||||
assertThat(unpriced.detail()).contains("mystery-model-1");
|
||||
assertThat(priceItem("openai-compatible", "qwen3:30b-a3b", "LOCAL").state()).as("local").isEqualTo(AssistantSetupStatus.State.OK);
|
||||
store.insert(new AssistantModelPrice().withProviderType("anthropic").withModel("mystery-model-1").withInputUsdPerMTok(new BigDecimal("1.5"))
|
||||
.withOutputUsdPerMTok(new BigDecimal("7")));
|
||||
config.getRuntime().getProviderService().invalidate();
|
||||
assertThat(priceItem("anthropic", "mystery-model-1", "CLOUD").state()).as("price row added").isEqualTo(AssistantSetupStatus.State.OK);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Model prices item for an active provider of this type and model.
|
||||
*******************************************************************************/
|
||||
private AssistantSetupStatus.Item priceItem(String type, String model, String location) throws Exception
|
||||
{
|
||||
AssistantProvider row = new AssistantProvider().withId(90_000 + (int) (System.nanoTime() % 1000)).withName(type + " " + model).withProviderType(type).withModel(model)
|
||||
.withLocation(location).withEnabled(true);
|
||||
AssistantHomeData.ProviderHealth health = new AssistantHomeData.ProviderHealth(row, type, "Set", true, List.of(), true, 0L, null, null, location, 0L);
|
||||
return (AssistantSetupStatus.load(config.getRuntime(), List.of(health)).stream().filter(i -> i.label().equals("Model prices")).findFirst().orElseThrow());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The action writes the defaults out as rows (READ, or NONE for sensitive
|
||||
** tables), keeps existing rows, adds nothing the second time, and the
|
||||
** written rows are what DataAccessService then reads.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void addRowsForEveryTable() throws Exception
|
||||
{
|
||||
AssistantStore store = adminStore();
|
||||
store.insert(new AssistantDataAccess().withObjectType(AssistantDataAccess.TYPE_TABLE).withObjectName("media")
|
||||
.withMode(DataAccessMode.READ_WRITE.name()));
|
||||
|
||||
runAction();
|
||||
List<AssistantDataAccess> rows = adminStore().query(AssistantDataAccess.TABLE_NAME, new QQueryFilter(), AssistantDataAccess.class);
|
||||
long appTables = qInstance.getTables().keySet().stream().filter(t -> !t.startsWith("assistant_")).count();
|
||||
assertThat(rows).hasSize((int) appTables);
|
||||
assertThat(rows).noneMatch(r -> r.getObjectName().startsWith("assistant_"));
|
||||
assertThat(rows).filteredOn(r -> r.getObjectName().equals("media")).singleElement()
|
||||
.extracting(AssistantDataAccess::getMode).isEqualTo("READ_WRITE");
|
||||
for(AssistantDataAccess row : rows)
|
||||
{
|
||||
if(!"media".equals(row.getObjectName()))
|
||||
{
|
||||
assertThat(row.getNotes()).isEqualTo(DataAccessDefaultsProcess.NOTE);
|
||||
assertThat(config.getRuntime().getDataAccessService().forTable(row.getObjectName()).source()).as(row.getObjectName()).isEqualTo("setting");
|
||||
assertThat(config.getRuntime().getDataAccessService().forTable(row.getObjectName()).mode().name()).isEqualTo(row.getMode());
|
||||
}
|
||||
}
|
||||
assertThat(rows).anyMatch(r -> "READ".equals(r.getMode()));
|
||||
|
||||
runAction();
|
||||
assertThat(adminStore().query(AssistantDataAccess.TABLE_NAME, new QQueryFilter(), AssistantDataAccess.class)).hasSize(rows.size());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void runAction() throws Exception
|
||||
{
|
||||
adminStore();
|
||||
RunProcessInput input = new RunProcessInput();
|
||||
input.setProcessName(DataAccessDefaultsProcess.NAME);
|
||||
input.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP);
|
||||
new RunProcessAction().execute(input);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private Map<String, AssistantSetupStatus.Item> items() throws Exception
|
||||
{
|
||||
adminStore();
|
||||
return (AssistantHomeData.load(config.getRuntime()).getSetup().stream()
|
||||
.collect(Collectors.toMap(AssistantSetupStatus.Item::label, i -> i, (a, b) -> a, java.util.LinkedHashMap::new)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private AssistantStore adminStore()
|
||||
{
|
||||
QContext.init(qInstance, AssistantTestFixture.ownerSession(AssistantTestFixture.ADMIN, config));
|
||||
return (config.getRuntime().getStore());
|
||||
}
|
||||
|
||||
}
|
||||
+387
@@ -0,0 +1,387 @@
|
||||
/*
|
||||
* 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_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
@@ -99,6 +99,7 @@ public class AssistantTestFixture
|
||||
*******************************************************************************/
|
||||
public static QInstance build(AssistantQBitConfig config) throws Exception
|
||||
{
|
||||
config.setRegisterShutdownHook(false);
|
||||
MemoryRecordStore.fullReset();
|
||||
QContext.clear();
|
||||
|
||||
|
||||
@@ -173,7 +173,7 @@ public class TurnRunner
|
||||
{
|
||||
last = batch.get(batch.size() - 1).getSeq();
|
||||
}
|
||||
if(batch.isEmpty() && running.getLog().isClosed())
|
||||
if(batch.isEmpty() && running.getLog().isClosed() && running.getLog().size() <= last)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user