Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3539f5fd93
|
||
|
|
2d6df82f16
|
||
|
|
db8f5398b8
|
||
|
|
f51482f69d
|
||
|
|
4083e5041f
|
||
|
|
45b34b7ccd
|
||
|
|
2652843c29
|
||
|
|
ca9212731d | ||
|
|
bcf173f4f2 | ||
|
|
5a1985fd96 | ||
|
|
055df2e764 | ||
|
|
0f79e7352a | ||
|
|
6bb8c7d826 | ||
|
|
3c04d1d1d7 | ||
|
|
a348a4b050 | ||
|
|
4049dffd0a | ||
|
|
ebfdae8561
|
||
|
|
429397a4f1
|
||
|
|
a0f9187f71
|
||
|
|
141de96626
|
||
|
|
dcf883fccb | ||
|
|
3637ca3ad0
|
||
|
|
63afe0b587 | ||
|
|
589d8f5c1c
|
||
|
|
b5cd747899
|
No files matched your search
+11
-57
@@ -1,60 +1,14 @@
|
||||
version: 2.1
|
||||
|
||||
setup: true
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.6.9
|
||||
|
||||
commands:
|
||||
configure-package-install:
|
||||
steps:
|
||||
- run:
|
||||
name: Keep package installation unattended
|
||||
command: |
|
||||
# These build machines are discarded after the job.
|
||||
sudo install -d /etc/needrestart/conf.d
|
||||
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
|
||||
|
||||
qqq-orb: kingsrook/qqq-orb@0.8
|
||||
workflows:
|
||||
test_only:
|
||||
jobs:
|
||||
- qqq-orb/mvn_test_only:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|release\/.*)/
|
||||
|
||||
publish_snapshot:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: snapshot
|
||||
filters:
|
||||
branches:
|
||||
only: [develop]
|
||||
|
||||
publish_release_candidate:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release_candidate
|
||||
filters:
|
||||
branches:
|
||||
only: [/release\/.*/]
|
||||
|
||||
publish_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release
|
||||
filters:
|
||||
branches:
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: [/^v\d+\.\d+\.\d+$/]
|
||||
setup:
|
||||
jobs:
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: '0.8'
|
||||
filters:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
tags:
|
||||
only: /^v\d+\.\d+\.\d+$/
|
||||
@@ -0,0 +1,25 @@
|
||||
# Standard QRun release caller (v2, thin contract): every push and manual dispatch delegates to the protected qrun-release-v1 channel. Do not edit per repository.
|
||||
name: qrun-release
|
||||
on:
|
||||
push: {}
|
||||
workflow_dispatch: {}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
jobs:
|
||||
release:
|
||||
uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/java-docker.yaml@qrun-release-v1
|
||||
with:
|
||||
runner: linux-small
|
||||
docker-runner: container-build
|
||||
browsers: true
|
||||
browser-runner: browser-build
|
||||
postpublish:
|
||||
if: ${{ needs.release.outputs.capture == 'true' }}
|
||||
needs: release
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/qrun-source-version.yaml@qrun-release-v1
|
||||
with:
|
||||
runner: linux-small
|
||||
@@ -0,0 +1,72 @@
|
||||
name: Release candidate
|
||||
'on':
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Plan a cut without changing release state
|
||||
type: boolean
|
||||
default: false
|
||||
finalize:
|
||||
description: Retry finalization of an existing cut; do not cut another RC
|
||||
type: boolean
|
||||
default: false
|
||||
recover:
|
||||
description: Recover the same published RC after a deployment timeout; never
|
||||
reupload
|
||||
type: boolean
|
||||
default: false
|
||||
circle_build:
|
||||
description: Original release run id (recovery only)
|
||||
type: string
|
||||
default: ''
|
||||
merge_sha:
|
||||
description: Existing cut merge_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
bump_sha:
|
||||
description: Existing cut bump_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
branch:
|
||||
description: Existing cut branch (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
version:
|
||||
description: Existing cut version (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
pull_request:
|
||||
types:
|
||||
- closed
|
||||
branches:
|
||||
- develop
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
release-rc:
|
||||
# The old qbit-rc-published repository_dispatch is replaced by the already-proven workflow_dispatch
|
||||
# completion: the publisher's own ActionsWRITE job dispatches this workflow with finalize=true plus
|
||||
# merge_sha/bump_sha/branch/version (same inputs as the manual finalize retry).
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
pull-requests: write
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
|
||||
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
|
||||
'release:rc'))
|
||||
uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/qbit-release-rc.yaml@qrun-release-v1
|
||||
with:
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
|
||||
}}
|
||||
finalize: ${{ github.event_name == 'workflow_dispatch' && inputs.finalize }}
|
||||
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
|
||||
}}
|
||||
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|
||||
|| '' }}
|
||||
merge_sha: ${{ inputs.merge_sha || '' }}
|
||||
bump_sha: ${{ inputs.bump_sha || '' }}
|
||||
branch: ${{ inputs.branch || '' }}
|
||||
version: ${{ inputs.version || '' }}
|
||||
secrets:
|
||||
QRUN_TAG_SIGNING_KEY: ${{ secrets.QRUN_TAG_SIGNING_KEY }}
|
||||
@@ -0,0 +1,23 @@
|
||||
name: CodeQL
|
||||
'on':
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- release/**
|
||||
- hotfix/**
|
||||
pull_request: {}
|
||||
schedule:
|
||||
- cron: 41 16 * * 0
|
||||
workflow_dispatch: {}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
security-events: write
|
||||
packages: read
|
||||
concurrency:
|
||||
group: codeql-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
jobs:
|
||||
analyze:
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
|
||||
@@ -1,26 +1,74 @@
|
||||
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
|
||||
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
|
||||
# the release line and the release/* push runs CircleCI publish_release_candidate.
|
||||
name: Release candidate
|
||||
|
||||
on:
|
||||
'on':
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Print the plan without merging or creating branches
|
||||
description: Plan a cut without changing release state
|
||||
type: boolean
|
||||
default: false
|
||||
finalize:
|
||||
description: Retry finalization of an existing cut; do not cut another RC
|
||||
type: boolean
|
||||
default: false
|
||||
recover:
|
||||
description: Recover the same published RC after a deployment timeout; never
|
||||
reupload
|
||||
type: boolean
|
||||
default: false
|
||||
circle_build:
|
||||
description: Original Circle publisher build number (recovery only)
|
||||
type: string
|
||||
default: ''
|
||||
merge_sha:
|
||||
description: Existing cut merge_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
bump_sha:
|
||||
description: Existing cut bump_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
branch:
|
||||
description: Existing cut branch (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
version:
|
||||
description: Existing cut version (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [develop]
|
||||
|
||||
types:
|
||||
- closed
|
||||
branches:
|
||||
- develop
|
||||
repository_dispatch:
|
||||
types:
|
||||
- qbit-rc-published
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
actions: read
|
||||
checks: read
|
||||
jobs:
|
||||
release-rc:
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
|
||||
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
|
||||
secrets: inherit
|
||||
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|
||||
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
|
||||
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
|
||||
'release:rc'))
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
|
||||
with:
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
|
||||
}}
|
||||
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
|
||||
== 'workflow_dispatch' && inputs.finalize) }}
|
||||
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
|
||||
}}
|
||||
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|
||||
|| '' }}
|
||||
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|
||||
|| inputs.merge_sha || '' }}
|
||||
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|
||||
|| inputs.bump_sha || '' }}
|
||||
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|
||||
|| inputs.branch || '' }}
|
||||
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|
||||
|| inputs.version || '' }}
|
||||
secrets: inherit
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
profile: qbit
|
||||
orb_version: '0.8'
|
||||
default_branch: develop
|
||||
java_version: '21'
|
||||
browsers: true
|
||||
api_checks: false
|
||||
docs_only: false
|
||||
release_on_main: false
|
||||
release_tag_policy: stable
|
||||
test_branch_policy: non-release
|
||||
publish:
|
||||
snapshot: true
|
||||
release_candidate: true
|
||||
release: true
|
||||
asciidoc: false
|
||||
release_completion: true
|
||||
@@ -1,5 +1,24 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased - Answer visible at the end of the stream
|
||||
|
||||
- The turn's redacted transcript (the question and answer parts, pending actions, stored events) is now written before `message.end` is sent. The panel reloads the conversation the moment the stream ends, and used to read the empty placeholders (`[]`) the transcript redactor stores until its flush, so the newest question and answer showed as empty bubbles until the panel was closed and reopened.
|
||||
|
||||
## Unreleased - Clear reason without a model provider (UI API extension 1.7.0)
|
||||
|
||||
- `GET /config` checks the server-wide causes before the user's permission: `DISABLED`, then `NOT_CONFIGURED` (no usable model provider), then `NO_PERMISSION`, then `KILL_SWITCH`. A server with no provider no longer tells users to ask for access (#82).
|
||||
- UI API extension assistant 1.7.0: `GET /config` has `message` whenever `reason` is set: a sentence for the user. For `NOT_CONFIGURED` it says what an administrator configures (an enabled row on **Assistant > Providers**, for example Anthropic with `ANTHROPIC_API_KEY` set in the server environment, or a local OpenAI-compatible server such as Ollama).
|
||||
- Without a usable provider, calls from a user who also lacks `assistant.hasAccess` answer `503 NOT_CONFIGURED` with that message instead of `403 NO_PERMISSION`; users with access keep their conversations, and a new question answers `503 NOT_CONFIGURED`.
|
||||
- Verified the OpenAI-compatible adapter (kind `OLLAMA`) live against a local Ollama `llama3.3:70b-instruct-q4_K_M`, including a tool call (`LiveOpenAiCompatibleTest`).
|
||||
|
||||
## Unreleased - Conversation management (UI API extension 1.6.0)
|
||||
|
||||
- `assistant_conversation` is open to every assistant user (`assistant.hasAccess` or `assistant.admin`, through the new `AssistantAccessPermissionChecker`) in the standard table UI. Rows stay locked to their owner by the `userId` record lock, so a user only ever sees, updates and deletes their own conversations; admins with the all-access key still see everyone's. The Assistant app (its nav) and every other assistant table stay admin-only.
|
||||
- Users change only a conversation's title, pinned flag and status (Active / Archived) through the table; the other fields are the assistant's own. A new title is encrypted in HIPAA mode, including literal text beginning with `enc:v1:`. Only an exact match to the same record's stored ciphertext is preserved unchanged; internal SYSTEM writes retain their existing storage path.
|
||||
- A table delete of a conversation also deletes its messages, turns, turn events, tool calls, feedback, data audits and pending actions (the same rows `DELETE /conversations/{id}` removes), so nothing is orphaned on a database without the cascading foreign keys. A conversation with an answer in progress is refused.
|
||||
- New record actions on `assistant_conversation`: **Archive conversations**, **Restore conversations** and **Delete conversations** (standard streamed ETL: selected records, Review, Result; same permission as the table; only rows the user can see; delete skips conversations with an answer in progress).
|
||||
- UI API extension assistant 1.6.0: `GET /conversations` items have `running` (an answer in progress: a RUNNING or CANCELLING turn that is not orphaned); `PATCH /conversations/{id}` accepts `{"read": true}` (sets `unreadCount` to 0); `GET /config` has `conversationsTable: "assistant_conversation"` when the session may query that table.
|
||||
|
||||
## Unreleased - Starter prompts follow access
|
||||
|
||||
- `profile.starterPrompts` in `GET /assistant/config` is built per user: up to three prompts generated from tables the user can read that have rows (UI API metadata and count as the user, bounded, current access rechecked on every request, stable order for the day), plus configured prompts only when tagged with tables the user can read (`[table] prompt`, `[none] prompt`; untagged lines are dropped). See UPGRADE.md.
|
||||
@@ -19,6 +38,7 @@
|
||||
- Provider SPI: `ModelPart.Type.IMAGE` (`mediaType`, base64 `data`), mapped by the Anthropic, Bedrock and OpenAI-compatible providers. Claude models report vision. `ModelPart.toString()` never includes text or image data.
|
||||
- `GET /config`: `features.attachments`, `limits.maxAttachmentBytes`, `limits.maxAttachments`, `limits.attachmentTypes`.
|
||||
- System prompt: when `create_record` / `update_record` are offered, the model proposes the change instead of saying it cannot create records.
|
||||
||||||| parent of c5125f9 (fix(assistant): store the transcript before message.end so the panel shows the answer)
|
||||
|
||||
## Unreleased - Optional provider health
|
||||
|
||||
|
||||
@@ -479,6 +479,17 @@ OLLAMA, LM_STUDIO, VLLM, LLAMA_CPP or OPENAI_COMPATIBLE as their kind.
|
||||
Registry prices override type/model prices, then configured prices. Unpriced
|
||||
local models cost zero. Capabilities can be adjusted per registered model.
|
||||
|
||||
For a local Ollama with no key (the live test ran against `llama3.3:70b-instruct-q4_K_M`):
|
||||
|
||||
```java
|
||||
config.withProviderType(new OpenAiCompatibleProviderType());
|
||||
// provider row: type openai-compatible, kind OLLAMA, model llama3.3:70b-instruct-q4_K_M,
|
||||
// base URL empty (http://localhost:11434/v1) or an allow-listed override such as http://127.0.0.1:11434/v1
|
||||
```
|
||||
|
||||
Without any usable provider `GET /config` answers `enabled: false, reason: NOT_CONFIGURED` with a
|
||||
`message` that says what to configure, before any permission check.
|
||||
|
||||
For Ollama behind a TLS proxy with a private CA and bearer authentication:
|
||||
|
||||
```java
|
||||
|
||||
+13
@@ -1,5 +1,18 @@
|
||||
# Upgrading qbit-assistant
|
||||
|
||||
## Conversation management (Unreleased, UI API extension assistant 1.6.0)
|
||||
|
||||
No schema change. `assistant_conversation` is now usable by every user holding
|
||||
`assistant.hasAccess` (not only `assistant.admin`), together with three new
|
||||
record actions (Archive, Restore and Delete conversations). Rows stay locked to
|
||||
their owner. A user's table queries only return their own rows if their session
|
||||
carries their owner key: call `AssistantSecurity.applySessionKeys` from the
|
||||
authentication customizer for **every** user, not only for admins. Without it,
|
||||
non-admin users see an empty table.
|
||||
|
||||
The panel can link to the table using `conversationsTable` from `GET /config`.
|
||||
That field is present only when the user may query the table.
|
||||
|
||||
## Starter prompts follow the user's access (Unreleased)
|
||||
|
||||
`GET /assistant/config` no longer returns the profile's starter prompts as written.
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@ Guards:
|
||||
`ProviderService.resolve(policyModel)`:
|
||||
1. The enabled row marked default (marking one default unmarks the others), else the first enabled row.
|
||||
2. With no enabled row, the provider registered in code (`withModelProvider`) and the policy's main model: hosts that never add a row keep working.
|
||||
3. A row that cannot be used (no key, a disallowed name or URL, an uninstalled type) makes the assistant report `NOT_CONFIGURED` rather than fall back silently.
|
||||
3. A row that cannot be used (no key, a disallowed name or URL, an uninstalled type) makes the assistant report `NOT_CONFIGURED` rather than fall back silently. `GET /config` reports it before `NO_PERMISSION`, with a `message` naming what to configure (#82).
|
||||
|
||||
The model is the row's, else the policy's main model. The rows and prices are cached for `policyCacheSeconds` and dropped on any change.
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ 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.ConversationActionsProcess;
|
||||
import com.kingsrook.qbits.assistant.processes.DataAccessDefaultsProcess;
|
||||
import com.kingsrook.qbits.assistant.processes.DiscoverModelsProcess;
|
||||
import com.kingsrook.qbits.assistant.processes.KnowledgeIndexProcess;
|
||||
@@ -184,6 +185,11 @@ public class AssistantQBitProducer implements QBitMetaDataProducer<AssistantQBit
|
||||
QProcessMetaData testConnection = TestConnectionProcess.process();
|
||||
testConnection.setSourceQBitName(qBitMetaData.getName());
|
||||
qInstance.addProcess(testConnection);
|
||||
for(QProcessMetaData process : ConversationActionsProcess.processes())
|
||||
{
|
||||
process.setSourceQBitName(qBitMetaData.getName());
|
||||
qInstance.addProcess(process);
|
||||
}
|
||||
for(QProcessMetaData process : List.of(DiscoverModelsProcess.process(), TestModelConnectionProcess.process(), DataAccessDefaultsProcess.process()))
|
||||
{
|
||||
process.setSourceQBitName(qBitMetaData.getName());
|
||||
|
||||
@@ -26,6 +26,7 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
import com.kingsrook.qbits.assistant.AssistantRuntime;
|
||||
@@ -60,8 +61,12 @@ 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.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
|
||||
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.QueryInput;
|
||||
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;
|
||||
@@ -81,6 +86,20 @@ public class AssistantApi
|
||||
|
||||
public static final int TITLE_MAX_CHARS = 60;
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
// GET /config reason (and error code) when no model provider is usable: //
|
||||
// no enabled provider row that can be built (a missing key, a disallowed //
|
||||
// variable or URL, an uninstalled type) and no provider registered in code //
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
public static final String NOT_CONFIGURED = "NOT_CONFIGURED";
|
||||
|
||||
public static final String NO_MODEL_PROVIDER_MESSAGE = "No model provider is configured on this server. An administrator adds an enabled provider on "
|
||||
+ "Assistant > Providers (for example type Anthropic, whose API key variable ANTHROPIC_API_KEY must be set in the server environment, or a local "
|
||||
+ "OpenAI-compatible server such as Ollama).";
|
||||
public static final String NO_PERMISSION_MESSAGE = "You do not have access to the assistant.";
|
||||
public static final String DISABLED_MESSAGE = "The assistant is turned off for this application.";
|
||||
public static final String KILL_SWITCH_MESSAGE = "The assistant has been turned off by an administrator.";
|
||||
|
||||
private final AssistantRuntime runtime;
|
||||
private final AssistantQBitConfig config;
|
||||
private final AssistantStore store;
|
||||
@@ -129,27 +148,39 @@ public class AssistantApi
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
String reason = null;
|
||||
|
||||
String message = null;
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// server-wide causes come before the user's own permission, so a //
|
||||
// server without a usable model provider says so (QRun-IO/qbit- //
|
||||
// assistant#82) instead of sending the user to ask for access //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
if(!config.isEffectivelyEnabled())
|
||||
{
|
||||
reason = "DISABLED";
|
||||
message = DISABLED_MESSAGE;
|
||||
}
|
||||
else if(activeProvider() == null)
|
||||
{
|
||||
reason = NOT_CONFIGURED;
|
||||
message = NO_MODEL_PROVIDER_MESSAGE;
|
||||
}
|
||||
else if(!hasAccess())
|
||||
{
|
||||
reason = "NO_PERMISSION";
|
||||
}
|
||||
else if(activeProvider() == null)
|
||||
{
|
||||
reason = "NOT_CONFIGURED";
|
||||
message = NO_PERMISSION_MESSAGE;
|
||||
}
|
||||
else if(runtime.getPolicyService().current(false).killSwitch())
|
||||
{
|
||||
reason = "KILL_SWITCH";
|
||||
message = KILL_SWITCH_MESSAGE;
|
||||
}
|
||||
|
||||
result.put("enabled", reason == null);
|
||||
if(reason != null)
|
||||
{
|
||||
result.put("reason", reason);
|
||||
result.put("message", message);
|
||||
}
|
||||
result.put("shortcut", config.getShortcut());
|
||||
result.put("model", reason == null ? activeProvider().model() : config.getModels().getMainModel());
|
||||
@@ -170,6 +201,10 @@ public class AssistantApi
|
||||
profile.put("greeting", persona.greeting());
|
||||
profile.put("starterPrompts", reason == null ? starterPrompts(persona) : List.of());
|
||||
result.put("profile", profile);
|
||||
if(mayQueryConversationsTable())
|
||||
{
|
||||
result.put("conversationsTable", AssistantConversation.TABLE_NAME);
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
|
||||
@@ -204,10 +239,13 @@ public class AssistantApi
|
||||
{
|
||||
requireAccess();
|
||||
String effective = "ARCHIVED".equalsIgnoreCase(status) ? "ARCHIVED" : "ACTIVE";
|
||||
Set<Integer> running = runningConversationIds();
|
||||
List<Map<String, Object>> list = new ArrayList<>();
|
||||
for(AssistantConversation conversation : store.listConversations(userId, effective))
|
||||
{
|
||||
list.add(toJson(conversation));
|
||||
Map<String, Object> item = toJson(conversation);
|
||||
item.put("running", running.contains(conversation.getId()));
|
||||
list.add(item);
|
||||
}
|
||||
return (Map.of("conversations", list));
|
||||
}
|
||||
@@ -337,6 +375,18 @@ public class AssistantApi
|
||||
update.setValue("status", status);
|
||||
conversation.setStatus((String) status);
|
||||
}
|
||||
if(body.containsKey("read"))
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// the panel watched the reply stream in: clear the unread count //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
if(!Boolean.TRUE.equals(body.get("read")))
|
||||
{
|
||||
throw (new AssistantApiException(400, "read can only be true."));
|
||||
}
|
||||
update.setValue("unreadCount", 0);
|
||||
conversation.setUnreadCount(0);
|
||||
}
|
||||
|
||||
store.updateFields(AssistantConversation.TABLE_NAME, update);
|
||||
return (toJson(conversation));
|
||||
@@ -401,11 +451,11 @@ public class AssistantApi
|
||||
ProviderService.Active active = runtime.getProviderService().resolve(policy.mainModel());
|
||||
if(active == null)
|
||||
{
|
||||
throw (new AssistantApiException(503, "NOT_CONFIGURED", "The assistant is not configured on this server."));
|
||||
throw (new AssistantApiException(503, NOT_CONFIGURED, NO_MODEL_PROVIDER_MESSAGE));
|
||||
}
|
||||
if(policy.killSwitch())
|
||||
{
|
||||
throw (new AssistantApiException(503, "KILL_SWITCH", "The assistant has been turned off by an administrator."));
|
||||
throw (new AssistantApiException(503, "KILL_SWITCH", KILL_SWITCH_MESSAGE));
|
||||
}
|
||||
|
||||
Long capMicros = ProviderService.capMicros(active.row());
|
||||
@@ -676,6 +726,53 @@ public class AssistantApi
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Ids of the user's conversations with an answer in progress: a RUNNING or
|
||||
** CANCELLING turn that is not orphaned (orphans are recovered on the way,
|
||||
** as in GET /turns/active).
|
||||
*******************************************************************************/
|
||||
Set<Integer> runningConversationIds() throws QException
|
||||
{
|
||||
Set<Integer> ids = new HashSet<>();
|
||||
for(AssistantTurn turn : store.listRunningTurns(userId))
|
||||
{
|
||||
turn = reapIfOrphaned(turn);
|
||||
if(isRunning(turn) && turn.getConversationId() != null)
|
||||
{
|
||||
ids.add(turn.getConversationId());
|
||||
}
|
||||
}
|
||||
return (ids);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether a turn row (of any owner) is an answer still in progress: running
|
||||
** here, or running elsewhere and not orphaned. Read-only (no recovery).
|
||||
*******************************************************************************/
|
||||
boolean inProgress(AssistantTurn turn)
|
||||
{
|
||||
return (isRunning(turn) && (runtime.getRunningTurn(turn.getId()) != null || !orphaned(turn)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether this session may query the conversation table in the standard
|
||||
** table UI (assistant access or admin; rows stay owner-locked).
|
||||
*******************************************************************************/
|
||||
private boolean mayQueryConversationsTable()
|
||||
{
|
||||
if(userId == null || QContext.getQInstance() == null || QContext.getQInstance().getTable(AssistantConversation.TABLE_NAME) == null)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
return (PermissionsHelper.hasTablePermission(new QueryInput(AssistantConversation.TABLE_NAME), AssistantConversation.TABLE_NAME, TablePermissionSubType.READ));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A remote run loses its lease, exceeds the absolute deadline, or has a
|
||||
** cancellation request that its owner did not acknowledge in time.
|
||||
@@ -1094,11 +1191,33 @@ public class AssistantApi
|
||||
{
|
||||
if(!config.isEffectivelyEnabled())
|
||||
{
|
||||
throw (new AssistantApiException(503, "DISABLED", "The assistant is turned off for this application."));
|
||||
throw (new AssistantApiException(503, "DISABLED", DISABLED_MESSAGE));
|
||||
}
|
||||
if(!hasAccess())
|
||||
{
|
||||
throw (new AssistantApiException(403, "NO_PERMISSION", "You do not have access to the assistant."));
|
||||
if(activeProviderOrNull() == null)
|
||||
{
|
||||
throw (new AssistantApiException(503, NOT_CONFIGURED, NO_MODEL_PROVIDER_MESSAGE));
|
||||
}
|
||||
throw (new AssistantApiException(403, "NO_PERMISSION", NO_PERMISSION_MESSAGE));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The active provider, or null when none is usable or it cannot be read
|
||||
** (only used to pick the clearer refusal).
|
||||
*******************************************************************************/
|
||||
private ProviderService.Active activeProviderOrNull()
|
||||
{
|
||||
try
|
||||
{
|
||||
return (activeProvider());
|
||||
}
|
||||
catch(QException | RuntimeException e)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* 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.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.assistant.AssistantRuntime;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantConversation;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantTurn;
|
||||
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.QInputSource;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
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.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The conversation table as users use it in the standard table UI (their own
|
||||
** rows, through the owner record lock):
|
||||
** - update: only title, pinned and status (ACTIVE / ARCHIVED) can change;
|
||||
** the other fields (owner, classification, counters, context) are the
|
||||
** assistant's own. A new title is encrypted (HIPAA mode) as the panel's
|
||||
** writes are; a stored, still-encrypted title sent back unchanged is kept.
|
||||
** - delete: refused while the conversation has an answer in progress (as
|
||||
** DELETE /conversations/{id}); afterwards its child rows are deleted the
|
||||
** same way that route deletes them, so nothing is orphaned.
|
||||
** The assistant's own writes (system input source) pass through unchanged.
|
||||
*******************************************************************************/
|
||||
public class AssistantConversationCustomizer implements TableCustomizerInterface
|
||||
{
|
||||
public static final Set<String> USER_EDITABLE_FIELDS = Set.of("title", "pinned", "status");
|
||||
|
||||
public static final String IN_PROGRESS = "This conversation has an answer in progress. Stop it first.";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Users change only title, pinned and status.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preUpdate(UpdateInput updateInput, List<QRecord> records, boolean isPreview, Optional<List<QRecord>> oldRecordList) throws QException
|
||||
{
|
||||
if(updateInput.getInputSource() == QInputSource.SYSTEM)
|
||||
{
|
||||
return (records);
|
||||
}
|
||||
AssistantRuntime runtime = AssistantRuntime.of(QContext.getQInstance());
|
||||
for(QRecord record : records)
|
||||
{
|
||||
for(String field : new ArrayList<>(record.getValues().keySet()))
|
||||
{
|
||||
if(!"id".equals(field) && !USER_EDITABLE_FIELDS.contains(field))
|
||||
{
|
||||
record.removeValue(field);
|
||||
}
|
||||
}
|
||||
Serializable status = record.getValue("status");
|
||||
if(record.getValues().containsKey("status") && !"ACTIVE".equals(status) && !"ARCHIVED".equals(status))
|
||||
{
|
||||
record.addError(new BadInputStatusMessage("Status must be Active or Archived."));
|
||||
}
|
||||
if(!isPreview && runtime != null && record.getValue("title") instanceof String title)
|
||||
{
|
||||
boolean unchangedStoredCiphertext = AssistantStore.isEncryptedValue(title) && record.getValueInteger("id") != null
|
||||
&& oldRecordList.orElse(List.of()).stream().anyMatch(oldRecord -> record.getValueInteger("id").equals(oldRecord.getValueInteger("id"))
|
||||
&& title.equals(oldRecord.getValueString("title")));
|
||||
if(!unchangedStoredCiphertext)
|
||||
{
|
||||
record.setValue("title", runtime.getStore().encryptForStorage(AssistantConversation.TABLE_NAME, new QRecord().withValue("title", title)).getValue("title"));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A conversation with an answer in progress is not deleted.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preDelete(DeleteInput deleteInput, List<QRecord> records, boolean isPreview) throws QException
|
||||
{
|
||||
AssistantRuntime runtime = AssistantRuntime.of(QContext.getQInstance());
|
||||
if(runtime == null || deleteInput.getInputSource() == QInputSource.SYSTEM || records.isEmpty())
|
||||
{
|
||||
return (records);
|
||||
}
|
||||
AssistantApi api = new AssistantApi(runtime, QContext.getQSession());
|
||||
for(QRecord record : records)
|
||||
{
|
||||
if(hasAnswerInProgress(runtime, api, record.getValueInteger("id")))
|
||||
{
|
||||
record.addError(new BadInputStatusMessage(IN_PROGRESS));
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete the deleted conversations' child rows, as the owner's rows.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postDelete(DeleteInput deleteInput, List<QRecord> records) throws QException
|
||||
{
|
||||
AssistantRuntime runtime = AssistantRuntime.of(QContext.getQInstance());
|
||||
if(runtime != null)
|
||||
{
|
||||
for(QRecord record : records)
|
||||
{
|
||||
if(record.getValueInteger("id") != null && record.getValueString("userId") != null)
|
||||
{
|
||||
runtime.getStore().deleteConversationChildren(record.getValueString("userId"), record.getValueInteger("id"));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether a conversation (of any owner the session can see) has a running
|
||||
** or cancelling turn that is not orphaned.
|
||||
*******************************************************************************/
|
||||
public static boolean hasAnswerInProgress(AssistantRuntime runtime, AssistantApi api, Integer conversationId) throws QException
|
||||
{
|
||||
if(conversationId == null)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
List<AssistantTurn> turns = runtime.getStore().query(AssistantTurn.TABLE_NAME, new QQueryFilter()
|
||||
.withCriteria("conversationId", QCriteriaOperator.EQUALS, conversationId)
|
||||
.withCriteria("status", QCriteriaOperator.IN, List.of("RUNNING", "CANCELLING")), AssistantTurn.class);
|
||||
return (turns.stream().anyMatch(api::inProgress));
|
||||
}
|
||||
}
|
||||
+11
-6
@@ -63,7 +63,7 @@ import io.javalin.http.HttpStatus;
|
||||
public final class AssistantSpecs
|
||||
{
|
||||
public static final String EXTENSION_NAME = "assistant";
|
||||
public static final String EXTENSION_VERSION = "1.5.0";
|
||||
public static final String EXTENSION_VERSION = "1.7.0";
|
||||
public static final String PREFIX = "/assistant";
|
||||
|
||||
|
||||
@@ -198,17 +198,21 @@ public final class AssistantSpecs
|
||||
"starterPrompts", array(string(null), "Up to three suggested first questions in total: permitted configured questions first, then generated questions. Table, row and field access are checked on every request.")));
|
||||
rs.put("AssistantConfigResponseV1", object("Whether the assistant is available to this user, and how.",
|
||||
"enabled", bool("Whether the panel may be used."),
|
||||
"reason", string("Why not: DISABLED, NO_PERMISSION, NOT_CONFIGURED or KILL_SWITCH.").withNullable(true),
|
||||
"reason", string("Why not: DISABLED, NOT_CONFIGURED (no usable model provider on the server), NO_PERMISSION or KILL_SWITCH, checked in that order.").withNullable(true),
|
||||
"message", string("Since 1.7.0: when reason is set, a sentence for the user that names the cause (for NOT_CONFIGURED, what an administrator configures).").withNullable(true),
|
||||
"shortcut", string("Keyboard shortcut, e.g. mod+j."),
|
||||
"model", string("The model that answers."),
|
||||
"limits", freeObject("maxTurnSeconds and maxToolCallsPerTurn. When features.attachments is true also maxAttachmentBytes (the largest file read, in bytes), "
|
||||
+ "maxAttachments (files per question) and attachmentTypes (human-readable type names for the file picker hint; images only when the model reads them)."),
|
||||
"features", freeObject("writes: some table allows writes; pendingActions: writes wait for confirmation; attachments: files uploaded with POST /qqq/v1/uploads "
|
||||
+ "and sent as pageContext.attachments [{uploadRef, fileName}] are read for the question."),
|
||||
"profile", ref("AssistantProfileV1")));
|
||||
"profile", ref("AssistantProfileV1"),
|
||||
"conversationsTable", string("Since 1.6.0: the table name of the user's conversations (assistant_conversation) for the standard table UI "
|
||||
+ "(manage, archive, restore and delete); present only when this session may query that table.").withNullable(true)));
|
||||
rs.put("AssistantConversationV1", object("One of the user's own conversations.",
|
||||
"id", integer(null), "title", string(null).withNullable(true), "status", string("ACTIVE or ARCHIVED."), "pinned", bool(null),
|
||||
"lastMessageAt", string("ISO-8601 instant.").withNullable(true), "unreadCount", integer(null), "createDate", string("ISO-8601 instant.").withNullable(true)));
|
||||
"lastMessageAt", string("ISO-8601 instant.").withNullable(true), "unreadCount", integer(null), "createDate", string("ISO-8601 instant.").withNullable(true),
|
||||
"running", bool("Since 1.6.0, in GET /conversations items only: whether an answer is in progress (a RUNNING or CANCELLING turn that is not orphaned).").withNullable(true)));
|
||||
rs.put("AssistantConversationListResponseV1", object("The user's conversations, newest first.", "conversations", array(ref("AssistantConversationV1"), null)));
|
||||
rs.put("AssistantMessageV1", object("A message: the user's text or the assistant's reply as parts (text, tool, record_refs, navigate, notice).",
|
||||
"id", integer(null), "role", string("USER or ASSISTANT."), "status", string(null), "turnId", integer(null).withNullable(true),
|
||||
@@ -818,7 +822,7 @@ public final class AssistantSpecs
|
||||
@Override
|
||||
protected String summary()
|
||||
{
|
||||
return ("Rename, pin or archive a conversation");
|
||||
return ("Rename, pin, archive or mark read a conversation");
|
||||
}
|
||||
|
||||
|
||||
@@ -834,7 +838,8 @@ public final class AssistantSpecs
|
||||
@Override
|
||||
protected Schema defineRequestBodySchema()
|
||||
{
|
||||
return (object("The changed properties.", "title", string(null), "pinned", bool(null), "status", string("ACTIVE or ARCHIVED.")));
|
||||
return (object("The changed properties.", "title", string(null), "pinned", bool(null), "status", string("ACTIVE or ARCHIVED."),
|
||||
"read", bool("Since 1.6.0: true sets unreadCount to 0 (the user watched the reply stream in). Only true is accepted.")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1343,6 +1343,17 @@ public class TurnEngine implements Runnable
|
||||
String finalErrorText = errorText;
|
||||
safely(() -> emit(TurnEvent.ERROR, map("code", finalErrorCode, "message", finalErrorText)));
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// the stream ends at message.end and the panel reloads the //
|
||||
// conversation at once, so the redacted transcript (message parts, //
|
||||
// pending actions, events) must be stored before that event is sent //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
if(!alreadyTerminal)
|
||||
{
|
||||
safely(this::persistPendingEvents);
|
||||
safely(() -> transcript.flush(store));
|
||||
}
|
||||
safely(() -> emit(TurnEvent.MESSAGE_END, map("messageId", assistantMessage.getId(), "status", finalStatus, "stopReason", finalStopReason)));
|
||||
if(!alreadyTerminal)
|
||||
{
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* 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 com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Guards what every assistant user may use in the standard table UI (their
|
||||
** own conversations and the conversation record actions): the user needs
|
||||
** assistant access ("assistant.hasAccess") or "assistant.admin". Which rows
|
||||
** they see is decided by the owner record lock, not by this checker.
|
||||
*******************************************************************************/
|
||||
public class AssistantAccessPermissionChecker implements CustomPermissionChecker
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void checkPermissionsThrowing(AbstractActionInput actionInput, MetaDataWithPermissionRules metaDataWithPermissionRules) throws QPermissionDeniedException
|
||||
{
|
||||
QSession session = QContext.getQSession();
|
||||
if(session == null || !(session.hasPermission(AssistantQBitConfig.ACCESS_PERMISSION) || session.hasPermission(AssistantQBitConfig.ADMIN_PERMISSION)))
|
||||
{
|
||||
throw (new QPermissionDeniedException("Not authorized to use the assistant."));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+26
-2
@@ -20,6 +20,7 @@ package com.kingsrook.qbits.assistant.metadata;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
import com.kingsrook.qbits.assistant.api.AssistantConversationCustomizer;
|
||||
import com.kingsrook.qbits.assistant.health.AssistantHealthWidget;
|
||||
import com.kingsrook.qbits.assistant.home.AssistantAccessWidget;
|
||||
import com.kingsrook.qbits.assistant.home.AssistantHome;
|
||||
@@ -90,7 +91,10 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
** - The key type's all-access key (assistant_user_id_all_access) lets admins see
|
||||
** everyone's rows; hosts grant it to users holding assistant.admin with
|
||||
** AssistantSecurity.applySessionKeys in their authentication customizer.
|
||||
** - Tables, app and process need assistant.admin in the generic UI / API.
|
||||
** - Tables, app and process need assistant.admin in the generic UI / API,
|
||||
** except assistant_conversation and its record actions (archive, restore,
|
||||
** delete), which every assistant user may use on their own rows. The app
|
||||
** (its nav) stays admin-only.
|
||||
** - assistant_usage_daily rows with a null userId are the INSTANCE rollup and
|
||||
** are readable by any session (the lock allows null).
|
||||
** - The assistant's own tool kernel never exposes assistant_* tables to the
|
||||
@@ -160,10 +164,15 @@ public class AssistantMetaDataBuilder
|
||||
{
|
||||
List<QTableMetaData> tables = new ArrayList<>();
|
||||
|
||||
QCodeReference conversationCustomizer = new QCodeReference(AssistantConversationCustomizer.class);
|
||||
tables.add(owned(base(AssistantConversation.TABLE_NAME, "Assistant Conversation", "forum", AssistantConversation.class), false)
|
||||
.withRecordLabelFormat("%s")
|
||||
.withRecordLabelFields("title")
|
||||
.withoutCapabilities(Capability.TABLE_INSERT));
|
||||
.withoutCapabilities(Capability.TABLE_INSERT)
|
||||
.withPermissionRules(accessRules())
|
||||
.withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, conversationCustomizer)
|
||||
.withCustomizer(TableCustomizers.PRE_DELETE_RECORD, conversationCustomizer)
|
||||
.withCustomizer(TableCustomizers.POST_DELETE_RECORD, conversationCustomizer));
|
||||
text(tables.get(0), "contextJson");
|
||||
|
||||
tables.add(text(readOnly(owned(base(AssistantMessage.TABLE_NAME, "Assistant Message", "chat", AssistantMessage.class), false)), "partsJson")
|
||||
@@ -415,6 +424,21 @@ public class AssistantMetaDataBuilder
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** assistant access or assistant.admin, through the custom checker (explicit
|
||||
** level and deny behavior, as in adminRules): for the conversation table
|
||||
** and its record actions, which users use on their own (record-locked) rows.
|
||||
*******************************************************************************/
|
||||
public static QPermissionRules accessRules()
|
||||
{
|
||||
return (new QPermissionRules()
|
||||
.withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)
|
||||
.withDenyBehavior(DenyBehavior.HIDDEN)
|
||||
.withCustomPermissionChecker(new QCodeReference(AssistantAccessPermissionChecker.class)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
|
||||
+35
-1
@@ -313,6 +313,20 @@ public class AssistantStore
|
||||
** Delete a conversation and everything under it.
|
||||
*******************************************************************************/
|
||||
public void deleteConversation(String userId, Integer conversationId) throws QException
|
||||
{
|
||||
deleteConversationChildren(userId, conversationId);
|
||||
delete(AssistantConversation.TABLE_NAME, owned(userId).withCriteria("id", QCriteriaOperator.EQUALS, conversationId));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete a conversation's child rows (turns and their events, messages,
|
||||
** tool calls, feedback, data audits, pending actions), not the conversation
|
||||
** itself. A table delete of the conversation calls this afterwards, so
|
||||
** nothing is orphaned on a database without the cascading foreign keys.
|
||||
*******************************************************************************/
|
||||
public void deleteConversationChildren(String userId, Integer conversationId) throws QException
|
||||
{
|
||||
List<Serializable> turnIds = new ArrayList<>();
|
||||
for(AssistantTurn turn : query(AssistantTurn.TABLE_NAME, owned(userId).withCriteria("conversationId", QCriteriaOperator.EQUALS, conversationId), AssistantTurn.class))
|
||||
@@ -329,7 +343,6 @@ public class AssistantStore
|
||||
}
|
||||
delete(AssistantDataAudit.TABLE_NAME, owned(userId).withCriteria("conversationId", QCriteriaOperator.EQUALS, conversationId));
|
||||
delete(AssistantPendingAction.TABLE_NAME, owned(userId).withCriteria("conversationId", QCriteriaOperator.EQUALS, conversationId));
|
||||
delete(AssistantConversation.TABLE_NAME, owned(userId).withCriteria("id", QCriteriaOperator.EQUALS, conversationId));
|
||||
}
|
||||
|
||||
|
||||
@@ -958,6 +971,27 @@ public class AssistantStore
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A copy of a record with the stored-encrypted fields encrypted (HIPAA
|
||||
** mode), for writes that do not go through this store (the table UI).
|
||||
*******************************************************************************/
|
||||
public QRecord encryptForStorage(String tableName, QRecord record)
|
||||
{
|
||||
return (cryptRecord(tableName, record, true));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether a stored value is in the encrypted form (HIPAA mode).
|
||||
*******************************************************************************/
|
||||
public static boolean isEncryptedValue(String value)
|
||||
{
|
||||
return (ConversationEncryption.isEncrypted(value));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Copy records at the storage boundary so memory-backend records and callers are never mutated.
|
||||
*******************************************************************************/
|
||||
|
||||
+10
@@ -89,6 +89,16 @@ final class ConversationEncryption
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether a stored value is in this codec's encrypted form.
|
||||
*******************************************************************************/
|
||||
public static boolean isEncrypted(String value)
|
||||
{
|
||||
return (value != null && value.startsWith(PREFIX));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Always encrypt plaintext, even strings that happen to start with enc:v1:.
|
||||
*******************************************************************************/
|
||||
|
||||
+302
@@ -0,0 +1,302 @@
|
||||
/*
|
||||
* 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.EnumMap;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.assistant.AssistantRuntime;
|
||||
import com.kingsrook.qbits.assistant.api.AssistantApi;
|
||||
import com.kingsrook.qbits.assistant.api.AssistantConversationCustomizer;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantConversation;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.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.data.QRecord;
|
||||
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.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** "Archive conversations", "Restore conversations" and "Delete conversations":
|
||||
** standard streamed-ETL record actions on assistant_conversation. The
|
||||
** selected records come in through the user's own query (so the owner
|
||||
** record lock decides which rows they act on), Review says what will
|
||||
** happen, Result what happened. Same permission as the table: assistant
|
||||
** access or assistant.admin. Table actions, never navigation entries.
|
||||
** Delete removes a conversation with its messages, turns, events, tool
|
||||
** calls, feedback and pending actions, and skips one whose answer is still
|
||||
** in progress, as DELETE /conversations/{id} does.
|
||||
*******************************************************************************/
|
||||
public final class ConversationActionsProcess
|
||||
{
|
||||
public static final String ARCHIVE = "assistantConversationArchive";
|
||||
public static final String RESTORE = "assistantConversationRestore";
|
||||
public static final String DELETE = "assistantConversationDelete";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What a process does with one selected conversation.
|
||||
*******************************************************************************/
|
||||
enum Outcome
|
||||
{
|
||||
ACT,
|
||||
SKIP,
|
||||
IN_PROGRESS
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private ConversationActionsProcess()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The three processes.
|
||||
*******************************************************************************/
|
||||
public static List<QProcessMetaData> processes()
|
||||
{
|
||||
return (List.of(
|
||||
process(ARCHIVE, "Archive conversations", "archive", "The selected conversations will be archived. They leave the assistant's list; nothing is deleted."),
|
||||
process(RESTORE, "Restore conversations", "unarchive", "The selected archived conversations will return to the assistant's list."),
|
||||
process(DELETE, "Delete conversations", "delete", "The selected conversations will be deleted with their messages. This cannot be undone.")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One standard streamed-ETL record action on the conversation table.
|
||||
*******************************************************************************/
|
||||
private static QProcessMetaData process(String name, String label, String icon, String previewMessage)
|
||||
{
|
||||
Map<String, Serializable> values = new HashMap<>();
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, AssistantConversation.TABLE_NAME);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, AssistantConversation.TABLE_NAME);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, previewMessage);
|
||||
QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(ExtractViaQueryStep.class, Review.class, Apply.class, values);
|
||||
process.setName(name);
|
||||
process.setLabel(label);
|
||||
process.setIcon(new QIcon(icon));
|
||||
process.setTableName(AssistantConversation.TABLE_NAME);
|
||||
process.setPermissionRules(AssistantMetaDataBuilder.accessRules());
|
||||
return (process);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Classify one selected conversation for this process.
|
||||
*******************************************************************************/
|
||||
static Outcome outcome(String processName, QRecord record, AssistantRuntime runtime, AssistantApi api) throws QException
|
||||
{
|
||||
String status = record.getValueString("status");
|
||||
return (switch(processName)
|
||||
{
|
||||
case ARCHIVE -> "ARCHIVED".equals(status) ? Outcome.SKIP : Outcome.ACT;
|
||||
case RESTORE -> "ARCHIVED".equals(status) ? Outcome.ACT : Outcome.SKIP;
|
||||
default -> AssistantConversationCustomizer.hasAnswerInProgress(runtime, api, record.getValueInteger("id")) ? Outcome.IN_PROGRESS : Outcome.ACT;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Summary lines, worded for the process (future on Review, past on Result).
|
||||
*******************************************************************************/
|
||||
static Map<Outcome, ProcessSummaryLine> lines(String processName)
|
||||
{
|
||||
Map<Outcome, ProcessSummaryLine> lines = new EnumMap<>(Outcome.class);
|
||||
switch(processName)
|
||||
{
|
||||
case ARCHIVE ->
|
||||
{
|
||||
lines.put(Outcome.ACT, DiscoverModelsProcess.line(Status.OK, "conversation will be archived", "conversations will be archived",
|
||||
"conversation was archived", "conversations were archived"));
|
||||
lines.put(Outcome.SKIP, DiscoverModelsProcess.line(Status.INFO, "conversation is already archived", "conversations are already archived",
|
||||
"conversation was already archived", "conversations were already archived"));
|
||||
}
|
||||
case RESTORE ->
|
||||
{
|
||||
lines.put(Outcome.ACT, DiscoverModelsProcess.line(Status.OK, "conversation will be restored", "conversations will be restored",
|
||||
"conversation was restored", "conversations were restored"));
|
||||
lines.put(Outcome.SKIP, DiscoverModelsProcess.line(Status.INFO, "conversation is not archived", "conversations are not archived",
|
||||
"conversation was not archived", "conversations were not archived"));
|
||||
}
|
||||
default ->
|
||||
{
|
||||
lines.put(Outcome.ACT, DiscoverModelsProcess.line(Status.OK, "conversation will be deleted with its messages", "conversations will be deleted with their messages",
|
||||
"conversation was deleted", "conversations were deleted"));
|
||||
lines.put(Outcome.IN_PROGRESS, DiscoverModelsProcess.line(Status.ERROR, "conversation has an answer in progress and will not be deleted",
|
||||
"conversations have an answer in progress and will not be deleted", "conversation was not deleted: an answer was in progress",
|
||||
"conversations were not deleted: an answer was in progress"));
|
||||
}
|
||||
}
|
||||
return (lines);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The lines that have a count, in a fixed order.
|
||||
*******************************************************************************/
|
||||
static ArrayList<ProcessSummaryLineInterface> summary(Map<Outcome, ProcessSummaryLine> lines, String nothing)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> rs = new ArrayList<>();
|
||||
for(ProcessSummaryLine line : lines.values())
|
||||
{
|
||||
line.addSelfToListIfAnyCount(rs);
|
||||
}
|
||||
if(rs.isEmpty())
|
||||
{
|
||||
rs.add(new ProcessSummaryLine(Status.INFO, null, nothing));
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Review: what will happen to each selected conversation. Changes nothing.
|
||||
*******************************************************************************/
|
||||
public static class Review extends AbstractTransformStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private Map<Outcome, ProcessSummaryLine> lines;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
AssistantRuntime runtime = runtime();
|
||||
AssistantApi api = new AssistantApi(runtime, QContext.getQSession());
|
||||
if(lines == null)
|
||||
{
|
||||
lines = lines(input.getProcessName());
|
||||
}
|
||||
for(QRecord record : input.getRecords())
|
||||
{
|
||||
lines.get(outcome(input.getProcessName(), record, runtime, api)).incrementCountAndAddPrimaryKey(record.getValue("id"));
|
||||
output.getRecords().add(record);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
return (summary(lines == null ? Map.of() : lines, "No conversations were selected."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Result: archive, restore or delete each conversation (checked again), as
|
||||
** the user, through the assistant's store.
|
||||
*******************************************************************************/
|
||||
public static class Apply extends AbstractLoadStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private Map<Outcome, ProcessSummaryLine> lines;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
AssistantRuntime runtime = runtime();
|
||||
AssistantApi api = new AssistantApi(runtime, QContext.getQSession());
|
||||
if(lines == null)
|
||||
{
|
||||
lines = lines(input.getProcessName());
|
||||
}
|
||||
for(QRecord record : input.getRecords())
|
||||
{
|
||||
Integer id = record.getValueInteger("id");
|
||||
Outcome outcome = outcome(input.getProcessName(), record, runtime, api);
|
||||
if(outcome == Outcome.ACT)
|
||||
{
|
||||
switch(input.getProcessName())
|
||||
{
|
||||
case ARCHIVE -> runtime.getStore().updateFields(AssistantConversation.TABLE_NAME, new QRecord().withValue("id", id).withValue("status", "ARCHIVED"));
|
||||
case RESTORE -> runtime.getStore().updateFields(AssistantConversation.TABLE_NAME, new QRecord().withValue("id", id).withValue("status", "ACTIVE"));
|
||||
default -> runtime.getStore().deleteConversation(record.getValueString("userId"), id);
|
||||
}
|
||||
}
|
||||
lines.get(outcome).incrementCountAndAddPrimaryKey(id);
|
||||
output.getRecords().add(record);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
return (summary(lines == null ? Map.of() : lines, "No conversations were changed."));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,9 +19,11 @@ entries:
|
||||
content: |
|
||||
A conversation is created when a user asks their first question in the assistant panel. Everything that follows (messages, turns, tool calls, feedback, model calls) hangs off it and is listed on its screen.
|
||||
|
||||
Most fields are filled in automatically and cannot be edited. You can only change the **{{label:assistant_conversation.title}}** and whether it is **{{label:assistant_conversation.pinned}}**.
|
||||
Most fields are filled in automatically and cannot be edited. You can only change the **{{label:assistant_conversation.title}}**, whether it is **{{label:assistant_conversation.pinned}}** and its **{{label:assistant_conversation.status}}** (Active or Archived).
|
||||
|
||||
Use this list to see who is using the assistant, how much it costs, and to open a conversation when you need to find out why an answer went wrong. Conversations are not kept forever: the **{{label:assistantRetention}}** action deletes conversations that have passed the configured inactivity period (90 days by default), together with their messages, turns, tool calls and feedback.
|
||||
Every assistant user can open this table to manage their own conversations: only their own rows are listed. They can archive, restore and delete conversations with **{{label:assistantConversationArchive}}**, **{{label:assistantConversationRestore}}** and **{{label:assistantConversationDelete}}**. Deleting a conversation also deletes its messages, turns, tool calls, feedback and pending actions. A conversation whose answer is still in progress cannot be deleted until the answer is stopped.
|
||||
|
||||
Administrators see every user's conversations here. Use this list to see who is using the assistant, how much it costs, and to open a conversation when you need to find out why an answer went wrong. Conversations are not kept forever: the **{{label:assistantRetention}}** action deletes conversations that have passed the configured inactivity period (90 days by default), together with their messages, turns, tool calls and feedback.
|
||||
|
||||
See also **{{label:assistant_policy}}** for limits and **{{label:assistant_provider}}** for where answers come from.
|
||||
- key: table:assistant_conversation;section:conversation
|
||||
@@ -1555,6 +1557,21 @@ entries:
|
||||
summary: When the worker last reported that this answer was still running.
|
||||
- key: table:assistant_turn;field:cancelRequestedAt
|
||||
summary: When a request to stop this answer was recorded.
|
||||
- key: process:assistantConversationArchive;slot:help
|
||||
summary: Archive the selected conversations. They leave the assistant's list, and nothing is deleted.
|
||||
content: |
|
||||
Select conversations in **{{label:assistant_conversation}}** and choose **Archive conversations**. Review shows how many will be archived and how many are already archived. You can only act on conversations you can see: your own, or everyone's if you are an assistant administrator.
|
||||
|
||||
- key: process:assistantConversationRestore;slot:help
|
||||
summary: Restore the selected archived conversations to the assistant's list.
|
||||
content: |
|
||||
Select archived conversations in **{{label:assistant_conversation}}** and choose **Restore conversations**. Conversations that are not archived are skipped.
|
||||
|
||||
- key: process:assistantConversationDelete;slot:help
|
||||
summary: Delete the selected conversations with their messages. This cannot be undone.
|
||||
content: |
|
||||
Select conversations in **{{label:assistant_conversation}}** and choose **Delete conversations**. Each conversation is deleted with its messages, turns, tool calls, feedback and pending actions. A conversation whose answer is still in progress is skipped. Stop the answer first, then delete it.
|
||||
|
||||
- key: process:assistantDataAccessAddDefaults;slot:help
|
||||
summary: Add missing data access rules for application tables using their current effective access.
|
||||
content: |
|
||||
|
||||
@@ -175,7 +175,13 @@ components:
|
||||
description: "Whether the panel may be used."
|
||||
type: "boolean"
|
||||
reason:
|
||||
description: "Why not: DISABLED, NO_PERMISSION, NOT_CONFIGURED or KILL_SWITCH."
|
||||
description: "Why not: DISABLED, NOT_CONFIGURED (no usable model provider\
|
||||
\ on the server), NO_PERMISSION or KILL_SWITCH, checked in that order."
|
||||
nullable: true
|
||||
type: "string"
|
||||
message:
|
||||
description: "Since 1.7.0: when reason is set, a sentence for the user that\
|
||||
\ names the cause (for NOT_CONFIGURED, what an administrator configures)."
|
||||
nullable: true
|
||||
type: "string"
|
||||
shortcut:
|
||||
@@ -200,6 +206,12 @@ components:
|
||||
type: "object"
|
||||
profile:
|
||||
$ref: "#/components/schemas/AssistantProfileV1"
|
||||
conversationsTable:
|
||||
description: "Since 1.6.0: the table name of the user's conversations (assistant_conversation)\
|
||||
\ for the standard table UI (manage, archive, restore and delete); present\
|
||||
\ only when this session may query that table."
|
||||
nullable: true
|
||||
type: "string"
|
||||
type: "object"
|
||||
AssistantConversationDetailResponseV1:
|
||||
description: "A conversation and a page of its messages, oldest first."
|
||||
@@ -242,6 +254,11 @@ components:
|
||||
description: "ISO-8601 instant."
|
||||
nullable: true
|
||||
type: "string"
|
||||
running:
|
||||
description: "Since 1.6.0, in GET /conversations items only: whether an\
|
||||
\ answer is in progress (a RUNNING or CANCELLING turn that is not orphaned)."
|
||||
nullable: true
|
||||
type: "boolean"
|
||||
type: "object"
|
||||
AssistantErrorV1:
|
||||
description: "Deprecated since 1.3.0: errors use BasicErrorResponseV1."
|
||||
@@ -294,7 +311,9 @@ components:
|
||||
nullable: true
|
||||
type: "string"
|
||||
starterPrompts:
|
||||
description: "Up to three suggested first questions in total: permitted configured questions first, then generated questions. Table, row and field access are checked on every request."
|
||||
description: "Up to three suggested first questions in total: permitted\
|
||||
\ configured questions first, then generated questions. Table, row and\
|
||||
\ field access are checked on every request."
|
||||
items:
|
||||
type: "string"
|
||||
type: "array"
|
||||
@@ -457,7 +476,7 @@ info:
|
||||
description: "QQQ Assistant: conversations, streamed turns, feedback, pending actions\
|
||||
\ and data access explanations"
|
||||
title: "QQQ Middleware API extension: assistant"
|
||||
version: "1.5.0"
|
||||
version: "1.7.0"
|
||||
openapi: "3.0.3"
|
||||
paths:
|
||||
/qqq/v1/assistant/config:
|
||||
@@ -833,7 +852,7 @@ paths:
|
||||
tags:
|
||||
- "assistant"
|
||||
patch:
|
||||
description: "Rename, pin or archive a conversation"
|
||||
description: "Rename, pin, archive or mark read a conversation"
|
||||
operationId: "patchAssistantConversationsById"
|
||||
parameters:
|
||||
- description: "The conversation id."
|
||||
@@ -855,6 +874,10 @@ paths:
|
||||
status:
|
||||
description: "ACTIVE or ARCHIVED."
|
||||
type: "string"
|
||||
read:
|
||||
description: "Since 1.6.0: true sets unreadCount to 0 (the user\
|
||||
\ watched the reply stream in). Only true is accepted."
|
||||
type: "boolean"
|
||||
type: "object"
|
||||
required: true
|
||||
responses:
|
||||
@@ -916,7 +939,7 @@ paths:
|
||||
- "openid"
|
||||
- "profile"
|
||||
- "email"
|
||||
summary: "Rename, pin or archive a conversation"
|
||||
summary: "Rename, pin, archive or mark read a conversation"
|
||||
tags:
|
||||
- "assistant"
|
||||
/qqq/v1/assistant/conversations/{id}/turns:
|
||||
|
||||
+14
-2
@@ -19,10 +19,12 @@ package com.kingsrook.qbits.assistant;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantAccessPermissionChecker;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantAdminPermissionChecker;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantInstanceMetaData;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantConversation;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantMessage;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantPolicy;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantUsageDaily;
|
||||
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
|
||||
@@ -78,7 +80,8 @@ class AssistantQBitProducerTest extends BaseAssistantTest
|
||||
RecordSecurityLock lock = table.getRecordSecurityLocks().get(0);
|
||||
assertThat(lock.getFieldName()).isEqualTo("userId");
|
||||
assertThat(lock.getSecurityKeyType()).isEqualTo(AssistantQBitConfig.DEFAULT_OWNER_KEY_TYPE);
|
||||
assertThat(table.getPermissionRules().getCustomPermissionChecker().getName()).isEqualTo(AssistantAdminPermissionChecker.class.getName());
|
||||
assertThat(table.getPermissionRules().getCustomPermissionChecker().getName()).isEqualTo(AssistantConversation.TABLE_NAME.equals(name)
|
||||
? AssistantAccessPermissionChecker.class.getName() : AssistantAdminPermissionChecker.class.getName());
|
||||
}
|
||||
assertThat(qInstance.getTable(AssistantUsageDaily.TABLE_NAME).getRecordSecurityLocks().get(0).getNullValueBehavior()).isEqualTo(RecordSecurityLock.NullValueBehavior.ALLOW);
|
||||
assertThat(qInstance.getTable(AssistantPolicy.TABLE_NAME).getRecordSecurityLocks()).isNullOrEmpty();
|
||||
@@ -163,7 +166,7 @@ class AssistantQBitProducerTest extends BaseAssistantTest
|
||||
new QInstanceEnricher(host).enrich();
|
||||
|
||||
List<MetaDataWithPermissionRules> adminObjects = List.of(host.getApp(AssistantMetaDataBuilder.APP_NAME),
|
||||
host.getProcess(AssistantMetaDataBuilder.RETENTION_PROCESS_NAME), host.getTable(AssistantPolicy.TABLE_NAME), host.getTable(AssistantConversation.TABLE_NAME));
|
||||
host.getProcess(AssistantMetaDataBuilder.RETENTION_PROCESS_NAME), host.getTable(AssistantPolicy.TABLE_NAME), host.getTable(AssistantMessage.TABLE_NAME));
|
||||
|
||||
QContext.init(host, AssistantTestFixture.sessionFor(AssistantTestFixture.EDITOR));
|
||||
for(MetaDataWithPermissionRules object : adminObjects)
|
||||
@@ -178,6 +181,15 @@ class AssistantQBitProducerTest extends BaseAssistantTest
|
||||
assertThat(PermissionsHelper.getPermissionCheckResult(new MetaDataInput(), object)).as(object.getName()).isEqualTo(PermissionCheckResult.ALLOW);
|
||||
}
|
||||
QContext.clear();
|
||||
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
// the conversation table is for every assistant user (own rows only) //
|
||||
////////////////////////////////////////////////////////////////////////
|
||||
QContext.init(host, AssistantTestFixture.sessionFor(AssistantTestFixture.EDITOR));
|
||||
assertThat(PermissionsHelper.getPermissionCheckResult(new MetaDataInput(), host.getTable(AssistantConversation.TABLE_NAME))).isEqualTo(PermissionCheckResult.ALLOW);
|
||||
QContext.init(host, AssistantTestFixture.sessionFor(AssistantTestFixture.NO_ACCESS));
|
||||
assertThat(PermissionsHelper.getPermissionCheckResult(new MetaDataInput(), host.getTable(AssistantConversation.TABLE_NAME))).isEqualTo(PermissionCheckResult.DENY_HIDE);
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
+41
-1
@@ -166,6 +166,45 @@ class AssistantRouteProviderTest extends BaseAssistantTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** No usable model provider (QRun-IO/qbit-assistant#82): the probe names the
|
||||
** server's configuration, for users with and without access, and calls
|
||||
** answer 503 NOT_CONFIGURED instead of 403 NO_PERMISSION.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testNoModelProviderIsNotAPermissionProblem() throws Exception
|
||||
{
|
||||
String providerName = config.getProviderName();
|
||||
config.setProviderName("no-such-provider");
|
||||
try
|
||||
{
|
||||
for(String user : new String[] {EDITOR, NO_ACCESS})
|
||||
{
|
||||
Map<String, Object> probe = json(send("GET", "/config", user, null));
|
||||
assertThat(probe).containsEntry("enabled", false).containsEntry("reason", "NOT_CONFIGURED");
|
||||
assertThat((String) probe.get("message")).contains("No model provider").contains("Assistant > Providers").contains("ANTHROPIC_API_KEY");
|
||||
}
|
||||
|
||||
HttpResponse<String> denied = send("GET", "/conversations", NO_ACCESS, null);
|
||||
assertThat(denied.statusCode()).isEqualTo(503);
|
||||
assertThat(json(denied)).containsEntry("code", "NOT_CONFIGURED");
|
||||
assertThat((String) json(denied).get("message")).contains("No model provider");
|
||||
|
||||
assertThat(send("GET", "/conversations", EDITOR, null).statusCode()).isEqualTo(200);
|
||||
HttpResponse<String> turn = send("POST", "/conversations", EDITOR, "{}");
|
||||
Object id = json(turn).get("id");
|
||||
HttpResponse<String> ask = send("POST", "/conversations/" + id + "/turns", EDITOR, "{\"text\":\"hi\"}");
|
||||
assertThat(ask.statusCode()).isEqualTo(503);
|
||||
assertThat(json(ask)).containsEntry("code", "NOT_CONFIGURED");
|
||||
}
|
||||
finally
|
||||
{
|
||||
config.setProviderName(providerName);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@@ -178,7 +217,8 @@ class AssistantRouteProviderTest extends BaseAssistantTest
|
||||
assertThat((Map<String, Object>) config.get("limits")).containsEntry("maxTurnSeconds", 300).containsEntry("maxToolCallsPerTurn", 25);
|
||||
assertThat((Map<String, Object>) config.get("features")).containsEntry("writes", false);
|
||||
|
||||
assertThat(json(send("GET", "/config", NO_ACCESS, null))).containsEntry("enabled", false).containsEntry("reason", "NO_PERMISSION");
|
||||
assertThat(json(send("GET", "/config", NO_ACCESS, null))).containsEntry("enabled", false).containsEntry("reason", "NO_PERMISSION")
|
||||
.containsEntry("message", "You do not have access to the assistant.");
|
||||
HttpResponse<String> denied = send("GET", "/conversations", NO_ACCESS, null);
|
||||
assertThat(denied.statusCode()).isEqualTo(403);
|
||||
assertThat(json(denied)).containsEntry("code", "NO_PERMISSION")
|
||||
|
||||
+429
@@ -0,0 +1,429 @@
|
||||
/*
|
||||
* 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.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantAccessPermissionChecker;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantAdminPermissionChecker;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantConversation;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantFeedback;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantMessage;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantTurn;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantTurnEvent;
|
||||
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
|
||||
import com.kingsrook.qbits.assistant.processes.ConversationActionsProcess;
|
||||
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
|
||||
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionCheckResult;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.QProcessCallbackFactory;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.metadata.MetaDataInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.QInputSource;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteOutput;
|
||||
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.actions.tables.update.UpdateOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.ADMIN;
|
||||
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.EDITOR;
|
||||
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.NO_ACCESS;
|
||||
import static com.kingsrook.qbits.assistant.testing.AssistantTestFixture.OTHER_EDITOR;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Conversation management: the conversation table open to assistant users
|
||||
** (owner-locked), its archive / restore / delete record actions, the table
|
||||
** delete that leaves no child rows behind, and the UI API additions
|
||||
** (running, read, conversationsTable).
|
||||
*******************************************************************************/
|
||||
class ConversationManagementTest extends BaseAssistantTest
|
||||
{
|
||||
private static final List<String> CHILD_TABLES = List.of(AssistantMessage.TABLE_NAME, AssistantTurn.TABLE_NAME, AssistantToolCall.TABLE_NAME,
|
||||
AssistantFeedback.TABLE_NAME, AssistantPendingAction.TABLE_NAME);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void clearContext()
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Assistant users may use the conversation table (and its actions); the
|
||||
** admin app, its nav and the other tables stay admin-only.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void tableOpenToAssistantUsersAppStaysAdminOnly()
|
||||
{
|
||||
assertThat(qInstance.getTable(AssistantConversation.TABLE_NAME).getPermissionRules().getCustomPermissionChecker().getName())
|
||||
.isEqualTo(AssistantAccessPermissionChecker.class.getName());
|
||||
assertThat(qInstance.getTable(AssistantConversation.TABLE_NAME).getRecordSecurityLocks().get(0).getFieldName()).isEqualTo("userId");
|
||||
assertThat(qInstance.getTable(AssistantMessage.TABLE_NAME).getPermissionRules().getCustomPermissionChecker().getName())
|
||||
.isEqualTo(AssistantAdminPermissionChecker.class.getName());
|
||||
assertThat(qInstance.getApp(AssistantMetaDataBuilder.APP_NAME).getPermissionRules().getCustomPermissionChecker().getName())
|
||||
.isEqualTo(AssistantAdminPermissionChecker.class.getName());
|
||||
for(String name : List.of(ConversationActionsProcess.ARCHIVE, ConversationActionsProcess.RESTORE, ConversationActionsProcess.DELETE))
|
||||
{
|
||||
QProcessMetaData process = qInstance.getProcess(name);
|
||||
assertThat(process.getTableName()).isEqualTo(AssistantConversation.TABLE_NAME);
|
||||
assertThat(process.getPermissionRules().getCustomPermissionChecker().getName()).isEqualTo(AssistantAccessPermissionChecker.class.getName());
|
||||
assertThat(process.getPermissionRules().getDeniedClients()).contains("assistant");
|
||||
}
|
||||
assertThat(qInstance.getApp(AssistantMetaDataBuilder.APP_NAME).getChildren()).extracting(c -> c.getName())
|
||||
.doesNotContain(ConversationActionsProcess.ARCHIVE, ConversationActionsProcess.RESTORE, ConversationActionsProcess.DELETE);
|
||||
|
||||
QContext.init(qInstance, AssistantTestFixture.ownerSession(EDITOR, config));
|
||||
for(TablePermissionSubType type : List.of(TablePermissionSubType.READ, TablePermissionSubType.EDIT, TablePermissionSubType.DELETE))
|
||||
{
|
||||
assertThat(PermissionsHelper.hasTablePermission(new QueryInput(AssistantConversation.TABLE_NAME), AssistantConversation.TABLE_NAME, type)).as(type.name()).isTrue();
|
||||
}
|
||||
assertThat(PermissionsHelper.hasTablePermission(new QueryInput(AssistantMessage.TABLE_NAME), AssistantMessage.TABLE_NAME, TablePermissionSubType.READ)).isFalse();
|
||||
assertThat(PermissionsHelper.getPermissionCheckResult(new MetaDataInput(), qInstance.getApp(AssistantMetaDataBuilder.APP_NAME))).isEqualTo(PermissionCheckResult.DENY_HIDE);
|
||||
assertThat(PermissionsHelper.hasProcessPermission(new MetaDataInput(), ConversationActionsProcess.DELETE)).isTrue();
|
||||
|
||||
QContext.init(qInstance, AssistantTestFixture.ownerSession(NO_ACCESS, config));
|
||||
assertThat(PermissionsHelper.hasTablePermission(new QueryInput(AssistantConversation.TABLE_NAME), AssistantConversation.TABLE_NAME, TablePermissionSubType.READ)).isFalse();
|
||||
assertThat(PermissionsHelper.hasProcessPermission(new MetaDataInput(), ConversationActionsProcess.DELETE)).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** User A cannot see, update or delete user B's conversation through the
|
||||
** table actions; their own rows they can.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void ownerLockOnTableActions() throws Exception
|
||||
{
|
||||
Integer mine = conversation(EDITOR, "Mine");
|
||||
Integer theirs = conversation(OTHER_EDITOR, "Theirs");
|
||||
|
||||
asUser(EDITOR);
|
||||
List<Serializable> visible = new QueryAction().execute(new QueryInput(AssistantConversation.TABLE_NAME).withInputSource(QInputSource.USER)).getRecords()
|
||||
.stream().map(r -> r.getValue("id")).toList();
|
||||
assertThat(visible).contains(mine).doesNotContain(theirs);
|
||||
|
||||
UpdateOutput update = new UpdateAction().execute(new UpdateInput(AssistantConversation.TABLE_NAME).withInputSource(QInputSource.USER)
|
||||
.withRecord(new QRecord().withValue("id", theirs).withValue("title", "Hijacked")));
|
||||
assertThat(update.getRecords()).allSatisfy(r -> assertThat(r.getErrors()).isNotEmpty());
|
||||
|
||||
assertThatDeleteFailsOrDoesNothing(theirs);
|
||||
|
||||
asUser(ADMIN);
|
||||
AssistantConversation stillThere = store().getConversation(OTHER_EDITOR, theirs);
|
||||
assertThat(stillThere).isNotNull();
|
||||
assertThat(stillThere.getTitle()).isEqualTo("Theirs");
|
||||
|
||||
////////////////////////////////////////////////////////////////
|
||||
// own row: title / pinned / status change; the rest is ours //
|
||||
////////////////////////////////////////////////////////////////
|
||||
asUser(EDITOR);
|
||||
UpdateOutput giveAway = new UpdateAction().execute(new UpdateInput(AssistantConversation.TABLE_NAME).withInputSource(QInputSource.USER)
|
||||
.withRecord(new QRecord().withValue("id", mine).withValue("userId", OTHER_EDITOR)));
|
||||
assertThat(giveAway.getRecords().get(0).getErrors()).as("the owner lock refuses moving a row to another user").isNotEmpty();
|
||||
new UpdateAction().execute(new UpdateInput(AssistantConversation.TABLE_NAME).withInputSource(QInputSource.USER)
|
||||
.withRecord(new QRecord().withValue("id", mine).withValue("title", "Renamed").withValue("status", "ARCHIVED")
|
||||
.withValue("unreadCount", 99).withValue("maxClassification", "NONE")));
|
||||
AssistantConversation updated = store().getConversation(EDITOR, mine);
|
||||
assertThat(updated.getTitle()).isEqualTo("Renamed");
|
||||
assertThat(updated.getStatus()).isEqualTo("ARCHIVED");
|
||||
assertThat(updated.getUnreadCount()).isNotEqualTo(99);
|
||||
assertThat(updated.getUserId()).isEqualTo(EDITOR);
|
||||
|
||||
UpdateOutput bad = new UpdateAction().execute(new UpdateInput(AssistantConversation.TABLE_NAME).withInputSource(QInputSource.USER)
|
||||
.withRecord(new QRecord().withValue("id", mine).withValue("status", "DELETED")));
|
||||
assertThat(bad.getRecords().get(0).getErrors()).isNotEmpty();
|
||||
assertThat(store().getConversation(EDITOR, mine).getStatus()).isEqualTo("ARCHIVED");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A table delete removes the conversation's child rows too; a conversation
|
||||
** with an answer in progress is refused.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void tableDeleteRemovesChildrenAndRefusesRunning() throws Exception
|
||||
{
|
||||
asUser(ADMIN);
|
||||
new DeleteAction().execute(new DeleteInput(AssistantTurnEvent.TABLE_NAME).withQueryFilter(new QQueryFilter()));
|
||||
Integer mine = conversation(EDITOR, "With children");
|
||||
children(EDITOR, mine);
|
||||
Integer other = conversation(EDITOR, "Sibling");
|
||||
children(EDITOR, other);
|
||||
|
||||
asUser(EDITOR);
|
||||
DeleteOutput output = new DeleteAction().execute(new DeleteInput(AssistantConversation.TABLE_NAME).withInputSource(QInputSource.USER).withPrimaryKeys(List.of(mine)));
|
||||
assertThat(output.getRecordsWithErrors()).isNullOrEmpty();
|
||||
assertThat(store().getConversation(EDITOR, mine)).isNull();
|
||||
for(String table : CHILD_TABLES)
|
||||
{
|
||||
assertThat(rows(table, mine)).as(table).isEmpty();
|
||||
assertThat(rows(table, other)).as(table + " of another conversation").isNotEmpty();
|
||||
}
|
||||
asUser(ADMIN);
|
||||
assertThat(new QueryAction().execute(new QueryInput(AssistantTurnEvent.TABLE_NAME)).getRecords()).as("only the sibling's turn event is left").hasSize(1);
|
||||
|
||||
Integer busy = conversation(EDITOR, "Busy");
|
||||
runningTurn(EDITOR, busy, Instant.now());
|
||||
asUser(EDITOR);
|
||||
DeleteOutput refused = new DeleteAction().execute(new DeleteInput(AssistantConversation.TABLE_NAME).withInputSource(QInputSource.USER).withPrimaryKeys(List.of(busy)));
|
||||
assertThat(refused.getRecordsWithErrors()).isNotEmpty();
|
||||
assertThat(store().getConversation(EDITOR, busy)).isNotNull();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Archive, restore and delete as standard record actions, only on rows the
|
||||
** user can see.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void bulkRecordActions() throws Exception
|
||||
{
|
||||
Integer one = conversation(EDITOR, "One");
|
||||
Integer two = conversation(EDITOR, "Two");
|
||||
Integer busy = conversation(EDITOR, "Busy");
|
||||
Integer theirs = conversation(OTHER_EDITOR, "Theirs");
|
||||
children(EDITOR, one);
|
||||
runningTurn(EDITOR, busy, Instant.now());
|
||||
|
||||
asUser(EDITOR);
|
||||
runProcess(ConversationActionsProcess.ARCHIVE, List.of(one, two, theirs));
|
||||
assertThat(store().getConversation(EDITOR, one).getStatus()).isEqualTo("ARCHIVED");
|
||||
assertThat(store().getConversation(EDITOR, two).getStatus()).isEqualTo("ARCHIVED");
|
||||
|
||||
runProcess(ConversationActionsProcess.RESTORE, List.of(two, theirs));
|
||||
assertThat(store().getConversation(EDITOR, two).getStatus()).isEqualTo("ACTIVE");
|
||||
assertThat(store().getConversation(EDITOR, one).getStatus()).isEqualTo("ARCHIVED");
|
||||
|
||||
runProcess(ConversationActionsProcess.DELETE, List.of(one, busy, theirs));
|
||||
assertThat(store().getConversation(EDITOR, one)).isNull();
|
||||
assertThat(rows(AssistantMessage.TABLE_NAME, one)).isEmpty();
|
||||
assertThat(store().getConversation(EDITOR, busy)).as("an answer in progress is not deleted").isNotNull();
|
||||
|
||||
asUser(ADMIN);
|
||||
AssistantConversation other = store().getConversation(OTHER_EDITOR, theirs);
|
||||
assertThat(other).isNotNull();
|
||||
assertThat(other.getStatus()).isEqualTo("ACTIVE");
|
||||
|
||||
////////////////////////////////////////////////////////
|
||||
// review and result summaries use the standard lines //
|
||||
////////////////////////////////////////////////////////
|
||||
assertThat(ConversationActionsProcess.processes()).extracting(QProcessMetaData::getLabel)
|
||||
.containsExactly("Archive conversations", "Restore conversations", "Delete conversations");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** GET /conversations items carry running; an orphaned run is not running.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void listShowsRunning() throws Exception
|
||||
{
|
||||
Integer live = conversation(EDITOR, "Live");
|
||||
Integer stale = conversation(EDITOR, "Stale");
|
||||
Integer idle = conversation(EDITOR, "Idle");
|
||||
runningTurn(EDITOR, live, Instant.now());
|
||||
runningTurn(EDITOR, stale, Instant.now().minusSeconds(3600));
|
||||
|
||||
AssistantApi api = asUser(EDITOR);
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> list = (List<Map<String, Object>>) api.listConversations(null).get("conversations");
|
||||
Map<Object, Object> running = list.stream().collect(Collectors.toMap(c -> c.get("id"), c -> c.get("running")));
|
||||
assertThat(running).containsEntry(live, true).containsEntry(stale, false).containsEntry(idle, false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** PATCH {"read": true} clears the unread count; anything else is a 400.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void patchRead() throws Exception
|
||||
{
|
||||
Integer id = conversation(EDITOR, "Unread");
|
||||
asUser(EDITOR);
|
||||
store().updateFields(AssistantConversation.TABLE_NAME, new QRecord().withValue("id", id).withValue("unreadCount", 3));
|
||||
|
||||
AssistantApi api = asUser(EDITOR);
|
||||
assertThat(api.patchConversation(id, Map.of("read", true))).containsEntry("unreadCount", 0);
|
||||
assertThat(store().getConversation(EDITOR, id).getUnreadCount()).isZero();
|
||||
assertThatThrownBy(() -> api.patchConversation(id, Map.of("read", false)))
|
||||
.isInstanceOfSatisfying(AssistantApiException.class, e -> assertThat(e.getStatus()).isEqualTo(400));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** GET /config names the conversation table only when the session may
|
||||
** query it.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void configNamesTheTable() throws Exception
|
||||
{
|
||||
assertThat(asUser(EDITOR).config()).containsEntry("conversationsTable", AssistantConversation.TABLE_NAME);
|
||||
assertThat(asUser(ADMIN).config()).containsEntry("conversationsTable", AssistantConversation.TABLE_NAME);
|
||||
assertThat(asUser(NO_ACCESS).config()).doesNotContainKey("conversationsTable");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A delete of someone else's row either errors or deletes nothing.
|
||||
*******************************************************************************/
|
||||
private void assertThatDeleteFailsOrDoesNothing(Integer id)
|
||||
{
|
||||
try
|
||||
{
|
||||
DeleteOutput output = new DeleteAction().execute(new DeleteInput(AssistantConversation.TABLE_NAME).withInputSource(QInputSource.USER).withPrimaryKeys(List.of(id)));
|
||||
assertThat(output.getDeletedRecordCount()).isZero();
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
//////////////////////////////////
|
||||
// refused outright: also fine //
|
||||
//////////////////////////////////
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run a record action as the current user over the given ids.
|
||||
*******************************************************************************/
|
||||
private void runProcess(String process, List<Integer> ids) throws Exception
|
||||
{
|
||||
RunProcessInput input = new RunProcessInput();
|
||||
input.setProcessName(process);
|
||||
input.setCallback(QProcessCallbackFactory.forPrimaryKeys("id", ids));
|
||||
input.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP);
|
||||
new RunProcessAction().execute(input);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Switch the context to a user's own session; returns an API bound to it.
|
||||
*******************************************************************************/
|
||||
private AssistantApi asUser(String userId)
|
||||
{
|
||||
QSession session = AssistantTestFixture.ownerSession(userId, config);
|
||||
QContext.init(qInstance, session);
|
||||
return (new AssistantApi(config.getRuntime(), session));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private AssistantStore store()
|
||||
{
|
||||
return (config.getRuntime().getStore());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A conversation owned by a user (created as an admin).
|
||||
*******************************************************************************/
|
||||
private Integer conversation(String owner, String title) throws Exception
|
||||
{
|
||||
asUser(ADMIN);
|
||||
return (store().createConversation(owner, title, null).getId());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One of each child row under a conversation.
|
||||
*******************************************************************************/
|
||||
private void children(String owner, Integer conversationId) throws Exception
|
||||
{
|
||||
asUser(ADMIN);
|
||||
AssistantMessage message = store().insert(new AssistantMessage().withConversationId(conversationId).withUserId(owner).withRole("USER").withStatus("COMPLETE").withSeq(1).withPartsJson("[]"));
|
||||
AssistantTurn turn = store().insert(new AssistantTurn().withConversationId(conversationId).withUserId(owner).withStatus("COMPLETE").withUserMessageId(message.getId()));
|
||||
store().insert(new AssistantTurnEvent().withTurnId(turn.getId()).withUserId(owner).withSeq(1).withEventType("message.end"));
|
||||
store().insert(new AssistantToolCall().withConversationId(conversationId).withTurnId(turn.getId()).withUserId(owner).withToolName("query"));
|
||||
store().insert(new AssistantFeedback().withConversationId(conversationId).withMessageId(message.getId()).withUserId(owner).withRating(1));
|
||||
store().insert(new AssistantPendingAction().withConversationId(conversationId).withTurnId(turn.getId()).withUserId(owner).withStatus(AssistantPendingAction.STATUS_PENDING));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A RUNNING turn on another (possibly dead) pod with the given heartbeat.
|
||||
*******************************************************************************/
|
||||
private void runningTurn(String owner, Integer conversationId, Instant heartbeat) throws Exception
|
||||
{
|
||||
asUser(ADMIN);
|
||||
store().insert(new AssistantTurn().withConversationId(conversationId).withUserId(owner).withStatus("RUNNING")
|
||||
.withStartedAt(heartbeat).withHeartbeatAt(heartbeat).withLeaseOwner("another-pod").withPhase("thinking"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Rows of a child table for one conversation (any owner).
|
||||
*******************************************************************************/
|
||||
private List<QRecord> rows(String table, Integer conversationId) throws QException
|
||||
{
|
||||
QSession previous = QContext.getQSession();
|
||||
asUser(ADMIN);
|
||||
try
|
||||
{
|
||||
return (new QueryAction().execute(new QueryInput(table).withFilter(new QQueryFilter().withCriteria("conversationId", QCriteriaOperator.EQUALS, conversationId))).getRecords());
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.init(qInstance, previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,6 +141,27 @@ class TurnEngineTest extends BaseAssistantTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The panel reloads the conversation as soon as message.end arrives, so the
|
||||
** question and the answer must already be stored then, not after it.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testMessagesAreStoredBeforeMessageEnd() throws Exception
|
||||
{
|
||||
provider.thenText("Four posts are published.");
|
||||
|
||||
TurnRunner.Outcome outcome = runner.run(EDITOR, "how many published posts?");
|
||||
assertThat(outcome.end()).containsEntry("status", "COMPLETE");
|
||||
|
||||
List<AssistantMessage> stored = runner.messagesAtEnd();
|
||||
assertThat(stored).hasSize(2);
|
||||
assertThat(stored).extracting(AssistantMessage::getPartsJson).allSatisfy(parts -> assertThat(parts).isNotEqualTo("[]"));
|
||||
assertThat(stored.get(0).getPartsJson()).contains("how many published posts?");
|
||||
assertThat(stored.get(1).getPartsJson()).contains("Four posts are published.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Parallel tool calls: all results in ONE user message, in order.
|
||||
*******************************************************************************/
|
||||
|
||||
+48
@@ -50,6 +50,8 @@ 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.QInputSource;
|
||||
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;
|
||||
@@ -166,6 +168,52 @@ class HipaaStorageTest extends BaseAssistantTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** User titles are plaintext unless they match this record's stored ciphertext.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void tableTitleUpdatesEncryptPrefixLookingPlaintext() throws Exception
|
||||
{
|
||||
AssistantStore store = setupProvider();
|
||||
String owner = AssistantTestFixture.ADMIN;
|
||||
AssistantConversation conversation = store.createConversation(owner, "Original title", null);
|
||||
Integer id = conversation.getId();
|
||||
for(String title : List.of("Renamed title", "enc:v1:literal private user text"))
|
||||
{
|
||||
var updated = new UpdateAction().execute(new UpdateInput(AssistantConversation.TABLE_NAME).withInputSource(QInputSource.USER)
|
||||
.withRecord(new QRecord().withValue("id", id).withValue("title", title)));
|
||||
assertThat(updated.getRecords()).hasSize(1).allSatisfy(record -> assertThat(record.getErrors()).isEmpty());
|
||||
assertThat(rawTitle(id)).startsWith("enc:v1:").isNotEqualTo(title).doesNotContain("private user text");
|
||||
assertThat(store.getConversation(owner, id).getTitle()).isEqualTo(title);
|
||||
}
|
||||
|
||||
String storedTitle = rawTitle(id);
|
||||
new UpdateAction().execute(new UpdateInput(AssistantConversation.TABLE_NAME).withInputSource(QInputSource.USER)
|
||||
.withRecord(new QRecord().withValue("id", id).withValue("title", storedTitle).withValue("pinned", true)));
|
||||
assertThat(rawTitle(id)).isEqualTo(storedTitle);
|
||||
assertThat(store.getConversation(owner, id).getTitle()).isEqualTo("enc:v1:literal private user text");
|
||||
assertThat(store.getConversation(owner, id).getPinned()).isTrue();
|
||||
|
||||
AssistantConversation second = store.createConversation(owner, "Another title", null);
|
||||
new UpdateAction().execute(new UpdateInput(AssistantConversation.TABLE_NAME).withInputSource(QInputSource.USER)
|
||||
.withRecord(new QRecord().withValue("id", second.getId()).withValue("title", storedTitle)));
|
||||
assertThat(rawTitle(second.getId())).isNotEqualTo(storedTitle);
|
||||
assertThat(store.getConversation(owner, second.getId()).getTitle()).isEqualTo(storedTitle);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Inspect stored bytes without the assistant store's decryption boundary.
|
||||
*******************************************************************************/
|
||||
private String rawTitle(Integer id) throws Exception
|
||||
{
|
||||
return (new QueryAction().execute(new QueryInput(AssistantConversation.TABLE_NAME)
|
||||
.withFilter(new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, id))).getRecords().get(0).getValueString("title"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Authentication failure never returns ciphertext or unauthenticated plaintext; legacy values still load.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -27,6 +27,7 @@ import com.kingsrook.qbits.assistant.AssistantRuntime;
|
||||
import com.kingsrook.qbits.assistant.api.AssistantApi;
|
||||
import com.kingsrook.qbits.assistant.engine.RunningTurn;
|
||||
import com.kingsrook.qbits.assistant.engine.TurnEvent;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantMessage;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
|
||||
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
@@ -47,6 +48,8 @@ public class TurnRunner
|
||||
private final QInstance qInstance;
|
||||
private final AssistantQBitConfig config;
|
||||
|
||||
private List<AssistantMessage> messagesAtEnd = List.of();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -169,6 +172,10 @@ public class TurnRunner
|
||||
{
|
||||
List<TurnEvent> batch = running.getLog().after(last, 1000);
|
||||
events.addAll(batch);
|
||||
if(batch.stream().anyMatch(e -> TurnEvent.MESSAGE_END.equals(e.getType())))
|
||||
{
|
||||
messagesAtEnd = messagesOf(userId, convId);
|
||||
}
|
||||
if(!batch.isEmpty())
|
||||
{
|
||||
last = batch.get(batch.size() - 1).getSeq();
|
||||
@@ -194,6 +201,36 @@ public class TurnRunner
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The conversation's stored messages, read the moment the run's message.end
|
||||
** was seen: what the panel finds when it reloads the conversation at the end
|
||||
** of the stream.
|
||||
*******************************************************************************/
|
||||
public List<AssistantMessage> messagesAtEnd()
|
||||
{
|
||||
return (messagesAtEnd);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The stored messages of a conversation (read as the user).
|
||||
*******************************************************************************/
|
||||
private List<AssistantMessage> messagesOf(String userId, Integer conversationId) throws Exception
|
||||
{
|
||||
QContext.init(qInstance, AssistantTestFixture.ownerSession(userId, config));
|
||||
try
|
||||
{
|
||||
return (new AssistantStore().listMessages(userId, conversationId, null, 50));
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The audited tool calls of a turn (read as the user).
|
||||
*******************************************************************************/
|
||||
|
||||
Reference in new issue
Block a user