diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c27843..e281885 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,7 +61,7 @@ - Add HIPAA mode, eligible-provider enforcement, AES-256-GCM conversation storage, provider diagnostic redaction, and independent PHI conversation retention. - Add owner-locked, admin read-only data routing audits and HIPAA status on the home. -- Keep QQQ RC.4 compatibility with a classification stand-in and core #1142 adapter seam. +- Classification levels are core's `DataClassification` (the qbit stand-in is removed); the default source reads core AI metadata merged with configuration. All notable changes to this project are documented here. The format follows Keep a Changelog, and versions follow semantic versioning. @@ -79,6 +79,11 @@ Phase 0/1 of the QQQ Assistant: read-only Q&A and navigation. Requires QQQ 4.1 w - the AI tool kernel (`qqq-backend-core` `actions.ai`, QRun-IO/qqq#1078) - the developer-panel contract (QRun-IO/qqq#1076). +### Changed (0.1.0-RC.7, needs QQQ with QRun-IO/qqq#1128) +- The assistant's endpoints are spec'd `/qqq/v1` operations (UI API extension `assistant` 1.0.0, prefix `/assistant`): same URLs, bodies and statuses, now in the served OpenAPI; turns and events are `text/event-stream`. The raw routes are gone. +- Tools call core's `UiApiInvoker` with the caller's own request credentials (`UiApiCredentials`); the loopback stand-in is removed. The assistant's own operations are never invocable by its tools. +- The OpenAPI fragment is committed (`openapi/qqq-v1/assistant.yaml`) and contract-tested. + ### Changed (0.1.0-RC.6): tools go through the UI API (owner decision 2026-10-13) - **Every data tool runs a spec'd `/qqq/v1` operation with the user's own session** (`UiApiGateway`, loopback HTTP with the `sessionUUID` cookie): the same authentication, permissions, record locks, validation, audit and errors as the UI. A user without a permission gets the identical denial through the assistant. `navigate` and `search_help` (no records) stay on the core kernel. `LoopbackUiApiGateway` is a stand-in for core's `UiApiInvoker` (QRun-IO/qqq#1128). - Tools: `list_tables`, `describe_table`, `query_records`, `count_records`, `get_record`, `search_records`, `render_widget`, `get_record_history`, and new **`create_record`, `update_record`, `delete_record`**. diff --git a/README.md b/README.md index 0bf9ff1..004b8c7 100644 --- a/README.md +++ b/README.md @@ -448,10 +448,10 @@ Apache 2.0. ## Data classification and HIPAA mode -On QQQ 4.1.0-RC.5, classify application tables/fields in the host configuration: +Classify application tables and fields in core's AI metadata (`AiTableMetaData` / `AiFieldMetaData`, QQQ 4.1.0-RC.6), or in the host configuration (a configured value wins when both are set): ```java -import com.kingsrook.qbits.assistant.classification.DataClassification; +import com.kingsrook.qqq.backend.core.actions.ai.DataClassification; AssistantQBitConfig config = new AssistantQBitConfig() .withDataClassification("patient", DataClassification.PHI) @@ -469,8 +469,8 @@ The existing server-side `withSecretResolver` hook also resolves this reference. Startup validates the key. HIPAA mode defaults off; encryption can also be enabled with the key reference while HIPAA mode is off. -The qbit `DataClassification` enum is a stand-in for core QRun-IO/qqq#1142, with -PUBLIC < CONFIDENTIAL < PII < PHI. Unconfigured tables default to PUBLIC; a field +The levels are core's `DataClassification` (PUBLIC < CONFIDENTIAL < PII < PHI); the +qbit's former stand-in enum is gone. Unconfigured tables default to PUBLIC; a field falls back to its table unless explicitly overridden. A field subset uses its selected fields; unrestricted reads and record page contexts use the highest classification across the table and its fields. Returned columns and record-label @@ -479,10 +479,9 @@ explicitly if the host considers it safe for that subset. Cross-table results wi tables use the highest classification anywhere. Metadata-only tools remain PUBLIC. The assistant's own tables remain denied to tools. -Hosts can supply `withClassificationSource(ClassificationSource)` instead. When -upgrading to an RC containing core #1142, implement that interface with -`AiDataClassifications`/`AiTableMetaData`/`AiFieldMetaData` (map enum names), then -replace the stand-in once the qbit's minimum core version includes it. +The default `ClassificationSource` reads core's metadata (`AiDataClassifications`, +`AiTableMetaData`, `AiFieldMetaData`) merged with the configured map; configuration +wins when both are set. Hosts can still supply `withClassificationSource(ClassificationSource)`. PHI is sent only to a persisted provider row explicitly marked `hipaaEligible`. Every model dispatch rechecks eligibility, including calls after tools and strict @@ -602,3 +601,20 @@ skills can extend the built-in presentation instructions. See the The image-breakdown golden task also runs in the existing opt-in live provider suite: `./mvnw verify -Dassistant.live=true -Dtest.excludedGroups= -Dgroups=live -Djacoco.skip=true -pl qbit-assistant-provider-anthropic -am` (with `ANTHROPIC_API_KEY` set). Live calls are excluded from normal verification. + +### Record history and the assistant channel + +Core's default `ClientChannelsMetaData` denies `audit*` to the `assistant` +channel. Accordingly, `get_record_history` is offered only when all three audit +tables it queries (`auditTable`, `audit`, and `auditDetail`) exist and are not +channel-denied, including their effective permission rules. A host that intends +to expose record history can opt in during instance configuration: + +```java +ClientChannelsMetaData.ofOrWithNew(qInstance) + .getDeniedPatterns().get("assistant").remove("audit*"); +``` + +This removes only the default audit pattern; other channel denials and the +signed-in user's audit permissions still apply. Keep explicit denials for audit +surfaces the host does not intend to expose. The default remains unchanged. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 40295e6..d28383b 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -143,7 +143,7 @@ Owner: *"the agent should have to go through the same API that the UIUX does. th - A request to the server's own port runs the identical pipeline by construction. It costs one local hop, which is milliseconds next to a model call. This matches core's design (`docs/UI-API-EXTENSIONS.md` ยง5, `UiApiInvoker`, QRun-IO/qqq#1128). -- `LoopbackUiApiGateway` is the stand-in until a QQQ release has the invoker. Then an adapter over `UiApiInvoker.forServer(server)` replaces it (or a host injects one with `withUiApiGateway`), and nothing else changes. +- `CoreUiApiGateway` adapts core's `UiApiInvoker` (the loopback stand-in is deleted). It labels calls with the client channel `assistant`, plus the turn id as detail (`assistant/`). Form params go to the invoker's `withFormParam`; extra headers (the bulk-load tools' `X-QQQ-AI-Route`) go through `HeaderedUiApiInvoker`, a small subclass in core's package because core's request has no `withHeader` yet. A host can still inject its own gateway with `withUiApiGateway`. - The base URL is `uiApiBaseUrl` if configured. Otherwise it is `127.0.0.1` plus the local port the first assistant request arrived on, so in a multi-replica deployment the call stays on the replica that holds the session's process state. ### The tools @@ -228,6 +228,18 @@ The golden and red-team evals now run through the UI API too. - Choosing *another* user would need that user's permissions without their session. QQQ core has no API to evaluate another user's permissions, and the assistant never builds or borrows a session. That part waits for a core API, so the report is always computed with the requester's own session. - **Recent denials:** the home's "Recent denials" card lists the newest tool calls the UI API, data access or the policy refused, with who, the table, the operation and the HTTP status, or the assistant setting that refused it. +## Spec'd assistant endpoints and the core invoker (phase d, QQQ 4.1.0-RC.6) + +- **The endpoints are spec'd `/qqq/v1` operations.** `api/specs/AssistantSpecs` registers the UI API extension `assistant` (semver 1.0.0, established prefix `/assistant`) through `MiddlewareSpecExtensionsMetaData`. + - The URLs, bodies and statuses stay the same (201 create, 202 cancel, 204 delete and feedback, and the assistant's `{"error","code"}` errors), so Next needs no change. + - They appear in the served `/qqq/v1/openapi.yaml`. The turn and events streams are declared as `text/event-stream`, with `AssistantTurnEventV1` describing the events. + - The fragment is committed as `qbit-assistant/src/main/resources/openapi/qqq-v1/assistant.yaml`. `AssistantSpecsContractTest` checks it against the code, and the release baseline in test resources, with core's `UiApiContract`. +- **No raw routes.** `AssistantRouteProvider` keeps only a before-handler that learns the server's local port from the first `/qqq/v1` request. +- **Tools call core's `UiApiInvoker`** (`CoreUiApiGateway`), and the loopback stand-in is removed. + - The request's credentials (`UiApiCredentials.fromContext`: session cookies, Authorization, API key, exactly as they came in) are captured by the spec that starts the turn, confirms an action or explains access. They are passed to the tools, so OAuth2, table auth and API-key sessions all work. They are never logged. + - The gateway also refuses the assistant's own operations, so a turn can never start a turn. +- QQQ 4.1.0-RC.6 (the default `qqq.snapshot.version`) contains #1128, so no local build is needed. + ## Owner decisions (2026-10-13), continued: write mode, the hard wall, audit attribution, providers These were added on the same day. All recommendations below are accepted; we adjust once they can be seen working. @@ -354,10 +366,8 @@ AI exposure NONE is not enough, because the assistant acts as the user and an ad ### P-c as built -- The qbit classification enum is a stand-in for core #1142 until a released RC - includes it. Configured table/field overrides are the default; hosts can supply - a `ClassificationSource` over core metadata, mapping enum names, then replace - the stand-in when the minimum QQQ version advances. +- The qbit classification stand-in is gone: the levels are core's `actions.ai.DataClassification` (QQQ 4.1.0-RC.6, core #1142). + The default `ClassificationSource` reads core metadata (`AiDataClassifications`, `AiTableMetaData`, `AiFieldMetaData`) merged with the configured table/field map; configuration wins when both are set. Hosts can still supply their own source with `withClassificationSource`. - Turn/page/tool classification is monotonic, persisted on the turn and retained on the conversation. Field subsets use overrides; unknown cross-table results conservatively use the highest configured level. Metadata tools stay PUBLIC. diff --git a/pom.xml b/pom.xml index 173f755..736bda5 100644 --- a/pom.xml +++ b/pom.xml @@ -194,8 +194,9 @@ - - + + + @@ -206,7 +207,7 @@ true - 4.1.0-RC.5 + 4.1.0-RC.6 diff --git a/qbit-assistant-provider-anthropic/src/test/java/com/kingsrook/qbits/assistant/provider/anthropic/LiveGoldenEvalTest.java b/qbit-assistant-provider-anthropic/src/test/java/com/kingsrook/qbits/assistant/provider/anthropic/LiveGoldenEvalTest.java index 95802d3..bfce31f 100644 --- a/qbit-assistant-provider-anthropic/src/test/java/com/kingsrook/qbits/assistant/provider/anthropic/LiveGoldenEvalTest.java +++ b/qbit-assistant-provider-anthropic/src/test/java/com/kingsrook/qbits/assistant/provider/anthropic/LiveGoldenEvalTest.java @@ -24,6 +24,7 @@ import com.kingsrook.qbits.assistant.testing.GoldenTasks; import com.kingsrook.qbits.assistant.testing.TurnRunner; import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.ClientChannelsMetaData; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Tag; @@ -102,6 +103,10 @@ class LiveGoldenEvalTest @MethodSource("tasks") void live(GoldenTasks.Task task) throws Exception { + if("D-page-12-this-week".equals(task.id())) + { + ClientChannelsMetaData.ofOrWithNew(qInstance).getDeniedPatterns().get("assistant").remove("audit*"); + } TurnRunner.Outcome outcome = new TurnRunner(qInstance, config).run(task.userId(), task.prompt()); assertThat(outcome.errorCode()).as("error").isNull(); task.liveCheck().accept(outcome); diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/AssistantQBitConfig.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/AssistantQBitConfig.java index 0c8b63c..0094a4a 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/AssistantQBitConfig.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/AssistantQBitConfig.java @@ -32,7 +32,6 @@ import java.util.regex.Pattern; import com.fasterxml.jackson.annotation.JsonIgnore; import com.kingsrook.qbits.assistant.classification.ClassificationSource; import com.kingsrook.qbits.assistant.classification.ConfiguredClassificationSource; -import com.kingsrook.qbits.assistant.classification.DataClassification; import com.kingsrook.qbits.assistant.engine.ModelPricing; import com.kingsrook.qbits.assistant.provider.AssistantModelProvider; import com.kingsrook.qbits.assistant.provider.AssistantProviderType; @@ -40,6 +39,7 @@ import com.kingsrook.qbits.assistant.provider.TrustedCaBundle; import com.kingsrook.qbits.assistant.rag.VectorStore; import com.kingsrook.qbits.assistant.uiapi.UiApiGateway; import com.kingsrook.qqq.backend.core.actions.ai.AiExposure; +import com.kingsrook.qqq.backend.core.actions.ai.DataClassification; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface; import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig; diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/AssistantRuntime.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/AssistantRuntime.java index 6da73ab..97c24a2 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/AssistantRuntime.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/AssistantRuntime.java @@ -38,9 +38,9 @@ import com.kingsrook.qbits.assistant.rag.RagService; import com.kingsrook.qbits.assistant.rag.VectorStore; import com.kingsrook.qbits.assistant.scheduled.ScheduledTurn; import com.kingsrook.qbits.assistant.tools.KnowledgeTool; -import com.kingsrook.qbits.assistant.tools.bulkload.BulkLoadTools; import com.kingsrook.qbits.assistant.tools.UiApiToolbox; -import com.kingsrook.qbits.assistant.uiapi.LoopbackUiApiGateway; +import com.kingsrook.qbits.assistant.tools.bulkload.BulkLoadTools; +import com.kingsrook.qbits.assistant.uiapi.CoreUiApiGateway; import com.kingsrook.qbits.assistant.uiapi.UiApiCall; import com.kingsrook.qbits.assistant.uiapi.UiApiGateway; import com.kingsrook.qbits.assistant.uiapi.UiApiResult; @@ -120,7 +120,7 @@ public class AssistantRuntime this.accessExplainer = new AccessExplainer(qInstance, dataAccessService); this.toolbox = new UiApiToolbox(qInstance, dataAccessService, exposurePolicy, new AiRedactor(settings.getCustomRedactionPatterns()), config.getMaxCellChars() == null ? AiToolKernelSettings.DEFAULT_MAX_CELL_CHARS : config.getMaxCellChars()); - UiApiGateway delegate = config.getUiApiGateway() != null ? config.getUiApiGateway() : new LoopbackUiApiGateway(this::uiApiBaseUrl, qInstance); + UiApiGateway delegate = config.getUiApiGateway() != null ? config.getUiApiGateway() : new CoreUiApiGateway(this::uiApiBaseUrl, qInstance); this.uiApiGateway = new UiApiGateway() { /******************************************************************************* @@ -141,11 +141,11 @@ public class AssistantRuntime String channel = scheduledTurn.channel(call.getSessionUUID()); if(channel != null) { - if(delegate instanceof LoopbackUiApiGateway loopback) + if(!(delegate instanceof CoreUiApiGateway)) { - return (loopback.invokeWithClient(call, channel)); + throw (new QException("Scheduled tasks require the core UI API gateway for client attribution.")); } - throw (new QException("Scheduled tasks require the loopback gateway for client attribution.")); + call.withClientDetail(channel.substring(CoreUiApiGateway.CLIENT_CHANNEL.length() + 1)); } return (delegate.invoke(call)); } diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/access/AccessExplainer.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/access/AccessExplainer.java index 3e522e0..5ccbd62 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/access/AccessExplainer.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/access/AccessExplainer.java @@ -31,6 +31,7 @@ import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import com.kingsrook.qqq.backend.core.utils.StringUtils; +import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials; /******************************************************************************* @@ -92,7 +93,17 @@ public class AccessExplainer *******************************************************************************/ public List explain(UiApiGateway gateway, String sessionUUID, PolicyService.ToolPolicy policy, String tableName, String prompt) throws QException { - UiApiResult response = gateway.invoke(new UiApiCall("GET", "/metaData").withSessionUUID(sessionUUID).withInvokedBy("assistant-explain")); + return (explain(gateway, sessionUUID, null, policy, tableName, prompt)); + } + + + + /******************************************************************************* + ** Explain, with the caller's request credentials when they are known. + *******************************************************************************/ + public List explain(UiApiGateway gateway, String sessionUUID, UiApiCredentials credentials, PolicyService.ToolPolicy policy, String tableName, String prompt) throws QException + { + UiApiResult response = gateway.invoke(new UiApiCall("GET", "/metaData").withSessionUUID(sessionUUID).withCredentials(credentials).withInvokedBy("assistant-explain")); if(!response.isSuccess()) { throw (new QException("GET /metaData answered HTTP " + response.status() + (response.errorMessage() == null ? "" : ": " + response.errorMessage()))); diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/AssistantApi.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/AssistantApi.java index 29cf24b..f76988f 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/AssistantApi.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/AssistantApi.java @@ -55,6 +55,7 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.session.QSession; import com.kingsrook.qqq.backend.core.utils.JsonUtils; import com.kingsrook.qqq.backend.core.utils.StringUtils; +import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials; /******************************************************************************* @@ -71,6 +72,7 @@ public class AssistantApi private final AssistantStore store; private final QSession session; private final String userId; + private UiApiCredentials credentials; @@ -93,6 +95,18 @@ public class AssistantApi + /******************************************************************************* + ** The credentials of the request this API serves, exactly as they came in; + ** the tools forward them to the UI API. Never logged. + *******************************************************************************/ + public AssistantApi withCredentials(UiApiCredentials credentials) + { + this.credentials = credentials; + return (this); + } + + + /******************************************************************************* ** GET /config - the feature probe. Never throws for a signed-in user. *******************************************************************************/ @@ -373,7 +387,7 @@ public class AssistantApi RunningTurn runningTurn = runtime.register(new RunningTurn(turn.getId(), userId)); TurnEngine engine = new TurnEngine(runtime, session, userId, conversation, userMessage, assistantMessage, turn, text, pageContext, policy, runningTurn, - budget.softExceeded() ? budget.message() : null); + budget.softExceeded() ? budget.message() : null).withCredentials(credentials); runtime.getExecutor().submit(engine); return (runningTurn); } @@ -598,7 +612,7 @@ public class AssistantApi throw (new QException("The stored values of action " + id + " are not valid JSON", e)); } PolicyService.EffectivePolicy policy = runtime.getPolicyService().current(true); - UiApiToolbox.Context context = new UiApiToolbox.Context(session.getUuid(), session.getValue(QSession.VALUE_KEY_USER_TIMEZONE), runtime.getUiApiGateway(), policy.tools(), null); + UiApiToolbox.Context context = new UiApiToolbox.Context(session.getUuid(), session.getValue(QSession.VALUE_KEY_USER_TIMEZONE), runtime.getUiApiGateway(), policy.tools(), null, credentials, action.getTurnId() == null ? null : String.valueOf(action.getTurnId())); UiApiToolbox.Outcome outcome = BulkLoadTools.WRITE_TOOLS.contains(action.getToolName()) ? runtime.getBulkLoadTools().executeConfirmed(context, action.getToolCallId(), action.getToolName(), action.getTableName(), values) : runtime.getToolbox().executeConfirmedWrite(context, action.getToolCallId(), action.getToolName(), action.getTableName(), action.getRecordId(), values); @@ -722,7 +736,7 @@ public class AssistantApi { requireAccess(); List> tables = new ArrayList<>(); - for(AccessExplainer.TableAccess access : runtime.getAccessExplainer().explain(runtime.getUiApiGateway(), session.getUuid(), runtime.getPolicyService().current(false).tools(), table, prompt)) + for(AccessExplainer.TableAccess access : runtime.getAccessExplainer().explain(runtime.getUiApiGateway(), session.getUuid(), credentials, runtime.getPolicyService().current(false).tools(), table, prompt)) { if(!access.uiVisible() && access.mode() == DataAccessMode.NONE) { diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/AssistantRouteProvider.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/AssistantRouteProvider.java index 16ff446..0a2e15c 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/AssistantRouteProvider.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/AssistantRouteProvider.java @@ -17,63 +17,27 @@ package com.kingsrook.qbits.assistant.api; -import java.io.IOException; -import java.io.OutputStream; -import java.nio.charset.StandardCharsets; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.DeserializationFeature; import com.kingsrook.qbits.assistant.AssistantRuntime; -import com.kingsrook.qbits.assistant.engine.RunningTurn; -import com.kingsrook.qbits.assistant.engine.TurnEvent; -import com.kingsrook.qbits.assistant.engine.TurnEventLog; -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.metadata.QInstance; -import com.kingsrook.qqq.backend.core.model.session.QSession; -import com.kingsrook.qqq.backend.core.utils.JsonUtils; -import com.kingsrook.qqq.backend.core.utils.StringUtils; import com.kingsrook.qqq.middleware.javalin.QJavalinRouteProviderInterface; -import com.kingsrook.qqq.middleware.javalin.QJavalinUtils; -import com.kingsrook.qqq.middleware.javalin.executors.ExecutorSessionUtils; import io.javalin.config.JavalinConfig; -import io.javalin.http.ContentType; -import io.javalin.http.Context; -import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; /******************************************************************************* - ** Javalin routes under /qqq/v1/assistant (contract section 3). - ** - ** Every request gets the user's own session from the instance's - ** authentication module (ExecutorSessionUtils.setupSession: session cookie, - ** bearer token or API key) - never a system session. Bodies must be JSON - ** (a cross-site form cannot send that without a CORS preflight). - ** - ** Streams (POST /conversations/{id}/turns, GET /turns/{id}/events) are - ** Server-Sent Events written to the response as they happen: "id: seq", - ** "event: type", "data: json", a blank line; a ping every 15 seconds; - ** Cache-Control no-cache and X-Accel-Buffering no (proxies must not - ** buffer; give the route a long idle timeout). The turn itself runs on its - ** own thread, so a client disconnect does not stop it; the client resumes - ** with /events?after=. - ** - ** Request and response bodies are never logged. + ** No routes any more: the assistant's endpoints are spec'd /qqq/v1 + ** operations ({@link com.kingsrook.qbits.assistant.api.specs.AssistantSpecs}). + ** What is left is a before-handler that learns this server's local port from + ** the first /qqq/v1 request, so the tools (and Explain access) can call the + ** UI API on 127.0.0.1 before any assistant operation has run. *******************************************************************************/ public class AssistantRouteProvider implements QJavalinRouteProviderInterface { private static final QLogger LOG = QLogger.getLogger(AssistantRouteProvider.class); - public static final String BASE_PATH = "/qqq/v1/assistant"; - public static final long PING_MILLIS = 15_000; - public static final long REMOTE_POLL_MS = 1_000; + public static final String BASE_PATH = "/qqq/v1/assistant"; private QInstance qInstance; - private long pingMillis = PING_MILLIS; @@ -88,28 +52,12 @@ public class AssistantRouteProvider implements QJavalinRouteProviderInterface - /******************************************************************************* - ** Ping interval (tests use a short one). - *******************************************************************************/ - public AssistantRouteProvider withPingMillis(long pingMillis) - { - this.pingMillis = pingMillis; - return (this); - } - - - /******************************************************************************* ** *******************************************************************************/ @Override public void acceptJavalinConfig(JavalinConfig config) { - /////////////////////////////////////////////////////////////////////// - // learn this server's local port from the first UI request (e.g. // - // the metaData load), so tools and Explain access can loop back to // - // /qqq/v1 before any assistant route has been called // - /////////////////////////////////////////////////////////////////////// config.routes.before("/qqq/v1/*", ctx -> { AssistantRuntime runtime = AssistantRuntime.of(qInstance); @@ -125,412 +73,6 @@ public class AssistantRouteProvider implements QJavalinRouteProviderInterface } } }); - config.routes.get(BASE_PATH + "/config", ctx -> json(ctx, false, (api, body) -> api.config())); - config.routes.get(BASE_PATH + "/conversations", ctx -> json(ctx, false, (api, body) -> api.listConversations(ctx.queryParam("status")))); - config.routes.post(BASE_PATH + "/conversations", ctx -> json(ctx, true, (api, body) -> - { - ctx.status(201); - return (api.createConversation(body)); - })); - config.routes.get(BASE_PATH + "/conversations/{id}", ctx -> json(ctx, false, (api, body) -> api.getConversation(pathInt(ctx, "id"), queryInt(ctx, "before"), queryInt(ctx, "limit")))); - config.routes.patch(BASE_PATH + "/conversations/{id}", ctx -> json(ctx, true, (api, body) -> api.patchConversation(pathInt(ctx, "id"), body))); - config.routes.delete(BASE_PATH + "/conversations/{id}", ctx -> json(ctx, false, (api, body) -> - { - api.deleteConversation(pathInt(ctx, "id")); - ctx.status(204); - return (null); - })); - config.routes.post(BASE_PATH + "/conversations/{id}/turns", this::startTurn); - config.routes.get(BASE_PATH + "/turns/{turnId}/events", this::turnEvents); - config.routes.post(BASE_PATH + "/turns/{turnId}/cancel", ctx -> json(ctx, true, (api, body) -> - { - ctx.status(202); - return (api.cancelTurn(pathInt(ctx, "turnId"))); - })); - config.routes.get(BASE_PATH + "/access", ctx -> json(ctx, false, (api, body) -> api.access(ctx.queryParam("table"), ctx.queryParam("prompt")))); - config.routes.get(BASE_PATH + "/actions/{id}", ctx -> json(ctx, false, (api, body) -> api.getAction(pathInt(ctx, "id")))); - config.routes.post(BASE_PATH + "/actions/{id}/confirm", ctx -> json(ctx, true, (api, body) -> api.confirmAction(pathInt(ctx, "id")))); - config.routes.post(BASE_PATH + "/actions/{id}/reject", ctx -> json(ctx, true, (api, body) -> api.rejectAction(pathInt(ctx, "id")))); - config.routes.post(BASE_PATH + "/messages/{id}/feedback", ctx -> json(ctx, true, (api, body) -> - { - api.feedback(pathInt(ctx, "id"), body); - ctx.status(204); - return (null); - })); - } - - - - /******************************************************************************* - ** Session, runtime, body, handler, JSON - or the error. - *******************************************************************************/ - private void json(Context context, boolean readsBody, Handler handler) - { - try - { - AssistantApi api = api(context); - Map body = readsBody ? readJsonBody(context) : Map.of(); - Object result = handler.handle(api, body); - if(result != null) - { - context.contentType(ContentType.APPLICATION_JSON); - context.result(JsonUtils.toJsonCustomized(result, builder -> builder.serializationInclusion(JsonInclude.Include.ALWAYS))); - } - } - catch(Exception e) - { - handleException(context, e); - } - finally - { - QContext.clear(); - } - } - - - - /******************************************************************************* - ** POST /conversations/{id}/turns - *******************************************************************************/ - private void startTurn(Context context) - { - RunningTurn turn; - try - { - AssistantApi api = api(context); - turn = api.startTurn(pathInt(context, "id"), readJsonBody(context)); - } - catch(Exception e) - { - handleException(context, e); - QContext.clear(); - return; - } - QContext.clear(); - streamLive(context, turn.getLog(), 0); - } - - - - /******************************************************************************* - ** GET /turns/{turnId}/events?after= - *******************************************************************************/ - private void turnEvents(Context context) - { - Integer turnId; - int after; - AssistantApi api; - AssistantApi.TurnEvents events; - try - { - api = api(context); - turnId = pathInt(context, "turnId"); - Integer afterParam = queryInt(context, "after"); - after = afterParam == null ? 0 : Math.max(0, afterParam); - events = api.turnEvents(turnId, after); - } - catch(Exception e) - { - handleException(context, e); - QContext.clear(); - return; - } - - if(events.live() != null) - { - QContext.clear(); - streamLive(context, events.live().getLog(), after); - return; - } - - /////////////////////////////////////////////////////////////////////// - // persisted events; if the turn still runs on another replica, poll // - /////////////////////////////////////////////////////////////////////// - try - { - OutputStream out = startStream(context); - int last = after; - long lastWrite = System.currentTimeMillis(); - long giveUpAt = System.currentTimeMillis() + (api.maxTurnSeconds() + 60L) * 1000L; - AssistantApi.TurnEvents current = events; - while(true) - { - boolean ended = false; - for(TurnEvent event : current.persisted()) - { - write(out, event.toSseFrame()); - last = event.getSeq(); - lastWrite = System.currentTimeMillis(); - ended |= event.isTerminal(); - } - if(ended || (current.finished() && !current.more()) || System.currentTimeMillis() > giveUpAt) - { - return; - } - if(!current.more()) - { - Thread.sleep(REMOTE_POLL_MS); - } - if(System.currentTimeMillis() - lastWrite >= pingMillis) - { - write(out, ping()); - lastWrite = System.currentTimeMillis(); - } - current = api.turnEvents(turnId, last); - if(current.live() != null) - { - QContext.clear(); - writeLive(out, current.live().getLog(), last); - return; - } - } - } - catch(IOException e) - { - LOG.debug("Assistant event stream closed by the client", logPair("turnId", turnId)); - } - catch(InterruptedException e) - { - Thread.currentThread().interrupt(); - } - catch(Exception e) - { - LOG.warn("Assistant event replay failed", e, logPair("turnId", turnId)); - } - finally - { - QContext.clear(); - } - } - - - - /******************************************************************************* - ** Stream a live log from a sequence number until message.end. - *******************************************************************************/ - private void streamLive(Context context, TurnEventLog log, int after) - { - try - { - writeLive(startStream(context), log, after); - } - catch(IOException e) - { - LOG.debug("Assistant stream closed by the client (the turn continues)"); - } - catch(InterruptedException e) - { - Thread.currentThread().interrupt(); - } - } - - - - /******************************************************************************* - ** - *******************************************************************************/ - private void writeLive(OutputStream out, TurnEventLog log, int after) throws IOException, InterruptedException - { - int last = after; - while(true) - { - List events = log.after(last, pingMillis); - if(events.isEmpty()) - { - if(log.isClosed()) - { - return; - } - write(out, ping()); - continue; - } - for(TurnEvent event : events) - { - write(out, event.toSseFrame()); - last = event.getSeq(); - if(event.isTerminal()) - { - return; - } - } - } - } - - - - /******************************************************************************* - ** Start an SSE response. - *******************************************************************************/ - private static OutputStream startStream(Context context) throws IOException - { - context.status(200); - context.disableCompression(); - context.res().setContentType("text/event-stream; charset=utf-8"); - context.res().setHeader("Cache-Control", "no-cache"); - context.res().setHeader("X-Accel-Buffering", "no"); - context.res().setHeader("Connection", "keep-alive"); - OutputStream out = context.res().getOutputStream(); - out.flush(); - return (out); - } - - - - /******************************************************************************* - ** - *******************************************************************************/ - private static void write(OutputStream out, String frame) throws IOException - { - out.write(frame.getBytes(StandardCharsets.UTF_8)); - out.flush(); - } - - - - /******************************************************************************* - ** - *******************************************************************************/ - private static String ping() - { - return ("event: " + TurnEvent.PING + "\ndata: {}\n\n"); - } - - - - /******************************************************************************* - ** Session + API for a request. 404 when the QBit is not in the instance. - *******************************************************************************/ - private AssistantApi api(Context context) throws Exception - { - AssistantRuntime runtime = AssistantRuntime.of(qInstance); - if(runtime == null) - { - throw (new AssistantApiException(404, "The assistant is not installed.")); - } - //////////////////////////////////////////////////////////////////// - // the tools call /qqq/v1 on this server: remember its local port // - //////////////////////////////////////////////////////////////////// - try - { - runtime.learnLocalPort(context.req().getLocalPort()); - } - catch(RuntimeException e) - { - LOG.debug("Could not read the local port", e); - } - QSession session = ExecutorSessionUtils.setupSession(context, qInstance); - return (new AssistantApi(runtime, session)); - } - - - - /******************************************************************************* - ** Map errors: API errors with their status, others through QQQ's handler - ** (401 / 403 / 404 / 500). - *******************************************************************************/ - private static void handleException(Context context, Exception e) - { - if(e instanceof AssistantApiException apiException) - { - Map body = new LinkedHashMap<>(); - body.put("error", apiException.getMessage()); - if(apiException.getCode() != null) - { - body.put("code", apiException.getCode()); - } - context.status(apiException.getStatus()); - context.contentType(ContentType.APPLICATION_JSON); - context.result(JsonUtils.toJson(body)); - return; - } - if(!(e instanceof QException)) - { - LOG.warn("Assistant request failed", e); - } - QJavalinUtils.handleException(null, context, e); - } - - - - /******************************************************************************* - ** A JSON object body (empty body = empty object). - *******************************************************************************/ - static Map readJsonBody(Context context) throws AssistantApiException - { - String body = context.body(); - if(!StringUtils.hasContent(body)) - { - return (new LinkedHashMap<>()); - } - - String contentType = context.contentType(); - if(contentType == null || !contentType.toLowerCase().startsWith("application/json")) - { - throw (new AssistantApiException(400, "Request bodies must be JSON (Content-Type: application/json).")); - } - - try - { - Map map = JsonUtils.toObject(body, new TypeReference>() {}, mapper -> mapper.enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS)); - return (map == null ? new LinkedHashMap<>() : map); - } - catch(Exception e) - { - throw (new AssistantApiException(400, "The request body is not a JSON object.")); - } - } - - - - /******************************************************************************* - ** - *******************************************************************************/ - private static Integer pathInt(Context context, String name) throws AssistantApiException - { - try - { - return (Integer.parseInt(context.pathParam(name))); - } - catch(NumberFormatException e) - { - throw (new AssistantApiException(400, name + " must be an integer.")); - } - } - - - - /******************************************************************************* - ** - *******************************************************************************/ - private static Integer queryInt(Context context, String name) throws AssistantApiException - { - String value = context.queryParam(name); - if(!StringUtils.hasContent(value)) - { - return (null); - } - try - { - return (Integer.parseInt(value.trim())); - } - catch(NumberFormatException e) - { - throw (new AssistantApiException(400, name + " must be an integer.")); - } - } - - - - /******************************************************************************* - ** One JSON endpoint's work. - *******************************************************************************/ - @FunctionalInterface - private interface Handler - { - /******************************************************************************* - ** - *******************************************************************************/ - Object handle(AssistantApi api, Map body) throws Exception; } } diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/AssistantRoutes.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/AssistantRoutes.java index be76268..e5a1728 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/AssistantRoutes.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/AssistantRoutes.java @@ -19,7 +19,9 @@ package com.kingsrook.qbits.assistant.api; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qbits.assistant.api.specs.AssistantSpecs; import com.kingsrook.qqq.backend.core.utils.CollectionUtils; +import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData; import com.kingsrook.qqq.middleware.javalin.QJavalinMetaData; @@ -43,6 +45,16 @@ public class AssistantRoutes { javalinMetaData.withAdditionalRouteProviderReference(new QCodeReference(AssistantRouteProvider.class)); } + + //////////////////////////////////////////////////////////////////// + // the endpoints: the spec'd "assistant" UI API extension // + //////////////////////////////////////////////////////////////////// + MiddlewareSpecExtensionsMetaData extensions = MiddlewareSpecExtensionsMetaData.ofOrWithNew(qInstance); + boolean hasExtension = extensions.getExtensions() != null && extensions.getExtensions().containsKey(AssistantSpecs.EXTENSION_NAME); + if(!hasExtension) + { + extensions.withExtension(AssistantSpecs.extension()); + } } } diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/TurnStreams.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/TurnStreams.java new file mode 100644 index 0000000..9e9ccb3 --- /dev/null +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/TurnStreams.java @@ -0,0 +1,222 @@ +/* + * 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.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import com.kingsrook.qbits.assistant.engine.TurnEvent; +import com.kingsrook.qbits.assistant.engine.TurnEventLog; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import io.javalin.http.Context; +import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; + + +/******************************************************************************* + ** Writes a turn's events as server-sent events: the live log of a turn + ** running here, or the persisted events (polling while the turn runs on + ** another replica), with a ping every pingMillis while idle. + *******************************************************************************/ +public final class TurnStreams +{ + private static final QLogger LOG = QLogger.getLogger(TurnStreams.class); + + public static final long PING_MILLIS = 15_000; + public static final long REMOTE_POLL_MS = 1_000; + + private static volatile long pingMillis = PING_MILLIS; + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private TurnStreams() + { + } + + + + /******************************************************************************* + ** The ping interval (tests use a short one). + *******************************************************************************/ + public static void setPingMillis(long millis) + { + pingMillis = millis; + } + + + + /******************************************************************************* + ** Stream a live log from a sequence number until message.end. + *******************************************************************************/ + public static void streamLive(Context context, TurnEventLog log, int after) + { + try + { + writeLive(startStream(context), log, after); + } + catch(IOException e) + { + LOG.debug("Assistant stream closed by the client (the turn continues)"); + } + catch(InterruptedException e) + { + Thread.currentThread().interrupt(); + } + } + + + + /******************************************************************************* + ** Stream the events of a turn after a sequence number: live when it runs + ** here, else the persisted ones (polling while it runs elsewhere). The + ** caller's QContext (session) must be set: the replay reads the store. + *******************************************************************************/ + public static void streamEvents(Context context, AssistantApi api, Integer turnId, int after, AssistantApi.TurnEvents first) + { + if(first.live() != null) + { + streamLive(context, first.live().getLog(), after); + return; + } + try + { + OutputStream out = startStream(context); + int last = after; + long lastWrite = System.currentTimeMillis(); + long giveUpAt = System.currentTimeMillis() + (api.maxTurnSeconds() + 60L) * 1000L; + AssistantApi.TurnEvents current = first; + while(true) + { + boolean ended = false; + for(TurnEvent event : current.persisted()) + { + write(out, event.toSseFrame()); + last = event.getSeq(); + lastWrite = System.currentTimeMillis(); + ended |= event.isTerminal(); + } + if(ended || (current.finished() && !current.more()) || System.currentTimeMillis() > giveUpAt) + { + return; + } + if(!current.more()) + { + Thread.sleep(REMOTE_POLL_MS); + } + if(System.currentTimeMillis() - lastWrite >= pingMillis) + { + write(out, ping()); + lastWrite = System.currentTimeMillis(); + } + current = api.turnEvents(turnId, last); + if(current.live() != null) + { + writeLive(out, current.live().getLog(), last); + return; + } + } + } + catch(IOException e) + { + LOG.debug("Assistant event stream closed by the client", logPair("turnId", turnId)); + } + catch(InterruptedException e) + { + Thread.currentThread().interrupt(); + } + catch(Exception e) + { + LOG.warn("Assistant event replay failed", e, logPair("turnId", turnId)); + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static void writeLive(OutputStream out, TurnEventLog log, int after) throws IOException, InterruptedException + { + int last = after; + while(true) + { + List events = log.after(last, pingMillis); + if(events.isEmpty()) + { + if(log.isClosed()) + { + return; + } + write(out, ping()); + continue; + } + for(TurnEvent event : events) + { + write(out, event.toSseFrame()); + last = event.getSeq(); + if(event.isTerminal()) + { + return; + } + } + } + } + + + + /******************************************************************************* + ** Start an SSE response. + *******************************************************************************/ + private static OutputStream startStream(Context context) throws IOException + { + context.status(200); + context.disableCompression(); + context.res().setContentType("text/event-stream; charset=utf-8"); + context.res().setHeader("Cache-Control", "no-cache"); + context.res().setHeader("X-Accel-Buffering", "no"); + context.res().setHeader("Connection", "keep-alive"); + OutputStream out = context.res().getOutputStream(); + out.flush(); + return (out); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static void write(OutputStream out, String frame) throws IOException + { + out.write(frame.getBytes(StandardCharsets.UTF_8)); + out.flush(); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static String ping() + { + return ("event: " + TurnEvent.PING + "\ndata: {}\n\n"); + } + +} diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/specs/AssistantSpecs.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/specs/AssistantSpecs.java new file mode 100644 index 0000000..518e982 --- /dev/null +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/api/specs/AssistantSpecs.java @@ -0,0 +1,1336 @@ +/* + * 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.specs; + + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.kingsrook.qbits.assistant.AssistantRuntime; +import com.kingsrook.qbits.assistant.api.AssistantApi; +import com.kingsrook.qbits.assistant.api.AssistantApiException; +import com.kingsrook.qbits.assistant.api.TurnStreams; +import com.kingsrook.qbits.assistant.engine.RunningTurn; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException; +import com.kingsrook.qqq.backend.core.utils.ExceptionUtils; +import com.kingsrook.qqq.backend.core.utils.JsonUtils; +import com.kingsrook.qqq.backend.core.utils.StringUtils; +import com.kingsrook.qqq.middleware.javalin.QJavalinUtils; +import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation; +import com.kingsrook.qqq.middleware.javalin.specs.BasicResponse; +import com.kingsrook.qqq.middleware.javalin.specs.extensions.AbstractJsonEndpointSpec; +import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtension; +import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials; +import com.kingsrook.qqq.openapi.model.HttpMethod; +import com.kingsrook.qqq.openapi.model.In; +import com.kingsrook.qqq.openapi.model.Parameter; +import com.kingsrook.qqq.openapi.model.Schema; +import com.kingsrook.qqq.openapi.model.Type; +import io.javalin.http.ContentType; +import io.javalin.http.Context; +import io.javalin.http.HttpStatus; + + +/******************************************************************************* + ** The assistant's own endpoints as spec'd /qqq/v1 operations (UI API + ** extension "assistant", established prefix /assistant; QRun-IO/qqq#1128, + ** docs/UI-API-EXTENSIONS.md). They replace the raw AssistantRouteProvider + ** routes, with the same URLs and bodies, so they appear in the served + ** /qqq/v1/openapi.yaml and a generated client covers them. + ** + ** Errors keep the assistant's shape: HTTP status plus {"error", "code"}. + ** Turns and events are text/event-stream; each event type is documented as + ** AssistantTurnEventV1. + *******************************************************************************/ +public final class AssistantSpecs +{ + public static final String EXTENSION_NAME = "assistant"; + public static final String EXTENSION_VERSION = "1.0.0"; + public static final String PREFIX = "/assistant"; + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private AssistantSpecs() + { + } + + + + /******************************************************************************* + ** The extension to register in MiddlewareSpecExtensionsMetaData. + *******************************************************************************/ + public static MiddlewareSpecExtension extension() + { + return (new MiddlewareSpecExtension() + .withName(EXTENSION_NAME) + .withSemanticVersion(EXTENSION_VERSION) + .withPathPrefix(PREFIX) + .withDescription("QQQ Assistant: conversations, streamed turns, feedback, pending actions and data access explanations") + .withSpec(ConfigSpec.class) + .withSpec(ListConversationsSpec.class) + .withSpec(CreateConversationSpec.class) + .withSpec(GetConversationSpec.class) + .withSpec(UpdateConversationSpec.class) + .withSpec(DeleteConversationSpec.class) + .withSpec(StartTurnSpec.class) + .withSpec(TurnEventsSpec.class) + .withSpec(CancelTurnSpec.class) + .withSpec(FeedbackSpec.class) + .withSpec(GetActionSpec.class) + .withSpec(ConfirmActionSpec.class) + .withSpec(RejectActionSpec.class) + .withSpec(AccessSpec.class)); + } + + + + //////////////////////////////////////////////////////////////////////////// + // schemas // + //////////////////////////////////////////////////////////////////////////// + + /******************************************************************************* + ** + *******************************************************************************/ + static Schema object(String description, Object... namesAndSchemas) + { + Map properties = new LinkedHashMap<>(); + for(int i = 0; i + 1 < namesAndSchemas.length; i += 2) + { + properties.put((String) namesAndSchemas[i], (Schema) namesAndSchemas[i + 1]); + } + return (new Schema().withType(Type.OBJECT).withDescription(description).withProperties(properties)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + static Schema string(String description) + { + return (new Schema().withType(Type.STRING).withDescription(description)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + static Schema integer(String description) + { + return (new Schema().withType(Type.INTEGER).withDescription(description)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + static Schema bool(String description) + { + return (new Schema().withType(Type.BOOLEAN).withDescription(description)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + static Schema array(Schema items, String description) + { + return (new Schema().withType(Type.ARRAY).withItems(items).withDescription(description)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + static Schema ref(String component) + { + return (new Schema().withRefToSchema(component)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + static Schema freeObject(String description) + { + return (new Schema().withType(Type.OBJECT).withDescription(description).withAdditionalProperties(true)); + } + + + + /******************************************************************************* + ** Every component schema of the extension (each spec publishes all of them; + ** the OpenAPI model merges equal components). + *******************************************************************************/ + static Map components() + { + Map rs = new LinkedHashMap<>(); + rs.put("AssistantProfileV1", object("How the assistant presents itself (admin-configured; text only, it never changes access).", + "displayName", string("The assistant's name."), "icon", string("A Material icon name."), "greeting", string("Shown on a new conversation.").withNullable(true), + "starterPrompts", array(string(null), "Suggested first questions."))); + 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), + "shortcut", string("Keyboard shortcut, e.g. mod+j."), + "model", string("The model that answers."), + "limits", freeObject("maxTurnSeconds and maxToolCallsPerTurn."), + "features", freeObject("writes: some table allows writes; pendingActions: writes wait for confirmation."), + "profile", ref("AssistantProfileV1"))); + rs.put("AssistantConversationV1", object("One of the user's own conversations.", + "id", integer(null), "title", string(null).withNullable(true), "status", string("ACTIVE or ARCHIVED."), "pinned", bool(null), + "lastMessageAt", string("ISO-8601 instant.").withNullable(true), "unreadCount", integer(null), "createDate", string("ISO-8601 instant.").withNullable(true))); + rs.put("AssistantConversationListResponseV1", object("The user's conversations, newest first.", "conversations", array(ref("AssistantConversationV1"), null))); + rs.put("AssistantMessageV1", object("A message: the user's text or the assistant's reply as parts (text, tool, record_refs, navigate, notice).", + "id", integer(null), "role", string("USER or ASSISTANT."), "status", string(null), "turnId", integer(null).withNullable(true), + "createDate", string(null).withNullable(true), "parts", array(freeObject("A message part."), null))); + rs.put("AssistantConversationDetailResponseV1", object("A conversation and a page of its messages, oldest first.", + "conversation", ref("AssistantConversationV1"), "messages", array(ref("AssistantMessageV1"), null))); + rs.put("AssistantStatusResponseV1", object("An acknowledgement.", "status", string(null))); + rs.put("AssistantActionV1", object("A write the assistant proposed and its state.", + "id", integer(null), "status", string("PENDING, RUNNING, EXECUTED, FAILED, REJECTED or EXPIRED."), "toolName", string(null), "tableName", string(null), + "recordId", string(null).withNullable(true), "summary", string(null), "httpStatus", integer("The UI API status of the write.").withNullable(true), + "resultSummary", string(null).withNullable(true), "expiresAt", string(null).withNullable(true), + "outcome", string("After a confirm: the tool outcome.").withNullable(true), "message", string("After a confirm: what happened.").withNullable(true), + "href", string("After a confirm: the written record's /app link.").withNullable(true))); + rs.put("AssistantAccessResponseV1", object("Explain access: per table, the user's UI permissions, the assistant's data access, and each tool's verdict.", + "tables", array(freeObject("table, label, dataAccess, dataAccessSource, ui {visible, read, insert, edit, delete}, operations [{tool, operation, allowed, reason}]."), null))); + rs.put("AssistantTurnEventV1", object("One server-sent event of a turn (event: , id: , data: this object). Types: message.start, text.delta, tool.start, tool.result, ui.navigate, notice, usage, message.end, error, ping.", + "seq", integer("Sequence number; resume with ?after=."), "type", string("The event type (also the SSE event name)."), + "data", freeObject("The event's fields, by type."))); + rs.put("AssistantErrorV1", object("An error.", "error", string("A message for the user."), "code", string("BUDGET, RATE_LIMIT, NOT_CONFIGURED, KILL_SWITCH, DISABLED, NO_PERMISSION...").withNullable(true))); + return (rs); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + static Parameter pathParam(String name, String description) + { + return (new Parameter().withName(name).withDescription(description).withRequired(true).withSchema(new Schema().withType(Type.INTEGER)).withIn(In.PATH)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + static Parameter queryParam(String name, Type type, String description) + { + return (new Parameter().withName(name).withDescription(description).withRequired(false).withSchema(new Schema().withType(type)).withIn(In.QUERY)); + } + + + + //////////////////////////////////////////////////////////////////////////// + // base // + //////////////////////////////////////////////////////////////////////////// + + /******************************************************************************* + ** Base of the assistant's JSON specs: the user's own AssistantApi (with the + ** request's credentials, forwarded to the UI API by the tools), errors in + ** the assistant's {"error","code"} shape with their status. + *******************************************************************************/ + public abstract static class AssistantJsonSpec extends AbstractJsonEndpointSpec + { + private static final ThreadLocal CONTEXT = new ThreadLocal<>(); + + + + /******************************************************************************* + ** The operation: method and path under /assistant. + *******************************************************************************/ + protected abstract HttpMethod method(); + + /******************************************************************************* + ** The path, e.g. /assistant/conversations/{id}. + *******************************************************************************/ + protected abstract String path(); + + /******************************************************************************* + ** A one-line summary. + *******************************************************************************/ + protected abstract String summary(); + + /******************************************************************************* + ** The work, with the user's AssistantApi. + *******************************************************************************/ + protected abstract Object handle(AssistantApi api, JsonInput input) throws Exception; + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public BasicOperation defineBasicOperation() + { + return (new BasicOperation().withPath(path()).withHttpMethod(method()).withShortSummary(summary()).withLongDescription(summary())); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public Map defineComponentSchemas() + { + return (components()); + } + + + + /******************************************************************************* + ** The success status (200 unless an operation says otherwise). + *******************************************************************************/ + protected HttpStatus successStatus() + { + return (HttpStatus.OK); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public BasicResponse defineBasicSuccessResponse() + { + HttpStatus status = successStatus(); + return (status == HttpStatus.NO_CONTENT + ? new BasicResponse(ContentType.APPLICATION_JSON.getMimeType(), status, "Done (no body)", null, null) + : new BasicResponse(ContentType.APPLICATION_JSON.getMimeType(), status, "Successful Response", getResponseSchemaName(), null)); + } + + + + /******************************************************************************* + ** Keep the request (for its credentials and the server's local port). + *******************************************************************************/ + @Override + public JsonInput buildInput(Context context) throws Exception + { + CONTEXT.set(context); + return (super.buildInput(context)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + protected Object handle(JsonInput input) throws Exception + { + AssistantRuntime runtime = AssistantRuntime.of(QContext.getQInstance()); + if(runtime == null) + { + throw (new AssistantApiException(404, "The assistant is not installed.")); + } + Context context = CONTEXT.get(); + if(context != null) + { + try + { + runtime.learnLocalPort(context.req().getLocalPort()); + } + catch(RuntimeException e) + { + //////////////////////////////////// + // not a servlet request (tests) // + //////////////////////////////////// + } + } + AssistantApi api = new AssistantApi(runtime, QContext.getQSession()).withCredentials(context == null ? null : UiApiCredentials.fromContext(context)); + return (handle(api, input)); + } + + + + /******************************************************************************* + ** JSON with nulls kept (as before). + *******************************************************************************/ + @Override + public void handleOutput(Context context, JsonOutput output) throws Exception + { + try + { + context.status(successStatus().getCode()); + if(successStatus() != HttpStatus.NO_CONTENT) + { + context.contentType(ContentType.APPLICATION_JSON); + context.result(JsonUtils.toJsonCustomized(output.getResult(), builder -> builder.serializationInclusion(JsonInclude.Include.ALWAYS))); + } + } + finally + { + CONTEXT.remove(); + } + } + + + + /******************************************************************************* + ** Assistant errors keep their status and {"error","code"}; others go + ** through QQQ's mapping (400 / 401 / 403 / 404 / 500). + *******************************************************************************/ + @Override + protected void handleException(Context context, Exception e) + { + CONTEXT.remove(); + AssistantApiException apiException = ExceptionUtils.findClassInRootChain(e, AssistantApiException.class); + if(apiException != null) + { + Map body = new LinkedHashMap<>(); + body.put("error", apiException.getMessage()); + if(apiException.getCode() != null) + { + body.put("code", apiException.getCode()); + } + context.status(apiException.getStatus()); + context.contentType(ContentType.APPLICATION_JSON); + context.result(JsonUtils.toJson(body)); + return; + } + QJavalinUtils.handleException(null, context, e); + } + + + + /******************************************************************************* + ** The error statuses every assistant operation may answer. + *******************************************************************************/ + @Override + public List defineAdditionalBasicResponses() + { + return (List.of( + new BasicResponse(HttpStatus.BAD_REQUEST, "Invalid input", "AssistantErrorV1"), + new BasicResponse(HttpStatus.FORBIDDEN, "No access to the assistant", "AssistantErrorV1"), + new BasicResponse(HttpStatus.NOT_FOUND, "Not found (or not the user's own)", "AssistantErrorV1"), + new BasicResponse(HttpStatus.CONFLICT, "Already finished or decided", "AssistantErrorV1"), + new BasicResponse(HttpStatus.TOO_MANY_REQUESTS, "Budget or rate limit", "AssistantErrorV1"), + new BasicResponse(HttpStatus.SERVICE_UNAVAILABLE, "Disabled, not configured, or the kill switch is on", "AssistantErrorV1"))); + } + + + + /******************************************************************************* + ** An integer path parameter, or 400. + *******************************************************************************/ + protected static Integer pathInt(JsonInput input, String name) throws QBadRequestException + { + try + { + return (Integer.parseInt(input.getPathParam(name))); + } + catch(NumberFormatException | NullPointerException e) + { + throw (new QBadRequestException(name + " must be an integer.")); + } + } + } + + + + //////////////////////////////////////////////////////////////////////////// + // the operations // + //////////////////////////////////////////////////////////////////////////// + + /******************************************************************************* + ** GET /assistant/config + *******************************************************************************/ + public static class ConfigSpec extends AssistantJsonSpec + { + @Override + protected HttpMethod method() + { + return (HttpMethod.GET); + } + + + + @Override + protected String path() + { + return (PREFIX + "/config"); + } + + + + @Override + protected String summary() + { + return ("Whether the assistant is available to this user, its persona and limits"); + } + + + + @Override + protected String getResponseSchemaName() + { + return ("AssistantConfigResponseV1"); + } + + + + @Override + protected Object handle(AssistantApi api, JsonInput input) throws Exception + { + return (api.config()); + } + } + + + + /******************************************************************************* + ** GET /assistant/conversations + *******************************************************************************/ + public static class ListConversationsSpec extends AssistantJsonSpec + { + @Override + protected HttpMethod method() + { + return (HttpMethod.GET); + } + + + + @Override + protected String path() + { + return (PREFIX + "/conversations"); + } + + + + @Override + protected String summary() + { + return ("The user's own conversations"); + } + + + + @Override + public List defineRequestParameters() + { + return (List.of(queryParam("status", Type.STRING, "ARCHIVED for archived conversations; active ones by default."))); + } + + + + @Override + protected String getResponseSchemaName() + { + return ("AssistantConversationListResponseV1"); + } + + + + @Override + protected Object handle(AssistantApi api, JsonInput input) throws Exception + { + return (api.listConversations(input.getQueryParam("status"))); + } + } + + + + /******************************************************************************* + ** POST /assistant/conversations + *******************************************************************************/ + public static class CreateConversationSpec extends AssistantJsonSpec + { + @Override + protected HttpMethod method() + { + return (HttpMethod.POST); + } + + + + @Override + protected String path() + { + return (PREFIX + "/conversations"); + } + + + + @Override + protected String summary() + { + return ("Start a conversation"); + } + + + + @Override + protected Schema defineRequestBodySchema() + { + return (object("Optional title and the page context it starts from.", "title", string(null), "context", freeObject("route, tableName, recordId, filter, selectedIds."))); + } + + + + @Override + protected String getResponseSchemaName() + { + return ("AssistantConversationV1"); + } + + + + @Override + protected HttpStatus successStatus() + { + return (HttpStatus.CREATED); + } + + + + @Override + protected Object handle(AssistantApi api, JsonInput input) throws Exception + { + return (api.createConversation(input.getBody())); + } + } + + + + /******************************************************************************* + ** GET /assistant/conversations/{id} + *******************************************************************************/ + public static class GetConversationSpec extends AssistantJsonSpec + { + @Override + protected HttpMethod method() + { + return (HttpMethod.GET); + } + + + + @Override + protected String path() + { + return (PREFIX + "/conversations/{id}"); + } + + + + @Override + protected String summary() + { + return ("A conversation and a page of its messages"); + } + + + + @Override + public List defineRequestParameters() + { + return (List.of(pathParam("id", "The conversation id."), queryParam("before", Type.INTEGER, "Only messages older than this message id."), + queryParam("limit", Type.INTEGER, "Page size (default 50, at most 200)."))); + } + + + + @Override + protected String getResponseSchemaName() + { + return ("AssistantConversationDetailResponseV1"); + } + + + + @Override + protected Object handle(AssistantApi api, JsonInput input) throws Exception + { + return (api.getConversation(pathInt(input, "id"), input.getQueryParamInteger("before"), input.getQueryParamInteger("limit"))); + } + } + + + + /******************************************************************************* + ** PATCH /assistant/conversations/{id} + *******************************************************************************/ + public static class UpdateConversationSpec extends AssistantJsonSpec + { + @Override + protected HttpMethod method() + { + return (HttpMethod.PATCH); + } + + + + @Override + protected String path() + { + return (PREFIX + "/conversations/{id}"); + } + + + + @Override + protected String summary() + { + return ("Rename, pin or archive a conversation"); + } + + + + @Override + public List defineRequestParameters() + { + return (List.of(pathParam("id", "The conversation id."))); + } + + + + @Override + protected Schema defineRequestBodySchema() + { + return (object("The changed properties.", "title", string(null), "pinned", bool(null), "status", string("ACTIVE or ARCHIVED."))); + } + + + + @Override + protected String getResponseSchemaName() + { + return ("AssistantConversationV1"); + } + + + + @Override + protected Object handle(AssistantApi api, JsonInput input) throws Exception + { + return (api.patchConversation(pathInt(input, "id"), input.getBody())); + } + } + + + + /******************************************************************************* + ** DELETE /assistant/conversations/{id} + *******************************************************************************/ + public static class DeleteConversationSpec extends AssistantJsonSpec + { + @Override + protected HttpMethod method() + { + return (HttpMethod.DELETE); + } + + + + @Override + protected String path() + { + return (PREFIX + "/conversations/{id}"); + } + + + + @Override + protected String summary() + { + return ("Delete a conversation"); + } + + + + @Override + public List defineRequestParameters() + { + return (List.of(pathParam("id", "The conversation id."))); + } + + + + @Override + protected String getResponseSchemaName() + { + return ("AssistantStatusResponseV1"); + } + + + + @Override + protected Object handle(AssistantApi api, JsonInput input) throws Exception + { + api.deleteConversation(pathInt(input, "id")); + return (null); + } + + + + @Override + protected HttpStatus successStatus() + { + return (HttpStatus.NO_CONTENT); + } + } + + + + /******************************************************************************* + ** POST /assistant/conversations/{id}/turns (text/event-stream) + *******************************************************************************/ + public static class StartTurnSpec extends AssistantJsonSpec + { + @Override + protected HttpMethod method() + { + return (HttpMethod.POST); + } + + + + @Override + protected String path() + { + return (PREFIX + "/conversations/{id}/turns"); + } + + + + @Override + protected String summary() + { + return ("Ask: start a turn and stream the reply as server-sent events"); + } + + + + @Override + public List defineRequestParameters() + { + return (List.of(pathParam("id", "The conversation id."))); + } + + + + @Override + protected Schema defineRequestBodySchema() + { + return (object("The user's text and the page context hint.", "text", string("The user's message."), "pageContext", freeObject("Where the user is in the app."))); + } + + + + @Override + protected String getResponseSchemaName() + { + return ("AssistantTurnEventV1"); + } + + + + @Override + public BasicResponse defineBasicSuccessResponse() + { + return (new BasicResponse("text/event-stream", HttpStatus.OK, "The turn's events, until message.end", "AssistantTurnEventV1", null)); + } + + + + @Override + protected Object handle(AssistantApi api, JsonInput input) throws Exception + { + return (api.startTurn(pathInt(input, "id"), input.getBody())); + } + + + + /******************************************************************************* + ** Stream the running turn's live log. + *******************************************************************************/ + @Override + public void handleOutput(Context context, JsonOutput output) throws Exception + { + try + { + TurnStreams.streamLive(context, ((RunningTurn) output.getResult()).getLog(), 0); + } + finally + { + AssistantJsonSpec.CONTEXT.remove(); + } + } + } + + + + /******************************************************************************* + ** GET /assistant/turns/{turnId}/events?after= (text/event-stream) + *******************************************************************************/ + public static class TurnEventsSpec extends AssistantJsonSpec + { + @Override + protected HttpMethod method() + { + return (HttpMethod.GET); + } + + + + @Override + protected String path() + { + return (PREFIX + "/turns/{turnId}/events"); + } + + + + @Override + protected String summary() + { + return ("Resume a turn's events after a sequence number (replay, then the live tail)"); + } + + + + @Override + public List defineRequestParameters() + { + return (List.of(pathParam("turnId", "The turn id."), queryParam("after", Type.INTEGER, "The last sequence number already received (0 for all)."))); + } + + + + @Override + protected String getResponseSchemaName() + { + return ("AssistantTurnEventV1"); + } + + + + @Override + public BasicResponse defineBasicSuccessResponse() + { + return (new BasicResponse("text/event-stream", HttpStatus.OK, "The turn's events after `after`, until message.end", "AssistantTurnEventV1", null)); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + record Events(AssistantApi api, Integer turnId, int after, AssistantApi.TurnEvents first) + { + } + + + + @Override + protected Object handle(AssistantApi api, JsonInput input) throws Exception + { + Integer turnId = pathInt(input, "turnId"); + Integer after = input.getQueryParamInteger("after"); + int from = after == null ? 0 : Math.max(0, after); + return (new Events(api, turnId, from, api.turnEvents(turnId, from))); + } + + + + /******************************************************************************* + ** Stream (the QContext session is still set: the replay reads the store). + *******************************************************************************/ + @Override + public void handleOutput(Context context, JsonOutput output) throws Exception + { + try + { + Events events = (Events) output.getResult(); + TurnStreams.streamEvents(context, events.api(), events.turnId(), events.after(), events.first()); + } + finally + { + AssistantJsonSpec.CONTEXT.remove(); + } + } + } + + + + /******************************************************************************* + ** POST /assistant/turns/{turnId}/cancel + *******************************************************************************/ + public static class CancelTurnSpec extends AssistantJsonSpec + { + @Override + protected HttpMethod method() + { + return (HttpMethod.POST); + } + + + + @Override + protected String path() + { + return (PREFIX + "/turns/{turnId}/cancel"); + } + + + + @Override + protected String summary() + { + return ("Stop a running turn"); + } + + + + @Override + public List defineRequestParameters() + { + return (List.of(pathParam("turnId", "The turn id."))); + } + + + + @Override + protected String getResponseSchemaName() + { + return ("AssistantStatusResponseV1"); + } + + + + @Override + protected Object handle(AssistantApi api, JsonInput input) throws Exception + { + return (api.cancelTurn(pathInt(input, "turnId"))); + } + + + + @Override + protected HttpStatus successStatus() + { + return (HttpStatus.ACCEPTED); + } + } + + + + /******************************************************************************* + ** POST /assistant/messages/{id}/feedback + *******************************************************************************/ + public static class FeedbackSpec extends AssistantJsonSpec + { + @Override + protected HttpMethod method() + { + return (HttpMethod.POST); + } + + + + @Override + protected String path() + { + return (PREFIX + "/messages/{id}/feedback"); + } + + + + @Override + protected String summary() + { + return ("Thumbs up or down on an assistant message"); + } + + + + @Override + public List defineRequestParameters() + { + return (List.of(pathParam("id", "The assistant message id."))); + } + + + + @Override + protected Schema defineRequestBodySchema() + { + return (object("The rating and an optional reason or comment.", "rating", integer("1 or -1."), "reason", string(null), "comment", string(null))); + } + + + + @Override + protected String getResponseSchemaName() + { + return ("AssistantStatusResponseV1"); + } + + + + @Override + protected Object handle(AssistantApi api, JsonInput input) throws Exception + { + api.feedback(pathInt(input, "id"), input.getBody()); + return (null); + } + + + + @Override + protected HttpStatus successStatus() + { + return (HttpStatus.NO_CONTENT); + } + } + + + + /******************************************************************************* + ** GET /assistant/actions/{id} + *******************************************************************************/ + public static class GetActionSpec extends AssistantJsonSpec + { + @Override + protected HttpMethod method() + { + return (HttpMethod.GET); + } + + + + @Override + protected String path() + { + return (PREFIX + "/actions/{id}"); + } + + + + @Override + protected String summary() + { + return ("A write the assistant proposed, and its state"); + } + + + + @Override + public List defineRequestParameters() + { + return (List.of(pathParam("id", "The pending action id."))); + } + + + + @Override + protected String getResponseSchemaName() + { + return ("AssistantActionV1"); + } + + + + @Override + protected Object handle(AssistantApi api, JsonInput input) throws Exception + { + return (api.getAction(pathInt(input, "id"))); + } + } + + + + /******************************************************************************* + ** POST /assistant/actions/{id}/confirm + *******************************************************************************/ + public static class ConfirmActionSpec extends AssistantJsonSpec + { + @Override + protected HttpMethod method() + { + return (HttpMethod.POST); + } + + + + @Override + protected String path() + { + return (PREFIX + "/actions/{id}/confirm"); + } + + + + @Override + protected String summary() + { + return ("Confirm a proposed write: it runs once, through the UI API, with this request's session"); + } + + + + @Override + public List defineRequestParameters() + { + return (List.of(pathParam("id", "The pending action id."))); + } + + + + @Override + protected String getResponseSchemaName() + { + return ("AssistantActionV1"); + } + + + + @Override + protected Object handle(AssistantApi api, JsonInput input) throws Exception + { + return (api.confirmAction(pathInt(input, "id"))); + } + } + + + + /******************************************************************************* + ** POST /assistant/actions/{id}/reject + *******************************************************************************/ + public static class RejectActionSpec extends AssistantJsonSpec + { + @Override + protected HttpMethod method() + { + return (HttpMethod.POST); + } + + + + @Override + protected String path() + { + return (PREFIX + "/actions/{id}/reject"); + } + + + + @Override + protected String summary() + { + return ("Cancel a proposed write; nothing changes"); + } + + + + @Override + public List defineRequestParameters() + { + return (List.of(pathParam("id", "The pending action id."))); + } + + + + @Override + protected String getResponseSchemaName() + { + return ("AssistantActionV1"); + } + + + + @Override + protected Object handle(AssistantApi api, JsonInput input) throws Exception + { + return (api.rejectAction(pathInt(input, "id"))); + } + } + + + + /******************************************************************************* + ** GET /assistant/access + *******************************************************************************/ + public static class AccessSpec extends AssistantJsonSpec + { + @Override + protected HttpMethod method() + { + return (HttpMethod.GET); + } + + + + @Override + protected String path() + { + return (PREFIX + "/access"); + } + + + + @Override + protected String summary() + { + return ("Explain access: which UI API operations the assistant could call for this user, and why not"); + } + + + + @Override + public List defineRequestParameters() + { + return (List.of(queryParam("table", Type.STRING, "Only this table."), queryParam("prompt", Type.STRING, "Only the tables this text mentions."))); + } + + + + @Override + protected String getResponseSchemaName() + { + return ("AssistantAccessResponseV1"); + } + + + + @Override + protected Object handle(AssistantApi api, JsonInput input) throws Exception + { + String table = input.getQueryParam("table"); + return (api.access(StringUtils.hasContent(table) ? table : null, input.getQueryParam("prompt"))); + } + } + +} diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/classification/ClassificationSource.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/classification/ClassificationSource.java index 7703b5a..6f44326 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/classification/ClassificationSource.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/classification/ClassificationSource.java @@ -22,11 +22,12 @@ package com.kingsrook.qbits.assistant.classification; import java.util.Collection; +import com.kingsrook.qqq.backend.core.actions.ai.DataClassification; import com.kingsrook.qqq.backend.core.context.QContext; /******************************************************************************* - ** Host seam for table and field classifications; a core metadata adapter can replace the configured source. + ** Host seam for table and field classifications; the default reads core metadata merged with configuration. *******************************************************************************/ public interface ClassificationSource { @@ -63,7 +64,7 @@ public interface ClassificationSource DataClassification result = DataClassification.PUBLIC; for(String field : fields) { - result = DataClassification.max(result, field == null || "*".equals(field) ? highest(table) : ofField(table, field)); + result = Classifications.max(result, field == null || "*".equals(field) ? highest(table) : ofField(table, field)); } return (result); } @@ -80,7 +81,7 @@ public interface ClassificationSource { if(!table.startsWith("assistant_")) { - result = DataClassification.max(result, highest(table)); + result = Classifications.max(result, highest(table)); } } return (result); diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/classification/DataClassification.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/classification/Classifications.java similarity index 62% rename from qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/classification/DataClassification.java rename to qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/classification/Classifications.java index 227e6b5..af4500f 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/classification/DataClassification.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/classification/Classifications.java @@ -1,15 +1,11 @@ /* - * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2026. Kingsrook, LLC - * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States - * contact@kingsrook.com - * https://github.com/Kingsrook/ + * 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 * - * https://www.apache.org/licenses/LICENSE-2.0 + * 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, @@ -21,24 +17,31 @@ package com.kingsrook.qbits.assistant.classification; - +import com.kingsrook.qqq.backend.core.actions.ai.DataClassification; /******************************************************************************* - ** Stand-in for core DataClassification (QRun-IO/qqq#1142); replace when the qbit builds on an RC that has it. + ** The assistant's conventions over core's {@link DataClassification}: an + ** absent classification is PUBLIC, and a stored level this version does not + ** know is treated as PHI (fail conservative). *******************************************************************************/ -public enum DataClassification +public final class Classifications { - PUBLIC, CONFIDENTIAL, PII, PHI; + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private Classifications() + { + } /******************************************************************************* - ** Whether this classification is at least the supplied level; null means PUBLIC. + ** The level, or PUBLIC when absent. *******************************************************************************/ - public boolean isAtLeast(DataClassification other) + public static DataClassification orPublic(DataClassification classification) { - return (ordinal() >= (other == null ? PUBLIC : other).ordinal()); + return (classification == null ? DataClassification.PUBLIC : classification); } @@ -48,9 +51,7 @@ public enum DataClassification *******************************************************************************/ public static DataClassification max(DataClassification left, DataClassification right) { - DataClassification a = left == null ? PUBLIC : left; - DataClassification b = right == null ? PUBLIC : right; - return (a.isAtLeast(b) ? a : b); + return (DataClassification.max(orPublic(left), orPublic(right))); } @@ -62,15 +63,15 @@ public enum DataClassification { if(value == null) { - return (PUBLIC); + return (DataClassification.PUBLIC); } try { - return (valueOf(value)); + return (DataClassification.valueOf(value)); } catch(IllegalArgumentException e) { - return (PHI); + return (DataClassification.PHI); } } } diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/classification/ConfiguredClassificationSource.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/classification/ConfiguredClassificationSource.java index 91a8486..23f91fe 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/classification/ConfiguredClassificationSource.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/classification/ConfiguredClassificationSource.java @@ -23,12 +23,21 @@ package com.kingsrook.qbits.assistant.classification; import java.util.LinkedHashMap; import java.util.Map; +import com.kingsrook.qqq.backend.core.actions.ai.AiDataClassifications; +import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData; +import com.kingsrook.qqq.backend.core.actions.ai.DataClassification; import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; /******************************************************************************* - ** Default classification source for RCs without core classification metadata. + ** The default classification source: core's AI classification metadata + ** (AiTableMetaData / AiFieldMetaData, via AiDataClassifications) merged with + ** the classifications configured on the qbit. A configured value wins when + ** both are set; a field falls back to its own core classification, then to + ** its table's; anything unclassified (and the assistant's own tables) is + ** PUBLIC. *******************************************************************************/ public class ConfiguredClassificationSource implements ClassificationSource { @@ -71,7 +80,7 @@ public class ConfiguredClassificationSource implements ClassificationSource @Override public DataClassification ofTable(String table) { - return (table == null || table.startsWith("assistant_") ? DataClassification.PUBLIC : DataClassification.max(null, tables.get(table))); + return (table == null || table.startsWith("assistant_") ? DataClassification.PUBLIC : Classifications.max(null, tables.get(table))); } @@ -87,11 +96,28 @@ public class ConfiguredClassificationSource implements ClassificationSource return (DataClassification.PUBLIC); } DataClassification value = field == null ? null : fields.getOrDefault(table, Map.of()).get(field); + if(value == null) + { + QTableMetaData metadata = coreTable(table); + AiFieldMetaData coreField = metadata == null || metadata.getFields() == null || field == null ? null : AiFieldMetaData.of(metadata.getFields().get(field)); + value = coreField == null ? null : coreField.getClassification(); + } return (value == null ? ofTable(table) : value); } + /******************************************************************************* + ** The table's core metadata, or null (no instance in context, or unknown). + *******************************************************************************/ + private static QTableMetaData coreTable(String table) + { + QInstance qInstance = QContext.getQInstance(); + return (qInstance == null ? null : qInstance.getTable(table)); + } + + + /******************************************************************************* ** Resolve all field names from the current instance, also retaining configured overrides. *******************************************************************************/ @@ -108,12 +134,12 @@ public class ConfiguredClassificationSource implements ClassificationSource { for(String field : metadata.getFields().keySet()) { - result = DataClassification.max(result, ofField(table, field)); + result = Classifications.max(result, ofField(table, field)); } } for(DataClassification value : fields.getOrDefault(table, Map.of()).values()) { - result = DataClassification.max(result, value); + result = Classifications.max(result, value); } return (result); } @@ -129,11 +155,11 @@ public class ConfiguredClassificationSource implements ClassificationSource DataClassification result = ClassificationSource.super.highestAnywhere(); for(String table : tables.keySet()) { - result = DataClassification.max(result, highest(table)); + result = Classifications.max(result, highest(table)); } for(String table : fields.keySet()) { - result = DataClassification.max(result, highest(table)); + result = Classifications.max(result, highest(table)); } return (result); } diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/ConnectionTest.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/ConnectionTest.java index a877985..45289f2 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/ConnectionTest.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/ConnectionTest.java @@ -21,7 +21,6 @@ import java.time.Duration; import java.util.ArrayList; import java.util.List; import com.kingsrook.qbits.assistant.AssistantRuntime; -import com.kingsrook.qbits.assistant.classification.DataClassification; import com.kingsrook.qbits.assistant.model.AssistantModel; import com.kingsrook.qbits.assistant.model.AssistantProvider; import com.kingsrook.qbits.assistant.provider.AssistantProviderType; @@ -33,6 +32,7 @@ import com.kingsrook.qbits.assistant.provider.ModelResponse; import com.kingsrook.qbits.assistant.provider.RegistryModelProvider; import com.kingsrook.qbits.assistant.provider.StreamHandle; import com.kingsrook.qbits.assistant.rag.EmbeddingService; +import com.kingsrook.qqq.backend.core.actions.ai.DataClassification; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.model.data.QRecord; diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/FallbackChain.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/FallbackChain.java index 028300e..967b1ca 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/FallbackChain.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/FallbackChain.java @@ -27,7 +27,6 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; import java.util.function.Supplier; import com.kingsrook.qbits.assistant.AssistantRuntime; -import com.kingsrook.qbits.assistant.classification.DataClassification; import com.kingsrook.qbits.assistant.model.AssistantModel; import com.kingsrook.qbits.assistant.model.AssistantProvider; import com.kingsrook.qbits.assistant.model.AssistantTurn; @@ -36,6 +35,7 @@ import com.kingsrook.qbits.assistant.provider.ModelMessage; import com.kingsrook.qbits.assistant.provider.ModelProviderException; import com.kingsrook.qbits.assistant.provider.ModelRequest; import com.kingsrook.qbits.assistant.provider.ModelResponse; +import com.kingsrook.qqq.backend.core.actions.ai.DataClassification; import com.kingsrook.qqq.backend.core.exceptions.QException; diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/ModelRouter.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/ModelRouter.java index b91e084..9e29a55 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/ModelRouter.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/ModelRouter.java @@ -29,7 +29,6 @@ import java.util.function.Consumer; import java.util.regex.Pattern; import java.util.stream.Collectors; import com.kingsrook.qbits.assistant.AssistantRuntime; -import com.kingsrook.qbits.assistant.classification.DataClassification; import com.kingsrook.qbits.assistant.model.AssistantConversation; import com.kingsrook.qbits.assistant.model.AssistantPendingAction; import com.kingsrook.qbits.assistant.model.AssistantProfile; @@ -44,6 +43,7 @@ import com.kingsrook.qbits.assistant.provider.ModelUsage; import com.kingsrook.qbits.assistant.provider.StreamHandle; import com.kingsrook.qbits.assistant.provider.SystemBlock; import com.kingsrook.qbits.assistant.scheduled.ScheduledTurn; +import com.kingsrook.qqq.backend.core.actions.ai.DataClassification; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/PhiRoutingGuard.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/PhiRoutingGuard.java index 976fbee..cb59e75 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/PhiRoutingGuard.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/PhiRoutingGuard.java @@ -22,8 +22,8 @@ package com.kingsrook.qbits.assistant.engine; import java.util.Optional; -import com.kingsrook.qbits.assistant.classification.DataClassification; import com.kingsrook.qbits.assistant.model.AssistantProvider; +import com.kingsrook.qqq.backend.core.actions.ai.DataClassification; /******************************************************************************* diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/ProviderService.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/ProviderService.java index 1badf42..240646f 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/ProviderService.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/ProviderService.java @@ -17,18 +17,6 @@ package com.kingsrook.qbits.assistant.engine; -import com.kingsrook.qbits.assistant.AssistantQBitConfig; -import com.kingsrook.qbits.assistant.model.AssistantModel; -import com.kingsrook.qbits.assistant.model.AssistantModelPrice; -import com.kingsrook.qbits.assistant.model.AssistantProvider; -import com.kingsrook.qbits.assistant.persistence.AssistantStore; -import com.kingsrook.qbits.assistant.provider.AssistantModelProvider; -import com.kingsrook.qbits.assistant.provider.AssistantProviderType; -import com.kingsrook.qbits.assistant.provider.ProviderSettings; -import com.kingsrook.qbits.assistant.provider.RegistryModelProvider; -import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.logging.QLogger; -import com.kingsrook.qqq.backend.core.utils.StringUtils; import java.math.BigDecimal; import java.math.RoundingMode; import java.nio.charset.StandardCharsets; @@ -43,6 +31,18 @@ import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Semaphore; +import com.kingsrook.qbits.assistant.AssistantQBitConfig; +import com.kingsrook.qbits.assistant.model.AssistantModel; +import com.kingsrook.qbits.assistant.model.AssistantModelPrice; +import com.kingsrook.qbits.assistant.model.AssistantProvider; +import com.kingsrook.qbits.assistant.persistence.AssistantStore; +import com.kingsrook.qbits.assistant.provider.AssistantModelProvider; +import com.kingsrook.qbits.assistant.provider.AssistantProviderType; +import com.kingsrook.qbits.assistant.provider.ProviderSettings; +import com.kingsrook.qbits.assistant.provider.RegistryModelProvider; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.utils.StringUtils; import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; @@ -738,7 +738,7 @@ public class ProviderService /******************************************************************************* ** P-e seam: no automatic alternative is selected in P-c. Never downgrade PHI eligibility. *******************************************************************************/ - public java.util.Optional eligibleAlternative(com.kingsrook.qbits.assistant.classification.DataClassification classification, AssistantProvider blockedProvider) + public java.util.Optional eligibleAlternative(com.kingsrook.qqq.backend.core.actions.ai.DataClassification classification, AssistantProvider blockedProvider) { return (java.util.Optional.empty()); } diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnClassification.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnClassification.java index 3945e7c..9643cf1 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnClassification.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnClassification.java @@ -27,11 +27,12 @@ import java.util.List; import java.util.Map; import java.util.Set; import com.kingsrook.qbits.assistant.classification.ClassificationSource; -import com.kingsrook.qbits.assistant.classification.DataClassification; +import com.kingsrook.qbits.assistant.classification.Classifications; import com.kingsrook.qbits.assistant.model.AssistantConversation; import com.kingsrook.qbits.assistant.provider.ModelPart; import com.kingsrook.qqq.backend.core.actions.ai.AiRecordRef; import com.kingsrook.qqq.backend.core.actions.ai.AiToolResult; +import com.kingsrook.qqq.backend.core.actions.ai.DataClassification; import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import com.kingsrook.qqq.backend.core.utils.JsonUtils; @@ -55,7 +56,7 @@ public class TurnClassification public TurnClassification(ClassificationSource source, AssistantConversation conversation, Map pageContext) { this.source = source; - classification = DataClassification.stored(conversation.getMaxClassification()); + classification = Classifications.stored(conversation.getMaxClassification()); if(pageContext != null && pageContext.get("tableName") instanceof String table) { touch(table, pageContext.get("recordId") == null ? source.ofTable(table) : source.highest(table)); @@ -210,7 +211,7 @@ public class TurnClassification *******************************************************************************/ private void touchUnknown() { - classification = DataClassification.max(classification, source.highestAnywhere()); + classification = Classifications.max(classification, source.highestAnywhere()); for(String table : QContext.getQInstance().getTables().keySet()) { if(!table.startsWith("assistant_") && source.highest(table) != DataClassification.PUBLIC) @@ -229,7 +230,7 @@ public class TurnClassification { if(table != null && !table.startsWith("assistant_")) { - classification = DataClassification.max(classification, value); + classification = Classifications.max(classification, value); if(QContext.getQInstance().getTable(table) != null) { tables.add(table); @@ -256,7 +257,7 @@ public class TurnClassification { if(conversation != null) { - classification = DataClassification.max(classification, DataClassification.stored(conversation.getMaxClassification())); + classification = Classifications.max(classification, Classifications.stored(conversation.getMaxClassification())); } } diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnDataRouting.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnDataRouting.java index 100bb3a..01da802 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnDataRouting.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnDataRouting.java @@ -22,10 +22,10 @@ package com.kingsrook.qbits.assistant.engine; import com.kingsrook.qbits.assistant.AssistantRuntime; -import com.kingsrook.qbits.assistant.classification.DataClassification; import com.kingsrook.qbits.assistant.model.AssistantDataAudit; import com.kingsrook.qbits.assistant.model.AssistantProvider; import com.kingsrook.qbits.assistant.model.AssistantTurn; +import com.kingsrook.qqq.backend.core.actions.ai.DataClassification; import com.kingsrook.qqq.backend.core.exceptions.QException; diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnEngine.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnEngine.java index 06a8262..eca8012 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnEngine.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnEngine.java @@ -58,8 +58,8 @@ import com.kingsrook.qbits.assistant.provider.StreamHandle; import com.kingsrook.qbits.assistant.scheduled.ScheduledTurn; import com.kingsrook.qbits.assistant.tools.KnowledgeTool; import com.kingsrook.qbits.assistant.tools.PresentResultsTool; -import com.kingsrook.qbits.assistant.tools.bulkload.BulkLoadTools; import com.kingsrook.qbits.assistant.tools.UiApiToolbox; +import com.kingsrook.qbits.assistant.tools.bulkload.BulkLoadTools; import com.kingsrook.qqq.backend.core.actions.ai.AiRecordRef; import com.kingsrook.qqq.backend.core.actions.ai.AiToolCall; import com.kingsrook.qqq.backend.core.actions.ai.AiToolOutcome; @@ -72,6 +72,7 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.session.QSession; import com.kingsrook.qqq.backend.core.utils.JsonUtils; import com.kingsrook.qqq.backend.core.utils.StringUtils; +import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials; import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; @@ -148,6 +149,7 @@ public class TurnEngine implements Runnable private ProviderService.Active budgetOrigin; private volatile boolean finished = false; + private UiApiCredentials credentials; @@ -176,6 +178,19 @@ public class TurnEngine implements Runnable + /******************************************************************************* + ** The credentials of the request that started the turn, forwarded to the + ** UI API by the tools (session cookies, Authorization, API key as they came + ** in). Without them the session UUID is used. Never logged. + *******************************************************************************/ + public TurnEngine withCredentials(UiApiCredentials credentials) + { + this.credentials = credentials; + return (this); + } + + + /******************************************************************************* ** Run the turn to completion. Never throws. *******************************************************************************/ @@ -917,7 +932,7 @@ public class TurnEngine implements Runnable *******************************************************************************/ private UiApiToolbox.Context toolContext() { - return (new UiApiToolbox.Context(session.getUuid(), userTimezone(), runtime.getUiApiGateway(), toolPolicy, this::proposeWrite)); + return (new UiApiToolbox.Context(session.getUuid(), userTimezone(), runtime.getUiApiGateway(), toolPolicy, this::proposeWrite, credentials, turn.getId() == null ? null : String.valueOf(turn.getId()))); } diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/persistence/AssistantStore.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/persistence/AssistantStore.java index ef195d9..6cfe697 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/persistence/AssistantStore.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/persistence/AssistantStore.java @@ -29,7 +29,7 @@ import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; import com.kingsrook.qbits.assistant.AssistantQBitConfig; import com.kingsrook.qbits.assistant.AssistantRuntime; -import com.kingsrook.qbits.assistant.classification.DataClassification; +import com.kingsrook.qbits.assistant.classification.Classifications; import com.kingsrook.qbits.assistant.engine.TurnClassification; import com.kingsrook.qbits.assistant.model.AssistantBudget; import com.kingsrook.qbits.assistant.model.AssistantBudgetEvent; @@ -788,8 +788,8 @@ public class AssistantStore .withValue("classification", classification.getClassification().name()).withValue("classificationTables", classification.getTables())); if(current != null) { - var maximum = DataClassification.max( - DataClassification.stored(current.getMaxClassification()), classification.getClassification()); + var maximum = Classifications.max( + Classifications.stored(current.getMaxClassification()), classification.getClassification()); if(!maximum.name().equals(current.getMaxClassification())) { updateFields(AssistantConversation.TABLE_NAME, new QRecord().withValue("id", conversationId).withValue("maxClassification", maximum.name())); diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/processes/KnowledgeIndexProcess.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/processes/KnowledgeIndexProcess.java index 71555b4..17e187c 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/processes/KnowledgeIndexProcess.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/processes/KnowledgeIndexProcess.java @@ -23,10 +23,10 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import com.kingsrook.qbits.assistant.AssistantRuntime; -import com.kingsrook.qbits.assistant.classification.DataClassification; import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder; import com.kingsrook.qbits.assistant.model.AssistantRagSource; import com.kingsrook.qbits.assistant.rag.RagService; +import com.kingsrook.qqq.backend.core.actions.ai.DataClassification; import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface; diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/rag/EmbeddingService.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/rag/EmbeddingService.java index 7f98e75..f9fb197 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/rag/EmbeddingService.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/rag/EmbeddingService.java @@ -25,7 +25,6 @@ import java.util.Objects; import java.util.concurrent.Semaphore; import com.kingsrook.qbits.assistant.AssistantRuntime; import com.kingsrook.qbits.assistant.AssistantSecurity; -import com.kingsrook.qbits.assistant.classification.DataClassification; import com.kingsrook.qbits.assistant.engine.BudgetService; import com.kingsrook.qbits.assistant.engine.PolicyService; import com.kingsrook.qbits.assistant.engine.ProviderService; @@ -36,6 +35,7 @@ import com.kingsrook.qbits.assistant.provider.EmbeddingResponse; import com.kingsrook.qbits.assistant.provider.ModelProviderException; import com.kingsrook.qbits.assistant.provider.ModelResponse; import com.kingsrook.qbits.assistant.scheduled.ScheduledTurn; +import com.kingsrook.qqq.backend.core.actions.ai.DataClassification; import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.QException; diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/rag/RagService.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/rag/RagService.java index e0a376d..5d75cd1 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/rag/RagService.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/rag/RagService.java @@ -223,7 +223,7 @@ public class RagService checkKillSwitch(); filter.setSkip(skip); UiApiResult page = runtime.getUiApiGateway().invoke(new UiApiCall("POST", "/table/{tableName}/query").withPathParam("tableName", source.getTableName()) - .withSessionUUID(context.sessionUUID()).withUserTimezone(context.userTimezone()).withJsonBody("{\"filter\":" + JsonUtils.toJson(filter) + "}")); + .withSessionUUID(context.sessionUUID()).withCredentials(context.credentials()).withUserTimezone(context.userTimezone()).withJsonBody("{\"filter\":" + JsonUtils.toJson(filter) + "}")); if(!page.isSuccess()) { throw (new QException("Index query answered HTTP " + page.status() + "; existing chunks were preserved.")); diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/scheduled/ScheduledTaskRunner.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/scheduled/ScheduledTaskRunner.java index c840e2b..f6d64a0 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/scheduled/ScheduledTaskRunner.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/scheduled/ScheduledTaskRunner.java @@ -40,6 +40,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials; /******************************************************************************* @@ -118,7 +119,7 @@ public class ScheduledTaskRunner { session = login.login(runtime, task); QContext.init(runtime.getQInstance(), session); - AssistantApi api = new AssistantApi(runtime, session); + AssistantApi api = new AssistantApi(runtime, session).withCredentials(UiApiCredentials.ofSessionUUID(session.getUuid())); Integer conversationId = ((Number) api.createConversation(Map.of("title", task.getName())).get("id")).intValue(); run.setConversationId(conversationId); store.updateFields(AssistantConversation.TABLE_NAME, new QRecord().withValue("id", conversationId) diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/KnowledgeTool.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/KnowledgeTool.java index fab725e..b4c5e4f 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/KnowledgeTool.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/KnowledgeTool.java @@ -25,7 +25,6 @@ import java.util.List; import java.util.Map; import java.util.Set; import com.kingsrook.qbits.assistant.AssistantRuntime; -import com.kingsrook.qbits.assistant.classification.DataClassification; import com.kingsrook.qbits.assistant.engine.PolicyService; import com.kingsrook.qbits.assistant.engine.TurnEngine; import com.kingsrook.qbits.assistant.model.AssistantRagSource; @@ -37,6 +36,7 @@ import com.kingsrook.qbits.assistant.rag.VectorStore; import com.kingsrook.qqq.backend.core.actions.ai.AiRecordRef; import com.kingsrook.qqq.backend.core.actions.ai.AiToolOutcome; import com.kingsrook.qqq.backend.core.actions.ai.AiToolResult; +import com.kingsrook.qqq.backend.core.actions.ai.DataClassification; import com.kingsrook.qqq.backend.core.actions.ai.output.AiDataEnvelope; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.model.session.QSession; diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/PresentResultsTool.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/PresentResultsTool.java index 28c14f2..12cd62c 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/PresentResultsTool.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/PresentResultsTool.java @@ -187,7 +187,7 @@ public class PresentResultsTool { try { - metadata = context.gateway().invoke(new UiApiCall("GET", "/metaData").withSessionUUID(context.sessionUUID()).withUserTimezone(context.userTimezone())); + metadata = context.gateway().invoke(context.prepare(new UiApiCall("GET", "/metaData"))); } catch(QException e) { diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/UiApiToolbox.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/UiApiToolbox.java index 905f9a2..d90e23a 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/UiApiToolbox.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/UiApiToolbox.java @@ -47,6 +47,7 @@ import com.kingsrook.qqq.backend.core.actions.ai.output.AiRedactor; import com.kingsrook.qqq.backend.core.actions.ai.tools.AiJsonSchema; import com.kingsrook.qqq.backend.core.actions.ai.tools.AiToolContext; import com.kingsrook.qqq.backend.core.actions.ai.tools.AiToolException; +import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; @@ -56,9 +57,11 @@ import com.kingsrook.qqq.backend.core.model.audits.AuditsMetaDataProvider; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.ClientChannelsMetaData; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import com.kingsrook.qqq.backend.core.utils.JsonUtils; import com.kingsrook.qqq.backend.core.utils.StringUtils; +import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials; /******************************************************************************* @@ -150,8 +153,37 @@ public class UiApiToolbox ** Where a tool call runs: the user's session, the gateway, the policy, and ** what to do with a write that needs confirmation. *******************************************************************************/ - public record Context(String sessionUUID, String userTimezone, UiApiGateway gateway, PolicyService.ToolPolicy policy, PendingActions pendingActions) + public record Context(String sessionUUID, String userTimezone, UiApiGateway gateway, PolicyService.ToolPolicy policy, PendingActions pendingActions, UiApiCredentials credentials, String turnId) { + /******************************************************************************* + ** With the session UUID only (no request credentials captured). + *******************************************************************************/ + public Context(String sessionUUID, String userTimezone, UiApiGateway gateway, PolicyService.ToolPolicy policy, PendingActions pendingActions) + { + this(sessionUUID, userTimezone, gateway, policy, pendingActions, null, null); + } + + + + /******************************************************************************* + ** With the request credentials, outside a turn. + *******************************************************************************/ + public Context(String sessionUUID, String userTimezone, UiApiGateway gateway, PolicyService.ToolPolicy policy, PendingActions pendingActions, UiApiCredentials credentials) + { + this(sessionUUID, userTimezone, gateway, policy, pendingActions, credentials, null); + } + + + + /******************************************************************************* + ** A call made by this context: the caller's credentials, timezone and + ** the client detail (the turn id, labelled assistant/turnId in the access + ** log) filled in. + *******************************************************************************/ + public UiApiCall prepare(UiApiCall call) + { + return (call.withSessionUUID(sessionUUID).withCredentials(credentials).withUserTimezone(userTimezone).withClientDetail(turnId)); + } } @@ -328,11 +360,34 @@ public class UiApiToolbox byName.put(DELETE_RECORD, tool(DELETE_RECORD, "Delete a record. The user confirms it in the panel before anything is deleted, unless the administrator turned confirmation off.", AiJsonSchema.object(AiJsonSchema.properties("table", AiJsonSchema.string("Table name from list_tables"), "id", AiJsonSchema.string("The record's primary key value.")), List.of("table", "id")))); + if(!historyAllowed()) + { + byName.remove(RECORD_HISTORY); + } return (new ArrayList<>(byName.values())); } + /******************************************************************************* + ** Offer history only when every audit table it queries is available to the + ** assistant channel, including inherited permission rules. + *******************************************************************************/ + private boolean historyAllowed() + { + for(String name : List.of(AuditsMetaDataProvider.TABLE_NAME_AUDIT_TABLE, AuditsMetaDataProvider.TABLE_NAME_AUDIT, AuditsMetaDataProvider.TABLE_NAME_AUDIT_DETAIL)) + { + QTableMetaData table = qInstance.getTable(name); + if(table == null || ClientChannelsMetaData.of(qInstance).isDenied("assistant", table.getName(), PermissionsHelper.getEffectivePermissionRules(table, qInstance))) + { + return (false); + } + } + return (true); + } + + + /******************************************************************************* ** The definitions a policy allows; write tools only when some table allows ** writes (anyWritableTable). @@ -658,7 +713,7 @@ public class UiApiToolbox if(truncated) { UiApiResult counted = context.gateway().invoke(new UiApiCall("POST", "/table/{tableName}/count").withPathParam("tableName", tableName).withJsonBody(filterBody(linkFilter)) - .withSessionUUID(context.sessionUUID()).withUserTimezone(context.userTimezone())); + .withSessionUUID(context.sessionUUID()).withCredentials(context.credentials()).withUserTimezone(context.userTimezone()).withClientDetail(context.turnId())); totalCount = counted.isSuccess() ? asMap(counted.json()).get("count") : null; } @@ -1255,7 +1310,7 @@ public class UiApiToolbox *******************************************************************************/ private UiApiResult call(Context context, Outcome outcome, UiApiCall call) throws QException { - call.withSessionUUID(context.sessionUUID()).withUserTimezone(context.userTimezone()).withInvokedBy("assistant"); + context.prepare(call).withInvokedBy("assistant"); outcome.operation = call.operation(); UiApiResult response = context.gateway().invoke(call); outcome.httpStatus = response.status(); diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/bulkload/BulkLoadRunner.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/bulkload/BulkLoadRunner.java index 04868d6..0fc44e6 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/bulkload/BulkLoadRunner.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/bulkload/BulkLoadRunner.java @@ -19,6 +19,7 @@ package com.kingsrook.qbits.assistant.tools.bulkload; import com.kingsrook.qbits.assistant.uiapi.UiApiGateway; import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials; /******************************************************************************* @@ -31,8 +32,15 @@ public interface BulkLoadRunner /******************************************************************************* ** A load to start: the target table, the upload, and the saved profile. *******************************************************************************/ - record Request(String sessionUUID, String userTimezone, UiApiGateway gateway, String tableName, String uploadRef, Integer savedBulkLoadProfileId) + record Request(String sessionUUID, String userTimezone, UiApiGateway gateway, String tableName, String uploadRef, Integer savedBulkLoadProfileId, UiApiCredentials credentials, String turnId) { + /******************************************************************************* + ** Without request credentials or a turn. + *******************************************************************************/ + public Request(String sessionUUID, String userTimezone, UiApiGateway gateway, String tableName, String uploadRef, Integer savedBulkLoadProfileId) + { + this(sessionUUID, userTimezone, gateway, tableName, uploadRef, savedBulkLoadProfileId, null, null); + } } diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/bulkload/BulkLoadTools.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/bulkload/BulkLoadTools.java index ee703d1..e7a855f 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/bulkload/BulkLoadTools.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/bulkload/BulkLoadTools.java @@ -541,7 +541,7 @@ public class BulkLoadTools private void run(Call call, AiToolResult result, UiApiToolbox.Context context, String tableName, Map values) throws QException { BulkLoadRunner.RunHandle handle = runner.start(new BulkLoadRunner.Request(context.sessionUUID(), context.userTimezone(), context.gateway(), tableName, - String.valueOf(values.get("uploadRef")), ValueUtils.getValueAsInteger(values.get("profileId")))); + String.valueOf(values.get("uploadRef")), ValueUtils.getValueAsInteger(values.get("profileId")), context.credentials(), context.turnId())); call.operation = handle.operation(); call.httpStatus = handle.httpStatus(); boolean ok = !"FAILED".equals(handle.status()) && !"NOT_AVAILABLE".equals(handle.status()); @@ -863,7 +863,7 @@ public class BulkLoadTools { return (null); } - uiApiCall.withSessionUUID(context.sessionUUID()).withUserTimezone(context.userTimezone()).withInvokedBy("assistant") + context.prepare(uiApiCall).withInvokedBy("assistant") .withHeader(ROUTE_HEADER, hipaaRoute ? "hipaa" : "standard"); UiApiResult response = context.gateway().invoke(uiApiCall); httpStatus = response.status(); diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/bulkload/JobsLaunchBulkLoadRunner.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/bulkload/JobsLaunchBulkLoadRunner.java index 17a4f2d..76a2333 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/bulkload/JobsLaunchBulkLoadRunner.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/tools/bulkload/JobsLaunchBulkLoadRunner.java @@ -70,6 +70,8 @@ public class JobsLaunchBulkLoadRunner implements BulkLoadRunner .withPathParam("processName", processName) .withJsonBody(body) .withSessionUUID(request.sessionUUID()) + .withCredentials(request.credentials()) + .withClientDetail(request.turnId()) .withUserTimezone(request.userTimezone()) .withInvokedBy("assistant")); diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/uiapi/CoreUiApiGateway.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/uiapi/CoreUiApiGateway.java new file mode 100644 index 0000000..11254b3 --- /dev/null +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/uiapi/CoreUiApiGateway.java @@ -0,0 +1,170 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.assistant.uiapi; + + +import java.util.Locale; +import java.util.function.Supplier; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.utils.JsonUtils; +import com.kingsrook.qqq.middleware.javalin.specs.invoke.HeaderedUiApiInvoker; +import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials; +import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiInvoker; +import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiResponse; +import com.kingsrook.qqq.middleware.javalin.specs.v1.MiddlewareVersionV1; +import com.kingsrook.qqq.openapi.model.HttpMethod; + + +/******************************************************************************* + ** The assistant's gateway over core's {@link UiApiInvoker} (QRun-IO/qqq#1128, + ** docs/UI-API-EXTENSIONS.md section 5): only spec'd /qqq/v1 operations (core + ** and registered extensions), a loopback request through the identical + ** Javalin pipeline, carrying the caller's own credentials exactly as they + ** came in (session cookies, Authorization, API key). There is no service + ** session and no way to elevate. + *******************************************************************************/ +public class CoreUiApiGateway implements UiApiGateway +{ + public static final String OWN_PREFIX = "/assistant"; + public static final String CLIENT_CHANNEL = "assistant"; + + private final Supplier baseUrlSupplier; + private final QInstance qInstance; + private volatile UiApiInvoker invoker; + + + + /******************************************************************************* + ** @param baseUrlSupplier this server's own root URL (e.g. http://127.0.0.1:8000) + ** @param qInstance the instance (its spec extensions are invocable too) + *******************************************************************************/ + public CoreUiApiGateway(Supplier baseUrlSupplier, QInstance qInstance) + { + this.baseUrlSupplier = baseUrlSupplier; + this.qInstance = qInstance; + } + + + + /******************************************************************************* + ** The invoker, over a v1 middleware version that knows the instance's + ** extensions (built on first use: the instance is complete by then). + *******************************************************************************/ + UiApiInvoker invoker() + { + UiApiInvoker current = invoker; + if(current == null) + { + MiddlewareVersionV1 version = new MiddlewareVersionV1(); + version.setQInstance(qInstance); + //////////////////////////////////////////////////////////////////////////// + // the same as UiApiInvoker.forServer(server, "assistant"): this server's // + // loopback address, its first version, the client channel "assistant" // + // (a call adds only the detail after the slash: the turn id) // + //////////////////////////////////////////////////////////////////////////// + current = new HeaderedUiApiInvoker(baseUrlSupplier, version).withClientChannel(CLIENT_CHANNEL); + invoker = current; + } + return (current); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public boolean isInvocable(String method, String pathTemplate) + { + /////////////////////////////////////////////////////////////////// + // never the assistant's own operations (no recursion, no turns // + // started by a turn) // + /////////////////////////////////////////////////////////////////// + if(pathTemplate == null || pathTemplate.equals(OWN_PREFIX) || pathTemplate.startsWith(OWN_PREFIX + "/")) + { + return (false); + } + try + { + return (invoker().findOperation(HttpMethod.valueOf(method.toUpperCase(Locale.ROOT)), pathTemplate).isPresent()); + } + catch(IllegalArgumentException e) + { + return (false); + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public UiApiResult invoke(UiApiCall call) throws QException + { + if(call == null || call.getMethod() == null || call.getPathTemplate() == null) + { + throw (new QException("A UI API call needs a method and a path template")); + } + if(!isInvocable(call.getMethod(), call.getPathTemplate())) + { + throw (new QException("Not a spec'd UI API operation: " + call.operation())); + } + UiApiCredentials credentials = call.getCredentials() != null ? call.getCredentials() + : (call.getSessionUUID() == null ? null : UiApiCredentials.ofSessionUUID(call.getSessionUUID())); + if(credentials == null || !credentials.isPresent()) + { + throw (new QException("A session is required to invoke " + call.operation())); + } + + HeaderedUiApiInvoker.HeaderedRequest request = new HeaderedUiApiInvoker.HeaderedRequest(); + request.withMethod(HttpMethod.valueOf(call.getMethod().toUpperCase(Locale.ROOT))) + .withPath(call.getPathTemplate()) + .withCredentials(credentials) + .withUserTimezone(call.getUserTimezone()) + .withInvokedBy(call.getInvokedBy()) + .withTimeout(call.getTimeout()) + .withClientDetail(call.getClientDetail()); + call.getHeaders().forEach(request::withHeader); + call.getPathParams().forEach(request::withPathParam); + call.getQueryParams().forEach(request::withQueryParam); + call.getFormParams().forEach(request::withFormParam); + if(call.getJsonBody() instanceof String json) + { + //////////////////////////////////////////////////////////////// + // a pre-serialized body (e.g. a QQueryFilter as QQQ writes // + // it): parse it, the invoker serializes the object // + //////////////////////////////////////////////////////////////// + try + { + request.withJsonBody(JsonUtils.toObject(json, Object.class)); + } + catch(Exception e) + { + throw (new QException("The body of " + call.operation() + " is not valid JSON", e)); + } + } + else if(call.getJsonBody() != null) + { + request.withJsonBody(call.getJsonBody()); + } + UiApiResponse response = invoker().invoke(request); + return (new UiApiResult(response.getStatus(), response.getBody())); + } + +} diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/uiapi/LoopbackUiApiGateway.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/uiapi/LoopbackUiApiGateway.java deleted file mode 100644 index 5a0e71d..0000000 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/uiapi/LoopbackUiApiGateway.java +++ /dev/null @@ -1,286 +0,0 @@ -/* - * Copyright 2026 Kingsrook, LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.kingsrook.qbits.assistant.uiapi; - - -import java.io.IOException; -import java.net.URI; -import java.net.URLEncoder; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.nio.charset.StandardCharsets; -import java.time.Duration; -import java.util.Collections; -import java.util.LinkedHashSet; -import java.util.Map; -import java.util.Set; -import java.util.StringJoiner; -import java.util.UUID; -import java.util.function.Supplier; -import com.kingsrook.qqq.backend.core.exceptions.QException; -import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType; -import com.kingsrook.qqq.backend.core.model.metadata.QInstance; -import com.kingsrook.qqq.backend.core.utils.JsonUtils; -import com.kingsrook.qqq.backend.core.utils.StringUtils; -import com.kingsrook.qqq.middleware.javalin.specs.AbstractEndpointSpec; -import com.kingsrook.qqq.middleware.javalin.specs.CompleteOperation; -import com.kingsrook.qqq.middleware.javalin.specs.v1.MiddlewareVersionV1; - - -/******************************************************************************* - ** Stand-in for core's `UiApiInvoker` (QRun-IO/qqq#1128; REPLACE with an - ** adapter over it once a QQQ release has it). Same contract: - ** - ** - only spec'd `/qqq/v1` operations (from {@link MiddlewareVersionV1}'s - ** endpoint specs) can be called; anything else is refused before a request - ** is made, so raw routes (including the assistant's own) are unreachable; - ** - a loopback HTTP request to this server, carrying the user's own session - ** cookie: the identical Javalin pipeline (session setup, permission checks, - ** record locks, validation, audit, error mapping) the UI goes through. There - ** is no service session and no way to elevate; - ** - X-QQQ-Invoked-By marks the request for the access log. - ** - ** Why loopback rather than calling spec executors directly: buildInput, the - ** version's preExecute, filters and error mapping hang off the Javalin - ** pipeline; calling executors would skip some of them and drift over time. - *******************************************************************************/ -public class LoopbackUiApiGateway implements UiApiGateway -{ - public static final String BASE_PATH = "/qqq/v1"; - public static final String INVOKED_BY_HEADER = "X-QQQ-Invoked-By"; - public static final String TIMEZONE_HEADER = "X-QQQ-UserTimezone"; - - /** Headers a call cannot set itself (lower case). */ - private static final Set RESERVED_HEADERS = Set.of("cookie", "authorization", "x-api-key", "content-type", "accept", INVOKED_BY_HEADER.toLowerCase(), TIMEZONE_HEADER.toLowerCase()); - - private static final Set SPEC_OPERATIONS = specOperations(); - - private final Supplier baseUrlSupplier; - private final boolean mockAuthentication; - private final HttpClient httpClient; - - - - /******************************************************************************* - ** @param baseUrlSupplier the server's own root URL, e.g. http://127.0.0.1:8000 - ** @param qInstance the instance (MOCK authentication reads the sessionId - ** cookie rather than sessionUUID, so both are sent there) - *******************************************************************************/ - public LoopbackUiApiGateway(Supplier baseUrlSupplier, QInstance qInstance) - { - this.baseUrlSupplier = baseUrlSupplier; - this.mockAuthentication = qInstance != null && qInstance.getAuthentication() != null && QAuthenticationType.MOCK.equals(qInstance.getAuthentication().getType()); - this.httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).version(HttpClient.Version.HTTP_1_1).build(); - } - - - - /******************************************************************************* - ** "METHOD /path/template" of every v1 endpoint spec. - *******************************************************************************/ - static Set specOperations() - { - Set operations = new LinkedHashSet<>(); - for(AbstractEndpointSpec spec : new MiddlewareVersionV1().getEndpointSpecs()) - { - CompleteOperation operation = spec.defineCompleteOperation(); - operations.add(operation.getHttpMethod().name() + " " + operation.getPath()); - } - return (Collections.unmodifiableSet(operations)); - } - - - - /******************************************************************************* - ** The spec'd operations ("METHOD template"). - *******************************************************************************/ - public static Set getSpecOperations() - { - return (SPEC_OPERATIONS); - } - - - - /******************************************************************************* - ** - *******************************************************************************/ - @Override - public boolean isInvocable(String method, String pathTemplate) - { - return (SPEC_OPERATIONS.contains(method + " " + pathTemplate)); - } - - - - /******************************************************************************* - ** - *******************************************************************************/ - @Override - public UiApiResult invoke(UiApiCall call) throws QException - { - if(call == null || !StringUtils.hasContent(call.getMethod()) || !StringUtils.hasContent(call.getPathTemplate())) - { - throw (new QException("A UI API call needs a method and a path template")); - } - if(!isInvocable(call.getMethod(), call.getPathTemplate())) - { - throw (new QException("Not a spec'd UI API operation: " + call.operation())); - } - if(!StringUtils.hasContent(call.getSessionUUID())) - { - throw (new QException("A session is required to invoke " + call.operation())); - } - - HttpRequest request = buildRequest(call); - try - { - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); - return (new UiApiResult(response.statusCode(), response.body())); - } - catch(InterruptedException e) - { - Thread.currentThread().interrupt(); - throw (new QException("Interrupted invoking " + call.operation(), e)); - } - catch(IOException e) - { - throw (new QException("Error invoking " + call.operation() + ": " + e.getMessage(), e)); - } - } - - - - /******************************************************************************* - ** Add server-owned client attribution for a scheduled turn. All operation, - ** session, encoding and transport rules remain those of this gateway. - *******************************************************************************/ - public UiApiResult invokeWithClient(UiApiCall call, String clientChannel) throws QException - { - if(call == null || !isInvocable(call.getMethod(), call.getPathTemplate()) || !StringUtils.hasContent(call.getSessionUUID())) - { - throw (new QException("A scheduled UI API call requires a spec'd operation and a session.")); - } - try - { - HttpRequest request = HttpRequest.newBuilder(buildRequest(call), (name, value) -> true).setHeader("X-QQQ-Client", clientChannel).build(); - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); - return (new UiApiResult(response.statusCode(), response.body())); - } - catch(InterruptedException e) - { - Thread.currentThread().interrupt(); - throw (new QException("Scheduled UI API request interrupted.")); - } - catch(IOException e) - { - throw (new QException("Scheduled UI API request failed.")); - } - } - - - - /******************************************************************************* - ** The HTTP request: path parameters filled and encoded, query string, JSON - ** body, the session cookie and the marker headers. - *******************************************************************************/ - HttpRequest buildRequest(UiApiCall call) throws QException - { - String path = call.getPathTemplate(); - for(Map.Entry entry : call.getPathParams().entrySet()) - { - if(entry.getValue() == null) - { - throw (new QException("Missing path parameter " + entry.getKey() + " for " + call.operation())); - } - path = path.replace("{" + entry.getKey() + "}", encode(entry.getValue())); - } - if(path.contains("{")) - { - throw (new QException("Missing path parameter(s) for " + call.operation() + ": " + path)); - } - - StringJoiner query = new StringJoiner("&", "?", "").setEmptyValue(""); - call.getQueryParams().forEach((name, value) -> query.add(encode(name) + "=" + encode(value))); - - String base = baseUrlSupplier.get(); - if(!StringUtils.hasContent(base)) - { - throw (new QException("The UI API base URL is not known yet")); - } - URI uri = URI.create(base.replaceAll("/+$", "") + BASE_PATH + path + query); - - HttpRequest.Builder builder = HttpRequest.newBuilder(uri).timeout(call.getTimeout() == null ? Duration.ofSeconds(60) : call.getTimeout()); - String cookie = "sessionUUID=" + call.getSessionUUID(); - if(mockAuthentication) - { - cookie += "; sessionId=" + call.getSessionUUID(); - } - builder.header("Cookie", cookie); - builder.header("Accept", "application/json"); - builder.header(INVOKED_BY_HEADER, StringUtils.hasContent(call.getInvokedBy()) ? call.getInvokedBy() : "assistant"); - if(StringUtils.hasContent(call.getUserTimezone())) - { - builder.header(TIMEZONE_HEADER, call.getUserTimezone()); - } - call.getHeaders().forEach((name, value) -> - { - if(!RESERVED_HEADERS.contains(name.toLowerCase())) - { - builder.header(name, value); - } - }); - - if(!call.getFormParams().isEmpty()) - { - //////////////////////////////////////////////////////////////// - // multipart/form-data text fields (the /processes endpoints) // - //////////////////////////////////////////////////////////////// - String boundary = "qqq-assistant-" + UUID.randomUUID(); - StringBuilder body = new StringBuilder(); - String lineEnd = "\r\n"; - call.getFormParams().forEach((name, value) -> body.append("--").append(boundary).append(lineEnd) - .append("Content-Disposition: form-data; name=\"").append(name.replace("\"", "")).append("\"").append(lineEnd) - .append("Content-Type: text/plain; charset=UTF-8").append(lineEnd).append(lineEnd) - .append(value).append(lineEnd)); - body.append("--").append(boundary).append("--").append(lineEnd); - builder.header("Content-Type", "multipart/form-data; boundary=" + boundary); - builder.method(call.getMethod(), HttpRequest.BodyPublishers.ofString(body.toString(), StandardCharsets.UTF_8)); - } - else if(call.getJsonBody() != null) - { - builder.header("Content-Type", "application/json"); - builder.method(call.getMethod(), HttpRequest.BodyPublishers.ofString(call.getJsonBody() instanceof String s ? s : JsonUtils.toJson(call.getJsonBody()), StandardCharsets.UTF_8)); - } - else - { - builder.method(call.getMethod(), HttpRequest.BodyPublishers.noBody()); - } - return (builder.build()); - } - - - - /******************************************************************************* - ** - *******************************************************************************/ - private static String encode(String value) - { - return (URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20")); - } - -} diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/uiapi/UiApiCall.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/uiapi/UiApiCall.java index 6b48370..0201e36 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/uiapi/UiApiCall.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/uiapi/UiApiCall.java @@ -20,6 +20,7 @@ package com.kingsrook.qbits.assistant.uiapi; import java.time.Duration; import java.util.LinkedHashMap; import java.util.Map; +import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials; /******************************************************************************* @@ -37,7 +38,9 @@ public class UiApiCall private final Map headers = new LinkedHashMap<>(); private Object jsonBody; private String sessionUUID; + private UiApiCredentials credentials; private String userTimezone; + private String clientDetail; private String invokedBy = "assistant"; private Duration timeout = Duration.ofSeconds(60); @@ -215,6 +218,29 @@ public class UiApiCall + /******************************************************************************* + ** The caller's credentials exactly as their request carried them (session + ** cookies, Authorization, API key); preferred over the session UUID alone. + ** Never logged (their toString is redacted). + *******************************************************************************/ + public UiApiCall withCredentials(UiApiCredentials credentials) + { + this.credentials = credentials; + return (this); + } + + + + /******************************************************************************* + ** Getter for credentials + *******************************************************************************/ + public UiApiCredentials getCredentials() + { + return (credentials); + } + + + /******************************************************************************* ** Getter for sessionUUID *******************************************************************************/ @@ -246,6 +272,29 @@ public class UiApiCall + /******************************************************************************* + ** Fluent setter for clientDetail: the detail after the slash of the client + ** channel the invoker labels the call with (assistant/turnId; a scheduled + ** run uses scheduled/taskId). Ignored when blank. + *******************************************************************************/ + public UiApiCall withClientDetail(String clientDetail) + { + this.clientDetail = clientDetail; + return (this); + } + + + + /******************************************************************************* + ** Getter for clientDetail + *******************************************************************************/ + public String getClientDetail() + { + return (clientDetail); + } + + + /******************************************************************************* ** Fluent setter for invokedBy (sent as X-QQQ-Invoked-By) *******************************************************************************/ diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/uiapi/UiApiGateway.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/uiapi/UiApiGateway.java index c2064c3..a15e57f 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/uiapi/UiApiGateway.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/uiapi/UiApiGateway.java @@ -25,9 +25,8 @@ import com.kingsrook.qqq.backend.core.exceptions.QException; ** user's own session, so every tool call passes the same authentication, ** permission, record-lock, validation and audit path as the UI. ** - ** {@link LoopbackUiApiGateway} is a stand-in until core's `UiApiInvoker` - ** (QRun-IO/qqq#1128) is released. Then an adapter over the invoker replaces - ** it, and nothing else changes. + ** {@link CoreUiApiGateway} implements it over core's `UiApiInvoker` + ** (QRun-IO/qqq#1128); a host may inject another (tests). *******************************************************************************/ public interface UiApiGateway { diff --git a/qbit-assistant/src/main/java/com/kingsrook/qqq/middleware/javalin/specs/invoke/HeaderedUiApiInvoker.java b/qbit-assistant/src/main/java/com/kingsrook/qqq/middleware/javalin/specs/invoke/HeaderedUiApiInvoker.java new file mode 100644 index 0000000..f52a076 --- /dev/null +++ b/qbit-assistant/src/main/java/com/kingsrook/qqq/middleware/javalin/specs/invoke/HeaderedUiApiInvoker.java @@ -0,0 +1,126 @@ +/* + * Copyright 2026 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qqq.middleware.javalin.specs.invoke; + + +import java.net.http.HttpRequest; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.function.Supplier; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.middleware.javalin.specs.AbstractMiddlewareVersion; + + +/******************************************************************************* + ** Core's {@link UiApiInvoker} plus extra request headers (the assistant's + ** X-QQQ-AI-Route). Core's {@link UiApiRequest} carries none, and the hook that + ** builds the HTTP request is package-private, so this subclass lives in core's + ** package to override it. Everything else (the operation allow-list, the + ** credentials, the client channel, the depth limit) is core's own. + ** + ** STAND-IN(core=QRun-IO/qqq#1190, removeBy=RC.8): delete this class and use + ** UiApiRequest.withHeader once the qbit builds on a QQQ RC that has it. + *******************************************************************************/ +public class HeaderedUiApiInvoker extends UiApiInvoker +{ + /** Headers a request cannot set itself (lower case): core sets these. */ + private static final Set RESERVED_HEADERS = Set.of("cookie", "authorization", "x-api-key", "content-type", "accept", + INVOKED_BY_HEADER.toLowerCase(Locale.ROOT), TIMEZONE_HEADER.toLowerCase(Locale.ROOT), CLIENT_CHANNEL_HEADER.toLowerCase(Locale.ROOT)); + + + + /******************************************************************************* + ** @param baseUrlSupplier the server's own root URL + ** @param middlewareVersion the version whose operations can be invoked + *******************************************************************************/ + public HeaderedUiApiInvoker(Supplier baseUrlSupplier, AbstractMiddlewareVersion middlewareVersion) + { + super(baseUrlSupplier, middlewareVersion); + } + + + + /******************************************************************************* + ** Core's request, then the extra headers of a {@link HeaderedRequest} + ** (never the reserved ones). + *******************************************************************************/ + @Override + HttpRequest buildHttpRequest(UiApiOperation operation, UiApiRequest request) throws QException + { + HttpRequest built = super.buildHttpRequest(operation, request); + HttpRequest.Builder builder = HttpRequest.newBuilder(built, (name, value) -> true); + //////////////////////////////////////////////////////////////////////////// + // RC.6 strips slashes from client detail. Preserve nested attribution // + // while keeping the channel chosen by core, never by extra headers. // + //////////////////////////////////////////////////////////////////////////// + if(request.getClientDetail() != null && request.getClientDetail().contains("/")) + { + String channel = built.headers().firstValue(CLIENT_CHANNEL_HEADER).orElseThrow().split("/", 2)[0]; + String detail = request.getClientDetail().replaceAll("[^A-Za-z0-9._:/-]", ""); + builder.setHeader(CLIENT_CHANNEL_HEADER, channel + "/" + detail); + } + if(!(request instanceof HeaderedRequest headered)) + { + return (builder.build()); + } + headered.getHeaders().forEach((name, value) -> + { + if(!RESERVED_HEADERS.contains(name.toLowerCase(Locale.ROOT))) + { + builder.header(name, value); + } + }); + return (builder.build()); + } + + + + /******************************************************************************* + ** A UI API request that also carries extra headers. + *******************************************************************************/ + public static class HeaderedRequest extends UiApiRequest + { + private final Map headers = new LinkedHashMap<>(); + + + + /******************************************************************************* + ** Add a header (ignored when the name or value is null). + *******************************************************************************/ + public HeaderedRequest withHeader(String name, String value) + { + if(name != null && value != null) + { + headers.put(name, value); + } + return (this); + } + + + + /******************************************************************************* + ** Getter for headers + *******************************************************************************/ + public Map getHeaders() + { + return (headers); + } + } + +} diff --git a/qbit-assistant/src/main/resources/openapi/qqq-v1/assistant.yaml b/qbit-assistant/src/main/resources/openapi/qqq-v1/assistant.yaml new file mode 100644 index 0000000..0a6097a --- /dev/null +++ b/qbit-assistant/src/main/resources/openapi/qqq-v1/assistant.yaml @@ -0,0 +1,1088 @@ +components: + schemas: + AssistantAccessResponseV1: + description: "Explain access: per table, the user's UI permissions, the assistant's\ + \ data access, and each tool's verdict." + properties: + tables: + items: + additionalProperties: true + description: "table, label, dataAccess, dataAccessSource, ui {visible,\ + \ read, insert, edit, delete}, operations [{tool, operation, allowed,\ + \ reason}]." + type: "object" + type: "array" + type: "object" + AssistantActionV1: + description: "A write the assistant proposed and its state." + properties: + id: + type: "integer" + status: + description: "PENDING, RUNNING, EXECUTED, FAILED, REJECTED or EXPIRED." + type: "string" + toolName: + type: "string" + tableName: + type: "string" + recordId: + nullable: true + type: "string" + summary: + type: "string" + httpStatus: + description: "The UI API status of the write." + nullable: true + type: "integer" + resultSummary: + nullable: true + type: "string" + expiresAt: + nullable: true + type: "string" + outcome: + description: "After a confirm: the tool outcome." + nullable: true + type: "string" + message: + description: "After a confirm: what happened." + nullable: true + type: "string" + href: + description: "After a confirm: the written record's /app link." + nullable: true + type: "string" + type: "object" + AssistantConfigResponseV1: + description: "Whether the assistant is available to this user, and how." + properties: + enabled: + description: "Whether the panel may be used." + type: "boolean" + reason: + description: "Why not: DISABLED, NO_PERMISSION, NOT_CONFIGURED or KILL_SWITCH." + nullable: true + type: "string" + shortcut: + description: "Keyboard shortcut, e.g. mod+j." + type: "string" + model: + description: "The model that answers." + type: "string" + limits: + additionalProperties: true + description: "maxTurnSeconds and maxToolCallsPerTurn." + type: "object" + features: + additionalProperties: true + description: "writes: some table allows writes; pendingActions: writes wait\ + \ for confirmation." + type: "object" + profile: + $ref: "#/components/schemas/AssistantProfileV1" + type: "object" + AssistantConversationDetailResponseV1: + description: "A conversation and a page of its messages, oldest first." + properties: + conversation: + $ref: "#/components/schemas/AssistantConversationV1" + messages: + items: + $ref: "#/components/schemas/AssistantMessageV1" + type: "array" + type: "object" + AssistantConversationListResponseV1: + description: "The user's conversations, newest first." + properties: + conversations: + items: + $ref: "#/components/schemas/AssistantConversationV1" + type: "array" + type: "object" + AssistantConversationV1: + description: "One of the user's own conversations." + properties: + id: + type: "integer" + title: + nullable: true + type: "string" + status: + description: "ACTIVE or ARCHIVED." + type: "string" + pinned: + type: "boolean" + lastMessageAt: + description: "ISO-8601 instant." + nullable: true + type: "string" + unreadCount: + type: "integer" + createDate: + description: "ISO-8601 instant." + nullable: true + type: "string" + type: "object" + AssistantErrorV1: + description: "An error." + properties: + error: + description: "A message for the user." + type: "string" + code: + description: "BUDGET, RATE_LIMIT, NOT_CONFIGURED, KILL_SWITCH, DISABLED,\ + \ NO_PERMISSION..." + nullable: true + type: "string" + type: "object" + AssistantMessageV1: + description: "A message: the user's text or the assistant's reply as parts (text,\ + \ tool, record_refs, navigate, notice)." + properties: + id: + type: "integer" + role: + description: "USER or ASSISTANT." + type: "string" + status: + type: "string" + turnId: + nullable: true + type: "integer" + createDate: + nullable: true + type: "string" + parts: + items: + additionalProperties: true + description: "A message part." + type: "object" + type: "array" + type: "object" + AssistantProfileV1: + description: "How the assistant presents itself (admin-configured; text only,\ + \ it never changes access)." + properties: + displayName: + description: "The assistant's name." + type: "string" + icon: + description: "A Material icon name." + type: "string" + greeting: + description: "Shown on a new conversation." + nullable: true + type: "string" + starterPrompts: + description: "Suggested first questions." + items: + type: "string" + type: "array" + type: "object" + AssistantStatusResponseV1: + description: "An acknowledgement." + properties: + status: + type: "string" + type: "object" + AssistantTurnEventV1: + description: "One server-sent event of a turn (event: , id: , data:\ + \ this object). Types: message.start, text.delta, tool.start, tool.result,\ + \ ui.navigate, notice, usage, message.end, error, ping." + properties: + seq: + description: "Sequence number; resume with ?after=." + type: "integer" + type: + description: "The event type (also the SSE event name)." + type: "string" + data: + additionalProperties: true + description: "The event's fields, by type." + type: "object" + type: "object" + securitySchemes: + sessionUuidCookie: + in: "cookie" + name: "sessionUUID" + type: "apiKey" +info: + contact: + email: "contact@kingsrook.com" + description: "QQQ Assistant: conversations, streamed turns, feedback, pending actions\ + \ and data access explanations" + title: "QQQ Middleware API extension: assistant" + version: "1.0.0" +openapi: "3.0.3" +paths: + /qqq/v1/assistant/config: + get: + description: "Whether the assistant is available to this user, its persona and\ + \ limits" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantConfigResponseV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "Whether the assistant is available to this user, its persona and limits" + /qqq/v1/assistant/conversations: + get: + description: "The user's own conversations" + parameters: + - description: "ARCHIVED for archived conversations; active ones by default." + in: "query" + name: "status" + required: false + schema: + type: "string" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantConversationListResponseV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "The user's own conversations" + post: + description: "Start a conversation" + requestBody: + content: + application/json: + schema: + description: "Optional title and the page context it starts from." + properties: + title: + type: "string" + context: + additionalProperties: true + description: "route, tableName, recordId, filter, selectedIds." + type: "object" + type: "object" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 201: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantConversationV1" + description: "Successful Response" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "Start a conversation" + /qqq/v1/assistant/conversations/{id}: + delete: + description: "Delete a conversation" + parameters: + - description: "The conversation id." + in: "path" + name: "id" + required: true + schema: + type: "integer" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 204: + content: + application/json: + schema: + $ref: "#/components/schemas/null" + description: "Done (no body)" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "Delete a conversation" + get: + description: "A conversation and a page of its messages" + parameters: + - description: "The conversation id." + in: "path" + name: "id" + required: true + schema: + type: "integer" + - description: "Only messages older than this message id." + in: "query" + name: "before" + required: false + schema: + type: "integer" + - description: "Page size (default 50, at most 200)." + in: "query" + name: "limit" + required: false + schema: + type: "integer" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantConversationDetailResponseV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "A conversation and a page of its messages" + patch: + description: "Rename, pin or archive a conversation" + parameters: + - description: "The conversation id." + in: "path" + name: "id" + required: true + schema: + type: "integer" + requestBody: + content: + application/json: + schema: + description: "The changed properties." + properties: + title: + type: "string" + pinned: + type: "boolean" + status: + description: "ACTIVE or ARCHIVED." + type: "string" + type: "object" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantConversationV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "Rename, pin or archive a conversation" + /qqq/v1/assistant/conversations/{id}/turns: + post: + description: "Ask: start a turn and stream the reply as server-sent events" + parameters: + - description: "The conversation id." + in: "path" + name: "id" + required: true + schema: + type: "integer" + requestBody: + content: + application/json: + schema: + description: "The user's text and the page context hint." + properties: + text: + description: "The user's message." + type: "string" + pageContext: + additionalProperties: true + description: "Where the user is in the app." + type: "object" + type: "object" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 200: + content: + text/event-stream: + schema: + $ref: "#/components/schemas/AssistantTurnEventV1" + description: "The turn's events, until message.end" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "Ask: start a turn and stream the reply as server-sent events" + /qqq/v1/assistant/turns/{turnId}/events: + get: + description: "Resume a turn's events after a sequence number (replay, then the\ + \ live tail)" + parameters: + - description: "The turn id." + in: "path" + name: "turnId" + required: true + schema: + type: "integer" + - description: "The last sequence number already received (0 for all)." + in: "query" + name: "after" + required: false + schema: + type: "integer" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 200: + content: + text/event-stream: + schema: + $ref: "#/components/schemas/AssistantTurnEventV1" + description: "The turn's events after `after`, until message.end" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "Resume a turn's events after a sequence number (replay, then the live\ + \ tail)" + /qqq/v1/assistant/turns/{turnId}/cancel: + post: + description: "Stop a running turn" + parameters: + - description: "The turn id." + in: "path" + name: "turnId" + required: true + schema: + type: "integer" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 202: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantStatusResponseV1" + description: "Successful Response" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "Stop a running turn" + /qqq/v1/assistant/messages/{id}/feedback: + post: + description: "Thumbs up or down on an assistant message" + parameters: + - description: "The assistant message id." + in: "path" + name: "id" + required: true + schema: + type: "integer" + requestBody: + content: + application/json: + schema: + description: "The rating and an optional reason or comment." + properties: + rating: + description: "1 or -1." + type: "integer" + reason: + type: "string" + comment: + type: "string" + type: "object" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 204: + content: + application/json: + schema: + $ref: "#/components/schemas/null" + description: "Done (no body)" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "Thumbs up or down on an assistant message" + /qqq/v1/assistant/actions/{id}: + get: + description: "A write the assistant proposed, and its state" + parameters: + - description: "The pending action id." + in: "path" + name: "id" + required: true + schema: + type: "integer" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantActionV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "A write the assistant proposed, and its state" + /qqq/v1/assistant/actions/{id}/confirm: + post: + description: "Confirm a proposed write: it runs once, through the UI API, with\ + \ this request's session" + parameters: + - description: "The pending action id." + in: "path" + name: "id" + required: true + schema: + type: "integer" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantActionV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "Confirm a proposed write: it runs once, through the UI API, with this\ + \ request's session" + /qqq/v1/assistant/actions/{id}/reject: + post: + description: "Cancel a proposed write; nothing changes" + parameters: + - description: "The pending action id." + in: "path" + name: "id" + required: true + schema: + type: "integer" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantActionV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "Cancel a proposed write; nothing changes" + /qqq/v1/assistant/access: + get: + description: "Explain access: which UI API operations the assistant could call\ + \ for this user, and why not" + parameters: + - description: "Only this table." + in: "query" + name: "table" + required: false + schema: + type: "string" + - description: "Only the tables this text mentions." + in: "query" + name: "prompt" + required: false + schema: + type: "string" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantAccessResponseV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "Explain access: which UI API operations the assistant could call for\ + \ this user, and why not" diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/ArchitectureTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/ArchitectureTest.java index e805cef..bba0177 100644 --- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/ArchitectureTest.java +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/ArchitectureTest.java @@ -24,7 +24,8 @@ import com.kingsrook.qbits.assistant.model.AssistantProfileSkill; import com.kingsrook.qbits.assistant.model.AssistantSkill; import com.kingsrook.qbits.assistant.persistence.AssistantStore; import com.kingsrook.qbits.assistant.tools.UiApiToolbox; -import com.kingsrook.qbits.assistant.uiapi.LoopbackUiApiGateway; +import com.kingsrook.qbits.assistant.uiapi.CoreUiApiGateway; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; import com.kingsrook.qqq.backend.core.actions.ai.AiToolKernel; import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction; import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; @@ -176,9 +177,10 @@ class ArchitectureTest { assertThat(ContextBuilder.KERNEL_TOOLS).containsExactlyInAnyOrder("navigate", "search_help"); assertThat(UiApiToolbox.OPERATIONS.keySet()).doesNotContainAnyElementsOf(ContextBuilder.KERNEL_TOOLS); + CoreUiApiGateway gateway = new CoreUiApiGateway(() -> "http://127.0.0.1:1", new QInstance()); for(String[] operation : UiApiToolbox.OPERATIONS.values()) { - assertThat(LoopbackUiApiGateway.getSpecOperations()).as("spec'd: " + operation[0] + " " + operation[1]).contains(operation[0] + " " + operation[1]); + assertThat(gateway.isInvocable(operation[0], operation[1])).as("spec'd: " + operation[0] + " " + operation[1]).isTrue(); } } diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/api/AssistantRouteProviderTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/api/AssistantRouteProviderTest.java index 9445e48..4dd4316 100644 --- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/api/AssistantRouteProviderTest.java +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/api/AssistantRouteProviderTest.java @@ -35,11 +35,11 @@ import com.kingsrook.qbits.assistant.model.AssistantPolicy; import com.kingsrook.qbits.assistant.persistence.AssistantStore; import com.kingsrook.qbits.assistant.testing.AssistantTestFixture; import com.kingsrook.qbits.assistant.testing.BaseAssistantTest; +import com.kingsrook.qbits.assistant.testing.UiApiTestServer; import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; import com.kingsrook.qqq.backend.core.utils.JsonUtils; import com.kingsrook.qqq.middleware.javalin.QJavalinMetaData; -import io.javalin.Javalin; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -57,7 +57,6 @@ import static org.assertj.core.api.Assertions.assertThat; *******************************************************************************/ class AssistantRouteProviderTest extends BaseAssistantTest { - private Javalin javalin; private String base; private HttpClient client = HttpClient.newHttpClient(); @@ -70,10 +69,8 @@ class AssistantRouteProviderTest extends BaseAssistantTest void startServer() { QContext.clear(); - AssistantRouteProvider routes = new AssistantRouteProvider().withPingMillis(200); - routes.setQInstance(qInstance); - javalin = Javalin.create(routes::acceptJavalinConfig).start(0); - base = "http://localhost:" + javalin.port() + AssistantRouteProvider.BASE_PATH; + TurnStreams.setPingMillis(200); + base = UiApiTestServer.serve(qInstance, config) + AssistantRouteProvider.BASE_PATH; } @@ -84,7 +81,7 @@ class AssistantRouteProviderTest extends BaseAssistantTest @AfterEach void stopServer() { - javalin.stop(); + TurnStreams.setPingMillis(TurnStreams.PING_MILLIS); } diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/api/specs/AssistantSpecsContractTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/api/specs/AssistantSpecsContractTest.java new file mode 100644 index 0000000..ba5f1ec --- /dev/null +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/api/specs/AssistantSpecsContractTest.java @@ -0,0 +1,73 @@ +/* + * 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.specs; + + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import com.kingsrook.qbits.assistant.testing.BaseAssistantTest; +import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData; +import com.kingsrook.qqq.middleware.javalin.specs.v1.MiddlewareVersionV1; +import com.kingsrook.qqq.middleware.javalin.tools.UiApiContract; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** The assistant's UI API extension: registered by the producer, and its + ** OpenAPI fragment matches the committed one + ** (src/main/resources/openapi/qqq-v1/assistant.yaml) and is compatible with + ** the last release's (baseline). Regenerate with + ** -Dassistant.regenerateOpenApi=true. + *******************************************************************************/ +class AssistantSpecsContractTest extends BaseAssistantTest +{ + static final Path COMMITTED = Path.of("src/main/resources/openapi/qqq-v1/assistant.yaml"); + static final Path BASELINE = Path.of("src/test/resources/openapi/qqq-v1/assistant-baseline.yaml"); + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Test + void testRegisteredAndCommitted() throws Exception + { + assertThat(MiddlewareSpecExtensionsMetaData.ofOrWithNew(qInstance).getExtensions()).containsKey(AssistantSpecs.EXTENSION_NAME); + + String generated = UiApiContract.generateExtensionYaml(new MiddlewareVersionV1(), AssistantSpecs.extension()); + if(Boolean.getBoolean("assistant.regenerateOpenApi")) + { + Files.createDirectories(COMMITTED.getParent()); + Files.writeString(COMMITTED, generated, StandardCharsets.UTF_8); + if(!Files.exists(BASELINE)) + { + Files.createDirectories(BASELINE.getParent()); + Files.writeString(BASELINE, generated, StandardCharsets.UTF_8); + } + } + + assertThat(generated).contains("/qqq/v1/assistant/conversations/{id}/turns").contains("text/event-stream").contains("/qqq/v1/assistant/actions/{id}/confirm") + .contains("AssistantTurnEventV1").contains("/qqq/v1/assistant/access"); + List problems = UiApiContract.check(generated, Files.readString(COMMITTED, StandardCharsets.UTF_8), + Files.exists(BASELINE) ? Files.readString(BASELINE, StandardCharsets.UTF_8) : null, 1, "./mvnw test -pl qbit-assistant -Dtest=AssistantSpecsContractTest -Dassistant.regenerateOpenApi=true"); + assertThat(problems).isEmpty(); + } + +} diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/classification/ClassificationTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/classification/ClassificationTest.java index 50a51f0..d00a1c8 100644 --- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/classification/ClassificationTest.java +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/classification/ClassificationTest.java @@ -32,6 +32,7 @@ import com.kingsrook.qbits.assistant.provider.ModelPart; import com.kingsrook.qbits.assistant.testing.AssistantTestFixture; import com.kingsrook.qbits.assistant.testing.BaseAssistantTest; import com.kingsrook.qqq.backend.core.actions.ai.AiToolResult; +import com.kingsrook.qqq.backend.core.actions.ai.DataClassification; import com.kingsrook.qqq.backend.core.context.QContext; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; @@ -67,11 +68,11 @@ class ClassificationTest extends BaseAssistantTest assertThat(source.highest(null)).isEqualTo(DataClassification.PUBLIC); assertThat(source.highest("assistant_message")).isEqualTo(DataClassification.PUBLIC); assertThat(source.highestAnywhere()).isEqualTo(DataClassification.PHI); - assertThat(DataClassification.max(null, null)).isEqualTo(DataClassification.PUBLIC); - assertThat(DataClassification.max(DataClassification.PHI, DataClassification.PII)).isEqualTo(DataClassification.PHI); + assertThat(Classifications.max(null, null)).isEqualTo(DataClassification.PUBLIC); + assertThat(Classifications.max(DataClassification.PHI, DataClassification.PII)).isEqualTo(DataClassification.PHI); assertThat(DataClassification.PII.isAtLeast(null)).isTrue(); assertThat(DataClassification.PUBLIC.isAtLeast(DataClassification.PII)).isFalse(); - assertThat(DataClassification.stored("future")).isEqualTo(DataClassification.PHI); + assertThat(Classifications.stored("future")).isEqualTo(DataClassification.PHI); ClassificationSource host = new ConfiguredClassificationSource().withClassification("media", DataClassification.PII); assertThat(config.withClassificationSource(host).getClassificationSource()).isSameAs(host); assertThat(new ConfiguredClassificationSource().withClassification(null, null).withClassification(null, null, null).ofTable(null)).isEqualTo(DataClassification.PUBLIC); diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/PhiRoutingTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/PhiRoutingTest.java index 9312ac3..0726156 100644 --- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/PhiRoutingTest.java +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/PhiRoutingTest.java @@ -26,7 +26,6 @@ import java.time.Instant; import java.util.List; import java.util.Map; import com.kingsrook.qbits.assistant.AssistantQBitConfig; -import com.kingsrook.qbits.assistant.classification.DataClassification; import com.kingsrook.qbits.assistant.model.AssistantDataAudit; import com.kingsrook.qbits.assistant.model.AssistantProvider; import com.kingsrook.qbits.assistant.model.AssistantTurn; @@ -37,6 +36,7 @@ import com.kingsrook.qbits.assistant.provider.AssistantProviderType; import com.kingsrook.qbits.assistant.testing.AssistantTestFixture; import com.kingsrook.qbits.assistant.testing.BaseAssistantTest; import com.kingsrook.qbits.assistant.testing.TurnRunner; +import com.kingsrook.qqq.backend.core.actions.ai.DataClassification; import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; import com.kingsrook.qqq.backend.core.model.data.QRecord; diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/RoutingFallbackTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/RoutingFallbackTest.java index c05a607..4221a3c 100644 --- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/RoutingFallbackTest.java +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/RoutingFallbackTest.java @@ -17,7 +17,6 @@ package com.kingsrook.qbits.assistant.engine; -import com.kingsrook.qbits.assistant.model.AssistantPendingAction; import java.math.BigDecimal; import java.time.Clock; import java.time.Duration; @@ -30,11 +29,11 @@ import java.util.Map; import java.util.Set; import com.kingsrook.qbits.assistant.AssistantQBitConfig; import com.kingsrook.qbits.assistant.AssistantRuntime; -import com.kingsrook.qbits.assistant.classification.DataClassification; import com.kingsrook.qbits.assistant.home.AssistantHomeData; import com.kingsrook.qbits.assistant.home.AssistantHomeView; import com.kingsrook.qbits.assistant.model.AssistantBudget; import com.kingsrook.qbits.assistant.model.AssistantModel; +import com.kingsrook.qbits.assistant.model.AssistantPendingAction; import com.kingsrook.qbits.assistant.model.AssistantProfile; import com.kingsrook.qbits.assistant.model.AssistantProvider; import com.kingsrook.qbits.assistant.model.AssistantTurn; @@ -55,6 +54,7 @@ import com.kingsrook.qbits.assistant.provider.fake.ScriptedModelProvider; import com.kingsrook.qbits.assistant.testing.AssistantTestFixture; import com.kingsrook.qbits.assistant.testing.BaseAssistantTest; import com.kingsrook.qbits.assistant.testing.TurnRunner; +import com.kingsrook.qqq.backend.core.actions.ai.DataClassification; import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; import com.kingsrook.qqq.backend.core.model.data.QRecord; diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/evals/GoldenEvalTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/evals/GoldenEvalTest.java index 5f87239..841c155 100644 --- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/evals/GoldenEvalTest.java +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/evals/GoldenEvalTest.java @@ -21,6 +21,7 @@ import java.util.stream.Stream; import com.kingsrook.qbits.assistant.testing.BaseAssistantTest; import com.kingsrook.qbits.assistant.testing.GoldenTasks; import com.kingsrook.qbits.assistant.testing.TurnRunner; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.ClientChannelsMetaData; import org.junit.jupiter.api.Tag; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; @@ -53,6 +54,10 @@ class GoldenEvalTest extends BaseAssistantTest @MethodSource("tasks") void golden(GoldenTasks.Task task) throws Exception { + if("D-page-12-this-week".equals(task.id())) + { + ClientChannelsMetaData.ofOrWithNew(qInstance).getDeniedPatterns().get("assistant").remove("audit*"); + } task.script().accept(provider); TurnRunner.Outcome outcome = runner.run(task.userId(), task.prompt()); diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/evals/RedTeamTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/evals/RedTeamTest.java index 714a38f..4d89d2d 100644 --- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/evals/RedTeamTest.java +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/evals/RedTeamTest.java @@ -90,7 +90,11 @@ class RedTeamTest extends BaseAssistantTest for(Map result : outcome.events(TurnEvent.TOOL_RESULT)) { - if(!"list_tables".equals(result.get("name")) && !"search_records".equals(result.get("name"))) + if("get_record_history".equals(result.get("name"))) + { + assertThat(result.get("outcome")).as("history is not offered under the default channel wall").isEqualTo("INVALID_INPUT"); + } + else if(!"list_tables".equals(result.get("name")) && !"search_records".equals(result.get("name"))) { assertThat(result.get("outcome")).as(String.valueOf(result.get("name"))).isIn("DENIED", "NOT_FOUND"); } diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/persistence/HipaaStorageTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/persistence/HipaaStorageTest.java index efbd6a4..b932449 100644 --- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/persistence/HipaaStorageTest.java +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/persistence/HipaaStorageTest.java @@ -27,7 +27,6 @@ import java.util.Map; import com.kingsrook.qbits.assistant.AssistantQBitConfig; import com.kingsrook.qbits.assistant.AssistantRuntime; import com.kingsrook.qbits.assistant.api.AssistantApi; -import com.kingsrook.qbits.assistant.classification.DataClassification; import com.kingsrook.qbits.assistant.engine.TurnEngine; import com.kingsrook.qbits.assistant.model.AssistantConversation; import com.kingsrook.qbits.assistant.model.AssistantMessage; @@ -46,6 +45,7 @@ import com.kingsrook.qbits.assistant.provider.StreamHandle; import com.kingsrook.qbits.assistant.provider.fake.ScriptedModelProvider; import com.kingsrook.qbits.assistant.testing.AssistantTestFixture; import com.kingsrook.qbits.assistant.testing.BaseAssistantTest; +import com.kingsrook.qqq.backend.core.actions.ai.DataClassification; import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; import com.kingsrook.qqq.backend.core.context.QContext; diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/rag/RagTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/rag/RagTest.java index 74136df..0867730 100644 --- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/rag/RagTest.java +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/rag/RagTest.java @@ -22,7 +22,6 @@ import java.util.List; import java.util.Map; import java.util.Set; import com.kingsrook.qbits.assistant.AssistantQBitConfig; -import com.kingsrook.qbits.assistant.classification.DataClassification; import com.kingsrook.qbits.assistant.engine.PolicyService; import com.kingsrook.qbits.assistant.engine.TurnClassification; import com.kingsrook.qbits.assistant.model.AssistantConversation; @@ -49,6 +48,7 @@ import com.kingsrook.qqq.backend.core.actions.ai.AiExposure; import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData; import com.kingsrook.qqq.backend.core.actions.ai.AiToolOutcome; import com.kingsrook.qqq.backend.core.actions.ai.AiToolResult; +import com.kingsrook.qqq.backend.core.actions.ai.DataClassification; import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/scheduled/ScheduledTasksTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/scheduled/ScheduledTasksTest.java index d33e05c..6c12ae8 100644 --- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/scheduled/ScheduledTasksTest.java +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/scheduled/ScheduledTasksTest.java @@ -39,7 +39,6 @@ import java.util.stream.Stream; import com.kingsrook.qbits.assistant.AssistantQBitConfig; import com.kingsrook.qbits.assistant.AssistantRuntime; import com.kingsrook.qbits.assistant.AssistantSecurity; -import com.kingsrook.qbits.assistant.classification.DataClassification; import com.kingsrook.qbits.assistant.model.AssistantDataAccess; import com.kingsrook.qbits.assistant.model.AssistantPendingAction; import com.kingsrook.qbits.assistant.model.AssistantPolicy; @@ -56,6 +55,7 @@ import com.kingsrook.qbits.assistant.testing.AssistantTestFixture; import com.kingsrook.qbits.assistant.testing.BaseAssistantTest; import com.kingsrook.qbits.assistant.testing.UiApiTestServer; import com.kingsrook.qbits.assistant.uiapi.UiApiCall; +import com.kingsrook.qqq.backend.core.actions.ai.DataClassification; import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException; @@ -72,7 +72,6 @@ import com.kingsrook.qqq.backend.core.model.session.QSession; import com.kingsrook.qqq.backend.core.modules.authentication.implementations.MockAuthenticationModule; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; - import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/testing/AssistantTestFixture.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/testing/AssistantTestFixture.java index e2e8cd1..635b60c 100644 --- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/testing/AssistantTestFixture.java +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/testing/AssistantTestFixture.java @@ -137,7 +137,7 @@ public class AssistantTestFixture .withField(new QFieldMetaData("statDate", QFieldType.DATE).withLabel("Stat Date")) .withField(new QFieldMetaData("pageviews", QFieldType.INTEGER).withLabel("Pageviews"))); - if(config.getClassificationSource().ofTable("patient") == com.kingsrook.qbits.assistant.classification.DataClassification.PHI) + if(config.getClassificationSource().ofTable("patient") == com.kingsrook.qqq.backend.core.actions.ai.DataClassification.PHI) { qInstance.addTable(table("patient", "Patient", "status") .withField(new QFieldMetaData("email", QFieldType.STRING)) diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/UiApiToolsSecurityTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/UiApiToolsSecurityTest.java index e0f3e9f..a7518cd 100644 --- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/UiApiToolsSecurityTest.java +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/tools/UiApiToolsSecurityTest.java @@ -41,9 +41,13 @@ import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.audits.AuditsMetaDataProvider; import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.ClientChannelsMetaData; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -58,6 +62,55 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; *******************************************************************************/ class UiApiToolsSecurityTest extends BaseAssistantTest { + /******************************************************************************* + ** The default channel wall hides history, even when the user can read audits. + *******************************************************************************/ + @Test + void historyHiddenByDefault() + { + assertThat(config.getRuntime().getToolbox().definitions()).extracting(definition -> definition.getName()).doesNotContain(UiApiToolbox.RECORD_HISTORY); + assertThat(tool(AssistantTestFixture.EDITOR, null, UiApiToolbox.RECORD_HISTORY, input("table", "page", "id", "12")).getHttpStatus()).isEqualTo(403); + } + + + + /******************************************************************************* + ** A host opt-in offers history and reads it through the real UI API. + *******************************************************************************/ + @Test + void historyOfferedAndReadableAfterHostOptIn() + { + ClientChannelsMetaData.ofOrWithNew(qInstance).getDeniedPatterns().get("assistant").remove("audit*"); + assertThat(config.getRuntime().getToolbox().definitions()).extracting(definition -> definition.getName()).contains(UiApiToolbox.RECORD_HISTORY); + UiApiToolbox.Outcome outcome = tool(AssistantTestFixture.EDITOR, null, UiApiToolbox.RECORD_HISTORY, input("table", "page", "id", "12", "since", "THIS_WEEK")); + assertThat(outcome.getResult().getOutcome()).isEqualTo(AiToolOutcome.OK); + assertThat(outcome.getHttpStatus()).isEqualTo(200); + assertThat(outcome.getResult().getModelContent()).contains("title", "metaDescription", "/app/page/12"); + } + + + + /******************************************************************************* + ** Each audit table's explicit or inherited channel denial still hides history. + *******************************************************************************/ + @ParameterizedTest + @ValueSource(strings = { AuditsMetaDataProvider.TABLE_NAME_AUDIT_TABLE, AuditsMetaDataProvider.TABLE_NAME_AUDIT, AuditsMetaDataProvider.TABLE_NAME_AUDIT_DETAIL }) + void historyRespectsEachAuditTableDenial(String name) + { + ClientChannelsMetaData channels = ClientChannelsMetaData.ofOrWithNew(qInstance); + channels.getDeniedPatterns().get("assistant").remove("audit*"); + channels.withDeniedObject("assistant", name); + assertThat(config.getRuntime().getToolbox().definitions()).extracting(definition -> definition.getName()).doesNotContain(UiApiToolbox.RECORD_HISTORY); + channels.getDeniedObjects().get("assistant").remove(name); + qInstance.getTable(name).getPermissionRules().withDeniedClients(Set.of("assistant")); + assertThat(config.getRuntime().getToolbox().definitions()).extracting(definition -> definition.getName()).doesNotContain(UiApiToolbox.RECORD_HISTORY); + qInstance.setDefaultPermissionRules(qInstance.getTable(name).getPermissionRules()); + qInstance.getTable(name).setPermissionRules(null); + assertThat(config.getRuntime().getToolbox().definitions()).extracting(definition -> definition.getName()).doesNotContain(UiApiToolbox.RECORD_HISTORY); + } + + + private final HttpClient client = HttpClient.newHttpClient(); diff --git a/qbit-assistant/src/test/java/com/kingsrook/qqq/middleware/javalin/specs/invoke/HeaderedUiApiInvokerTest.java b/qbit-assistant/src/test/java/com/kingsrook/qqq/middleware/javalin/specs/invoke/HeaderedUiApiInvokerTest.java new file mode 100644 index 0000000..d05cf4d --- /dev/null +++ b/qbit-assistant/src/test/java/com/kingsrook/qqq/middleware/javalin/specs/invoke/HeaderedUiApiInvokerTest.java @@ -0,0 +1,82 @@ +/* + * 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.qqq.middleware.javalin.specs.invoke; + + +import java.net.http.HttpRequest; +import java.util.List; +import com.kingsrook.qqq.middleware.javalin.specs.v1.MiddlewareVersionV1; +import com.kingsrook.qqq.openapi.model.HttpMethod; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** Extra route headers cannot override core-owned credentials or attribution. + *******************************************************************************/ +class HeaderedUiApiInvokerTest +{ + /******************************************************************************* + ** Non-reserved headers pass through; reserved headers are ignored regardless + ** of case, retaining only values supplied through core's request properties. + *******************************************************************************/ + @Test + void forwardsOnlyNonReservedHeaders() throws Exception + { + HeaderedUiApiInvoker invoker = new HeaderedUiApiInvoker(() -> "http://127.0.0.1:8000", new MiddlewareVersionV1()); + invoker.withClientChannel("assistant"); + HeaderedUiApiInvoker.HeaderedRequest request = new HeaderedUiApiInvoker.HeaderedRequest(); + request.withMethod(HttpMethod.POST).withPath("/table/{tableName}/query").withPathParam("tableName", "page") + .withCredentials(new UiApiCredentials("session-value", null, "Bearer credential-value", "key-value")) + .withJsonBody(java.util.Map.of()).withUserTimezone("America/Chicago").withInvokedBy("turn-engine").withClientDetail("scheduled/42"); + for(String name : List.of("cOoKiE", "AUTHORIZATION", "X-API-Key", "Content-Type", "ACCEPT", "X-QQQ-Invoked-By", "x-qqq-usertimezone", "X-QQQ-CLIENT")) + { + request.withHeader(name, "must-not-forward"); + } + request.withHeader("X-QQQ-AI-Route", "phi-route").withHeader("X-Request-Tag", "tag-value").withHeader(null, "ignored").withHeader("X-Null", null); + UiApiOperation operation = invoker.findOperation(HttpMethod.POST, request.getPath()).orElseThrow(); + HttpRequest built = invoker.buildHttpRequest(operation, request); + assertThat(built.headers().allValues("Cookie")).containsExactly("sessionUUID=session-value"); + assertThat(built.headers().allValues("Authorization")).containsExactly("Bearer credential-value"); + assertThat(built.headers().allValues("x-api-key")).containsExactly("key-value"); + assertThat(built.headers().allValues("Content-Type")).containsExactly("application/json"); + assertThat(built.headers().allValues("Accept")).isEmpty(); + assertThat(built.headers().allValues(UiApiInvoker.INVOKED_BY_HEADER)).containsExactly("turn-engine"); + assertThat(built.headers().allValues(UiApiInvoker.TIMEZONE_HEADER)).containsExactly("America/Chicago"); + assertThat(built.headers().allValues(UiApiInvoker.CLIENT_CHANNEL_HEADER)).containsExactly("assistant/scheduled/42"); + assertThat(built.headers().allValues("X-QQQ-AI-Route")).containsExactly("phi-route"); + assertThat(built.headers().allValues("X-Request-Tag")).containsExactly("tag-value"); + assertThat(built.headers().allValues("X-Null")).isEmpty(); + assertThat(built.headers().map().values()).allSatisfy(values -> assertThat(values).doesNotContain("must-not-forward")); + } + + + + /******************************************************************************* + ** Ordinary core requests retain attribution without requiring extra headers. + *******************************************************************************/ + @Test + void ordinaryRequestPreservesCoreHeaders() throws Exception + { + HeaderedUiApiInvoker invoker = new HeaderedUiApiInvoker(() -> "http://127.0.0.1:8000", new MiddlewareVersionV1()); + invoker.withClientChannel("assistant"); + UiApiRequest request = new UiApiRequest().withMethod(HttpMethod.GET).withPath("/metaData").withClientDetail("turn-42"); + HttpRequest built = invoker.buildHttpRequest(invoker.findOperation(HttpMethod.GET, request.getPath()).orElseThrow(), request); + assertThat(built.headers().allValues(UiApiInvoker.CLIENT_CHANNEL_HEADER)).containsExactly("assistant/turn-42"); + assertThat(built.headers().allValues("X-QQQ-AI-Route")).isEmpty(); + } +} diff --git a/qbit-assistant/src/test/resources/openapi/qqq-v1/assistant-baseline.yaml b/qbit-assistant/src/test/resources/openapi/qqq-v1/assistant-baseline.yaml new file mode 100644 index 0000000..0a6097a --- /dev/null +++ b/qbit-assistant/src/test/resources/openapi/qqq-v1/assistant-baseline.yaml @@ -0,0 +1,1088 @@ +components: + schemas: + AssistantAccessResponseV1: + description: "Explain access: per table, the user's UI permissions, the assistant's\ + \ data access, and each tool's verdict." + properties: + tables: + items: + additionalProperties: true + description: "table, label, dataAccess, dataAccessSource, ui {visible,\ + \ read, insert, edit, delete}, operations [{tool, operation, allowed,\ + \ reason}]." + type: "object" + type: "array" + type: "object" + AssistantActionV1: + description: "A write the assistant proposed and its state." + properties: + id: + type: "integer" + status: + description: "PENDING, RUNNING, EXECUTED, FAILED, REJECTED or EXPIRED." + type: "string" + toolName: + type: "string" + tableName: + type: "string" + recordId: + nullable: true + type: "string" + summary: + type: "string" + httpStatus: + description: "The UI API status of the write." + nullable: true + type: "integer" + resultSummary: + nullable: true + type: "string" + expiresAt: + nullable: true + type: "string" + outcome: + description: "After a confirm: the tool outcome." + nullable: true + type: "string" + message: + description: "After a confirm: what happened." + nullable: true + type: "string" + href: + description: "After a confirm: the written record's /app link." + nullable: true + type: "string" + type: "object" + AssistantConfigResponseV1: + description: "Whether the assistant is available to this user, and how." + properties: + enabled: + description: "Whether the panel may be used." + type: "boolean" + reason: + description: "Why not: DISABLED, NO_PERMISSION, NOT_CONFIGURED or KILL_SWITCH." + nullable: true + type: "string" + shortcut: + description: "Keyboard shortcut, e.g. mod+j." + type: "string" + model: + description: "The model that answers." + type: "string" + limits: + additionalProperties: true + description: "maxTurnSeconds and maxToolCallsPerTurn." + type: "object" + features: + additionalProperties: true + description: "writes: some table allows writes; pendingActions: writes wait\ + \ for confirmation." + type: "object" + profile: + $ref: "#/components/schemas/AssistantProfileV1" + type: "object" + AssistantConversationDetailResponseV1: + description: "A conversation and a page of its messages, oldest first." + properties: + conversation: + $ref: "#/components/schemas/AssistantConversationV1" + messages: + items: + $ref: "#/components/schemas/AssistantMessageV1" + type: "array" + type: "object" + AssistantConversationListResponseV1: + description: "The user's conversations, newest first." + properties: + conversations: + items: + $ref: "#/components/schemas/AssistantConversationV1" + type: "array" + type: "object" + AssistantConversationV1: + description: "One of the user's own conversations." + properties: + id: + type: "integer" + title: + nullable: true + type: "string" + status: + description: "ACTIVE or ARCHIVED." + type: "string" + pinned: + type: "boolean" + lastMessageAt: + description: "ISO-8601 instant." + nullable: true + type: "string" + unreadCount: + type: "integer" + createDate: + description: "ISO-8601 instant." + nullable: true + type: "string" + type: "object" + AssistantErrorV1: + description: "An error." + properties: + error: + description: "A message for the user." + type: "string" + code: + description: "BUDGET, RATE_LIMIT, NOT_CONFIGURED, KILL_SWITCH, DISABLED,\ + \ NO_PERMISSION..." + nullable: true + type: "string" + type: "object" + AssistantMessageV1: + description: "A message: the user's text or the assistant's reply as parts (text,\ + \ tool, record_refs, navigate, notice)." + properties: + id: + type: "integer" + role: + description: "USER or ASSISTANT." + type: "string" + status: + type: "string" + turnId: + nullable: true + type: "integer" + createDate: + nullable: true + type: "string" + parts: + items: + additionalProperties: true + description: "A message part." + type: "object" + type: "array" + type: "object" + AssistantProfileV1: + description: "How the assistant presents itself (admin-configured; text only,\ + \ it never changes access)." + properties: + displayName: + description: "The assistant's name." + type: "string" + icon: + description: "A Material icon name." + type: "string" + greeting: + description: "Shown on a new conversation." + nullable: true + type: "string" + starterPrompts: + description: "Suggested first questions." + items: + type: "string" + type: "array" + type: "object" + AssistantStatusResponseV1: + description: "An acknowledgement." + properties: + status: + type: "string" + type: "object" + AssistantTurnEventV1: + description: "One server-sent event of a turn (event: , id: , data:\ + \ this object). Types: message.start, text.delta, tool.start, tool.result,\ + \ ui.navigate, notice, usage, message.end, error, ping." + properties: + seq: + description: "Sequence number; resume with ?after=." + type: "integer" + type: + description: "The event type (also the SSE event name)." + type: "string" + data: + additionalProperties: true + description: "The event's fields, by type." + type: "object" + type: "object" + securitySchemes: + sessionUuidCookie: + in: "cookie" + name: "sessionUUID" + type: "apiKey" +info: + contact: + email: "contact@kingsrook.com" + description: "QQQ Assistant: conversations, streamed turns, feedback, pending actions\ + \ and data access explanations" + title: "QQQ Middleware API extension: assistant" + version: "1.0.0" +openapi: "3.0.3" +paths: + /qqq/v1/assistant/config: + get: + description: "Whether the assistant is available to this user, its persona and\ + \ limits" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantConfigResponseV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "Whether the assistant is available to this user, its persona and limits" + /qqq/v1/assistant/conversations: + get: + description: "The user's own conversations" + parameters: + - description: "ARCHIVED for archived conversations; active ones by default." + in: "query" + name: "status" + required: false + schema: + type: "string" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantConversationListResponseV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "The user's own conversations" + post: + description: "Start a conversation" + requestBody: + content: + application/json: + schema: + description: "Optional title and the page context it starts from." + properties: + title: + type: "string" + context: + additionalProperties: true + description: "route, tableName, recordId, filter, selectedIds." + type: "object" + type: "object" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 201: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantConversationV1" + description: "Successful Response" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "Start a conversation" + /qqq/v1/assistant/conversations/{id}: + delete: + description: "Delete a conversation" + parameters: + - description: "The conversation id." + in: "path" + name: "id" + required: true + schema: + type: "integer" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 204: + content: + application/json: + schema: + $ref: "#/components/schemas/null" + description: "Done (no body)" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "Delete a conversation" + get: + description: "A conversation and a page of its messages" + parameters: + - description: "The conversation id." + in: "path" + name: "id" + required: true + schema: + type: "integer" + - description: "Only messages older than this message id." + in: "query" + name: "before" + required: false + schema: + type: "integer" + - description: "Page size (default 50, at most 200)." + in: "query" + name: "limit" + required: false + schema: + type: "integer" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantConversationDetailResponseV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "A conversation and a page of its messages" + patch: + description: "Rename, pin or archive a conversation" + parameters: + - description: "The conversation id." + in: "path" + name: "id" + required: true + schema: + type: "integer" + requestBody: + content: + application/json: + schema: + description: "The changed properties." + properties: + title: + type: "string" + pinned: + type: "boolean" + status: + description: "ACTIVE or ARCHIVED." + type: "string" + type: "object" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantConversationV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "Rename, pin or archive a conversation" + /qqq/v1/assistant/conversations/{id}/turns: + post: + description: "Ask: start a turn and stream the reply as server-sent events" + parameters: + - description: "The conversation id." + in: "path" + name: "id" + required: true + schema: + type: "integer" + requestBody: + content: + application/json: + schema: + description: "The user's text and the page context hint." + properties: + text: + description: "The user's message." + type: "string" + pageContext: + additionalProperties: true + description: "Where the user is in the app." + type: "object" + type: "object" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 200: + content: + text/event-stream: + schema: + $ref: "#/components/schemas/AssistantTurnEventV1" + description: "The turn's events, until message.end" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "Ask: start a turn and stream the reply as server-sent events" + /qqq/v1/assistant/turns/{turnId}/events: + get: + description: "Resume a turn's events after a sequence number (replay, then the\ + \ live tail)" + parameters: + - description: "The turn id." + in: "path" + name: "turnId" + required: true + schema: + type: "integer" + - description: "The last sequence number already received (0 for all)." + in: "query" + name: "after" + required: false + schema: + type: "integer" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 200: + content: + text/event-stream: + schema: + $ref: "#/components/schemas/AssistantTurnEventV1" + description: "The turn's events after `after`, until message.end" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "Resume a turn's events after a sequence number (replay, then the live\ + \ tail)" + /qqq/v1/assistant/turns/{turnId}/cancel: + post: + description: "Stop a running turn" + parameters: + - description: "The turn id." + in: "path" + name: "turnId" + required: true + schema: + type: "integer" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 202: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantStatusResponseV1" + description: "Successful Response" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "Stop a running turn" + /qqq/v1/assistant/messages/{id}/feedback: + post: + description: "Thumbs up or down on an assistant message" + parameters: + - description: "The assistant message id." + in: "path" + name: "id" + required: true + schema: + type: "integer" + requestBody: + content: + application/json: + schema: + description: "The rating and an optional reason or comment." + properties: + rating: + description: "1 or -1." + type: "integer" + reason: + type: "string" + comment: + type: "string" + type: "object" + required: true + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 204: + content: + application/json: + schema: + $ref: "#/components/schemas/null" + description: "Done (no body)" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "Thumbs up or down on an assistant message" + /qqq/v1/assistant/actions/{id}: + get: + description: "A write the assistant proposed, and its state" + parameters: + - description: "The pending action id." + in: "path" + name: "id" + required: true + schema: + type: "integer" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantActionV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "A write the assistant proposed, and its state" + /qqq/v1/assistant/actions/{id}/confirm: + post: + description: "Confirm a proposed write: it runs once, through the UI API, with\ + \ this request's session" + parameters: + - description: "The pending action id." + in: "path" + name: "id" + required: true + schema: + type: "integer" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantActionV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "Confirm a proposed write: it runs once, through the UI API, with this\ + \ request's session" + /qqq/v1/assistant/actions/{id}/reject: + post: + description: "Cancel a proposed write; nothing changes" + parameters: + - description: "The pending action id." + in: "path" + name: "id" + required: true + schema: + type: "integer" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantActionV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "Cancel a proposed write; nothing changes" + /qqq/v1/assistant/access: + get: + description: "Explain access: which UI API operations the assistant could call\ + \ for this user, and why not" + parameters: + - description: "Only this table." + in: "query" + name: "table" + required: false + schema: + type: "string" + - description: "Only the tables this text mentions." + in: "query" + name: "prompt" + required: false + schema: + type: "string" + responses: + 400: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Invalid input" + 403: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "No access to the assistant" + 404: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Not found (or not the user's own)" + 503: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Disabled, not configured, or the kill switch is on" + 200: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantAccessResponseV1" + description: "Successful Response" + 409: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Already finished or decided" + 429: + content: + application/json: + schema: + $ref: "#/components/schemas/AssistantErrorV1" + description: "Budget or rate limit" + security: + - sessionUuidCookie: + - "N/A" + summary: "Explain access: which UI API operations the assistant could call for\ + \ this user, and why not"