Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7faf0a8c0
|
||
|
|
8fab9a856e
|
||
|
|
6e7438a2da
|
||
|
|
01309ddfe9 | ||
|
|
de04965cd7
|
||
|
|
8e8a5c59cd | ||
|
|
87835961f4
|
||
|
|
c3a828c1c3
|
||
|
|
9e1bda8dae
|
||
|
|
907d943f8d
|
||
|
|
5e1c213fd6 | ||
|
|
1a65aa2257 | ||
|
|
17c376c5b1
|
||
|
|
2e90fbdc5d
|
||
|
|
6a26a7d4fa
|
||
|
|
1d66154bd2 | ||
|
|
1947688c34
|
||
|
|
c487c87051
|
||
|
|
292277908b
|
||
|
|
77bd8df7a2
|
||
|
|
f6edea4b7b
|
No files matched your search
+33
-1
@@ -1,5 +1,32 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased - P-g
|
||||
|
||||
- Add admin Scheduled tasks, owner-locked run history, Quartz cron/timezones,
|
||||
and a 60-second QQQ scheduled process using the host's scheduler context.
|
||||
- Log in through the loopback UI API with a separately allow-listed credential
|
||||
environment reference; verify the designated user before starting a normal turn.
|
||||
- Add Review/Result Approve write mode, Revoke write mode and Run now actions.
|
||||
Approval binds the task definition; scheduled writes always await confirmation.
|
||||
- Attribute scheduled UI API requests and tool logs, apply per-run STOP caps,
|
||||
and send optional Alerts notifications to the designated user. HIPAA summaries
|
||||
contain only `see conversation`.
|
||||
- Add unique schedule-slot claims, fixed-clock fixture integration tests and
|
||||
additive Postgres DDL. No API routes or provider modules change.
|
||||
|
||||
## Unreleased - P-f
|
||||
|
||||
- Add OpenAI-compatible embeddings (including Azure), explicit CHAT/EMBEDDING
|
||||
model kinds, editable discovery hints, and budgeted EMBED usage.
|
||||
- Add admin Knowledge sources with streamed Review/Result Index now and Clear
|
||||
index actions. Indexing uses the clicking admin's UI API session, chunks text,
|
||||
skips unchanged hashes and reconciles disappeared records.
|
||||
- Add content-free in-memory and optional JDBC pgvector stores, per-instance
|
||||
schemas, HNSW cosine search and transactional record replacement.
|
||||
- Add permission-filtered `search_knowledge`, fresh UI API reads, existing
|
||||
masking/redaction, PHI embedding guards, classification propagation and citations.
|
||||
- Add embedding HTTP contracts, real UI API RAG security/golden tests and
|
||||
always-on JDBC contracts; the pgvector container test skips without Docker.
|
||||
## Unreleased - Answer presentation
|
||||
|
||||
- Add `present_results`: server-formatted metrics, tables, computed percentages,
|
||||
@@ -34,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.
|
||||
|
||||
@@ -52,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`**.
|
||||
|
||||
@@ -46,13 +46,17 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
|
||||
- `qbit-assistant-provider-bedrock`: Claude through the Anthropic Bedrock backend, AWS default credentials and SigV4; IRSA/web-identity hosts supply STS.
|
||||
- `qbit-assistant-provider-vertex`: Claude through the Anthropic Vertex backend and Gemini through OpenAI-compatible Chat Completions, with refreshed Google ADC tokens.
|
||||
|
||||
- `qbit-assistant-rag-pgvector`: optional host-supplied JDBC vector store, isolated by instance schema; no QQQ RDBMS internals.
|
||||
|
||||
## Invariants (do not break)
|
||||
|
||||
- **Sessions:**
|
||||
- Scheduled runs log in as the designated user through `POST /qqq/v1/manageSession`; resolve only its returned UUID, verify the user id, and never construct or elevate a session. The QQQ-supplied scheduler context touches only assistant bookkeeping.
|
||||
- Never use `QSystemUserSession` or construct a `QSession` in main code (ArchUnit).
|
||||
- Every route uses `ExecutorSessionUtils.setupSession` (the user's own session).
|
||||
- `AssistantSecurity` only ever adds the user's OWN id key, or the all-access key for `assistant.admin` holders.
|
||||
- **Data access:**
|
||||
- The vector index stores no record content: only source/table/key/field/chunk, content hash and embedding. Retrieval re-reads every hit through the UI API as the asking user, then applies the existing get_record exposure/masking path.
|
||||
- Only `persistence/` calls QueryAction, InsertAction and the other data actions (ArchUnit).
|
||||
- Data tools run only through `UiApiToolbox` → `UiApiGateway` (spec'd `/qqq/v1` operations with the user's `sessionUUID`); never core data actions for user data. Only `navigate` and `search_help` use `AiToolKernel.execute`.
|
||||
- Data access (`DataAccessService`) and the policy only ever narrow what the UI API allows; a table whose AI exposure is NONE stays NONE.
|
||||
@@ -64,6 +68,8 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
|
||||
- Errors (DENIED, NOT_FOUND, INVALID_INPUT, ERROR) are `is_error` results.
|
||||
- Unknown tools and over-limit calls also get error results.
|
||||
- **Keys:** never stored in a table. `assistant_provider.apiKeyEnvName` holds only an allowed environment variable NAME; `apiKeyStatus` is computed on read (last 4 at most) and stripped on save.
|
||||
- Scheduled task `credentialEnvName` is a separately allow-listed environment variable NAME. Tokens never enter rows, model context or logs. `credentialStatus` is computed and stripped on save.
|
||||
- Scheduled write approval is invalidated by changes to prompt, run-as user, credential reference, profile or write request. Scheduled writes always require the designated user's panel confirmation.
|
||||
- **Logging:** never log message content, prompts, tool results or keys. Structured end-of-turn log only.
|
||||
- **Thinking:** thinking is never streamed or stored. The Anthropic adapter keeps the raw assistant message (with signatures) only in memory, as `providerState`, for the tool loop.
|
||||
- **Links:** only `/app` or `/app/...` hrefs are emitted (`TurnEngine.isAppHref`).
|
||||
@@ -99,4 +105,4 @@ QQQ style:
|
||||
- `develop` publishes SNAPSHOTs.
|
||||
- `release/*` publishes RCs; the orb bumps `<revision>` in the root POM.
|
||||
- `v*` tags publish releases.
|
||||
- All five modules and the parent POM publish. The flatten plugin resolves `${revision}`.
|
||||
- All six modules and the parent POM publish. The flatten plugin resolves `${revision}`.
|
||||
@@ -9,6 +9,68 @@ Design spec: `QRun-IO/qqq` `docs/specs/2026-10-11-qqq-assistant-qbit-design.md`.
|
||||
| `com.kingsrook.qbits:qbit-assistant` | The QBit: conversation tables, turn engine, `/qqq/v1/assistant` routes (JSON + Server-Sent Events), budgets, kill switch, the provider SPI, and a scripted provider for tests and demos. |
|
||||
| `com.kingsrook.qbits:qbit-assistant-provider-anthropic` | The Claude provider (`com.anthropic:anthropic-java`). Add it to use Claude. The core QBit has no LLM SDK dependency. |
|
||||
|
||||
Data tools use the QQQ UI API (`/qqq/v1`) with the signed-in user's session. The UI API enforces permissions and record locks; assistant data-access policy and field exposure can only narrow access. Only record-free navigation and help use the core AI tool kernel. Every tool result reaches the model wrapped as untrusted data.
|
||||
|
||||
## RAG setup
|
||||
|
||||
Knowledge retrieval is optional. The vector index stores **no record content**:
|
||||
only source/table/key/field/chunk identifiers, SHA-256 hashes and embeddings.
|
||||
Indexing reads through the UI API as the clicking administrator. Retrieval
|
||||
re-reads each hit through that same API as the asking user, silently filters
|
||||
403/404 results, applies `get_record` field exposure/masking/redaction and returns
|
||||
fresh snippets with `/app/...` citations. `search_knowledge` follows `allowedTools`
|
||||
and is offered only with an enabled source the user may read in the UI.
|
||||
|
||||
1. Register the OpenAI-compatible provider module and configure the embedding
|
||||
endpoint through the existing provider settings/URL allowlist. Discover or add
|
||||
a model, set **Model kind = EMBEDDING** and its dimensions. Known discovery
|
||||
defaults are `bge-m3` (1024) and `nomic-embed-text` (768); these remain editable.
|
||||
CHAT models cannot be selected for embedding, and EMBEDDING models cannot answer
|
||||
chat turns. Each embedding batch contains at most 64 inputs.
|
||||
2. Use the default `InMemoryVectorStore` for tests/small hosts, or add
|
||||
`com.kingsrook.qbits:qbit-assistant-rag-pgvector` at the QBit's version for
|
||||
persistence. The host supplies its JDBC driver and DataSource; this module
|
||||
does not depend on QQQ RDBMS internals.
|
||||
3. For pgvector, have the DBA install the extension once in the chosen database:
|
||||
`CREATE EXTENSION IF NOT EXISTS vector`. Give the application a distinct schema
|
||||
per QQQ instance. Configure the store before producing the QBit:
|
||||
|
||||
```java
|
||||
PgVectorStore vectors = new PgVectorStore(dataSource, 1024)
|
||||
.withSchema("assistant_rag_my_instance");
|
||||
vectors.ensureSchema();
|
||||
config.withVectorStore(vectors);
|
||||
config.withUiApiBaseUrl("http://127.0.0.1:8000");
|
||||
```
|
||||
|
||||
The UI API URL is this server's own listening address. Configure it explicitly
|
||||
so Index now works before any chat request has taught the runtime its local
|
||||
port. The store creates a content-free `assistant_rag_chunk` table and an HNSW
|
||||
cosine index. Its fixed dimension must match every source using the store;
|
||||
different models are searched separately. No credentials go in source rows.
|
||||
See the [pgvector installation/index documentation](https://github.com/pgvector/pgvector)
|
||||
and the reference DDL in [schema-postgres.sql](docs/schema-postgres.sql).
|
||||
4. Open **Assistant → Knowledge sources** as an `assistant.admin`. Add the table,
|
||||
comma-separated text fields, optional title field and QQQ query-filter JSON,
|
||||
embedding model, and chunk size/overlap (defaults 1500/200). Tables with AI data
|
||||
access NONE, `assistant_*` tables, and fields with NONE/METADATA_ONLY exposure
|
||||
are refused. MASKED fields are masked before embedding and in snippets.
|
||||
5. Select **Index now**. Review makes no embedding calls; Result reports records
|
||||
seen, chunks embedded, unchanged, removed and errors. Re-running unchanged
|
||||
content sends no embeddings. A complete successful scan removes disappeared
|
||||
records; failed scans preserve unvisited entries. **Clear index** removes only
|
||||
that source's vectors. Changes after indexing are read fresh, but semantic
|
||||
ranking becomes current only after re-indexing.
|
||||
|
||||
Embedding usage has purpose `EMBED`, counts toward budgets, and observes the kill
|
||||
switch and provider concurrency limits. PHI sources and queries in PHI
|
||||
conversations require an eligible embedding provider; the eligibility flag is an
|
||||
operator assertion, as for chat. Automatic budget downgrades cannot change an
|
||||
embedding model's vector space, so an incompatible downgrade stops the call.
|
||||
|
||||
Scheduling is deferred to P-g. Its designated-user runner can call
|
||||
`RagService.index` within that user's authenticated context; P-f creates no system
|
||||
session, stored admin credentials, scheduler or background content crawler.
|
||||
Data tools call the QQQ UI API through `UiApiToolbox` and `UiApiGateway` with the signed-in user's session. The UI enforces permissions and record security; the assistant further narrows access and caps and redacts returned values. Data tool results reach the model wrapped as untrusted data. Only record-free navigation and help use the core AI tool kernel.
|
||||
|
||||
## Requirements
|
||||
@@ -386,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)
|
||||
@@ -407,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
|
||||
@@ -417,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
|
||||
@@ -489,6 +550,45 @@ health is probed when unknown/stale with a two-second timeout. Provider cards sh
|
||||
health and down-until, failures show fallback totals, and turn records show the
|
||||
route and final destination. See [UPGRADE.md](UPGRADE.md) for the additive schema.
|
||||
|
||||
## Scheduled tasks
|
||||
|
||||
Administrators configure recurring prompts in **Assistant → Scheduled tasks**.
|
||||
Tasks use Quartz cron syntax (for example `0 0 8 * * ?`) and an IANA timezone
|
||||
(for example `America/Chicago`; default `UTC`). Tasks start disabled.
|
||||
|
||||
```java
|
||||
config.withUiApiBaseUrl("http://127.0.0.1:8000")
|
||||
.withScheduledTasksSchedulerName("hostScheduler")
|
||||
.withAllowedScheduledCredentialEnvNames(Set.of("ASSISTANT_SCHEDULED_TOKEN"));
|
||||
```
|
||||
|
||||
Provide a long-lived access token accepted by the host's authentication provider
|
||||
in that environment variable. Store only its **name** on the task and set
|
||||
`runAsUserId` to the corresponding QQQ user id. The runner posts the token to
|
||||
`/qqq/v1/manageSession`, reads its session cookie and verifies the returned
|
||||
session's user before starting a new conversation. No system session reads user
|
||||
data. Configure the loopback URL at startup; a scheduled job cannot rely on a
|
||||
browser having visited first.
|
||||
|
||||
For kof22, provision a designated Authentik account with the intended application
|
||||
permissions, obtain a token that kof22's QQQ authentication provider accepts as an
|
||||
`accessToken`, and inject it through the existing secret-manager/ExternalSecret
|
||||
pipeline. An Authentik management API token is suitable only if the host's auth
|
||||
adapter explicitly accepts it. Rotate the token in the secret manager, never in
|
||||
the task table. This feature does not provision an account or token.
|
||||
|
||||
**Run now**, **Approve write mode** and **Revoke write mode** are Review/Result
|
||||
record actions. Write approval names the designated user and displays the exact
|
||||
prompt SHA-256. Changing prompt, user, credential reference, profile or write-mode
|
||||
request clears approval. Scheduled writes always wait for that user's panel
|
||||
confirmation, even if normal policy disables confirmation.
|
||||
|
||||
Each run keeps an owner-locked audit row and a normal conversation. RECORD adds
|
||||
no notification; NOTIFICATION and BOTH also notify the designated user through
|
||||
the optional Alerts QBit SPI. Without Alerts, notification delivery is log-only.
|
||||
HIPAA summaries are always `see conversation`. Cost caps stop at dispatch
|
||||
boundaries, so the last completed call can cross the cap; no further call starts.
|
||||
Apply the [P-g migration](UPGRADE.md#p-g-scheduled-tasks) before enabling jobs.
|
||||
### Answer presentation
|
||||
|
||||
The always-available `present_results` tool turns retrieved results into metrics,
|
||||
@@ -501,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.
|
||||
+62
@@ -1,5 +1,67 @@
|
||||
# Upgrading qbit-assistant
|
||||
|
||||
## P-g: scheduled tasks
|
||||
|
||||
Apply the **P-g** DDL in [docs/schema-postgres.sql](docs/schema-postgres.sql)
|
||||
before deploying. The unique `(task_id, scheduled_for)` constraint is required
|
||||
for multi-replica safety; retain run rows as schedule-slot claim history.
|
||||
Existing conversations default to channel `panel`. Tasks start disabled.
|
||||
|
||||
Set `withScheduledTasksSchedulerName(...)` to an existing QQQ scheduler and
|
||||
configure `withUiApiBaseUrl(...)` with this replica's own listener URL. The runner
|
||||
polls every 60 seconds. QQQ must supply a scheduler session able to manage the
|
||||
assistant tables; this QBit never creates that session. The configured backend
|
||||
must enforce unique keys atomically (Postgres does).
|
||||
|
||||
Allow only designated credential references with
|
||||
`withAllowedScheduledCredentialEnvNames(Set.of("ASSISTANT_SCHEDULED_TOKEN"))`.
|
||||
Provide that variable through the host's secret manager. It must hold a token
|
||||
accepted by the host's `manageSession` authentication provider for `runAsUserId`.
|
||||
The runner fails closed on missing/disallowed credentials or an identity mismatch.
|
||||
See [scheduled-task setup](README.md#scheduled-tasks) for kof22 provisioning.
|
||||
|
||||
QQQ RC.5 has no conditional UpdateInput. The runner uses a database-unique
|
||||
schedule-slot insertion as its optimistic claim, then advances `nextRunAt`
|
||||
before starting work. Crashes can skip a slot; they never retry it. No ArchUnit
|
||||
exception or fabricated session is introduced. Core client-channel enforcement
|
||||
arrives with the client-channel RC; the header and tool audit are already sent.
|
||||
|
||||
Notifications use the optional Alerts QBit notification SPI with a single-user
|
||||
audience. Without it, delivery logs run id/status only. All output modes retain
|
||||
the owner-locked audit row and conversation. HIPAA run summaries never copy the
|
||||
answer; they contain only `see conversation`.
|
||||
|
||||
## P-f: knowledge retrieval
|
||||
|
||||
Apply the **P-f** assistant-table DDL in
|
||||
[docs/schema-postgres.sql](docs/schema-postgres.sql) before deployment. It adds
|
||||
`assistant_model.model_kind` (existing rows default to CHAT),
|
||||
`embedding_dimensions`, and the admin-only `assistant_rag_source` table.
|
||||
No sources are seeded. Existing chat selections remain unchanged; explicitly
|
||||
EMBEDDING models are excluded from chat, classifier, fallback and downgrade
|
||||
selection. Correct discovery's kind/dimensions if a model name is ambiguous.
|
||||
|
||||
For persistent vectors, add `qbit-assistant-rag-pgvector` at the same version as
|
||||
the QBit, supply the host's JDBC DataSource/driver, and install pgvector in the
|
||||
database. A DBA runs `CREATE EXTENSION IF NOT EXISTS vector`; the application
|
||||
role needs schema creation rights for initial setup (or a DBA-created schema),
|
||||
then normal DML rights. `ensureSchema()` checks for an existing extension first
|
||||
and does not require extension installation privileges when it is already present.
|
||||
The optional pgvector DDL is a **commented reference block**, separate from the
|
||||
assistant-table migration. See [RAG setup](README.md#rag-setup).
|
||||
|
||||
Use a distinct validated schema for each QQQ instance. `vector(N)` fixes dimensions
|
||||
for that store; changing dimensions requires a new schema and re-indexing.
|
||||
Sources can use different models with that dimension; ranking is restricted to
|
||||
sources using the query's model. If the weights behind an unchanged model id
|
||||
change, clear and rebuild its sources. Editing source chunk/filter/model settings
|
||||
or registry model identity/dimensions automatically clears affected vectors.
|
||||
|
||||
Every indexing batch produces EMBED usage and updates existing budget rollups.
|
||||
PHI source content and PHI conversation queries require a HIPAA-eligible embedding
|
||||
provider. No background runner or designated credentials are installed in P-f.
|
||||
The default in-memory store is lost on restart and must be indexed again.
|
||||
|
||||
## P-e: profile routing and provider fallback
|
||||
|
||||
Apply the additive **P-e** ALTER TABLE statements at the end of
|
||||
|
||||
+148
-5
@@ -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/<turnId>`). 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.
|
||||
@@ -422,6 +432,139 @@ AI exposure NONE is not enough, because the assistant acts as the user and an ad
|
||||
- Admin record screens, additive Postgres DDL and the flow-layout home expose these
|
||||
settings and counters without granting AI access to assistant configuration.
|
||||
|
||||
### P-f as built
|
||||
|
||||
- `AssistantModelProvider.embed` is optional and unsupported by default. The
|
||||
OpenAI-compatible implementation shares chat's JDK HTTP authentication, TLS,
|
||||
Azure deployment paths, timeout and sanitized error mapping. A request accepts
|
||||
1–64 inputs; `EmbeddingService` batches larger jobs and records each call as
|
||||
EMBED, including daily rollups and provider/model budget accounting.
|
||||
- Registry rows have CHAT/EMBEDDING kinds and embedding dimensions. Legacy rows
|
||||
remain CHAT. Discovery suggests EMBEDDING for ids containing embed, bge, e5,
|
||||
gte or nomic; administrators can correct it. Chat routing/fallback/downgrades
|
||||
cannot select an embedding model. Embedding resolution is explicit and checks
|
||||
fresh provider enablement and HIPAA eligibility before dispatch.
|
||||
- `assistant_rag_source` is an admin-only navigation table. Its text fields must
|
||||
exist and allow VALUES/MASKED exposure; application data access must allow READ.
|
||||
Index now and Clear index are standard streamed ETL record actions with Review
|
||||
and Result, never navigation entries. Indexing pages the UI API as the clicking
|
||||
admin, reuses get_record's masking path, chunks deterministically and compares
|
||||
hashes. Complete scans alone prune disappeared records; per-record replacement
|
||||
also removes obsolete field chunks. Configuration/model identity changes clear
|
||||
stale vectors. No source text is saved on a source or vector row.
|
||||
- `VectorStore` supplies upsert/delete/search/count plus content-free chunk
|
||||
reconciliation and atomic record replacement. The in-memory implementation uses
|
||||
exact cosine; the optional pgvector module uses host JDBC, a validated instance
|
||||
schema, bound values and an HNSW cosine index. Sources from other embedding
|
||||
models are excluded before ranking. The DBA installs pgvector; existing
|
||||
installations do not require extension-installation privileges on startup.
|
||||
- `KnowledgeTool` is separate from the unchanged `UiApiToolbox`. A small
|
||||
ContextBuilder hook adds it only for an enabled source, policy permission and
|
||||
UI-readable source table. Search embeds the question, requests 4× the result
|
||||
limit, then re-reads each hit through get_record with the asking user's session.
|
||||
403/404 hits contribute only to the filtered count. Safe current values produce
|
||||
snippets and `/app` citations; record refs propagate touched tables into P-c
|
||||
classification and the normal tool-call log. The citation instruction is in
|
||||
the static rules block. The result limit is capped at eight.
|
||||
- PHI source content and PHI conversation queries only reach eligible embedding
|
||||
providers. Embedding budgets, kill switch and provider concurrency checks apply
|
||||
to each batch. A budget downgrade into another vector space stops embedding.
|
||||
- Tests exercise real fixture UI API reads, denials, record locks, field masking,
|
||||
PHI refusal, classification, reconciliation, embedding wire contracts and a
|
||||
golden cited answer. JDBC shape/transaction tests always run; a pgvector:pg16
|
||||
Testcontainers test skips on hosts without Docker.
|
||||
- P-g may invoke the manual service within a designated user's authenticated
|
||||
context. P-f supplies no scheduled runner or stored/elevated session. Multiple
|
||||
replicas should serialize manual re-indexing of a source at the host level;
|
||||
per-record pgvector replacement is transactional, while scan reconciliation is
|
||||
not a snapshot of concurrently changing application data.
|
||||
|
||||
### P-g as built
|
||||
|
||||
- `assistant_scheduled_task` is admin-only, AI exposure NONE, and appears as
|
||||
Scheduled tasks. The owner-locked, read-only `assistant_scheduled_run` child
|
||||
list retains run status, accounting and a bounded summary. Conversations gain
|
||||
read-only `scheduledTaskId` and `channel` (`panel` or `assistant/scheduled`).
|
||||
Task and run timestamps use the runtime clock; cron uses Quartz and a validated
|
||||
ZoneId, including DST. Enablement and schedule edits recompute `nextRunAt`.
|
||||
- Credentials are references, never stored tokens. `credentialEnvName` must
|
||||
match `[A-Z_][A-Z0-9_]*` and the dedicated
|
||||
`allowedScheduledCredentialEnvNames` allowlist. The existing secret resolver
|
||||
supplies its value. Read-only `credentialStatus` reports presence without
|
||||
exposing token characters and is stripped on every save.
|
||||
- A run sends JSON `{accessToken: ...}` to the same loopback base URL's
|
||||
`POST /qqq/v1/manageSession`. RC.5 with HTTP-only cookies returns the UUID in
|
||||
`Set-Cookie: sessionUUID`; legacy JSON `uuid` is also accepted. Only then does
|
||||
the runner resolve that UUID through the host authentication module, exactly
|
||||
the UUID lookup used by the UI request pipeline. No direct token authentication,
|
||||
automated-user session or constructed QSession exists in assistant code.
|
||||
The resolved user id must equal `runAsUserId` before any model call. MOCK in
|
||||
the integration fixture maps test tokens to the existing user-prefix sessions;
|
||||
that token adapter is test code only.
|
||||
- kof22 provisions an Authentik designated account with normal QQQ permissions
|
||||
and injects an accepted long-lived token through its secret manager and
|
||||
ExternalSecret into an allow-listed environment variable. The task holds the
|
||||
variable name and QQQ user id. Host authentication must accept that token type
|
||||
as `accessToken`; an Authentik management API token is not automatically an
|
||||
application access token. Neither account nor credential is provisioned here.
|
||||
- `assistantScheduledTasksRunner` registers a 60-second QScheduleMetaData when
|
||||
`scheduledTasksSchedulerName` names a host scheduler. QQQ supplies its normal
|
||||
scheduler context for assistant-table bookkeeping; no ArchUnit exception is
|
||||
necessary. Configure `uiApiBaseUrl` at startup. No browser visit is required.
|
||||
- **Claim compatibility:** RC.5 UpdateInput cannot express a conditional
|
||||
`WHERE next_run_at = expected`. AssistantStore therefore claims the expected
|
||||
schedule slot using the database-enforced unique `(taskId, scheduledFor)` run
|
||||
key and advances `nextRunAt` before login/dispatch. This is a unique-insert
|
||||
optimistic claim rather than a conditional update. The memory fixture also
|
||||
serializes claim operations on its instance. The database constraint, not
|
||||
that monitor, prevents cross-replica duplicate execution. Do not remove claim
|
||||
history or deploy to a backend without atomic unique keys. A crash can lose a
|
||||
run; it never retries a claimed slot. A later tick advances an already-claimed
|
||||
slot left behind by a crash between claim and schedule update.
|
||||
- The unchanged AssistantApi starts a new normal turn under the logged-in
|
||||
designated user, preserving start gates, rate limiting, P-c classification,
|
||||
P-d budgets and P-e routing/fallback. An internal context supplies the task's
|
||||
selected profile and additional cost cap. Cost includes chat, classifier,
|
||||
failed calls and knowledge-query embeddings. STOP is checked at dispatch
|
||||
boundaries; an in-flight call can exceed the cap, and no next call is sent.
|
||||
- Every scheduled UI API request, including metadata and knowledge retrieval,
|
||||
carries `X-QQQ-Client: assistant/scheduled/<taskId>`. The only uiapi change is
|
||||
additive `LoopbackUiApiGateway.invokeWithClient`; custom gateways currently
|
||||
fail closed for scheduled requests because their channel contract is unknown.
|
||||
Tool-call rows also record `clientChannel`. Core channel deny enforcement
|
||||
remains pending the client-channel RC; existing AI exposure/data-access
|
||||
restrictions continue to apply on RC.5.
|
||||
- Approve write mode, Revoke write mode and Run now are admin record actions
|
||||
using standard streamed ETL Review/Result. Review shows the exact prompt's
|
||||
SHA-256 and run-as user. Result rereads and compares the complete reviewed
|
||||
definition before recording the actual administrator's user id and time.
|
||||
A persisted approval hash additionally binds prompt, run-as user, credential
|
||||
reference, profile and requested mode. Editing any of these clears approval;
|
||||
ordinary saves cannot set approval or computed bookkeeping fields.
|
||||
- This base has create/update/delete tools, but no W1 conversation mode/policy
|
||||
switch. Scheduled runs explicitly narrow the existing tool policy: write tools
|
||||
require both request and matching approval, a READ_WRITE table and the policy's
|
||||
allowed-tools list. All scheduled writes require panel confirmation, including
|
||||
when normal policy allows immediate writes. A pending proposal adds a waiting
|
||||
notice to the run summary; the scheduler never confirms it.
|
||||
- RECORD keeps the run and conversation; NOTIFICATION and BOTH also submit an
|
||||
Alerts notification with `NotificationAudience.user(runAsUserId)` and an
|
||||
`/app/...` conversation link. Reflection targets the installed Alerts QBit's
|
||||
RC.5 notification SPI/sink, following WebhooksAlerts' optional-dependency
|
||||
pattern. Without it, a log contains run id and status only. Notification text
|
||||
contains no answer data. In HIPAA mode run summaries contain exactly
|
||||
`see conversation`; conversation payloads retain the existing encryption.
|
||||
The existing record URL is an admin-screen link. Non-admin owners can read
|
||||
their scheduled conversations in the panel's conversation list; opening a
|
||||
particular existing conversation from a notification still needs a frontend
|
||||
deep-link contract (the current panel only supports open/new-prompt links).
|
||||
This phase does not broaden existing conversation record-screen permissions.
|
||||
- Failures close the run as FAILED or BLOCKED with sanitized errors and wait
|
||||
until the next schedule. All output modes retain their audit record. Tests
|
||||
exercise real manageSession and UI API requests with a fixed clock, designated
|
||||
permissions and owner locks, headers, competing runners, approval invalidation,
|
||||
notification modes, PHI refusal, encrypted HIPAA storage and cost STOP.
|
||||
|
||||
### Phases (each a green PR)
|
||||
| | Scope | Depends on |
|
||||
|---|---|---|
|
||||
|
||||
@@ -477,3 +477,100 @@ ALTER TABLE assistant_turn ADD COLUMN route_decision VARCHAR(16);
|
||||
ALTER TABLE assistant_turn ADD COLUMN route_source VARCHAR(16);
|
||||
ALTER TABLE assistant_turn ADD COLUMN fallback_count INTEGER DEFAULT 0;
|
||||
ALTER TABLE assistant_turn ADD COLUMN provider_id INTEGER REFERENCES assistant_provider(id);
|
||||
|
||||
|
||||
-- P-f: embedding registry and admin-only knowledge-source configuration.
|
||||
ALTER TABLE assistant_model ADD COLUMN model_kind VARCHAR(16) DEFAULT 'CHAT'
|
||||
CHECK (model_kind IN ('CHAT', 'EMBEDDING'));
|
||||
ALTER TABLE assistant_model ADD COLUMN embedding_dimensions INTEGER
|
||||
CHECK (embedding_dimensions > 0);
|
||||
|
||||
CREATE TABLE assistant_rag_source (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
enabled BOOLEAN DEFAULT TRUE,
|
||||
table_name VARCHAR(200) NOT NULL,
|
||||
field_names VARCHAR(500) NOT NULL,
|
||||
title_field_name VARCHAR(200),
|
||||
filter_json TEXT,
|
||||
embedding_model_id INTEGER NOT NULL REFERENCES assistant_model(id),
|
||||
chunk_chars INTEGER DEFAULT 1500 CHECK (chunk_chars BETWEEN 100 AND 16000),
|
||||
chunk_overlap_chars INTEGER DEFAULT 200 CHECK (chunk_overlap_chars >= 0 AND chunk_overlap_chars < chunk_chars),
|
||||
last_indexed_at TIMESTAMPTZ,
|
||||
last_index_status VARCHAR(200),
|
||||
last_index_error VARCHAR(500),
|
||||
indexed_record_count INTEGER,
|
||||
indexed_chunk_count INTEGER,
|
||||
create_date TIMESTAMPTZ,
|
||||
modify_date TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- Optional pgvector part: reference only. Run the extension step as a DBA.
|
||||
-- PgVectorStore.ensureSchema() performs the remainder for the host's schema/N.
|
||||
-- Use a distinct schema per QQQ instance; match N to the embedding model.
|
||||
-- CREATE EXTENSION IF NOT EXISTS vector;
|
||||
-- CREATE SCHEMA IF NOT EXISTS assistant_rag;
|
||||
-- CREATE TABLE IF NOT EXISTS assistant_rag.assistant_rag_chunk (
|
||||
-- id BIGSERIAL PRIMARY KEY,
|
||||
-- source_id INT NOT NULL,
|
||||
-- table_name TEXT NOT NULL,
|
||||
-- record_pk TEXT NOT NULL,
|
||||
-- field_name TEXT NOT NULL,
|
||||
-- chunk_index INT NOT NULL,
|
||||
-- content_hash CHAR(64) NOT NULL,
|
||||
-- embedding vector(1024) NOT NULL,
|
||||
-- indexed_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
-- UNIQUE(source_id, table_name, record_pk, field_name, chunk_index)
|
||||
-- );
|
||||
-- CREATE INDEX IF NOT EXISTS assistant_rag_chunk_cosine
|
||||
-- ON assistant_rag.assistant_rag_chunk USING hnsw (embedding vector_cosine_ops);
|
||||
|
||||
-- P-g: designated-user scheduled assistant tasks. Install before enabling jobs.
|
||||
CREATE TABLE assistant_scheduled_task (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
prompt TEXT NOT NULL CHECK (char_length(prompt) BETWEEN 1 AND 4000),
|
||||
run_as_user_id VARCHAR(200) NOT NULL,
|
||||
credential_env_name VARCHAR(200) NOT NULL,
|
||||
credential_status VARCHAR(200), -- computed; always NULL in stored rows
|
||||
profile_id INTEGER REFERENCES assistant_profile(id),
|
||||
cron_expression VARCHAR(200) NOT NULL,
|
||||
timezone VARCHAR(100) NOT NULL DEFAULT 'UTC',
|
||||
next_run_at TIMESTAMPTZ,
|
||||
output_mode VARCHAR(20) NOT NULL DEFAULT 'RECORD' CHECK (output_mode IN ('RECORD', 'NOTIFICATION', 'BOTH')),
|
||||
write_mode_requested BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
write_mode_approved_by VARCHAR(200),
|
||||
write_mode_approved_at TIMESTAMPTZ,
|
||||
write_mode_approval_hash VARCHAR(64),
|
||||
max_cost_usd_per_run NUMERIC(18,6) CHECK (max_cost_usd_per_run >= 0),
|
||||
last_run_at TIMESTAMPTZ,
|
||||
last_run_status VARCHAR(20),
|
||||
last_run_error VARCHAR(500),
|
||||
create_date TIMESTAMPTZ,
|
||||
modify_date TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX assistant_scheduled_task_due ON assistant_scheduled_task(next_run_at) WHERE enabled;
|
||||
|
||||
ALTER TABLE assistant_conversation ADD COLUMN scheduled_task_id INTEGER REFERENCES assistant_scheduled_task(id);
|
||||
ALTER TABLE assistant_conversation ADD COLUMN channel VARCHAR(64) NOT NULL DEFAULT 'panel';
|
||||
ALTER TABLE assistant_tool_call ADD COLUMN client_channel VARCHAR(64);
|
||||
|
||||
CREATE TABLE assistant_scheduled_run (
|
||||
id SERIAL PRIMARY KEY,
|
||||
task_id INTEGER NOT NULL REFERENCES assistant_scheduled_task(id),
|
||||
user_id VARCHAR(200) NOT NULL,
|
||||
scheduled_for TIMESTAMPTZ NOT NULL,
|
||||
started_at TIMESTAMPTZ,
|
||||
ended_at TIMESTAMPTZ,
|
||||
status VARCHAR(20) NOT NULL CHECK (status IN ('RUNNING', 'OK', 'FAILED', 'SKIPPED', 'BLOCKED')),
|
||||
conversation_id INTEGER REFERENCES assistant_conversation(id) ON DELETE SET NULL,
|
||||
turn_id INTEGER REFERENCES assistant_turn(id) ON DELETE SET NULL,
|
||||
summary VARCHAR(2000),
|
||||
cost_micros BIGINT,
|
||||
error VARCHAR(500),
|
||||
create_date TIMESTAMPTZ,
|
||||
modify_date TIMESTAMPTZ,
|
||||
UNIQUE (task_id, scheduled_for)
|
||||
);
|
||||
CREATE INDEX assistant_scheduled_run_owner ON assistant_scheduled_run(user_id, started_at);
|
||||
@@ -35,3 +35,8 @@ Light and dark, 390 and 1440 px:
|
||||
- `p-provider-local-*`: Local GPU (Ollama), keyless LOCAL settings, discovery/test results and discovered models.
|
||||
- `p-model-*`: a discovered model's capabilities, compute prices and connection-test result.
|
||||
- `p-home-providers-*`: the home's provider summary and usage from the executed demo calls, including disabled OpenRouter with its key Not set.
|
||||
|
||||
Answer presentation (owner priority, 2026-10-13), taken against `OpenAiDemoServer` with the Next UI build of QRun-IO/qqq-frontend-next#111 (`next dev` proxied to the demo). The demo's OpenAI-compatible mock answers "How many images do I have by type?" the way a model would through the real adapter: five `count_records` calls through the UI API, then `present_results`.
|
||||
|
||||
Light and dark, 390 and 1440 px:
|
||||
- `panel-presentation-*`: the summary line, an Images metric, the "Media by type" table (counts and the server-computed 80.2% and 19.8%, each row a filtered-list link), the pie chart, two filter links (Open JPEG media, Open PNG media) and a follow-up action.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 124 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
@@ -55,6 +55,7 @@
|
||||
<module>qbit-assistant-provider-openai</module>
|
||||
<module>qbit-assistant-provider-bedrock</module>
|
||||
<module>qbit-assistant-provider-vertex</module>
|
||||
<module>qbit-assistant-rag-pgvector</module>
|
||||
</modules>
|
||||
|
||||
<licenses>
|
||||
@@ -193,8 +194,9 @@
|
||||
</build>
|
||||
|
||||
<profiles>
|
||||
<!-- The assistant needs QQQ 4.1.0-RC.5: the AI tool kernel (package -->
|
||||
<!-- actions.ai, qqq#1078) and the developer panels (qqq#1076). Until -->
|
||||
<!-- The assistant needs QQQ 4.1.0-RC.6: the AI tool kernel (qqq#1078), the -->
|
||||
<!-- developer panels (qqq#1076) and the UI API extension point, invoker -->
|
||||
<!-- and credentials (qqq#1128). Until -->
|
||||
<!-- qbit-build-parent imports the 4.1 BOM, this -->
|
||||
<!-- profile is active by default and imports qqq-bom-pom ahead of the -->
|
||||
<!-- parent's 4.0.0 BOM, as in qbit-quick-search (ADR-0007). Override with -->
|
||||
@@ -205,7 +207,7 @@
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<qqq.snapshot.version>4.1.0-RC.5</qqq.snapshot.version>
|
||||
<qqq.snapshot.version>4.1.0-RC.6</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
|
||||
+5
@@ -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);
|
||||
|
||||
+80
@@ -38,6 +38,8 @@ import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.kingsrook.qbits.assistant.provider.AssistantModelProvider;
|
||||
import com.kingsrook.qbits.assistant.provider.CompletableStreamHandle;
|
||||
import com.kingsrook.qbits.assistant.provider.EmbeddingRequest;
|
||||
import com.kingsrook.qbits.assistant.provider.EmbeddingResponse;
|
||||
import com.kingsrook.qbits.assistant.provider.ModelCapabilities;
|
||||
import com.kingsrook.qbits.assistant.provider.ModelEventListener;
|
||||
import com.kingsrook.qbits.assistant.provider.ModelPart;
|
||||
@@ -82,6 +84,84 @@ public class OpenAiCompatibleModelProvider implements AssistantModelProvider
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One bounded batch, with the deadline covering headers and the entire body.
|
||||
** Restore input order from the response indexes; reject malformed vectors.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public EmbeddingResponse embed(EmbeddingRequest request) throws ModelProviderException
|
||||
{
|
||||
ObjectNode body = OpenAiHttp.JSON.createObjectNode();
|
||||
body.put("model", request.model());
|
||||
body.set("input", OpenAiHttp.JSON.valueToTree(request.input()));
|
||||
var pending = http.client.sendAsync(http.request(request.model(), http.settings.getRequestTimeoutSeconds(), "embeddings")
|
||||
.POST(java.net.http.HttpRequest.BodyPublishers.ofString(body.toString())).build(), HttpResponse.BodyHandlers.ofString());
|
||||
try
|
||||
{
|
||||
HttpResponse<String> response = pending.get(http.settings.getRequestTimeoutSeconds(), TimeUnit.SECONDS);
|
||||
if(response.statusCode() != 200)
|
||||
{
|
||||
throw (http.error(response.statusCode(), response.body(), OpenAiHttp.requestId(response), false));
|
||||
}
|
||||
JsonNode json = OpenAiHttp.JSON.readTree(response.body());
|
||||
if(json.has("error"))
|
||||
{
|
||||
throw (http.error(500, response.body(), OpenAiHttp.requestId(response), false));
|
||||
}
|
||||
float[][] vectors = new float[request.input().size()][];
|
||||
Integer dimensions = null;
|
||||
for(JsonNode item : json.path("data"))
|
||||
{
|
||||
Integer index = item.path("index").asInt(-1);
|
||||
JsonNode values = item.path("embedding");
|
||||
if(index < 0 || index >= vectors.length || vectors[index] != null || !values.isArray() || values.isEmpty()
|
||||
|| (dimensions != null && dimensions != values.size()))
|
||||
{
|
||||
throw (new IOException("Invalid embedding response."));
|
||||
}
|
||||
dimensions = values.size();
|
||||
float[] vector = new float[dimensions];
|
||||
for(Integer i = 0; i < vector.length; i++)
|
||||
{
|
||||
if(!values.get(i).isNumber())
|
||||
{
|
||||
throw (new IOException("Invalid embedding coordinate."));
|
||||
}
|
||||
vector[i] = (float) values.get(i).asDouble();
|
||||
if(!Float.isFinite(vector[i]))
|
||||
{
|
||||
throw (new IOException("Non-finite embedding coordinate."));
|
||||
}
|
||||
}
|
||||
vectors[index] = vector;
|
||||
}
|
||||
if(java.util.Arrays.stream(vectors).anyMatch(java.util.Objects::isNull))
|
||||
{
|
||||
throw (new IOException("Missing embedding."));
|
||||
}
|
||||
return (new EmbeddingResponse(List.of(vectors), new ModelUsage().withInputTokens(json.path("usage").path("prompt_tokens").asLong()), OpenAiHttp.requestId(response)));
|
||||
}
|
||||
catch(java.util.concurrent.TimeoutException e)
|
||||
{
|
||||
throw (new ModelProviderException(ModelProviderException.Kind.TIMEOUT, null, "The provider did not answer in time."));
|
||||
}
|
||||
catch(InterruptedException e)
|
||||
{
|
||||
Thread.currentThread().interrupt();
|
||||
throw (new ModelProviderException(ModelProviderException.Kind.NETWORK, null, "The embedding request was interrupted."));
|
||||
}
|
||||
catch(java.util.concurrent.ExecutionException | IOException e)
|
||||
{
|
||||
throw (OpenAiHttp.transportError(e));
|
||||
}
|
||||
finally
|
||||
{
|
||||
pending.cancel(true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Conservative defaults; registry rows can override model-specific facts.
|
||||
*******************************************************************************/
|
||||
|
||||
+12
-2
@@ -84,13 +84,23 @@ final class OpenAiHttp
|
||||
** Azure uses deployment paths and a version query parameter.
|
||||
*******************************************************************************/
|
||||
HttpRequest.Builder request(String model, Integer seconds) throws ModelProviderException
|
||||
{
|
||||
return (request(model, seconds, "chat/completions"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Chat and embeddings share authentication, TLS, deadlines and Azure paths.
|
||||
*******************************************************************************/
|
||||
HttpRequest.Builder request(String model, Integer seconds, String operation) throws ModelProviderException
|
||||
{
|
||||
try
|
||||
{
|
||||
String path = model == null ? "/models" : "/chat/completions";
|
||||
String path = model == null ? "/models" : "/" + operation;
|
||||
if("AZURE_OPENAI".equals(settings.getKind()))
|
||||
{
|
||||
path = model == null ? "/openai/models" : "/openai/deployments/" + encode(model) + "/chat/completions";
|
||||
path = model == null ? "/openai/models" : "/openai/deployments/" + encode(model) + "/" + operation;
|
||||
path += "?api-version=" + encode(settings.getApiVersion() == null || settings.getApiVersion().isBlank() ? "2024-10-21" : settings.getApiVersion());
|
||||
}
|
||||
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(base + path)).timeout(Duration.ofSeconds(seconds)).header("Content-Type", "application/json");
|
||||
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.provider.openai;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.assistant.provider.EmbeddingRequest;
|
||||
import com.kingsrook.qbits.assistant.provider.ModelProviderException;
|
||||
import com.kingsrook.qbits.assistant.provider.ProviderSettings;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Embedding wire contracts through the same local HTTP harness as chat.
|
||||
*******************************************************************************/
|
||||
class OpenAiEmbeddingTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Input arrays, response indexes, usage and authentication are preserved.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void shapeOrderAndAzure() throws Exception
|
||||
{
|
||||
for(String kind : List.of("OPENAI", "AZURE_OPENAI", "OLLAMA"))
|
||||
{
|
||||
try(MockOpenAiServer server = new MockOpenAiServer())
|
||||
{
|
||||
server.enqueue(200, "{\"data\":[{\"index\":1,\"embedding\":[0,1]},{\"index\":0,\"embedding\":[1,0]}],\"usage\":{\"prompt_tokens\":7}}");
|
||||
var provider = new OpenAiCompatibleModelProvider(new ProviderSettings().withKind(kind).withBaseUrl(server.url()).withApiKey("synthetic-key").withApiVersion("2024-10-21"));
|
||||
var response = provider.embed(new EmbeddingRequest("bge-m3", List.of("first", "second")));
|
||||
assertThat(response.vectors()).hasSize(2);
|
||||
assertThat(response.vectors().getFirst()).containsExactly(1, 0);
|
||||
assertThat(response.usage().getInputTokens()).isEqualTo(7L);
|
||||
var captured = server.requests.getFirst();
|
||||
assertThat(captured.path()).isEqualTo(kind.equals("AZURE_OPENAI") ? "/v1/openai/deployments/bge-m3/embeddings?api-version=2024-10-21" : "/v1/embeddings");
|
||||
var body = OpenAiHttp.JSON.readTree(captured.body());
|
||||
assertThat(body.path("model").asText()).isEqualTo("bge-m3");
|
||||
assertThat(body.path("input").size()).isEqualTo(2);
|
||||
assertThat(body.has("messages")).isFalse();
|
||||
assertThat(body.has("stream")).isFalse();
|
||||
assertThat(kind.equals("AZURE_OPENAI") ? captured.apiKey() : captured.authorization()).contains("synthetic-key");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Reuse chat's safe error categories, without echoing credentials.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void errorsAndMalformedVectors() throws Exception
|
||||
{
|
||||
try(MockOpenAiServer server = new MockOpenAiServer())
|
||||
{
|
||||
var provider = new OpenAiCompatibleModelProvider(new ProviderSettings().withKind("OPENAI").withBaseUrl(server.url()).withApiKey("synthetic-key"));
|
||||
server.enqueue(429, "{\"error\":{\"message\":\"synthetic-key rejected\"}}");
|
||||
assertThatThrownBy(() -> provider.embed(new EmbeddingRequest("embed", List.of("text"))))
|
||||
.isInstanceOfSatisfying(ModelProviderException.class, e ->
|
||||
{
|
||||
assertThat(e.getKind()).isEqualTo(ModelProviderException.Kind.RATE_LIMITED);
|
||||
assertThat(e.getMessage()).doesNotContain("synthetic-key");
|
||||
});
|
||||
for(String body : List.of("{}", "{\"data\":[{\"index\":0,\"embedding\":[\"not numeric\"]}]}",
|
||||
"{\"data\":[{\"index\":2,\"embedding\":[1]}]}", "{\"data\":[{\"index\":0,\"embedding\":[1]},{\"index\":0,\"embedding\":[1]}]}"))
|
||||
{
|
||||
server.enqueue(200, body);
|
||||
assertThatThrownBy(() -> provider.embed(new EmbeddingRequest("embed", List.of("text")))).isInstanceOf(ModelProviderException.class);
|
||||
}
|
||||
assertThatThrownBy(() -> new EmbeddingRequest("embed", java.util.Collections.nCopies(65, "text"))).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> new EmbeddingRequest("embed", List.of())).isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Deadline covers a stalled response body, not only HTTP headers.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void bodyTimeout() throws Exception
|
||||
{
|
||||
try(MockOpenAiServer server = new MockOpenAiServer())
|
||||
{
|
||||
server.stall(true);
|
||||
var provider = new OpenAiCompatibleModelProvider(new ProviderSettings().withKind("OLLAMA").withBaseUrl(server.url()).withRequestTimeoutSeconds(1));
|
||||
assertThatThrownBy(() -> provider.embed(new EmbeddingRequest("embed", List.of("text"))))
|
||||
.isInstanceOfSatisfying(ModelProviderException.class, e -> assertThat(e.getKind()).isEqualTo(ModelProviderException.Kind.TIMEOUT));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The transport accepts a complete 64-input batch and rejects overflow
|
||||
** before sending. EmbeddingService owns splitting larger indexing jobs.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void maximumBatch() throws Exception
|
||||
{
|
||||
try(MockOpenAiServer server = new MockOpenAiServer())
|
||||
{
|
||||
var body = OpenAiHttp.JSON.createObjectNode();
|
||||
var data = body.putArray("data");
|
||||
for(Integer i = 0; i < 64; i++)
|
||||
{
|
||||
data.addObject().put("index", i).putArray("embedding").add(1).add(0);
|
||||
}
|
||||
server.enqueue(200, body.toString());
|
||||
var provider = new OpenAiCompatibleModelProvider(new ProviderSettings().withKind("OLLAMA").withBaseUrl(server.url()));
|
||||
assertThat(provider.embed(new EmbeddingRequest("embed", java.util.Collections.nCopies(64, "text"))).vectors()).hasSize(64);
|
||||
assertThat(OpenAiHttp.JSON.readTree(server.requests.getFirst().body()).path("input").size()).isEqualTo(64);
|
||||
assertThatThrownBy(() -> provider.embed(new EmbeddingRequest("embed", java.util.Collections.nCopies(65, "text")))).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThat(server.requests).hasSize(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
+95
-11
@@ -21,8 +21,13 @@ import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantModel;
|
||||
@@ -35,6 +40,9 @@ import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
|
||||
import com.kingsrook.qbits.assistant.provider.fake.ScriptedModelProvider;
|
||||
import com.kingsrook.qbits.assistant.provider.openai.OpenAiCompatibleProviderType;
|
||||
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
|
||||
import com.kingsrook.qbits.assistant.testing.EvalSupport;
|
||||
import com.kingsrook.qbits.assistant.testing.GoldenTasks;
|
||||
import com.kingsrook.qbits.assistant.testing.PresentationGoldenTask;
|
||||
import com.kingsrook.qbits.assistant.testing.TurnRunner;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.QProcessCallbackFactory;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
@@ -61,6 +69,8 @@ public class OpenAiDemoServer extends AbstractQQQApplication
|
||||
{
|
||||
private static final String ADMIN = "admin-demo";
|
||||
private static final String MODEL = "qwen3:30b-a3b";
|
||||
private static final String IMAGE_QUESTION = "How many images do I have by type?";
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
private static QInstance instance;
|
||||
|
||||
|
||||
@@ -160,6 +170,8 @@ public class OpenAiDemoServer extends AbstractQQQApplication
|
||||
{
|
||||
throw (new QException("Demo assistant turn failed: " + outcome.errorCode()));
|
||||
}
|
||||
TurnRunner.Outcome presentation = new TurnRunner(instance, config).run(ADMIN, IMAGE_QUESTION);
|
||||
PresentationGoldenTask.check(presentation);
|
||||
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK)
|
||||
.withCustomizer(new QCodeReference(DemoSessionCustomizer.class)));
|
||||
instance.setHomeAppName(AssistantMetaDataBuilder.APP_NAME);
|
||||
@@ -167,6 +179,7 @@ public class OpenAiDemoServer extends AbstractQQQApplication
|
||||
System.out.println("Assistant home: http://localhost:" + port + "/app/" + AssistantMetaDataBuilder.APP_NAME);
|
||||
System.out.println("Local provider: http://localhost:" + port + "/app/assistant_provider/" + providerId);
|
||||
System.out.println("Local model: http://localhost:" + port + "/app/assistant_model/" + modelId);
|
||||
System.out.println("Image presentation conversation id: " + presentation.conversationId());
|
||||
}
|
||||
|
||||
|
||||
@@ -211,7 +224,7 @@ public class OpenAiDemoServer extends AbstractQQQApplication
|
||||
{
|
||||
try(exchange)
|
||||
{
|
||||
exchange.getRequestBody().readAllBytes();
|
||||
byte[] request = exchange.getRequestBody().readAllBytes();
|
||||
String path = exchange.getRequestURI().getPath();
|
||||
String body;
|
||||
if("GET".equals(exchange.getRequestMethod()) && "/v1/models".equals(path))
|
||||
@@ -227,16 +240,7 @@ public class OpenAiDemoServer extends AbstractQQQApplication
|
||||
else if("POST".equals(exchange.getRequestMethod()) && "/v1/chat/completions".equals(path))
|
||||
{
|
||||
exchange.getResponseHeaders().set("Content-Type", "text/event-stream");
|
||||
body = """
|
||||
data: {"id":"demo-completion","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello from the local demo provider."}}]}
|
||||
|
||||
data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
|
||||
|
||||
data: {"choices":[],"usage":{"prompt_tokens":24,"completion_tokens":8,"total_tokens":32}}
|
||||
|
||||
data: [DONE]
|
||||
|
||||
""";
|
||||
body = completion(JSON.readTree(request));
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -248,4 +252,84 @@ public class OpenAiDemoServer extends AbstractQQQApplication
|
||||
exchange.getResponseBody().flush();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Follow the latest user turn, so connection tests and repeated chats are
|
||||
** independent. Counts are read from the adapter's actual tool messages.
|
||||
*******************************************************************************/
|
||||
private static String completion(JsonNode request) throws IOException
|
||||
{
|
||||
String question = "";
|
||||
Map<String, Integer> counts = new LinkedHashMap<>();
|
||||
boolean presented = false;
|
||||
for(JsonNode message : request.path("messages"))
|
||||
{
|
||||
if("user".equals(message.path("role").asText()))
|
||||
{
|
||||
question = message.path("content").asText();
|
||||
counts.clear();
|
||||
presented = false;
|
||||
}
|
||||
else if("tool".equals(message.path("role").asText()) && IMAGE_QUESTION.equals(question))
|
||||
{
|
||||
String id = message.path("tool_call_id").asText();
|
||||
if(id.startsWith("count-image/"))
|
||||
{
|
||||
counts.put(id.substring("count-".length()), ((Number) EvalSupport.unwrap(message.path("content").asText()).get("count")).intValue());
|
||||
}
|
||||
else if("present-images".equals(id))
|
||||
{
|
||||
presented = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> delta = Map.of("role", "assistant", "content", "Hello from the local demo provider.");
|
||||
String finish = "stop";
|
||||
if(IMAGE_QUESTION.equals(question) && !presented)
|
||||
{
|
||||
List<Map<String, Object>> calls = new ArrayList<>();
|
||||
if(counts.isEmpty())
|
||||
{
|
||||
for(String mime : List.of("image/jpeg", "image/png", "image/webp", "image/gif", "image/svg+xml"))
|
||||
{
|
||||
calls.add(toolCall(calls.size(), "count-" + mime, "count_records", Map.of("table", "media", "filter",
|
||||
GoldenTasks.filter("AND", GoldenTasks.criterion("mimeType", "EQUALS", mime)))));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if(counts.size() != 5 || counts.get("image/webp") != 0 || counts.get("image/gif") != 0 || counts.get("image/svg+xml") != 0)
|
||||
{
|
||||
throw (new IOException("Unexpected demo image counts."));
|
||||
}
|
||||
Map<String, Object> input = PresentationGoldenTask.input(counts.get("image/jpeg"), counts.get("image/png"));
|
||||
input.put("actions", List.of(Map.of("label", "Show the PNG files", "prompt", "Show the PNG files")));
|
||||
calls.add(toolCall(0, "present-images", "present_results", input));
|
||||
}
|
||||
delta = Map.of("role", "assistant", "tool_calls", calls);
|
||||
finish = "tool_calls";
|
||||
}
|
||||
else if(presented)
|
||||
{
|
||||
delta = Map.of("role", "assistant", "content", "You can open either image type above.");
|
||||
}
|
||||
return ("data: " + JSON.writeValueAsString(Map.of("id", "demo-completion", "object", "chat.completion.chunk",
|
||||
"choices", List.of(Map.of("index", 0, "delta", delta)))) + "\n\n"
|
||||
+ "data: " + JSON.writeValueAsString(Map.of("choices", List.of(Map.of("index", 0, "delta", Map.of(), "finish_reason", finish)))) + "\n\n"
|
||||
+ "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":24,\"completion_tokens\":8,\"total_tokens\":32}}\n\n"
|
||||
+ "data: [DONE]\n\n");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Encode tool arguments as a JSON string, as OpenAI streaming requires.
|
||||
*******************************************************************************/
|
||||
private static Map<String, Object> toolCall(Integer index, String id, String name, Map<String, Object> arguments) throws IOException
|
||||
{
|
||||
return (Map.of("index", index, "id", id, "type", "function", "function", Map.of("name", name, "arguments", JSON.writeValueAsString(arguments))));
|
||||
}
|
||||
}
|
||||
+13
-2
@@ -29,6 +29,11 @@ Startup prints these URLs (with the selected port):
|
||||
- Local provider: `http://localhost:8099/app/assistant_provider/1`
|
||||
- Priced and tested model: `http://localhost:8099/app/assistant_model/1`
|
||||
|
||||
Startup also prints `Image presentation conversation id: <id>`. Open that
|
||||
conversation in the assistant panel history as `admin-demo` to capture the
|
||||
answer to "How many images do I have by type?". The id is assigned on each run;
|
||||
the conversation and its presentation are memory-backed and reset on restart.
|
||||
|
||||
Every browser session is `admin-demo`. Startup inserts an enabled, default,
|
||||
keyless Local GPU (Ollama) provider, marked LOCAL and HIPAA eligible for the
|
||||
synthetic demo, and a disabled CLOUD OpenRouter provider whose key is Not set.
|
||||
@@ -40,8 +45,14 @@ mock: `qwen3:30b-a3b`, `gpt-oss:20b`, `qwen3-coder:30b`, and `bge-m3`.
|
||||
A normal update sets qwen3's input/output compute prices to $0.05/$0.10 per
|
||||
million tokens. The provider and model Test connection processes then each
|
||||
make a real HTTP completion call, recording two TEST usage rows and the
|
||||
last-test fields. One assistant turn records a third usage row with purpose
|
||||
TURN. Discovery timestamps/status also come from the actual process.
|
||||
last-test fields. The greeting turn records a third usage row with purpose
|
||||
TURN. An image breakdown turn then makes three more model calls through the
|
||||
real OpenAI adapter and engine: five MIME-filtered `count_records` calls,
|
||||
`present_results` using the returned counts, and a short closing message.
|
||||
The fixture seeds 81 JPEG and 20 PNG rows (no WebP, GIF or SVG). The presentation
|
||||
includes a table with computed shares, filtered row links, a pie chart, JPEG
|
||||
and PNG filter links, and a "Show the PNG files" prompt action.
|
||||
Discovery timestamps/status also come from the actual process.
|
||||
|
||||
Replies and token counts are synthetic mock API responses (24 input / 8 output
|
||||
tokens per completion), not live GPU measurements or billed cloud usage.
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
~ 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.
|
||||
-->
|
||||
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-assistant-parent</artifactId>
|
||||
<version>${revision}</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>qbit-assistant-rag-pgvector</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>QBit Assistant - pgvector</name>
|
||||
<description>Content-free per-instance vector storage through JDBC</description>
|
||||
<url>https://github.com/QRun-IO/qbit-assistant</url>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-assistant</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>1.21.4</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<version>1.21.4</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<version>42.7.14</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.assertj</groupId>
|
||||
<artifactId>assertj-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<!-- live evals call the real API: opt-in only -->
|
||||
<excludedGroups>${test.excludedGroups}</excludedGroups>
|
||||
<redirectTestOutputToFile>true</redirectTestOutputToFile>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>prepare-agent</id>
|
||||
<goals>
|
||||
<goal>prepare-agent</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>report</id>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>report</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>check</id>
|
||||
<goals>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<rules>
|
||||
<rule>
|
||||
<element>BUNDLE</element>
|
||||
<limits>
|
||||
<limit>
|
||||
<counter>INSTRUCTION</counter>
|
||||
<value>COVEREDRATIO</value>
|
||||
<minimum>0.70</minimum>
|
||||
</limit>
|
||||
<limit>
|
||||
<counter>CLASS</counter>
|
||||
<value>COVEREDRATIO</value>
|
||||
<minimum>0.90</minimum>
|
||||
</limit>
|
||||
</limits>
|
||||
</rule>
|
||||
</rules>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
+452
@@ -0,0 +1,452 @@
|
||||
/*
|
||||
* 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.rag.pgvector;
|
||||
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.StringJoiner;
|
||||
import javax.sql.DataSource;
|
||||
import com.kingsrook.qbits.assistant.rag.ChunkVector;
|
||||
import com.kingsrook.qbits.assistant.rag.VectorStore;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Host-supplied JDBC only, without QQQ RDBMS internals. No text is ever stored.
|
||||
*******************************************************************************/
|
||||
public class PgVectorStore implements VectorStore
|
||||
{
|
||||
private final ConnectionSupplier connections;
|
||||
private final Integer dimensions;
|
||||
private String schema = "assistant_rag";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The host owns credentials and connection pooling; each call closes its lease.
|
||||
*******************************************************************************/
|
||||
@FunctionalInterface
|
||||
public interface ConnectionSupplier
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Return a connection owned by this caller until it closes it.
|
||||
*******************************************************************************/
|
||||
Connection open() throws SQLException;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Use any host DataSource and a fixed vector dimension for this instance.
|
||||
*******************************************************************************/
|
||||
public PgVectorStore(DataSource dataSource, Integer dimensions)
|
||||
{
|
||||
this(dataSource::getConnection, dimensions);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** HNSW vector cosine indexes support up to 2000 dimensions.
|
||||
*******************************************************************************/
|
||||
public PgVectorStore(ConnectionSupplier connections, Integer dimensions)
|
||||
{
|
||||
if(connections == null || dimensions == null || dimensions < 1 || dimensions > 2000)
|
||||
{
|
||||
throw (new IllegalArgumentException("A connection supplier and 1 to 2000 dimensions are required."));
|
||||
}
|
||||
this.connections = connections;
|
||||
this.dimensions = dimensions;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure before use. Identifiers cannot be parameters, so validate strictly.
|
||||
*******************************************************************************/
|
||||
public PgVectorStore withSchema(String schema)
|
||||
{
|
||||
if(schema == null || !schema.matches("[a-z_][a-z0-9_]{0,62}"))
|
||||
{
|
||||
throw (new IllegalArgumentException("The vector schema must be a lower-case SQL identifier of at most 63 characters."));
|
||||
}
|
||||
this.schema = schema;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The DBA normally installs vector. An existing extension needs no elevated
|
||||
** privileges; if absent, attempt installation and report an actionable error.
|
||||
*******************************************************************************/
|
||||
public void ensureSchema() throws QException
|
||||
{
|
||||
try(Connection connection = connections.open())
|
||||
{
|
||||
try(PreparedStatement check = connection.prepareStatement("SELECT 1 FROM pg_extension WHERE extname = ?"))
|
||||
{
|
||||
check.setString(1, "vector");
|
||||
try(ResultSet rows = check.executeQuery())
|
||||
{
|
||||
if(!rows.next())
|
||||
{
|
||||
try(PreparedStatement extension = connection.prepareStatement("CREATE EXTENSION IF NOT EXISTS vector"))
|
||||
{
|
||||
extension.execute();
|
||||
}
|
||||
catch(SQLException e)
|
||||
{
|
||||
throw (new QException("Ask the DBA to run CREATE EXTENSION IF NOT EXISTS vector in this database, then retry.", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for(String sql : List.of("CREATE SCHEMA IF NOT EXISTS " + schema,
|
||||
"CREATE TABLE IF NOT EXISTS " + table() + " (id BIGSERIAL PRIMARY KEY, source_id INT NOT NULL, table_name TEXT NOT NULL, record_pk TEXT NOT NULL, field_name TEXT NOT NULL, chunk_index INT NOT NULL, content_hash CHAR(64) NOT NULL, embedding vector(" + dimensions + ") NOT NULL, indexed_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(source_id, table_name, record_pk, field_name, chunk_index))",
|
||||
"CREATE INDEX IF NOT EXISTS assistant_rag_chunk_cosine ON " + table() + " USING hnsw (embedding vector_cosine_ops)"))
|
||||
{
|
||||
try(PreparedStatement statement = connection.prepareStatement(sql))
|
||||
{
|
||||
statement.execute();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(SQLException e)
|
||||
{
|
||||
throw (failure(e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate the entire batch before sending anything to JDBC.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void upsert(List<ChunkVector> chunks) throws QException
|
||||
{
|
||||
chunks.forEach(chunk -> vector(chunk.embedding()));
|
||||
try(Connection connection = connections.open())
|
||||
{
|
||||
upsert(connection, chunks);
|
||||
}
|
||||
catch(SQLException e)
|
||||
{
|
||||
throw (failure(e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Values, including vectors and arbitrary record keys, are bound parameters.
|
||||
*******************************************************************************/
|
||||
private void upsert(Connection connection, List<ChunkVector> chunks) throws SQLException
|
||||
{
|
||||
String sql = "INSERT INTO " + table() + " (source_id, table_name, record_pk, field_name, chunk_index, content_hash, embedding) VALUES (?, ?, ?, ?, ?, ?, ?::vector) ON CONFLICT (source_id, table_name, record_pk, field_name, chunk_index) DO UPDATE SET content_hash = EXCLUDED.content_hash, embedding = EXCLUDED.embedding, indexed_at = CURRENT_TIMESTAMP";
|
||||
try(PreparedStatement statement = connection.prepareStatement(sql))
|
||||
{
|
||||
for(ChunkVector chunk : chunks)
|
||||
{
|
||||
statement.setInt(1, chunk.source());
|
||||
statement.setString(2, chunk.table());
|
||||
statement.setString(3, chunk.pk());
|
||||
statement.setString(4, chunk.field());
|
||||
statement.setInt(5, chunk.chunkIndex());
|
||||
statement.setString(6, chunk.contentHash());
|
||||
statement.setString(7, vector(chunk.embedding()));
|
||||
statement.addBatch();
|
||||
}
|
||||
statement.executeBatch();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A record replacement is transactional, including empty replacements.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void replaceRecord(Integer source, String table, String pk, List<ChunkVector> chunks) throws QException
|
||||
{
|
||||
for(ChunkVector chunk : chunks)
|
||||
{
|
||||
vector(chunk.embedding());
|
||||
if(!source.equals(chunk.source()) || !table.equals(chunk.table()) || !pk.equals(chunk.pk()))
|
||||
{
|
||||
throw (new IllegalArgumentException("Replacement chunks must belong to the specified record."));
|
||||
}
|
||||
}
|
||||
try(Connection connection = connections.open())
|
||||
{
|
||||
Boolean autoCommit = connection.getAutoCommit();
|
||||
connection.setAutoCommit(false);
|
||||
try
|
||||
{
|
||||
deleteRecord(connection, source, table, pk);
|
||||
upsert(connection, chunks);
|
||||
connection.commit();
|
||||
}
|
||||
catch(SQLException e)
|
||||
{
|
||||
connection.rollback();
|
||||
throw (e);
|
||||
}
|
||||
finally
|
||||
{
|
||||
connection.setAutoCommit(autoCommit);
|
||||
}
|
||||
}
|
||||
catch(SQLException e)
|
||||
{
|
||||
throw (failure(e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Remove one record, always scoped to its source.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void deleteRecord(Integer source, String table, String pk) throws QException
|
||||
{
|
||||
try(Connection connection = connections.open())
|
||||
{
|
||||
deleteRecord(connection, source, table, pk);
|
||||
}
|
||||
catch(SQLException e)
|
||||
{
|
||||
throw (failure(e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared transactional delete statement.
|
||||
*******************************************************************************/
|
||||
private void deleteRecord(Connection connection, Integer source, String table, String pk) throws SQLException
|
||||
{
|
||||
try(PreparedStatement statement = connection.prepareStatement("DELETE FROM " + table() + " WHERE source_id = ? AND table_name = ? AND record_pk = ?"))
|
||||
{
|
||||
statement.setInt(1, source);
|
||||
statement.setString(2, table);
|
||||
statement.setString(3, pk);
|
||||
statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Clear a source, preserving every other source in the instance.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void deleteSource(Integer source) throws QException
|
||||
{
|
||||
try(Connection connection = connections.open(); PreparedStatement statement = connection.prepareStatement("DELETE FROM " + table() + " WHERE source_id = ?"))
|
||||
{
|
||||
statement.setInt(1, source);
|
||||
statement.executeUpdate();
|
||||
}
|
||||
catch(SQLException e)
|
||||
{
|
||||
throw (failure(e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** HNSW cosine ordering; tables are values, never interpolated SQL identifiers.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Hit> search(float[] query, Integer k, Set<String> tables) throws QException
|
||||
{
|
||||
return (search(query, k, tables, Set.of()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply enabled compatible source restrictions before the nearest-hit limit.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Hit> search(float[] query, Integer k, Set<String> tables, Set<Integer> sources) throws QException
|
||||
{
|
||||
if(tables.isEmpty() || k <= 0)
|
||||
{
|
||||
return (List.of());
|
||||
}
|
||||
String vector = vector(query);
|
||||
String sql = "SELECT source_id, table_name, record_pk, field_name, chunk_index, 1 - (embedding <=> ?::vector) AS score FROM " + table()
|
||||
+ " WHERE table_name IN (" + String.join(", ", Collections.nCopies(tables.size(), "?")) + ")"
|
||||
+ (sources.isEmpty() ? "" : " AND source_id IN (" + String.join(", ", Collections.nCopies(sources.size(), "?")) + ")")
|
||||
+ " ORDER BY embedding <=> ?::vector LIMIT ?";
|
||||
try(Connection connection = connections.open(); PreparedStatement statement = connection.prepareStatement(sql))
|
||||
{
|
||||
Integer parameter = 1;
|
||||
statement.setString(parameter++, vector);
|
||||
for(String table : tables)
|
||||
{
|
||||
statement.setString(parameter++, table);
|
||||
}
|
||||
for(Integer source : sources)
|
||||
{
|
||||
statement.setInt(parameter++, source);
|
||||
}
|
||||
statement.setString(parameter++, vector);
|
||||
statement.setInt(parameter, k);
|
||||
List<Hit> hits = new ArrayList<>();
|
||||
try(ResultSet rows = statement.executeQuery())
|
||||
{
|
||||
while(rows.next())
|
||||
{
|
||||
Double score = rows.getDouble("score");
|
||||
if(Double.isFinite(score))
|
||||
{
|
||||
hits.add(new Hit(rows.getInt("source_id"), rows.getString("table_name"), rows.getString("record_pk"), rows.getString("field_name"), rows.getInt("chunk_index"), score));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (hits);
|
||||
}
|
||||
catch(SQLException e)
|
||||
{
|
||||
throw (failure(e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Count chunks for the source's status screen.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Long count(Integer source) throws QException
|
||||
{
|
||||
try(Connection connection = connections.open(); PreparedStatement statement = connection.prepareStatement("SELECT COUNT(*) FROM " + table() + " WHERE source_id = ?"))
|
||||
{
|
||||
statement.setInt(1, source);
|
||||
try(ResultSet rows = statement.executeQuery())
|
||||
{
|
||||
rows.next();
|
||||
return (rows.getLong(1));
|
||||
}
|
||||
}
|
||||
catch(SQLException e)
|
||||
{
|
||||
throw (failure(e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Content-free reconciliation data. Embedding text is pgvector's numeric form.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<ChunkVector> chunks(Integer source) throws QException
|
||||
{
|
||||
try(Connection connection = connections.open(); PreparedStatement statement = connection.prepareStatement("SELECT table_name, record_pk, field_name, chunk_index, content_hash, embedding::text FROM " + table() + " WHERE source_id = ?"))
|
||||
{
|
||||
statement.setInt(1, source);
|
||||
List<ChunkVector> chunks = new ArrayList<>();
|
||||
try(ResultSet rows = statement.executeQuery())
|
||||
{
|
||||
while(rows.next())
|
||||
{
|
||||
chunks.add(new ChunkVector(source, rows.getString("table_name"), rows.getString("record_pk"), rows.getString("field_name"), rows.getInt("chunk_index"), rows.getString("content_hash"), parse(rows.getString("embedding"))));
|
||||
}
|
||||
}
|
||||
return (chunks);
|
||||
}
|
||||
catch(SQLException e)
|
||||
{
|
||||
throw (failure(e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Qualified name uses only the validated instance schema.
|
||||
*******************************************************************************/
|
||||
private String table()
|
||||
{
|
||||
return (schema + ".assistant_rag_chunk");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Serialize only finite coordinates with the configured dimension.
|
||||
*******************************************************************************/
|
||||
private String vector(float[] vector)
|
||||
{
|
||||
if(vector == null || vector.length != dimensions)
|
||||
{
|
||||
throw (new IllegalArgumentException("Vector dimensions differ from the configured pgvector schema."));
|
||||
}
|
||||
StringJoiner values = new StringJoiner(",", "[", "]");
|
||||
for(float value : vector)
|
||||
{
|
||||
if(!Float.isFinite(value))
|
||||
{
|
||||
throw (new IllegalArgumentException("Vector coordinates must be finite."));
|
||||
}
|
||||
values.add(Float.toString(value));
|
||||
}
|
||||
return (values.toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse the database's numeric representation without any source content.
|
||||
*******************************************************************************/
|
||||
private float[] parse(String text)
|
||||
{
|
||||
String[] parts = text.substring(1, text.length() - 1).split(",");
|
||||
float[] vector = new float[parts.length];
|
||||
for(Integer i = 0; i < parts.length; i++)
|
||||
{
|
||||
vector[i] = Float.parseFloat(parts[i]);
|
||||
}
|
||||
vector(vector);
|
||||
return (vector);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Do not expose database URLs, SQL values or credentials in tool responses.
|
||||
*******************************************************************************/
|
||||
private static QException failure(SQLException cause)
|
||||
{
|
||||
return (new QException("The knowledge vector store is unavailable. Check its schema, dimensions and database permissions.", cause));
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* 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.rag.pgvector;
|
||||
|
||||
|
||||
import java.sql.DriverManager;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.assistant.rag.ChunkVector;
|
||||
import com.kingsrook.qbits.assistant.rag.TextChunks;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
import org.testcontainers.utility.DockerImageName;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Real pgvector contract; skipped automatically on hosts without Docker.
|
||||
*******************************************************************************/
|
||||
@Testcontainers(disabledWithoutDocker = true)
|
||||
class PgVectorContainerTest
|
||||
{
|
||||
@Container
|
||||
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>(DockerImageName.parse("pgvector/pgvector:pg16").asCompatibleSubstituteFor("postgres"));
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** DDL idempotence, cosine ranking, upsert, deletes and instance isolation.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void realVectorLifecycle() throws Exception
|
||||
{
|
||||
PgVectorStore.ConnectionSupplier connections = () -> DriverManager.getConnection(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword());
|
||||
PgVectorStore store = new PgVectorStore(connections, 3).withSchema("instance_one");
|
||||
PgVectorStore other = new PgVectorStore(connections, 3).withSchema("instance_two");
|
||||
store.ensureSchema();
|
||||
store.ensureSchema();
|
||||
other.ensureSchema();
|
||||
ChunkVector first = new ChunkVector(1, "article", "1", "body", 0, TextChunks.hash("one"), new float[] { 1, 0, 0 });
|
||||
ChunkVector second = new ChunkVector(1, "article", "2", "body", 0, TextChunks.hash("two"), new float[] { 0, 1, 0 });
|
||||
store.upsert(List.of(first, second));
|
||||
store.upsert(List.of(first));
|
||||
assertThat(store.count(1)).isEqualTo(2);
|
||||
assertThat(other.count(1)).isZero();
|
||||
assertThat(store.search(new float[] { 1, 0, 0 }, 1, Set.of("article"))).singleElement().satisfies(hit ->
|
||||
{
|
||||
assertThat(hit.pk()).isEqualTo("1");
|
||||
assertThat(hit.score()).isEqualTo(1.0);
|
||||
});
|
||||
assertThat(store.chunks(1)).hasSize(2);
|
||||
store.replaceRecord(1, "article", "1", List.of());
|
||||
assertThat(store.count(1)).isEqualTo(1);
|
||||
store.deleteRecord(1, "article", "2");
|
||||
assertThat(store.count(1)).isZero();
|
||||
store.upsert(List.of(first));
|
||||
store.deleteSource(1);
|
||||
assertThat(store.count(1)).isZero();
|
||||
}
|
||||
}
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
/*
|
||||
* 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.rag.pgvector;
|
||||
|
||||
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import javax.sql.DataSource;
|
||||
import com.kingsrook.qbits.assistant.rag.ChunkVector;
|
||||
import com.kingsrook.qbits.assistant.rag.TextChunks;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Always-on JDBC SQL-shape and transaction tests, with no Docker dependency.
|
||||
*******************************************************************************/
|
||||
class PgVectorStoreTest
|
||||
{
|
||||
private final List<String> sql = new ArrayList<>();
|
||||
private final List<Map<Integer, Object>> parameters = new ArrayList<>();
|
||||
private final List<String> operations = new ArrayList<>();
|
||||
private Boolean extensionExists = true;
|
||||
private Boolean failBatch = false;
|
||||
private Boolean failExtension = false;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** JDBC recording connection; values never appear in SQL text.
|
||||
*******************************************************************************/
|
||||
private Connection connection()
|
||||
{
|
||||
return ((Connection) Proxy.newProxyInstance(getClass().getClassLoader(), new Class<?>[] { Connection.class }, (proxy, method, args) ->
|
||||
{
|
||||
operations.add(method.getName());
|
||||
if(method.getName().equals("prepareStatement"))
|
||||
{
|
||||
return (statement((String) args[0]));
|
||||
}
|
||||
return (method.getName().equals("getAutoCommit") ? true : null);
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Capture each binding and emulate rows or SQL failures.
|
||||
*******************************************************************************/
|
||||
private PreparedStatement statement(String query)
|
||||
{
|
||||
sql.add(query);
|
||||
Map<Integer, Object> values = new HashMap<>();
|
||||
parameters.add(values);
|
||||
return ((PreparedStatement) Proxy.newProxyInstance(getClass().getClassLoader(), new Class<?>[] { PreparedStatement.class }, (proxy, method, args) ->
|
||||
{
|
||||
if(method.getName().startsWith("set"))
|
||||
{
|
||||
values.put((Integer) args[0], args[1]);
|
||||
}
|
||||
if(method.getName().equals("executeQuery"))
|
||||
{
|
||||
return (rows(query));
|
||||
}
|
||||
if(method.getName().equals("executeBatch"))
|
||||
{
|
||||
if(failBatch)
|
||||
{
|
||||
throw (new SQLException("synthetic batch failure"));
|
||||
}
|
||||
return (new int[] { 1 });
|
||||
}
|
||||
if(method.getName().equals("executeUpdate"))
|
||||
{
|
||||
return (1);
|
||||
}
|
||||
if(method.getName().equals("execute"))
|
||||
{
|
||||
if(failExtension && query.startsWith("CREATE EXTENSION"))
|
||||
{
|
||||
throw (new SQLException("extension privilege required"));
|
||||
}
|
||||
return (true);
|
||||
}
|
||||
return (null);
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One content-free result; the extension probe can return no rows.
|
||||
*******************************************************************************/
|
||||
private ResultSet rows(String query)
|
||||
{
|
||||
Boolean[] first = { true };
|
||||
return ((ResultSet) Proxy.newProxyInstance(getClass().getClassLoader(), new Class<?>[] { ResultSet.class }, (proxy, method, args) ->
|
||||
{
|
||||
if(method.getName().equals("next"))
|
||||
{
|
||||
Boolean next = first[0] && (!query.contains("pg_extension") || extensionExists);
|
||||
first[0] = false;
|
||||
return (next);
|
||||
}
|
||||
return (switch(method.getName())
|
||||
{
|
||||
case "getString" -> switch(String.valueOf(args[0]))
|
||||
{
|
||||
case "content_hash" -> TextChunks.hash("synthetic");
|
||||
case "embedding" -> "[1,0,0]";
|
||||
case "record_pk" -> "pk'; --";
|
||||
case "field_name" -> "body";
|
||||
default -> "article";
|
||||
};
|
||||
case "getInt" -> "chunk_index".equals(args[0]) ? 0 : 1;
|
||||
case "getLong" -> 1L;
|
||||
case "getDouble" -> 1.0;
|
||||
default -> null;
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Exercise every statement with values that would be unsafe to interpolate.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void statementsAreBoundAndContentFree() throws Exception
|
||||
{
|
||||
PgVectorStore store = new PgVectorStore(this::connection, 3).withSchema("instance_one");
|
||||
store.ensureSchema();
|
||||
assertThat(sql).anyMatch(s -> s.contains("embedding vector(3)"));
|
||||
assertThat(sql).anyMatch(s -> s.contains("USING hnsw (embedding vector_cosine_ops)"));
|
||||
assertThat(sql).noneMatch(s -> s.contains("CREATE EXTENSION"));
|
||||
ChunkVector chunk = new ChunkVector(1, "article", "pk'; --", "body", 0, TextChunks.hash("synthetic"), new float[] { 1, 0, 0 });
|
||||
store.upsert(List.of(chunk));
|
||||
store.replaceRecord(1, "article", "pk'; --", List.of(chunk));
|
||||
assertThat(store.search(new float[] { 1, 0, 0 }, 8, Set.of("article", "injection'; --"))).hasSize(1);
|
||||
assertThat(store.search(new float[] { 1, 0, 0 }, 8, Set.of("article"), Set.of(1))).hasSize(1);
|
||||
assertThat(store.chunks(1)).singleElement().satisfies(v -> assertThat(v.embedding()).containsExactly(1, 0, 0));
|
||||
assertThat(store.count(1)).isEqualTo(1);
|
||||
store.deleteRecord(1, "article", "pk'; --");
|
||||
store.deleteSource(1);
|
||||
assertThat(sql).anyMatch(s -> s.contains("?::vector") && s.contains("ON CONFLICT"));
|
||||
assertThat(sql).anyMatch(s -> s.contains("ORDER BY embedding <=> ?::vector LIMIT ?"));
|
||||
assertThat(sql).noneMatch(s -> s.contains("pk'; --") || s.contains("injection'; --") || s.contains("synthetic"));
|
||||
assertThat(parameters).anyMatch(p -> p.containsValue("pk'; --"));
|
||||
assertThat(parameters).anyMatch(p -> p.containsValue("injection'; --"));
|
||||
assertThat(operations).contains("commit", "close", "setAutoCommit");
|
||||
assertThat(store.search(new float[] { 1, 0, 0 }, 8, Set.of())).isEmpty();
|
||||
assertThat(store.search(new float[] { 1, 0, 0 }, 0, Set.of("article"))).isEmpty();
|
||||
DataSource dataSource = (DataSource) Proxy.newProxyInstance(getClass().getClassLoader(), new Class<?>[] { DataSource.class }, (p, method, args) -> connection());
|
||||
assertThat(new PgVectorStore(dataSource, 3).count(1)).isEqualTo(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** DBA failure is actionable; failed replacement rolls back before closing.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void extensionAndRollback() throws Exception
|
||||
{
|
||||
extensionExists = false;
|
||||
PgVectorStore store = new PgVectorStore(this::connection, 3);
|
||||
store.ensureSchema();
|
||||
assertThat(sql).contains("CREATE EXTENSION IF NOT EXISTS vector");
|
||||
failExtension = true;
|
||||
assertThatThrownBy(store::ensureSchema).hasMessageContaining("DBA");
|
||||
failBatch = true;
|
||||
assertThatThrownBy(() -> store.replaceRecord(1, "article", "1", List.of())).hasMessageContaining("vector store");
|
||||
assertThat(operations).contains("rollback");
|
||||
PgVectorStore down = new PgVectorStore(() ->
|
||||
{
|
||||
throw (new SQLException("offline"));
|
||||
}, 3);
|
||||
assertThatThrownBy(down::ensureSchema).hasMessageContaining("vector store");
|
||||
assertThatThrownBy(() -> down.count(1)).hasMessageContaining("vector store");
|
||||
assertThatThrownBy(() -> down.chunks(1)).hasMessageContaining("vector store");
|
||||
assertThatThrownBy(() -> down.upsert(List.of())).hasMessageContaining("vector store");
|
||||
assertThatThrownBy(() -> down.deleteRecord(1, "a", "1")).hasMessageContaining("vector store");
|
||||
assertThatThrownBy(() -> down.deleteSource(1)).hasMessageContaining("vector store");
|
||||
assertThatThrownBy(() -> down.search(new float[] { 1, 0, 0 }, 8, Set.of("a"))).hasMessageContaining("vector store");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Identifier and coordinate validation happens before opening a connection.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void invalidInputsNeverReachJdbc()
|
||||
{
|
||||
PgVectorStore store = new PgVectorStore(this::connection, 3);
|
||||
for(String name : List.of("a.b", "bad; DROP SCHEMA public", "UPPER", "a".repeat(64)))
|
||||
{
|
||||
assertThatThrownBy(() -> store.withSchema(name)).isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
assertThatThrownBy(() -> new PgVectorStore(this::connection, 2001)).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> store.search(new float[] { 1 }, 1, Set.of("a"))).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> store.search(new float[] { Float.NaN, 0, 0 }, 1, Set.of("a"))).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThat(operations).isEmpty();
|
||||
}
|
||||
}
|
||||
+140
-1
@@ -32,13 +32,14 @@ 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;
|
||||
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;
|
||||
@@ -59,6 +60,8 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
*******************************************************************************/
|
||||
public class AssistantQBitConfig implements QBitConfig
|
||||
{
|
||||
private VectorStore vectorStore;
|
||||
|
||||
public static final String ACCESS_PERMISSION = "assistant.hasAccess";
|
||||
public static final String ADMIN_PERMISSION = "assistant.admin";
|
||||
public static final String DEFAULT_OWNER_KEY_TYPE = "assistant_user_id";
|
||||
@@ -107,6 +110,9 @@ public class AssistantQBitConfig implements QBitConfig
|
||||
private Integer policyCacheSeconds = 30;
|
||||
private String ownerSecurityKeyType = DEFAULT_OWNER_KEY_TYPE;
|
||||
private String shortcut = DEFAULT_SHORTCUT;
|
||||
private Set<String> allowedScheduledCredentialEnvNames = new LinkedHashSet<>();
|
||||
private String scheduledTasksSchedulerName;
|
||||
|
||||
private String retentionSchedulerName;
|
||||
private Boolean registerRoutes = true;
|
||||
private Map<String, ModelPricing> pricing = ModelPricing.defaults();
|
||||
@@ -130,6 +136,7 @@ public class AssistantQBitConfig implements QBitConfig
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
private String uiApiBaseUrl;
|
||||
private UiApiGateway uiApiGateway;
|
||||
private com.kingsrook.qbits.assistant.tools.bulkload.BulkLoadRunner bulkLoadRunner;
|
||||
private Integer pendingActionTtlSeconds = 900;
|
||||
private List<String> customRedactionPatterns = new ArrayList<>();
|
||||
private Integer killSwitchPollMillis = 2000;
|
||||
@@ -210,6 +217,13 @@ public class AssistantQBitConfig implements QBitConfig
|
||||
requirePositive(errors, "killSwitchPollMillis", killSwitchPollMillis);
|
||||
requirePositive(errors, "providerTestTimeoutSeconds", providerTestTimeoutSeconds);
|
||||
requirePositive(errors, "homeMaxRows", homeMaxRows);
|
||||
for(String name : allowedScheduledCredentialEnvNames == null ? Set.<String>of() : allowedScheduledCredentialEnvNames)
|
||||
{
|
||||
if(name == null || !ENV_NAME.matcher(name).matches())
|
||||
{
|
||||
errors.add("allowedScheduledCredentialEnvNames contains an invalid environment variable name.");
|
||||
}
|
||||
}
|
||||
for(String name : allowedApiKeyEnvNames == null ? Set.<String>of() : allowedApiKeyEnvNames)
|
||||
{
|
||||
if(name == null || !ENV_NAME.matcher(name).matches())
|
||||
@@ -1199,6 +1213,38 @@ public class AssistantQBitConfig implements QBitConfig
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for bulkLoadRunner (null: the durable jobs launch,
|
||||
** JobsLaunchBulkLoadRunner) - how bulk_load_run starts a load.
|
||||
*******************************************************************************/
|
||||
public com.kingsrook.qbits.assistant.tools.bulkload.BulkLoadRunner getBulkLoadRunner()
|
||||
{
|
||||
return (this.bulkLoadRunner);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for bulkLoadRunner
|
||||
*******************************************************************************/
|
||||
public void setBulkLoadRunner(com.kingsrook.qbits.assistant.tools.bulkload.BulkLoadRunner bulkLoadRunner)
|
||||
{
|
||||
this.bulkLoadRunner = bulkLoadRunner;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for bulkLoadRunner
|
||||
*******************************************************************************/
|
||||
public AssistantQBitConfig withBulkLoadRunner(com.kingsrook.qbits.assistant.tools.bulkload.BulkLoadRunner bulkLoadRunner)
|
||||
{
|
||||
this.bulkLoadRunner = bulkLoadRunner;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for uiApiGateway (null: the loopback stand-in)
|
||||
*******************************************************************************/
|
||||
@@ -1788,4 +1834,97 @@ public class AssistantQBitConfig implements QBitConfig
|
||||
configuredClassifications.withClassification(tableName, fieldName, classification);
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for vectorStore
|
||||
*******************************************************************************/
|
||||
public VectorStore getVectorStore()
|
||||
{
|
||||
return (vectorStore);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for vectorStore
|
||||
*******************************************************************************/
|
||||
public void setVectorStore(VectorStore vectorStore)
|
||||
{
|
||||
this.vectorStore = vectorStore;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for vectorStore
|
||||
*******************************************************************************/
|
||||
public AssistantQBitConfig withVectorStore(VectorStore vectorStore)
|
||||
{
|
||||
this.vectorStore = vectorStore;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for allowedScheduledCredentialEnvNames
|
||||
*******************************************************************************/
|
||||
public Set<String> getAllowedScheduledCredentialEnvNames()
|
||||
{
|
||||
return (allowedScheduledCredentialEnvNames);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for allowedScheduledCredentialEnvNames
|
||||
*******************************************************************************/
|
||||
public void setAllowedScheduledCredentialEnvNames(Set<String> allowedScheduledCredentialEnvNames)
|
||||
{
|
||||
this.allowedScheduledCredentialEnvNames = allowedScheduledCredentialEnvNames;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for allowedScheduledCredentialEnvNames
|
||||
*******************************************************************************/
|
||||
public AssistantQBitConfig withAllowedScheduledCredentialEnvNames(Set<String> allowedScheduledCredentialEnvNames)
|
||||
{
|
||||
this.allowedScheduledCredentialEnvNames = allowedScheduledCredentialEnvNames;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for scheduledTasksSchedulerName
|
||||
*******************************************************************************/
|
||||
public String getScheduledTasksSchedulerName()
|
||||
{
|
||||
return (scheduledTasksSchedulerName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for scheduledTasksSchedulerName
|
||||
*******************************************************************************/
|
||||
public void setScheduledTasksSchedulerName(String scheduledTasksSchedulerName)
|
||||
{
|
||||
this.scheduledTasksSchedulerName = scheduledTasksSchedulerName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for scheduledTasksSchedulerName
|
||||
*******************************************************************************/
|
||||
public AssistantQBitConfig withScheduledTasksSchedulerName(String scheduledTasksSchedulerName)
|
||||
{
|
||||
this.scheduledTasksSchedulerName = scheduledTasksSchedulerName;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
+22
-6
@@ -21,16 +21,21 @@ import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
import com.kingsrook.qbits.assistant.api.AssistantRoutes;
|
||||
import com.kingsrook.qbits.assistant.developer.AssistantDeveloperPanels;
|
||||
import com.kingsrook.qbits.assistant.home.AssistantAccessWidget;
|
||||
import com.kingsrook.qbits.assistant.home.AssistantHome;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantInstanceMetaData;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantRecordScreens;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantBudget;
|
||||
import com.kingsrook.qbits.assistant.processes.DiscoverModelsProcess;
|
||||
import com.kingsrook.qbits.assistant.processes.KnowledgeIndexProcess;
|
||||
import com.kingsrook.qbits.assistant.processes.ScheduledTaskProcess;
|
||||
import com.kingsrook.qbits.assistant.processes.TestConnectionProcess;
|
||||
import com.kingsrook.qbits.assistant.processes.TestModelConnectionProcess;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantBudget;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
|
||||
@@ -173,12 +178,23 @@ public class AssistantQBitProducer implements QBitMetaDataProducer<AssistantQBit
|
||||
QProcessMetaData testConnection = TestConnectionProcess.process();
|
||||
testConnection.setSourceQBitName(qBitMetaData.getName());
|
||||
qInstance.addProcess(testConnection);
|
||||
for(QProcessMetaData process : java.util.List.of(DiscoverModelsProcess.process(), TestModelConnectionProcess.process()))
|
||||
for(QProcessMetaData process : List.of(DiscoverModelsProcess.process(), TestModelConnectionProcess.process()))
|
||||
{
|
||||
process.setSourceQBitName(qBitMetaData.getName());
|
||||
qInstance.addProcess(process);
|
||||
}
|
||||
|
||||
for(QProcessMetaData process : KnowledgeIndexProcess.processes())
|
||||
{
|
||||
process.setSourceQBitName(qBitMetaData.getName());
|
||||
qInstance.addProcess(process);
|
||||
}
|
||||
|
||||
for(QProcessMetaData process : ScheduledTaskProcess.processes(qBitConfig))
|
||||
{
|
||||
process.setSourceQBitName(qBitMetaData.getName());
|
||||
qInstance.addProcess(process);
|
||||
}
|
||||
qInstance.addWidget(AssistantHome.widget());
|
||||
qInstance.addWidget(AssistantAccessWidget.widget());
|
||||
|
||||
@@ -219,19 +235,19 @@ public class AssistantQBitProducer implements QBitMetaDataProducer<AssistantQBit
|
||||
boolean routes = false;
|
||||
if(Boolean.TRUE.equals(qBitConfig.getRegisterRoutes()) && isJavalinAvailable())
|
||||
{
|
||||
com.kingsrook.qbits.assistant.api.AssistantRoutes.register(qInstance);
|
||||
AssistantRoutes.register(qInstance);
|
||||
routes = true;
|
||||
}
|
||||
|
||||
if(qBitConfig.isEffectivelyEnabled())
|
||||
{
|
||||
com.kingsrook.qbits.assistant.metadata.AssistantInstanceMetaData.register(qInstance);
|
||||
AssistantInstanceMetaData.register(qInstance);
|
||||
}
|
||||
|
||||
boolean developerPanel = false;
|
||||
if(com.kingsrook.qbits.assistant.developer.AssistantDeveloperPanels.isContractAvailable())
|
||||
if(AssistantDeveloperPanels.isContractAvailable())
|
||||
{
|
||||
com.kingsrook.qbits.assistant.developer.AssistantDeveloperPanels.register(qInstance);
|
||||
AssistantDeveloperPanels.register(qInstance);
|
||||
developerPanel = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,12 @@
|
||||
package com.kingsrook.qbits.assistant;
|
||||
|
||||
|
||||
import java.time.Clock;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.regex.Pattern;
|
||||
import com.kingsrook.qbits.assistant.access.AccessExplainer;
|
||||
import com.kingsrook.qbits.assistant.access.DataAccessService;
|
||||
import com.kingsrook.qbits.assistant.engine.BudgetService;
|
||||
@@ -27,22 +33,25 @@ import com.kingsrook.qbits.assistant.engine.ProviderService;
|
||||
import com.kingsrook.qbits.assistant.engine.RateLimiter;
|
||||
import com.kingsrook.qbits.assistant.engine.RunningTurn;
|
||||
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
|
||||
import com.kingsrook.qbits.assistant.rag.InMemoryVectorStore;
|
||||
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.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;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiToolKernel;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiToolKernelSettings;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.exposure.AiExposurePolicy;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.output.AiRedactor;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import java.time.Clock;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -52,6 +61,12 @@ import java.util.regex.Pattern;
|
||||
*******************************************************************************/
|
||||
public class AssistantRuntime
|
||||
{
|
||||
private final ScheduledTurn scheduledTurn = new ScheduledTurn();
|
||||
|
||||
private final RagService ragService;
|
||||
private final KnowledgeTool knowledgeTool;
|
||||
private final BulkLoadTools bulkLoadTools;
|
||||
|
||||
private final QInstance qInstance;
|
||||
private final AssistantQBitConfig config;
|
||||
private final Clock clock;
|
||||
@@ -105,8 +120,41 @@ 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());
|
||||
this.uiApiGateway = config.getUiApiGateway() != null ? config.getUiApiGateway() : new LoopbackUiApiGateway(this::uiApiBaseUrl, qInstance);
|
||||
this.contextBuilder = new ContextBuilder(config, kernel, toolbox);
|
||||
UiApiGateway delegate = config.getUiApiGateway() != null ? config.getUiApiGateway() : new CoreUiApiGateway(this::uiApiBaseUrl, qInstance);
|
||||
this.uiApiGateway = new UiApiGateway()
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Delegate using this instance's configured collaborator.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean isInvocable(String method, String pathTemplate)
|
||||
{
|
||||
return (delegate.isInvocable(method, pathTemplate));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Delegate using this instance's configured collaborator.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public UiApiResult invoke(UiApiCall call) throws QException
|
||||
{
|
||||
String channel = scheduledTurn.channel(call.getSessionUUID());
|
||||
if(channel != null)
|
||||
{
|
||||
if(!(delegate instanceof CoreUiApiGateway))
|
||||
{
|
||||
throw (new QException("Scheduled tasks require the core UI API gateway for client attribution."));
|
||||
}
|
||||
call.withClientDetail(channel.substring(CoreUiApiGateway.CLIENT_CHANNEL.length() + 1));
|
||||
}
|
||||
return (delegate.invoke(call));
|
||||
}
|
||||
};
|
||||
VectorStore vectors = config.getVectorStore() == null ? new InMemoryVectorStore() : config.getVectorStore();
|
||||
this.ragService = new RagService(this, vectors);
|
||||
this.knowledgeTool = new KnowledgeTool(this, ragService, vectors);
|
||||
this.bulkLoadTools = new BulkLoadTools(qInstance, dataAccessService, config.getBulkLoadRunner());
|
||||
this.contextBuilder = new ContextBuilder(config, kernel, toolbox).withKnowledgeTool(knowledgeTool).withBulkLoadTools(bulkLoadTools);
|
||||
}
|
||||
|
||||
|
||||
@@ -417,4 +465,41 @@ public class AssistantRuntime
|
||||
{
|
||||
return (budgetService);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Manual indexing service, always using the current caller's session.
|
||||
*******************************************************************************/
|
||||
public RagService getRagService()
|
||||
{
|
||||
return (ragService);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Permission-filtered retrieval tool.
|
||||
*******************************************************************************/
|
||||
public KnowledgeTool getKnowledgeTool()
|
||||
{
|
||||
return (knowledgeTool);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Server-owned scheduled execution context.
|
||||
*******************************************************************************/
|
||||
public ScheduledTurn getScheduledTurn()
|
||||
{
|
||||
return (scheduledTurn);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The bulk-load tools (saved profiles, attached files, dry runs, loads).
|
||||
*******************************************************************************/
|
||||
public BulkLoadTools getBulkLoadTools()
|
||||
{
|
||||
return (bulkLoadTools);
|
||||
}
|
||||
|
||||
}
|
||||
+12
-1
@@ -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<TableAccess> 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<TableAccess> 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())));
|
||||
|
||||
@@ -46,6 +46,7 @@ import com.kingsrook.qbits.assistant.model.AssistantTurn;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantTurnEvent;
|
||||
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
|
||||
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
|
||||
import com.kingsrook.qbits.assistant.tools.bulkload.BulkLoadTools;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiToolOutcome;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiToolResult;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.output.AiRedactor;
|
||||
@@ -54,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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -70,6 +72,7 @@ public class AssistantApi
|
||||
private final AssistantStore store;
|
||||
private final QSession session;
|
||||
private final String userId;
|
||||
private UiApiCredentials credentials;
|
||||
|
||||
|
||||
|
||||
@@ -92,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.
|
||||
*******************************************************************************/
|
||||
@@ -372,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);
|
||||
}
|
||||
@@ -597,8 +612,10 @@ 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.Outcome outcome = runtime.getToolbox().executeConfirmedWrite(context, action.getToolCallId(), action.getToolName(), action.getTableName(), action.getRecordId(), values);
|
||||
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);
|
||||
AiToolResult result = outcome.getResult();
|
||||
boolean ok = result.getOutcome() == AiToolOutcome.OK;
|
||||
|
||||
@@ -719,7 +736,7 @@ public class AssistantApi
|
||||
{
|
||||
requireAccess();
|
||||
List<Map<String, Object>> 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)
|
||||
{
|
||||
|
||||
+6
-464
@@ -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=<last seq>.
|
||||
**
|
||||
** 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<String, Object> 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<TurnEvent> 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<String, Object> 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<String, Object> 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<String, Object> map = JsonUtils.toObject(body, new TypeReference<Map<String, Object>>() {}, 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<String, Object> body) throws Exception;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<TurnEvent> 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");
|
||||
}
|
||||
|
||||
}
|
||||
+1336
File diff suppressed because it is too large.
Load diff
+4
-3
@@ -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);
|
||||
|
||||
+20
-19
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
-6
@@ -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);
|
||||
}
|
||||
|
||||
+34
-4
@@ -31,6 +31,8 @@ import com.kingsrook.qbits.assistant.provider.ModelRequest;
|
||||
import com.kingsrook.qbits.assistant.provider.ModelResponse;
|
||||
import com.kingsrook.qbits.assistant.provider.RegistryModelProvider;
|
||||
import com.kingsrook.qbits.assistant.provider.StreamHandle;
|
||||
import com.kingsrook.qbits.assistant.rag.EmbeddingService;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
@@ -209,10 +211,17 @@ public final class ConnectionTest
|
||||
{
|
||||
throw (new ProviderService.ProviderSetupException("The model's provider no longer exists."));
|
||||
}
|
||||
AssistantProviderType type = runtime.getConfig().getProviderTypes().get(row.getProviderType());
|
||||
var settings = runtime.getProviderService().settings(row);
|
||||
var provider = new RegistryModelProvider(type.create(settings), model);
|
||||
result = call(runtime, new ProviderService.Active(row, row.getProviderType(), model.getModelId(), provider), userId);
|
||||
if(!model.isChat())
|
||||
{
|
||||
result = testEmbedding(runtime, model);
|
||||
}
|
||||
else
|
||||
{
|
||||
AssistantProviderType type = runtime.getConfig().getProviderTypes().get(row.getProviderType());
|
||||
var settings = runtime.getProviderService().settings(row);
|
||||
var provider = new RegistryModelProvider(type.create(settings), model);
|
||||
result = call(runtime, new ProviderService.Active(row, row.getProviderType(), model.getModelId(), provider), userId);
|
||||
}
|
||||
}
|
||||
catch(ProviderService.ProviderSetupException e)
|
||||
{
|
||||
@@ -226,6 +235,27 @@ public final class ConnectionTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An embedding model is tested with embeddings, never a chat completion.
|
||||
** The normal embedding budget, eligibility and usage rules still apply.
|
||||
*******************************************************************************/
|
||||
private static Result testEmbedding(AssistantRuntime runtime, AssistantModel model)
|
||||
{
|
||||
Long started = System.nanoTime();
|
||||
try
|
||||
{
|
||||
new EmbeddingService(runtime).embed(model.getId(), List.of("Connection test."),
|
||||
DataClassification.PUBLIC, "", null, null);
|
||||
return (new Result(STATUS_OK, (int) ((System.nanoTime() - started) / 1_000_000), model.getModelId(), null));
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
return (new Result(STATUS_ERROR, null, model.getModelId(), e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Map the provider error category.
|
||||
|
||||
@@ -34,10 +34,13 @@ import com.kingsrook.qbits.assistant.provider.ModelPart;
|
||||
import com.kingsrook.qbits.assistant.provider.ModelRequest;
|
||||
import com.kingsrook.qbits.assistant.provider.ModelToolDefinition;
|
||||
import com.kingsrook.qbits.assistant.provider.SystemBlock;
|
||||
import com.kingsrook.qbits.assistant.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.qqq.backend.core.actions.ai.AiToolDefinition;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiToolKernel;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
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;
|
||||
@@ -64,11 +67,14 @@ public class ContextBuilder
|
||||
|
||||
public static final int MAX_CONTEXT_CHARS = 512;
|
||||
public static final int MAX_SELECTED_IDS = 100;
|
||||
public static final int MAX_ATTACHMENTS = 5;
|
||||
|
||||
private final AssistantQBitConfig config;
|
||||
private final AiToolKernel kernel;
|
||||
private final UiApiToolbox toolbox;
|
||||
private volatile String appDigest;
|
||||
private KnowledgeTool knowledgeTool;
|
||||
private BulkLoadTools bulkLoadTools;
|
||||
|
||||
|
||||
|
||||
@@ -117,7 +123,18 @@ public class ContextBuilder
|
||||
.withStrict(isStrict(definition.getName(), !Boolean.FALSE.equals(definition.getStrict()))));
|
||||
}
|
||||
}
|
||||
if(knowledgeTool != null && knowledgeTool.offered(policy, QContext.getQSession()))
|
||||
{
|
||||
byName.put(KnowledgeTool.NAME, knowledgeTool.definition());
|
||||
}
|
||||
byName.put(PresentResultsTool.NAME, PresentResultsTool.definition(isStrict(PresentResultsTool.NAME, true)));
|
||||
if(bulkLoadTools != null && bulkLoadTools.offered())
|
||||
{
|
||||
for(ModelToolDefinition definition : bulkLoadTools.definitions(policy, toolbox.anyWritableTable()))
|
||||
{
|
||||
byName.put(definition.getName(), definition);
|
||||
}
|
||||
}
|
||||
return (new ArrayList<>(byName.values()));
|
||||
}
|
||||
|
||||
@@ -345,6 +362,32 @@ public class ContextBuilder
|
||||
}
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// files the user attached in the panel (already uploaded through //
|
||||
// the UI API as the user): only their opaque refs and names //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
if(raw.get("attachments") instanceof List<?> attachments)
|
||||
{
|
||||
List<Map<String, Object>> kept = new ArrayList<>();
|
||||
for(Object attachment : attachments)
|
||||
{
|
||||
if(attachment instanceof Map<?, ?> a && scalar(a.get("uploadRef")) != null && kept.size() < MAX_ATTACHMENTS)
|
||||
{
|
||||
Map<String, Object> one = new LinkedHashMap<>();
|
||||
one.put("uploadRef", scalar(a.get("uploadRef")));
|
||||
if(scalar(a.get("fileName")) != null)
|
||||
{
|
||||
one.put("fileName", scalar(a.get("fileName")));
|
||||
}
|
||||
kept.add(one);
|
||||
}
|
||||
}
|
||||
if(!kept.isEmpty())
|
||||
{
|
||||
clean.put("attachments", kept);
|
||||
}
|
||||
}
|
||||
|
||||
if(JsonUtils.toJson(clean).length() > 4000)
|
||||
{
|
||||
clean.remove("filter");
|
||||
@@ -372,4 +415,26 @@ public class ContextBuilder
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Additive optional tool hook; UI API toolbox stays independently maintained.
|
||||
*******************************************************************************/
|
||||
public ContextBuilder withKnowledgeTool(KnowledgeTool knowledgeTool)
|
||||
{
|
||||
this.knowledgeTool = knowledgeTool;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for the bulk-load tools.
|
||||
*******************************************************************************/
|
||||
public ContextBuilder withBulkLoadTools(BulkLoadTools bulkLoadTools)
|
||||
{
|
||||
this.bulkLoadTools = bulkLoadTools;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
+1
-1
@@ -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;
|
||||
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ public final class ModelDiscovery
|
||||
}
|
||||
ModelCapabilities caps = provider.getCapabilities(model.modelId());
|
||||
runtime.getStore().insert(new AssistantModel().withProviderId(row.getId()).withModelId(model.modelId())
|
||||
.withModelKind(embedding(model.modelId()) ? "EMBEDDING" : "CHAT").withEmbeddingDimensions(dimensions(model.modelId()))
|
||||
.withDisplayName(model.displayName()).withEnabled(local || discovered.size() <= 20)
|
||||
.withSource("DISCOVERED").withDiscoveredAt(runtime.getClock().instant())
|
||||
.withSupportsTools(caps.getTools()).withSupportsVision(caps.getVision()).withSupportsStreaming(caps.getStreaming())
|
||||
@@ -115,4 +116,33 @@ public final class ModelDiscovery
|
||||
return (result);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Discovery is a suggestion; an administrator can correct the kind.
|
||||
*******************************************************************************/
|
||||
public static Boolean embedding(String id)
|
||||
{
|
||||
String lower = id.toLowerCase(java.util.Locale.ROOT);
|
||||
return (List.of("embed", "bge", "e5", "gte", "nomic").stream().anyMatch(lower::contains));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Known local defaults; other models must declare their dimensions.
|
||||
*******************************************************************************/
|
||||
private static Integer dimensions(String id)
|
||||
{
|
||||
if(id.contains("bge-m3"))
|
||||
{
|
||||
return (1024);
|
||||
}
|
||||
if(id.contains("nomic-embed-text"))
|
||||
{
|
||||
return (768);
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -17,23 +17,22 @@
|
||||
package com.kingsrook.qbits.assistant.engine;
|
||||
|
||||
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.Objects;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProvider;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.Semaphore;
|
||||
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;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProvider;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantTurn;
|
||||
import com.kingsrook.qbits.assistant.provider.CacheHint;
|
||||
import com.kingsrook.qbits.assistant.provider.ModelMessage;
|
||||
@@ -43,6 +42,8 @@ import com.kingsrook.qbits.assistant.provider.ModelResponse;
|
||||
import com.kingsrook.qbits.assistant.provider.ModelUsage;
|
||||
import com.kingsrook.qbits.assistant.provider.StreamHandle;
|
||||
import com.kingsrook.qbits.assistant.provider.SystemBlock;
|
||||
import com.kingsrook.qbits.assistant.scheduled.ScheduledTurn;
|
||||
import com.kingsrook.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;
|
||||
@@ -116,7 +117,9 @@ public class ModelRouter
|
||||
public Route route(QSession session, AssistantConversation conversation, AssistantTurn turn, String question, Map<String, Object> pageContext,
|
||||
PolicyService.EffectivePolicy policy, TurnClassification classification, RunningTurn running, Instant deadline, Consumer<String> notice) throws QException, InterruptedException
|
||||
{
|
||||
AssistantProfile profile = runtime.getProfileService().pick(session, runtime.appNameFor(pageContext));
|
||||
var scheduled = runtime.getScheduledTurn().task(conversation.getId());
|
||||
ScheduledTurn.checkCost(scheduled, 0L);
|
||||
AssistantProfile profile = runtime.getProfileService().pick(session, runtime.appNameFor(pageContext), scheduled == null ? null : scheduled.getProfileId());
|
||||
String mode = profile == null || profile.getRoutingMode() == null ? "OFF" : profile.getRoutingMode();
|
||||
Boolean escalation = profile == null && Boolean.TRUE.equals(runtime.getConfig().getModels().getEscalationEnabled());
|
||||
String decision = "OFF";
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
+27
-1
@@ -107,7 +107,17 @@ public class ProfileService
|
||||
*******************************************************************************/
|
||||
public Persona persona(QSession session, String appName)
|
||||
{
|
||||
AssistantProfile profile = pick(session, appName);
|
||||
return (persona(session, appName, null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An administrator-selected scheduled profile still respects role scope.
|
||||
*******************************************************************************/
|
||||
public Persona persona(QSession session, String appName, Integer profileId)
|
||||
{
|
||||
AssistantProfile profile = pick(session, appName, profileId);
|
||||
if(profile == null)
|
||||
{
|
||||
return (new Persona(null, DEFAULT_DISPLAY_NAME, DEFAULT_ICON, null, null, List.of()));
|
||||
@@ -217,6 +227,22 @@ public class ProfileService
|
||||
*******************************************************************************/
|
||||
public AssistantProfile pick(QSession session, String appName)
|
||||
{
|
||||
return (pick(session, appName, null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit profiles supply instructions and routing, never permissions.
|
||||
*******************************************************************************/
|
||||
public AssistantProfile pick(QSession session, String appName, Integer profileId)
|
||||
{
|
||||
if(profileId != null)
|
||||
{
|
||||
return (load().profiles().stream().filter(p -> profileId.equals(p.getId()) && !Boolean.FALSE.equals(p.getEnabled())
|
||||
&& (!StringUtils.hasContent(p.getPermissionName()) || session.hasPermission(p.getPermissionName()))).findFirst().orElse(null));
|
||||
}
|
||||
|
||||
List<AssistantProfile> enabled = load().profiles().stream().filter(p -> !Boolean.FALSE.equals(p.getEnabled())).toList();
|
||||
return (enabled.stream()
|
||||
.filter(p -> appliesTo(p, session, appName))
|
||||
|
||||
+50
-16
@@ -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;
|
||||
|
||||
|
||||
@@ -186,6 +186,16 @@ public class ProviderService
|
||||
** with a message for the admin when the row cannot be used.
|
||||
*******************************************************************************/
|
||||
public Active forRow(AssistantProvider row, String fallbackModel) throws ProviderSetupException
|
||||
{
|
||||
return (forRow(row, fallbackModel, false));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve only the requested kind; embedding calls never select chat models.
|
||||
*******************************************************************************/
|
||||
private Active forRow(AssistantProvider row, String fallbackModel, Boolean embedding) throws ProviderSetupException
|
||||
{
|
||||
if(Boolean.TRUE.equals(config.getHipaaMode()) && !Boolean.TRUE.equals(row.getHipaaEligible()))
|
||||
{
|
||||
@@ -205,7 +215,7 @@ public class ProviderService
|
||||
String model = StringUtils.hasContent(row.getModel()) ? row.getModel() : null;
|
||||
if(model == null && !models.isEmpty())
|
||||
{
|
||||
model = models.stream().filter(m -> Boolean.TRUE.equals(m.getEnabled())).map(AssistantModel::getModelId).findFirst().orElse(null);
|
||||
model = models.stream().filter(m -> Boolean.TRUE.equals(m.getEnabled()) && !m.isChat().equals(embedding)).map(AssistantModel::getModelId).findFirst().orElse(null);
|
||||
if(model == null)
|
||||
{
|
||||
throw (new ProviderSetupException("All models of this provider are disabled. Enable a model first."));
|
||||
@@ -217,7 +227,7 @@ public class ProviderService
|
||||
}
|
||||
String selected = model;
|
||||
AssistantModel registry = models.stream().filter(m -> java.util.Objects.equals(selected, m.getModelId())).findFirst().orElse(null);
|
||||
if(registry != null && !Boolean.TRUE.equals(registry.getEnabled()))
|
||||
if(registry != null && (!Boolean.TRUE.equals(registry.getEnabled()) || registry.isChat().equals(embedding)))
|
||||
{
|
||||
throw (new ProviderSetupException("The selected model is disabled in this provider's model registry."));
|
||||
}
|
||||
@@ -728,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<Active> eligibleAlternative(com.kingsrook.qbits.assistant.classification.DataClassification classification, AssistantProvider blockedProvider)
|
||||
public java.util.Optional<Active> eligibleAlternative(com.kingsrook.qqq.backend.core.actions.ai.DataClassification classification, AssistantProvider blockedProvider)
|
||||
{
|
||||
return (java.util.Optional.empty());
|
||||
}
|
||||
@@ -752,7 +762,7 @@ public class ProviderService
|
||||
public Active resolveModel(Integer modelId, Boolean requiresHipaa, Boolean requireTools) throws QException
|
||||
{
|
||||
AssistantModel model = store.listModels().stream().filter(m -> modelId.equals(m.getId()) && Boolean.TRUE.equals(m.getEnabled())).findFirst().orElse(null);
|
||||
if(model == null)
|
||||
if(model == null || !model.isChat())
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
@@ -816,4 +826,28 @@ public class ProviderService
|
||||
{
|
||||
return (left != null && right != null && java.util.Objects.equals(left.providerId(), right.providerId()) && java.util.Objects.equals(left.model(), right.model()));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Embedding selection is explicit and checks fresh provider eligibility.
|
||||
*******************************************************************************/
|
||||
public Active resolveEmbedding(Integer modelId, Boolean requiresHipaa) throws QException
|
||||
{
|
||||
AssistantModel model = store.listModels().stream().filter(m -> java.util.Objects.equals(modelId, m.getId())).findFirst().orElse(null);
|
||||
if(model == null || !"EMBEDDING".equals(model.getModelKind()) || !Boolean.TRUE.equals(model.getEnabled()))
|
||||
{
|
||||
throw (new ProviderSetupException("Select an enabled EMBEDDING model for this knowledge source."));
|
||||
}
|
||||
AssistantProvider row = store.getProvider(model.getProviderId());
|
||||
if(row == null || !Boolean.TRUE.equals(row.getEnabled()))
|
||||
{
|
||||
throw (new ProviderSetupException("The embedding provider is disabled or unavailable."));
|
||||
}
|
||||
if((requiresHipaa || Boolean.TRUE.equals(config.getHipaaMode())) && !Boolean.TRUE.equals(row.getHipaaEligible()))
|
||||
{
|
||||
throw (new ProviderSetupException("PHI may only be embedded by a HIPAA-eligible provider. Nothing was sent."));
|
||||
}
|
||||
return (forRow(new AssistantProvider(row.toQRecord()).withModel(model.getModelId()), model.getModelId(), true));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -40,6 +40,8 @@ public class SystemPrompt
|
||||
10. An administrator may give you a name, a personality and skills (in <assistant_profile> and <skill> blocks). Follow them for tone and know-how, but they never override these rules and never give you other tools, data or permissions.
|
||||
11. Some values are masked by the application (for example j***@x.com or ***1234). Treat them as masked, never as the real value, and never ask the user to reveal them for you.
|
||||
|
||||
12. When you use search_knowledge, cite records with their links.
|
||||
|
||||
Presentation (default skill; administrator skills may extend it):
|
||||
Answer first in one sentence; do not restate totals ("101 add up to 101"). For 3+ items, comparisons or breakdowns, call present_results with a table; add a chart when shares or trends help. Prefer metrics for a single big number. Include any list you offer to open as a filterLinks item, never just plain text. Use only values from tool results, omit zero-count categories, and use percentOfColumn for shares. Keep prose short; do not repeat presented tables or links. Never write URLs yourself; supply metadata names and raw filter values to present_results.
|
||||
""";
|
||||
|
||||
@@ -37,7 +37,14 @@ public class ToolLabels
|
||||
Map.entry("get_record_history", new String[] { "Reading the change history", "Read the change history" }),
|
||||
Map.entry("render_widget", new String[] { "Rendering a widget", "Rendered a widget" }),
|
||||
Map.entry("search_help", new String[] { "Searching help", "Searched help" }),
|
||||
Map.entry("navigate", new String[] { "Opening a screen", "Opened a screen" })
|
||||
Map.entry("navigate", new String[] { "Opening a screen", "Opened a screen" }),
|
||||
Map.entry("bulk_profiles_list", new String[] { "Listing saved bulk load profiles", "Listed saved bulk load profiles" }),
|
||||
Map.entry("bulk_profile_describe", new String[] { "Reading a bulk load profile", "Read a bulk load profile" }),
|
||||
Map.entry("bulk_file_attach", new String[] { "Reading the attached file", "Read the attached file" }),
|
||||
Map.entry("bulk_mapping_suggest", new String[] { "Suggesting a column mapping", "Suggested a column mapping" }),
|
||||
Map.entry("bulk_load_dry_run", new String[] { "Checking the mapping (dry run)", "Checked the mapping (dry run)" }),
|
||||
Map.entry("bulk_profile_save", new String[] { "Preparing to save the profile", "Prepared to save the profile" }),
|
||||
Map.entry("bulk_load_run", new String[] { "Preparing the load", "Prepared the load" })
|
||||
);
|
||||
|
||||
|
||||
|
||||
+11
-6
@@ -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<String, Object> 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));
|
||||
@@ -75,7 +76,7 @@ public class TurnClassification
|
||||
return;
|
||||
}
|
||||
Map<String, Object> input = use.getInput() == null ? Map.of() : use.getInput();
|
||||
boolean crossTable = "search_records".equals(use.getToolName()) || "get_record_history".equals(use.getToolName());
|
||||
boolean crossTable = "search_knowledge".equals(use.getToolName()) || "search_records".equals(use.getToolName()) || "get_record_history".equals(use.getToolName());
|
||||
String table = result.getTableName() != null ? result.getTableName()
|
||||
: (input.get("table") instanceof String name ? name : (input.get("tableName") instanceof String name ? name : null));
|
||||
if(crossTable)
|
||||
@@ -98,6 +99,10 @@ public class TurnClassification
|
||||
}
|
||||
collectTables(result.getRender(), touched);
|
||||
collectResultTables(result.getModelContent(), touched);
|
||||
if("search_knowledge".equals(use.getToolName()) && touched.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
if(touched.isEmpty() || touched.stream().anyMatch(name -> QContext.getQInstance().getTable(name) == null))
|
||||
{
|
||||
touchUnknown();
|
||||
@@ -206,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)
|
||||
@@ -225,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);
|
||||
@@ -252,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()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
|
||||
|
||||
@@ -17,11 +17,30 @@
|
||||
package com.kingsrook.qbits.assistant.engine;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
import com.kingsrook.qbits.assistant.AssistantRuntime;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantConversation;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantMessage;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantScheduledTask;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantTurn;
|
||||
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
|
||||
@@ -36,8 +55,11 @@ import com.kingsrook.qbits.assistant.provider.ModelRequest;
|
||||
import com.kingsrook.qbits.assistant.provider.ModelResponse;
|
||||
import com.kingsrook.qbits.assistant.provider.ModelUsage;
|
||||
import com.kingsrook.qbits.assistant.provider.StreamHandle;
|
||||
import com.kingsrook.qbits.assistant.scheduled.ScheduledTurn;
|
||||
import com.kingsrook.qbits.assistant.tools.KnowledgeTool;
|
||||
import com.kingsrook.qbits.assistant.tools.PresentResultsTool;
|
||||
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;
|
||||
@@ -50,21 +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 java.io.Serializable;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
@@ -125,6 +133,9 @@ public class TurnEngine implements Runnable
|
||||
private final StringBuilder textBuffer = new StringBuilder();
|
||||
private long lastTextFlush = System.currentTimeMillis();
|
||||
|
||||
private AssistantScheduledTask scheduledTask;
|
||||
private PolicyService.ToolPolicy toolPolicy;
|
||||
|
||||
private Instant startedAt;
|
||||
private Instant deadline;
|
||||
private int toolCallCount = 0;
|
||||
@@ -138,6 +149,7 @@ public class TurnEngine implements Runnable
|
||||
private ProviderService.Active budgetOrigin;
|
||||
|
||||
private volatile boolean finished = false;
|
||||
private UiApiCredentials credentials;
|
||||
|
||||
|
||||
|
||||
@@ -166,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.
|
||||
*******************************************************************************/
|
||||
@@ -183,6 +208,13 @@ public class TurnEngine implements Runnable
|
||||
try
|
||||
{
|
||||
QContext.init(runtime.getQInstance(), session);
|
||||
scheduledTask = runtime.getScheduledTurn().task(conversation.getId());
|
||||
if(conversation.getScheduledTaskId() != null && scheduledTask == null)
|
||||
{
|
||||
throw (new QException("Scheduled execution context is no longer available; start a new conversation."));
|
||||
}
|
||||
toolPolicy = scheduledTask == null ? policy.tools() : ScheduledTurn.tools(runtime, scheduledTask, policy.tools());
|
||||
ScheduledTurn.checkCost(runtime, conversation.getId(), turn.getId());
|
||||
classification = new TurnClassification(config.getClassificationSource(), conversation, pageContext);
|
||||
store.saveClassification(userId, conversation.getId(), turn.getId(), classification);
|
||||
startKillSwitchWatcher();
|
||||
@@ -228,9 +260,9 @@ public class TurnEngine implements Runnable
|
||||
}
|
||||
|
||||
String appName = runtime.appNameFor(pageContext);
|
||||
ProfileService.Persona persona = runtime.getProfileService().persona(session, appName);
|
||||
ProfileService.Persona persona = runtime.getProfileService().persona(session, appName, scheduledTask == null ? null : scheduledTask.getProfileId());
|
||||
String profileText = ProfileService.instructions(persona, runtime.getProfileService().relevantSkills(persona.profileId(), userText, appName));
|
||||
ModelRequest request = runtime.getContextBuilder().build(session, model, route.effort(), history, userText, pageContext, policy.tools(), profileText);
|
||||
ModelRequest request = runtime.getContextBuilder().build(session, model, route.effort(), history, userText, pageContext, toolPolicy, profileText);
|
||||
Integer maxPerRequest = active.row() == null ? null : active.row().getMaxOutputTokensPerRequest();
|
||||
if(maxPerRequest != null && (request.getMaxTokens() == null || request.getMaxTokens() > maxPerRequest))
|
||||
{
|
||||
@@ -240,7 +272,7 @@ public class TurnEngine implements Runnable
|
||||
{
|
||||
request.setParallelToolCalls(false);
|
||||
}
|
||||
Set<String> allowedTools = runtime.getContextBuilder().toolNames(policy.tools());
|
||||
Set<String> allowedTools = runtime.getContextBuilder().toolNames(toolPolicy);
|
||||
|
||||
boolean toolLimitReached = false;
|
||||
while(true)
|
||||
@@ -257,6 +289,7 @@ public class TurnEngine implements Runnable
|
||||
|
||||
ModelResponse response = fallback.call(request, () -> active, selected -> callModelWithStrictFallback(selected, request));
|
||||
stopReason = response.getStopReason();
|
||||
ScheduledTurn.checkCost(runtime, conversation.getId(), turn.getId());
|
||||
|
||||
if(ModelResponse.STOP_CANCELLED.equals(stopReason))
|
||||
{
|
||||
@@ -541,6 +574,7 @@ public class TurnEngine implements Runnable
|
||||
{
|
||||
return (new ModelResponse().withStopReason(ModelResponse.STOP_CANCELLED));
|
||||
}
|
||||
ScheduledTurn.checkCost(runtime, conversation.getId(), turn.getId());
|
||||
PolicyService.BudgetStatus configuredBudget = runtime.getPolicyService().checkConfiguredBudget(policy, userId, usage.billableTokens(), costMicros);
|
||||
if(configuredBudget.hardExceeded())
|
||||
{
|
||||
@@ -554,7 +588,7 @@ public class TurnEngine implements Runnable
|
||||
throw (new BudgetService.Blocked(budgetDecision.reason()));
|
||||
}
|
||||
ProviderService.Active selected = budgetDecision.active();
|
||||
if(!java.util.Objects.equals(active.providerId(), selected.providerId()) || !java.util.Objects.equals(active.model(), selected.model()))
|
||||
if(!Objects.equals(active.providerId(), selected.providerId()) || !Objects.equals(active.model(), selected.model()))
|
||||
{
|
||||
active = selected;
|
||||
model = active.model();
|
||||
@@ -711,8 +745,20 @@ public class TurnEngine implements Runnable
|
||||
QContext.init(runtime.getQInstance(), session);
|
||||
try
|
||||
{
|
||||
outcomes[index] = runtime.getToolbox().execute(toolContext(), call.getId(), call.getName(), call.getInput());
|
||||
results[index] = outcomes[index].getResult();
|
||||
if(KnowledgeTool.NAME.equals(call.getName()))
|
||||
{
|
||||
results[index] = runtime.getKnowledgeTool().execute(toolContext(), call.getId(), call.getInput(), classification.getClassification(), conversation.getId(), turn.getId());
|
||||
}
|
||||
else if(BulkLoadTools.NAMES.contains(call.getName()))
|
||||
{
|
||||
outcomes[index] = runtime.getBulkLoadTools().execute(toolContext(), call.getId(), call.getName(), call.getInput(), hipaaRoute());
|
||||
results[index] = outcomes[index].getResult();
|
||||
}
|
||||
else
|
||||
{
|
||||
outcomes[index] = runtime.getToolbox().execute(toolContext(), call.getId(), call.getName(), call.getInput());
|
||||
results[index] = outcomes[index].getResult();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -747,11 +793,11 @@ public class TurnEngine implements Runnable
|
||||
{
|
||||
future.get(waitMillis, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
catch(java.util.concurrent.TimeoutException e)
|
||||
catch(TimeoutException e)
|
||||
{
|
||||
future.cancel(true);
|
||||
}
|
||||
catch(java.util.concurrent.ExecutionException e)
|
||||
catch(ExecutionException e)
|
||||
{
|
||||
LOG.debug("tool future failed", hipaaSafe(e));
|
||||
}
|
||||
@@ -839,6 +885,7 @@ public class TurnEngine implements Runnable
|
||||
.withUserId(userId)
|
||||
.withToolCallId(use.getToolUseId())
|
||||
.withToolName(use.getToolName())
|
||||
.withClientChannel(scheduledTask == null ? null : "assistant/scheduled/" + scheduledTask.getId())
|
||||
.withInputJson(truncate(redactInput(PresentResultsTool.NAME.equals(use.getToolName()) ? (presentation ? result.getRender() : Map.of()) : use.getInput()), MAX_INPUT_JSON_CHARS))
|
||||
.withOutcome(outcome)
|
||||
.withPermissionCheck(result.getPermissionCheck())
|
||||
@@ -848,9 +895,9 @@ public class TurnEngine implements Runnable
|
||||
.withRowCount(result.getRowCount())
|
||||
.withDurationMs(result.getDurationMs())
|
||||
.withResultSummary(summary)
|
||||
.withResultJson(truncate(result.getModelContent(), MAX_RESULT_JSON_CHARS))
|
||||
.withResultJson(truncate(BulkLoadTools.NAMES.contains(use.getToolName()) ? BulkLoadTools.forLog(result.getModelContent()) : result.getModelContent(), MAX_RESULT_JSON_CHARS))
|
||||
.withRedactionsJson(result.getRedactions() == null || result.getRedactions().isEmpty() ? null : JsonUtils.toJson(result.getRedactions()))
|
||||
.withUiApiOperation(extras == null ? null : extras.getOperation())
|
||||
.withUiApiOperation(extras == null ? ("search_knowledge".equals(use.getToolName()) ? "GET /table/{tableName}/{primaryKey}" : null) : extras.getOperation())
|
||||
.withHttpStatus(extras == null ? null : extras.getHttpStatus())
|
||||
.withAuditHref(extras == null ? null : extras.getAuditHref())
|
||||
.withPendingActionId(extras == null ? null : extras.getPendingActionId()));
|
||||
@@ -868,9 +915,24 @@ public class TurnEngine implements Runnable
|
||||
** API), the gateway, the policy's tool settings, and the pending-action
|
||||
** store for writes that need confirmation.
|
||||
*******************************************************************************/
|
||||
private boolean hipaaRoute()
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// the X-QQQ-AI-Route the bulk-load tools send: the turn's provider's //
|
||||
// HIPAA eligibility, never anything the model says //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
return (active != null && active.row() != null && Boolean.TRUE.equals(active.row().getHipaaEligible()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The UI API tool context: the user's session, the gateway, the policy's
|
||||
** tools and the pending-action hook.
|
||||
*******************************************************************************/
|
||||
private UiApiToolbox.Context toolContext()
|
||||
{
|
||||
return (new UiApiToolbox.Context(session.getUuid(), userTimezone(), runtime.getUiApiGateway(), policy.tools(), this::proposeWrite));
|
||||
return (new UiApiToolbox.Context(session.getUuid(), userTimezone(), runtime.getUiApiGateway(), toolPolicy, this::proposeWrite, credentials, turn.getId() == null ? null : String.valueOf(turn.getId())));
|
||||
}
|
||||
|
||||
|
||||
@@ -923,7 +985,7 @@ public class TurnEngine implements Runnable
|
||||
/*******************************************************************************
|
||||
** Only same-origin application links are passed on.
|
||||
*******************************************************************************/
|
||||
static boolean isAppHref(String href)
|
||||
public static boolean isAppHref(String href)
|
||||
{
|
||||
return (href != null && (href.equals("/app") || href.startsWith("/app/")) && !href.contains("//") && !href.contains("\\"));
|
||||
}
|
||||
@@ -1044,6 +1106,10 @@ public class TurnEngine implements Runnable
|
||||
finally
|
||||
{
|
||||
running.getLog().close();
|
||||
if(scheduledTask != null)
|
||||
{
|
||||
runtime.getScheduledTurn().detach(conversation.getId(), session.getUuid());
|
||||
}
|
||||
runtime.unregister(turn.getId());
|
||||
runtime.getRateLimiter().release(userId);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ public final class UsageRecorder
|
||||
|
||||
public static final String PURPOSE_TURN = "TURN";
|
||||
public static final String PURPOSE_TEST = "TEST";
|
||||
public static final String PURPOSE_EMBED = "EMBED";
|
||||
public static final String PURPOSE_ROUTE = "ROUTE";
|
||||
|
||||
public static final String STATUS_OK = "OK";
|
||||
|
||||
+24
-3
@@ -17,6 +17,8 @@
|
||||
package com.kingsrook.qbits.assistant.metadata;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
import com.kingsrook.qbits.assistant.home.AssistantAccessWidget;
|
||||
import com.kingsrook.qbits.assistant.home.AssistantHome;
|
||||
@@ -36,6 +38,9 @@ import com.kingsrook.qbits.assistant.model.AssistantPolicy;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProfile;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProfileSkill;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProvider;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantRagSource;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantScheduledRun;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantScheduledTask;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantSkill;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantTurn;
|
||||
@@ -67,8 +72,6 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -110,7 +113,7 @@ public class AssistantMetaDataBuilder
|
||||
** calls on a conversation; model calls on a provider) or reached from the
|
||||
** home. Processes are table and record actions, never nav entries.
|
||||
*******************************************************************************/
|
||||
public static final List<String> NAV_TABLES = List.of(AssistantBudget.TABLE_NAME, AssistantProvider.TABLE_NAME, AssistantProfile.TABLE_NAME, AssistantSkill.TABLE_NAME, AssistantDataAccess.TABLE_NAME, AssistantConversation.TABLE_NAME, AssistantModelPrice.TABLE_NAME, AssistantPolicy.TABLE_NAME);
|
||||
public static final List<String> NAV_TABLES = List.of(AssistantScheduledTask.TABLE_NAME, AssistantRagSource.TABLE_NAME, AssistantBudget.TABLE_NAME, AssistantProvider.TABLE_NAME, AssistantProfile.TABLE_NAME, AssistantSkill.TABLE_NAME, AssistantDataAccess.TABLE_NAME, AssistantConversation.TABLE_NAME, AssistantModelPrice.TABLE_NAME, AssistantPolicy.TABLE_NAME);
|
||||
|
||||
private final AssistantQBitConfig config;
|
||||
|
||||
@@ -186,6 +189,24 @@ public class AssistantMetaDataBuilder
|
||||
.withUniqueKey(new UniqueKey("name"))
|
||||
.withRecordLabelFormat("%s").withRecordLabelFields("name"));
|
||||
|
||||
QCodeReference scheduledCustomizer = new QCodeReference(AssistantScheduledTaskCustomizer.class);
|
||||
tables.add(text(base(AssistantScheduledTask.TABLE_NAME, "Scheduled tasks", "schedule", AssistantScheduledTask.class), "prompt")
|
||||
.withRecordLabelFormat("%s").withRecordLabelFields("name")
|
||||
.withCustomizer(TableCustomizers.PRE_INSERT_RECORD, scheduledCustomizer).withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, scheduledCustomizer)
|
||||
.withCustomizer(TableCustomizers.POST_QUERY_RECORD, scheduledCustomizer));
|
||||
tables.add(readOnly(owned(base(AssistantScheduledRun.TABLE_NAME, "Scheduled runs", "history", AssistantScheduledRun.class), false))
|
||||
.withoutCapabilities(Capability.TABLE_DELETE)
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED))
|
||||
.withUniqueKey(new UniqueKey("taskId", "scheduledFor"))
|
||||
.withRecordLabelFormat("Run #%s").withRecordLabelFields("id"));
|
||||
|
||||
QCodeReference ragCustomizer = new QCodeReference(AssistantRagSourceCustomizer.class);
|
||||
tables.add(text(base(AssistantRagSource.TABLE_NAME, "Knowledge sources", "manage_search", AssistantRagSource.class), "filterJson")
|
||||
.withRecordLabelFormat("%s").withRecordLabelFields("name")
|
||||
.withCustomizer(TableCustomizers.PRE_INSERT_RECORD, ragCustomizer).withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, ragCustomizer)
|
||||
.withCustomizer(TableCustomizers.POST_INSERT_RECORD, ragCustomizer).withCustomizer(TableCustomizers.POST_UPDATE_RECORD, ragCustomizer)
|
||||
.withCustomizer(TableCustomizers.POST_DELETE_RECORD, ragCustomizer));
|
||||
|
||||
tables.add(settings(base(AssistantModel.TABLE_NAME, "Assistant Model", "memory", AssistantModel.class))
|
||||
.withUniqueKey(new UniqueKey("providerId", "modelId"))
|
||||
.withRecordLabelFormat("%s").withRecordLabelFields("modelId"));
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.metadata;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qbits.assistant.AssistantRuntime;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantRagSource;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate source configuration; invalidate vectors whenever their meaning changes.
|
||||
*******************************************************************************/
|
||||
public class AssistantRagSourceCustomizer implements TableCustomizerInterface
|
||||
{
|
||||
private static final List<String> SEMANTIC_FIELDS = List.of("tableName", "fieldNames", "filterJson", "embeddingModelId", "chunkChars", "chunkOverlapChars");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate merged edits, not partial update payloads. Status writes remain
|
||||
** possible even after a host removes a table or disables a model.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preInsertOrUpdate(AbstractActionInput input, List<QRecord> records, boolean isPreview, Optional<List<QRecord>> oldRecordList) throws QException
|
||||
{
|
||||
Optional<Map<Serializable, QRecord>> old = oldRecordListToMap("id", oldRecordList);
|
||||
AssistantRuntime runtime = AssistantRuntime.of(QContext.getQInstance());
|
||||
for(QRecord record : records)
|
||||
{
|
||||
if(record.getValues().keySet().stream().allMatch(name -> name.equals("id") || name.startsWith("lastIndex") || name.startsWith("indexed") || name.endsWith("Date")))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
QRecord merged = new QRecord();
|
||||
old.map(m -> m.get(record.getValue("id"))).ifPresent(o -> merged.setValues(new LinkedHashMap<>(o.getValues())));
|
||||
record.getValues().forEach(merged::setValue);
|
||||
for(var entry : Map.<String, Serializable>of("enabled", true, "chunkChars", 1500, "chunkOverlapChars", 200).entrySet())
|
||||
{
|
||||
if(merged.getValue(entry.getKey()) == null)
|
||||
{
|
||||
record.setValue(entry.getKey(), entry.getValue());
|
||||
merged.setValue(entry.getKey(), entry.getValue());
|
||||
}
|
||||
}
|
||||
if(runtime != null)
|
||||
{
|
||||
for(String error : runtime.getRagService().validate(new AssistantRagSource(merged)))
|
||||
{
|
||||
record.addError(new BadInputStatusMessage(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Old vectors cannot survive a model, filter or chunk-layout change.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postInsertOrUpdate(AbstractActionInput input, List<QRecord> records, Optional<List<QRecord>> oldRecordList) throws QException
|
||||
{
|
||||
Optional<Map<Serializable, QRecord>> old = oldRecordListToMap("id", oldRecordList);
|
||||
AssistantRuntime runtime = AssistantRuntime.of(QContext.getQInstance());
|
||||
if(runtime != null && old.isPresent())
|
||||
{
|
||||
for(QRecord record : records)
|
||||
{
|
||||
QRecord previous = old.get().get(record.getValue("id"));
|
||||
if(previous != null && SEMANTIC_FIELDS.stream().anyMatch(name -> record.getValues().containsKey(name) && !Objects.equals(previous.getValue(name), record.getValue(name))))
|
||||
{
|
||||
runtime.getRagService().clear(new AssistantRagSource(previous));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Deleted sources no longer have searchable vectors.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postDelete(DeleteInput input, List<QRecord> records) throws QException
|
||||
{
|
||||
AssistantRuntime runtime = AssistantRuntime.of(QContext.getQInstance());
|
||||
if(runtime != null)
|
||||
{
|
||||
for(QRecord record : records)
|
||||
{
|
||||
runtime.getRagService().remove(new AssistantRagSource(record));
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
}
|
||||
+91
-8
@@ -17,6 +17,11 @@
|
||||
package com.kingsrook.qbits.assistant.metadata;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.assistant.AssistantModelRouting;
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
import com.kingsrook.qbits.assistant.access.DataAccessMode;
|
||||
@@ -37,6 +42,9 @@ import com.kingsrook.qbits.assistant.model.AssistantPolicy;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProfile;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProfileSkill;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProvider;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantRagSource;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantScheduledRun;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantScheduledTask;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantSkill;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantTurn;
|
||||
@@ -46,7 +54,10 @@ import com.kingsrook.qbits.assistant.model.AssistantUsageDaily;
|
||||
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReferenceWithProperties;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.AdornmentType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.DisplayFormat;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldAdornment;
|
||||
@@ -64,10 +75,6 @@ import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleVal
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -127,6 +134,8 @@ public final class AssistantRecordScreens
|
||||
}
|
||||
|
||||
return (List.of(
|
||||
QPossibleValueSource.newForTable(AssistantScheduledTask.TABLE_NAME),
|
||||
enumSource("assistant_scheduled_output", List.of(new QPossibleValue<>("RECORD", "Record"), new QPossibleValue<>("NOTIFICATION", "Notification"), new QPossibleValue<>("BOTH", "Both"))),
|
||||
QPossibleValueSource.newForTable(AssistantBudget.TABLE_NAME),
|
||||
QPossibleValueSource.newForTable(AssistantModel.TABLE_NAME).withName("assistant_registry_model"),
|
||||
QPossibleValueSource.newForTable(AssistantModel.TABLE_NAME).withName("assistant_budget_model"),
|
||||
@@ -168,6 +177,32 @@ public final class AssistantRecordScreens
|
||||
{
|
||||
switch(table.getName())
|
||||
{
|
||||
case AssistantScheduledTask.TABLE_NAME ->
|
||||
{
|
||||
link(table, "profileId", AssistantProfile.TABLE_NAME, "Profile");
|
||||
pvs(table, "outputMode", "assistant_scheduled_output", "Output mode");
|
||||
help(table, "credentialEnvName", "Name of an allowed environment variable holding this user's access token. Never enter the token here.");
|
||||
help(table, "cronExpression", "Quartz cron expression, including seconds; for example 0 0 8 * * ? for 08:00 daily.");
|
||||
help(table, "timezone", "IANA time zone, for example America/Chicago. Defaults to UTC; daylight saving time follows this zone.");
|
||||
help(table, "writeModeRequested", "Requires administrator approval of this exact prompt and identity. Each proposed write still requires the user's confirmation.");
|
||||
help(table, "maxCostUsdPerRun", "Optional USD STOP cap. Checked before each model or embedding call; a completed call may cross the cap.");
|
||||
sections(table,
|
||||
section("task", "Task", "schedule", Tier.T1, "id", "name", "enabled", "prompt", "profileId"),
|
||||
section("identity", "Run as", "person", Tier.T2, "runAsUserId", "credentialEnvName", "credentialStatus"),
|
||||
section("schedule", "Schedule and output", "event", Tier.T2, "cronExpression", "timezone", "nextRunAt", "outputMode", "maxCostUsdPerRun"),
|
||||
section("approval", "Write approval", "verified_user", Tier.T2, "writeModeRequested", "writeModeApprovedBy", "writeModeApprovedAt", "writeModeApprovalHash"),
|
||||
section("lastRun", "Last run", "history", Tier.T2, "lastRunAt", "lastRunStatus", "lastRunError"));
|
||||
}
|
||||
case AssistantScheduledRun.TABLE_NAME ->
|
||||
{
|
||||
link(table, "taskId", AssistantScheduledTask.TABLE_NAME, "Task");
|
||||
link(table, "conversationId", AssistantConversation.TABLE_NAME, "Conversation");
|
||||
link(table, "turnId", AssistantTurn.TABLE_NAME, "Turn");
|
||||
help(table, "costMicros", MICROS_HELP);
|
||||
sections(table,
|
||||
section("run", "Run", "history", Tier.T1, "id", "taskId", "userId", "status", "scheduledFor", "startedAt", "endedAt"),
|
||||
section("result", "Result", "chat", Tier.T2, "conversationId", "turnId", "summary", "costMicros", "error"));
|
||||
}
|
||||
case AssistantBudget.TABLE_NAME -> budget(table);
|
||||
case AssistantBudgetOverride.TABLE_NAME, AssistantBudgetEvent.TABLE_NAME ->
|
||||
{
|
||||
@@ -184,6 +219,7 @@ public final class AssistantRecordScreens
|
||||
case AssistantUsageDaily.TABLE_NAME -> usageDaily(table);
|
||||
case AssistantProvider.TABLE_NAME -> provider(table);
|
||||
case AssistantModel.TABLE_NAME -> model(table);
|
||||
case AssistantRagSource.TABLE_NAME -> knowledge(table);
|
||||
case AssistantModelPrice.TABLE_NAME -> price(table);
|
||||
case AssistantUsage.TABLE_NAME -> usage(table);
|
||||
case AssistantDataAccess.TABLE_NAME -> dataAccess(table);
|
||||
@@ -207,6 +243,11 @@ public final class AssistantRecordScreens
|
||||
*******************************************************************************/
|
||||
public static void addConversationChildLists(QInstance qInstance)
|
||||
{
|
||||
QTableMetaData scheduledTask = qInstance.getTable(AssistantScheduledTask.TABLE_NAME);
|
||||
if(scheduledTask != null)
|
||||
{
|
||||
childList(qInstance, scheduledTask, AssistantScheduledRun.TABLE_NAME, "Runs", "history", List.of("startedAt", "status", "conversationId", "costMicros", "summary"), "taskId", "scheduled");
|
||||
}
|
||||
QTableMetaData conversation = qInstance.getTable(AssistantConversation.TABLE_NAME);
|
||||
if(conversation == null)
|
||||
{
|
||||
@@ -286,17 +327,30 @@ public final class AssistantRecordScreens
|
||||
return (List.of(
|
||||
new QPossibleValueSource().withName(PROVIDER_TYPE_PVS_NAME).withType(QPossibleValueSourceType.ENUM).withIdType(QFieldType.STRING)
|
||||
.withValueFormatAndFields(PVSValueFormatAndFields.LABEL_ONLY).withEnumValues(types),
|
||||
new QPossibleValueSource().withName(MODEL_PVS_NAME).withType(QPossibleValueSourceType.ENUM).withIdType(QFieldType.STRING)
|
||||
.withValueFormatAndFields(PVSValueFormatAndFields.LABEL_ONLY).withEnumValues(modelValues),
|
||||
enumSource("assistant_provider_kind", kinds),
|
||||
settingsSource(MODEL_PVS_NAME, modelValues, true, true),
|
||||
settingsSource("assistant_price_model", modelValues, true, false),
|
||||
settingsSource("assistant_provider_kind", kinds, false, false),
|
||||
enumSource("assistant_provider_location", List.of(new QPossibleValue<>("LOCAL", "Local"), new QPossibleValue<>("CLOUD", "Cloud"))),
|
||||
enumSource("assistant_routing_mode", List.of(new QPossibleValue<>("OFF", "Off"), new QPossibleValue<>("HEURISTIC", "Heuristic"), new QPossibleValue<>("CLASSIFIER", "Classifier"))),
|
||||
enumSource("assistant_model_kind", List.of(new QPossibleValue<>("CHAT", "Chat"), new QPossibleValue<>("EMBEDDING", "Embedding"))),
|
||||
enumSource("assistant_model_source", List.of(new QPossibleValue<>("DISCOVERED", "Discovered"), new QPossibleValue<>("MANUAL", "Manual"))),
|
||||
QPossibleValueSource.newForTable(AssistantProvider.TABLE_NAME)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Contingent searches use RC.5's otherValues input.
|
||||
*******************************************************************************/
|
||||
private static QPossibleValueSource settingsSource(String name, List<QPossibleValue<?>> union, Boolean models, Boolean providerRow)
|
||||
{
|
||||
return (new QPossibleValueSource().withName(name).withType(QPossibleValueSourceType.CUSTOM).withIdType(QFieldType.STRING)
|
||||
.withValueFormatAndFields(PVSValueFormatAndFields.LABEL_ONLY)
|
||||
.withCustomCodeReference(new QCodeReferenceWithProperties(AssistantSettingsPossibleValueProvider.class, Map.of("union", new ArrayList<>(union), "models", models, "providerRow", providerRow))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Display label shared by provider settings and home cards. Custom kinds
|
||||
** retain their registered name.
|
||||
@@ -332,6 +386,11 @@ public final class AssistantRecordScreens
|
||||
private static void provider(QTableMetaData table)
|
||||
{
|
||||
pvs(table, "providerType", PROVIDER_TYPE_PVS_NAME, "Type");
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// Add possibleValueSourceDependsOn("providerType") to model and kind //
|
||||
// once the next QQQ RC includes core feature/contingent-possible-values. //
|
||||
// See QQQ docs/CONTINGENT-POSSIBLE-VALUES.md. //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
pvs(table, "model", MODEL_PVS_NAME, "Model");
|
||||
pvs(table, "kind", "assistant_provider_kind", "Kind");
|
||||
pvs(table, "location", "assistant_provider_location", "Location");
|
||||
@@ -380,6 +439,11 @@ public final class AssistantRecordScreens
|
||||
private static void price(QTableMetaData table)
|
||||
{
|
||||
pvs(table, "providerType", PROVIDER_TYPE_PVS_NAME, "Provider Type");
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// Add possibleValueSourceDependsOn("providerType") when the next QQQ //
|
||||
// RC includes it (QQQ docs/CONTINGENT-POSSIBLE-VALUES.md). //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
pvs(table, "model", "assistant_price_model", "Model");
|
||||
for(String field : List.of("inputUsdPerMTok", "outputUsdPerMTok", "cacheWrite5mUsdPerMTok", "cacheWrite1hUsdPerMTok"))
|
||||
{
|
||||
help(table, field, "USD per million tokens.");
|
||||
@@ -888,6 +952,7 @@ public final class AssistantRecordScreens
|
||||
{
|
||||
link(table, "providerId", AssistantProvider.TABLE_NAME, "Provider");
|
||||
pvs(table, "source", "assistant_model_source", "Source");
|
||||
pvs(table, "modelKind", "assistant_model_kind", "Model kind");
|
||||
pvs(table, "fallbackModelId", "assistant_registry_model", "Fallback model");
|
||||
table.getField("contextWindowTokens").withLabel("Context window (tokens)");
|
||||
table.getField("maxOutputTokens").withLabel("Max output tokens");
|
||||
@@ -896,7 +961,7 @@ public final class AssistantRecordScreens
|
||||
table.getField("outputUsdPerMTok").withLabel("Output (USD per million tokens)");
|
||||
table.getField("lastTestLatencyMs").withLabel("Last test latency (ms)");
|
||||
sections(table,
|
||||
section("model", "Model", "memory", Tier.T1, "id", "providerId", "modelId", "displayName", "enabled", "source", "discoveredAt", "fallbackModelId"),
|
||||
section("model", "Model", "memory", Tier.T1, "id", "providerId", "modelId", "displayName", "enabled", "modelKind", "embeddingDimensions", "source", "discoveredAt", "fallbackModelId"),
|
||||
section("capabilities", "Capabilities", "settings", Tier.T2, "supportsTools", "supportsVision", "supportsStreaming", "supportsReasoningEffort", "contextWindowTokens", "maxOutputTokens"),
|
||||
section("prices", "Prices", "payments", Tier.T2, "inputUsdPerMTok", "outputUsdPerMTok"),
|
||||
section("lastTest", "Last connection test", "network_check", Tier.T2, "lastTestAt", "lastTestStatus", "lastTestLatencyMs", "lastTestError"));
|
||||
@@ -936,4 +1001,22 @@ public final class AssistantRecordScreens
|
||||
section("budget", "Budget", "savings", Tier.T1, "id", "name", "enabled", "scopeType", "scopeValue", "period", "metric", "limitValue"),
|
||||
section("thresholds", "Thresholds", "notifications", Tier.T2, "warnPercent", "atLimitAction", "downgradeModelId", "hardCeilingPercent", "notes"));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Standard source editor and status, with explicit embedding model selection.
|
||||
*******************************************************************************/
|
||||
private static void knowledge(QTableMetaData table)
|
||||
{
|
||||
pvs(table, "embeddingModelId", "assistant_registry_model", "Embedding model");
|
||||
table.getField("embeddingModelId").withPossibleValueSourceFilter(new QQueryFilter()
|
||||
.withCriteria("modelKind", QCriteriaOperator.EQUALS, "EMBEDDING").withCriteria("enabled", QCriteriaOperator.EQUALS, true));
|
||||
help(table, "embeddingModelId", "Select an enabled EMBEDDING model. Chat models are refused on save.");
|
||||
help(table, "filterJson", "Optional QQQ query filter JSON. Index now reads it through the UI API as the clicking administrator.");
|
||||
sections(table,
|
||||
section("source", "Knowledge source", "manage_search", Tier.T1, "id", "name", "enabled", "tableName", "fieldNames", "titleFieldName", "filterJson"),
|
||||
section("embedding", "Embedding", "memory", Tier.T2, "embeddingModelId", "chunkChars", "chunkOverlapChars"),
|
||||
section("status", "Last index", "info", Tier.T2, "lastIndexedAt", "lastIndexStatus", "lastIndexError", "indexedRecordCount", "indexedChunkCount"));
|
||||
}
|
||||
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.metadata;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qbits.assistant.AssistantRuntime;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantScheduledTask;
|
||||
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
|
||||
import com.kingsrook.qbits.assistant.scheduled.ScheduledTasks;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.QueryOrGetInputInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate task edits and invalidate approvals before changed text is saved.
|
||||
*******************************************************************************/
|
||||
public class AssistantScheduledTaskCustomizer implements TableCustomizerInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Internal status writes bypass this customizer only inside AssistantStore.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preInsertOrUpdate(AbstractActionInput input, List<QRecord> records, boolean isPreview, Optional<List<QRecord>> oldRecordList) throws QException
|
||||
{
|
||||
Optional<Map<Serializable, QRecord>> old = oldRecordListToMap("id", oldRecordList);
|
||||
AssistantRuntime runtime = AssistantRuntime.of(QContext.getQInstance());
|
||||
for(QRecord record : records)
|
||||
{
|
||||
record.getValues().remove("credentialStatus");
|
||||
if(input instanceof UpdateInput update
|
||||
&& update.hasFlag(AssistantStore.ScheduledWrite.INTERNAL))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
QRecord previous = old.map(rows -> rows.get(record.getValue("id"))).orElse(null);
|
||||
for(String field : List.of("writeModeApprovedBy", "writeModeApprovedAt", "writeModeApprovalHash", "nextRunAt", "lastRunAt", "lastRunStatus", "lastRunError"))
|
||||
{
|
||||
record.getValues().remove(field);
|
||||
}
|
||||
QRecord merged = new QRecord();
|
||||
merged.setValues(previous == null ? new LinkedHashMap<>() : new LinkedHashMap<>(previous.getValues()));
|
||||
record.getValues().forEach(merged::setValue);
|
||||
for(var entry : Map.<String, Serializable>of("enabled", false, "timezone", "UTC", "outputMode", "RECORD", "writeModeRequested", false).entrySet())
|
||||
{
|
||||
if(merged.getValue(entry.getKey()) == null)
|
||||
{
|
||||
merged.setValue(entry.getKey(), entry.getValue());
|
||||
record.setValue(entry.getKey(), entry.getValue());
|
||||
}
|
||||
}
|
||||
if(previous == null || ScheduledTasks.APPROVAL_FIELDS.stream().anyMatch(field -> !Objects.equals(previous.getValue(field), merged.getValue(field))))
|
||||
{
|
||||
record.setValue("writeModeApprovedBy", null);
|
||||
record.setValue("writeModeApprovedAt", null);
|
||||
record.setValue("writeModeApprovalHash", null);
|
||||
}
|
||||
try
|
||||
{
|
||||
AssistantScheduledTask task = new AssistantScheduledTask(merged);
|
||||
ScheduledTasks.checkReference(runtime.getConfig(), task.getCredentialEnvName());
|
||||
if(task.getPrompt() == null || task.getPrompt().isBlank() || task.getPrompt().length() > 4000)
|
||||
{
|
||||
throw (new QException("The prompt must contain 1 to 4000 characters."));
|
||||
}
|
||||
if(!List.of("RECORD", "NOTIFICATION", "BOTH").contains(task.getOutputMode()))
|
||||
{
|
||||
throw (new QException("Output mode must be RECORD, NOTIFICATION or BOTH."));
|
||||
}
|
||||
if(task.getMaxCostUsdPerRun() != null && task.getMaxCostUsdPerRun().signum() < 0)
|
||||
{
|
||||
throw (new QException("The per-run cost cap cannot be negative."));
|
||||
}
|
||||
var next = ScheduledTasks.next(task, runtime.getClock().instant());
|
||||
if(previous == null || List.of("enabled", "cronExpression", "timezone").stream().anyMatch(field -> !Objects.equals(previous.getValue(field), merged.getValue(field))))
|
||||
{
|
||||
record.setValue("nextRunAt", Boolean.TRUE.equals(task.getEnabled()) ? next : null);
|
||||
}
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
record.addError(new BadInputStatusMessage(e.getMessage()));
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Computed status is never persisted.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postQuery(QueryOrGetInputInterface input, List<QRecord> records) throws QException
|
||||
{
|
||||
AssistantRuntime runtime = AssistantRuntime.of(QContext.getQInstance());
|
||||
for(QRecord record : records)
|
||||
{
|
||||
record.setValue("credentialStatus", ScheduledTasks.credentialStatus(runtime.getConfig(), new AssistantScheduledTask(record)));
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
}
|
||||
+31
@@ -83,6 +83,18 @@ public class AssistantSettingsCustomizer implements TableCustomizerInterface
|
||||
{
|
||||
for(QRecord record : records)
|
||||
{
|
||||
if(record.getValue("id") == null && record.getValue("modelKind") == null)
|
||||
{
|
||||
record.setValue("modelKind", "CHAT");
|
||||
}
|
||||
if(record.getValue("modelKind") != null && !List.of("CHAT", "EMBEDDING").contains(record.getValueString("modelKind")))
|
||||
{
|
||||
record.addError(new BadInputStatusMessage("Model kind must be CHAT or EMBEDDING."));
|
||||
}
|
||||
if(record.getValue("embeddingDimensions") != null && record.getValueInteger("embeddingDimensions") < 1)
|
||||
{
|
||||
record.addError(new BadInputStatusMessage("Embedding dimensions must be positive."));
|
||||
}
|
||||
if(record.getValue("id") == null)
|
||||
{
|
||||
if(record.getValue("source") == null)
|
||||
@@ -171,6 +183,25 @@ public class AssistantSettingsCustomizer implements TableCustomizerInterface
|
||||
}
|
||||
}
|
||||
}
|
||||
if(AssistantModel.TABLE_NAME.equals(tableName(input)) && oldRecordList.isPresent())
|
||||
{
|
||||
Map<Serializable, QRecord> previous = oldRecordListToMap("id", oldRecordList).orElse(Map.of());
|
||||
for(QRecord record : records)
|
||||
{
|
||||
QRecord before = previous.get(record.getValue("id"));
|
||||
if(before != null && List.of("providerId", "modelId", "modelKind", "embeddingDimensions").stream()
|
||||
.anyMatch(name -> record.getValues().containsKey(name) && !java.util.Objects.equals(before.getValue(name), record.getValue(name))))
|
||||
{
|
||||
for(var source : runtime.getRagService().sources())
|
||||
{
|
||||
if(java.util.Objects.equals(source.getEmbeddingModelId(), record.getValueInteger("id")))
|
||||
{
|
||||
runtime.getRagService().clear(source);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
runtime.getProviderService().invalidate();
|
||||
return (records);
|
||||
}
|
||||
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.metadata;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.assistant.AssistantRuntime;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantModel;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProvider;
|
||||
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
|
||||
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
|
||||
import com.kingsrook.qqq.backend.core.actions.values.QCustomPossibleValueProvider;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.values.SearchPossibleValueSourceInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.InitializableViaCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReferenceWithProperties;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Provider settings choices, narrowed by the selected provider type.
|
||||
*******************************************************************************/
|
||||
public class AssistantSettingsPossibleValueProvider implements QCustomPossibleValueProvider<String>, InitializableViaCodeReference
|
||||
{
|
||||
private final Map<String, QPossibleValue<String>> union = new LinkedHashMap<>();
|
||||
private Boolean models;
|
||||
private Boolean providerRow;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Price rows share model choices but their id is never a provider id.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void initialize(QCodeReference codeReference)
|
||||
{
|
||||
Map<String, Serializable> properties = ((QCodeReferenceWithProperties) codeReference).getProperties();
|
||||
models = (Boolean) properties.get("models");
|
||||
providerRow = (Boolean) properties.get("providerRow");
|
||||
for(Object object : (List<?>) properties.get("union"))
|
||||
{
|
||||
QPossibleValue<?> value = (QPossibleValue<?>) object;
|
||||
union.put(String.valueOf(value.getId()), new QPossibleValue<>(String.valueOf(value.getId()), value.getLabel()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Saved values resolve across types, including disabled registry models.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QPossibleValue<String> getPossibleValue(Serializable idValue) throws QException
|
||||
{
|
||||
String id = String.valueOf(idValue);
|
||||
if(union.containsKey(id))
|
||||
{
|
||||
return (union.get(id));
|
||||
}
|
||||
if(models && idValue != null)
|
||||
{
|
||||
for(AssistantModel model : new AssistantStore().listModels())
|
||||
{
|
||||
if(id.equals(model.getModelId()))
|
||||
{
|
||||
return (new QPossibleValue<>(id, id));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Keep union fallback for callers that do not supply a known provider type.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QPossibleValue<String>> search(SearchPossibleValueSourceInput input) throws QException
|
||||
{
|
||||
Map<String, Serializable> otherValues = input.getOtherValues();
|
||||
AssistantProviderType type = otherValues == null ? null : AssistantRuntime.of(QContext.getQInstance()).getConfig().getProviderTypes().get(otherValues.get("providerType"));
|
||||
Map<String, QPossibleValue<String>> values = new LinkedHashMap<>();
|
||||
if(type == null)
|
||||
{
|
||||
values.putAll(union);
|
||||
}
|
||||
else
|
||||
{
|
||||
for(String id : models ? type.getModels() : type.getKinds())
|
||||
{
|
||||
values.put(id, new QPossibleValue<>(id, models ? id : AssistantRecordScreens.providerKindLabel(id)));
|
||||
}
|
||||
if(models && providerRow && otherValues.get("id") != null)
|
||||
{
|
||||
AssistantStore store = new AssistantStore();
|
||||
String providerId = String.valueOf(otherValues.get("id"));
|
||||
AssistantProvider provider = store.listProviders().stream()
|
||||
.filter(row -> providerId.equals(String.valueOf(row.getId())) && type.getType().equals(row.getProviderType())).findFirst().orElse(null);
|
||||
if(provider != null)
|
||||
{
|
||||
for(AssistantModel model : store.listModels())
|
||||
{
|
||||
if(provider.getId().equals(model.getProviderId()) && Boolean.TRUE.equals(model.getEnabled()) && "CHAT".equals(model.getModelKind()))
|
||||
{
|
||||
values.put(model.getModelId(), new QPossibleValue<>(model.getModelId(), model.getModelId()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return (completeCustomPVSSearch(input, new ArrayList<>(values.values())));
|
||||
}
|
||||
}
|
||||
+68
@@ -38,6 +38,12 @@ public class AssistantConversation extends QRecordEntity
|
||||
@Deprecated(since = "0.1.0-RC.3")
|
||||
public static final String LEGACY_TABLE_NAME = "assistantConversation";
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Integer scheduledTaskId;
|
||||
|
||||
@QField(isEditable = false, maxLength = 64)
|
||||
private String channel = "panel";
|
||||
|
||||
@QField(isEditable = false, maxLength = 16)
|
||||
private String maxClassification;
|
||||
|
||||
@@ -535,4 +541,66 @@ public class AssistantConversation extends QRecordEntity
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for scheduledTaskId
|
||||
*******************************************************************************/
|
||||
public Integer getScheduledTaskId()
|
||||
{
|
||||
return (scheduledTaskId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for scheduledTaskId
|
||||
*******************************************************************************/
|
||||
public void setScheduledTaskId(Integer scheduledTaskId)
|
||||
{
|
||||
this.scheduledTaskId = scheduledTaskId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for scheduledTaskId
|
||||
*******************************************************************************/
|
||||
public AssistantConversation withScheduledTaskId(Integer scheduledTaskId)
|
||||
{
|
||||
this.scheduledTaskId = scheduledTaskId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for channel
|
||||
*******************************************************************************/
|
||||
public String getChannel()
|
||||
{
|
||||
return (channel);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for channel
|
||||
*******************************************************************************/
|
||||
public void setChannel(String channel)
|
||||
{
|
||||
this.channel = channel;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for channel
|
||||
*******************************************************************************/
|
||||
public AssistantConversation withChannel(String channel)
|
||||
{
|
||||
this.channel = channel;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,12 @@ public class AssistantModel extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "assistant_model";
|
||||
|
||||
@QField(maxLength = 16)
|
||||
private String modelKind;
|
||||
|
||||
@QField()
|
||||
private Integer embeddingDimensions;
|
||||
|
||||
@QField()
|
||||
private Integer fallbackModelId;
|
||||
|
||||
@@ -798,4 +804,76 @@ public class AssistantModel extends QRecordEntity
|
||||
this.fallbackModelId = fallbackModelId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for modelKind
|
||||
*******************************************************************************/
|
||||
public String getModelKind()
|
||||
{
|
||||
return (modelKind);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for modelKind
|
||||
*******************************************************************************/
|
||||
public void setModelKind(String modelKind)
|
||||
{
|
||||
this.modelKind = modelKind;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for modelKind
|
||||
*******************************************************************************/
|
||||
public AssistantModel withModelKind(String modelKind)
|
||||
{
|
||||
this.modelKind = modelKind;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for embeddingDimensions
|
||||
*******************************************************************************/
|
||||
public Integer getEmbeddingDimensions()
|
||||
{
|
||||
return (embeddingDimensions);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for embeddingDimensions
|
||||
*******************************************************************************/
|
||||
public void setEmbeddingDimensions(Integer embeddingDimensions)
|
||||
{
|
||||
this.embeddingDimensions = embeddingDimensions;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for embeddingDimensions
|
||||
*******************************************************************************/
|
||||
public AssistantModel withEmbeddingDimensions(Integer embeddingDimensions)
|
||||
{
|
||||
this.embeddingDimensions = embeddingDimensions;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Legacy registry rows remain chat models until explicitly classified.
|
||||
*******************************************************************************/
|
||||
public Boolean isChat()
|
||||
{
|
||||
return (modelKind == null || "CHAT".equals(modelKind));
|
||||
}
|
||||
|
||||
}
|
||||
+629
@@ -0,0 +1,629 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.model;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QField;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Admin-owned indexing configuration. Source records never contain indexed text.
|
||||
*******************************************************************************/
|
||||
public class AssistantRagSource extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "assistant_rag_source";
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@QField(isRequired = true, maxLength = 200)
|
||||
private String name;
|
||||
|
||||
@QField()
|
||||
private Boolean enabled;
|
||||
|
||||
@QField(isRequired = true, maxLength = 200)
|
||||
private String tableName;
|
||||
|
||||
@QField(isRequired = true, maxLength = 500)
|
||||
private String fieldNames;
|
||||
|
||||
@QField(maxLength = 200)
|
||||
private String titleFieldName;
|
||||
|
||||
@QField()
|
||||
private String filterJson;
|
||||
|
||||
@QField(isRequired = true)
|
||||
private Integer embeddingModelId;
|
||||
|
||||
@QField()
|
||||
private Integer chunkChars;
|
||||
|
||||
@QField()
|
||||
private Integer chunkOverlapChars;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant lastIndexedAt;
|
||||
|
||||
@QField(isEditable = false, maxLength = 200)
|
||||
private String lastIndexStatus;
|
||||
|
||||
@QField(isEditable = false, maxLength = 500)
|
||||
private String lastIndexError;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Integer indexedRecordCount;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Integer indexedChunkCount;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant modifyDate;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Empty entity for framework mapping.
|
||||
*******************************************************************************/
|
||||
public AssistantRagSource()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Map the source's own configuration record.
|
||||
*******************************************************************************/
|
||||
public AssistantRagSource(QRecord record) throws QException
|
||||
{
|
||||
populateFromQRecord(record);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for id
|
||||
*******************************************************************************/
|
||||
public Integer getId()
|
||||
{
|
||||
return (id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for id
|
||||
*******************************************************************************/
|
||||
public void setId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for id
|
||||
*******************************************************************************/
|
||||
public AssistantRagSource withId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for name
|
||||
*******************************************************************************/
|
||||
public String getName()
|
||||
{
|
||||
return (name);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for name
|
||||
*******************************************************************************/
|
||||
public void setName(String name)
|
||||
{
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for name
|
||||
*******************************************************************************/
|
||||
public AssistantRagSource withName(String name)
|
||||
{
|
||||
this.name = name;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for enabled
|
||||
*******************************************************************************/
|
||||
public Boolean getEnabled()
|
||||
{
|
||||
return (enabled);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for enabled
|
||||
*******************************************************************************/
|
||||
public void setEnabled(Boolean enabled)
|
||||
{
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for enabled
|
||||
*******************************************************************************/
|
||||
public AssistantRagSource withEnabled(Boolean enabled)
|
||||
{
|
||||
this.enabled = enabled;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for tableName
|
||||
*******************************************************************************/
|
||||
public String getTableName()
|
||||
{
|
||||
return (tableName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for tableName
|
||||
*******************************************************************************/
|
||||
public void setTableName(String tableName)
|
||||
{
|
||||
this.tableName = tableName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for tableName
|
||||
*******************************************************************************/
|
||||
public AssistantRagSource withTableName(String tableName)
|
||||
{
|
||||
this.tableName = tableName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for fieldNames
|
||||
*******************************************************************************/
|
||||
public String getFieldNames()
|
||||
{
|
||||
return (fieldNames);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for fieldNames
|
||||
*******************************************************************************/
|
||||
public void setFieldNames(String fieldNames)
|
||||
{
|
||||
this.fieldNames = fieldNames;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for fieldNames
|
||||
*******************************************************************************/
|
||||
public AssistantRagSource withFieldNames(String fieldNames)
|
||||
{
|
||||
this.fieldNames = fieldNames;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for titleFieldName
|
||||
*******************************************************************************/
|
||||
public String getTitleFieldName()
|
||||
{
|
||||
return (titleFieldName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for titleFieldName
|
||||
*******************************************************************************/
|
||||
public void setTitleFieldName(String titleFieldName)
|
||||
{
|
||||
this.titleFieldName = titleFieldName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for titleFieldName
|
||||
*******************************************************************************/
|
||||
public AssistantRagSource withTitleFieldName(String titleFieldName)
|
||||
{
|
||||
this.titleFieldName = titleFieldName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for filterJson
|
||||
*******************************************************************************/
|
||||
public String getFilterJson()
|
||||
{
|
||||
return (filterJson);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for filterJson
|
||||
*******************************************************************************/
|
||||
public void setFilterJson(String filterJson)
|
||||
{
|
||||
this.filterJson = filterJson;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for filterJson
|
||||
*******************************************************************************/
|
||||
public AssistantRagSource withFilterJson(String filterJson)
|
||||
{
|
||||
this.filterJson = filterJson;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for embeddingModelId
|
||||
*******************************************************************************/
|
||||
public Integer getEmbeddingModelId()
|
||||
{
|
||||
return (embeddingModelId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for embeddingModelId
|
||||
*******************************************************************************/
|
||||
public void setEmbeddingModelId(Integer embeddingModelId)
|
||||
{
|
||||
this.embeddingModelId = embeddingModelId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for embeddingModelId
|
||||
*******************************************************************************/
|
||||
public AssistantRagSource withEmbeddingModelId(Integer embeddingModelId)
|
||||
{
|
||||
this.embeddingModelId = embeddingModelId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for chunkChars
|
||||
*******************************************************************************/
|
||||
public Integer getChunkChars()
|
||||
{
|
||||
return (chunkChars);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for chunkChars
|
||||
*******************************************************************************/
|
||||
public void setChunkChars(Integer chunkChars)
|
||||
{
|
||||
this.chunkChars = chunkChars;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for chunkChars
|
||||
*******************************************************************************/
|
||||
public AssistantRagSource withChunkChars(Integer chunkChars)
|
||||
{
|
||||
this.chunkChars = chunkChars;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for chunkOverlapChars
|
||||
*******************************************************************************/
|
||||
public Integer getChunkOverlapChars()
|
||||
{
|
||||
return (chunkOverlapChars);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for chunkOverlapChars
|
||||
*******************************************************************************/
|
||||
public void setChunkOverlapChars(Integer chunkOverlapChars)
|
||||
{
|
||||
this.chunkOverlapChars = chunkOverlapChars;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for chunkOverlapChars
|
||||
*******************************************************************************/
|
||||
public AssistantRagSource withChunkOverlapChars(Integer chunkOverlapChars)
|
||||
{
|
||||
this.chunkOverlapChars = chunkOverlapChars;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for lastIndexedAt
|
||||
*******************************************************************************/
|
||||
public Instant getLastIndexedAt()
|
||||
{
|
||||
return (lastIndexedAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for lastIndexedAt
|
||||
*******************************************************************************/
|
||||
public void setLastIndexedAt(Instant lastIndexedAt)
|
||||
{
|
||||
this.lastIndexedAt = lastIndexedAt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for lastIndexedAt
|
||||
*******************************************************************************/
|
||||
public AssistantRagSource withLastIndexedAt(Instant lastIndexedAt)
|
||||
{
|
||||
this.lastIndexedAt = lastIndexedAt;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for lastIndexStatus
|
||||
*******************************************************************************/
|
||||
public String getLastIndexStatus()
|
||||
{
|
||||
return (lastIndexStatus);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for lastIndexStatus
|
||||
*******************************************************************************/
|
||||
public void setLastIndexStatus(String lastIndexStatus)
|
||||
{
|
||||
this.lastIndexStatus = lastIndexStatus;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for lastIndexStatus
|
||||
*******************************************************************************/
|
||||
public AssistantRagSource withLastIndexStatus(String lastIndexStatus)
|
||||
{
|
||||
this.lastIndexStatus = lastIndexStatus;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for lastIndexError
|
||||
*******************************************************************************/
|
||||
public String getLastIndexError()
|
||||
{
|
||||
return (lastIndexError);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for lastIndexError
|
||||
*******************************************************************************/
|
||||
public void setLastIndexError(String lastIndexError)
|
||||
{
|
||||
this.lastIndexError = lastIndexError;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for lastIndexError
|
||||
*******************************************************************************/
|
||||
public AssistantRagSource withLastIndexError(String lastIndexError)
|
||||
{
|
||||
this.lastIndexError = lastIndexError;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for indexedRecordCount
|
||||
*******************************************************************************/
|
||||
public Integer getIndexedRecordCount()
|
||||
{
|
||||
return (indexedRecordCount);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for indexedRecordCount
|
||||
*******************************************************************************/
|
||||
public void setIndexedRecordCount(Integer indexedRecordCount)
|
||||
{
|
||||
this.indexedRecordCount = indexedRecordCount;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for indexedRecordCount
|
||||
*******************************************************************************/
|
||||
public AssistantRagSource withIndexedRecordCount(Integer indexedRecordCount)
|
||||
{
|
||||
this.indexedRecordCount = indexedRecordCount;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for indexedChunkCount
|
||||
*******************************************************************************/
|
||||
public Integer getIndexedChunkCount()
|
||||
{
|
||||
return (indexedChunkCount);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for indexedChunkCount
|
||||
*******************************************************************************/
|
||||
public void setIndexedChunkCount(Integer indexedChunkCount)
|
||||
{
|
||||
this.indexedChunkCount = indexedChunkCount;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for indexedChunkCount
|
||||
*******************************************************************************/
|
||||
public AssistantRagSource withIndexedChunkCount(Integer indexedChunkCount)
|
||||
{
|
||||
this.indexedChunkCount = indexedChunkCount;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for createDate
|
||||
*******************************************************************************/
|
||||
public Instant getCreateDate()
|
||||
{
|
||||
return (createDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for createDate
|
||||
*******************************************************************************/
|
||||
public void setCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for createDate
|
||||
*******************************************************************************/
|
||||
public AssistantRagSource withCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for modifyDate
|
||||
*******************************************************************************/
|
||||
public Instant getModifyDate()
|
||||
{
|
||||
return (modifyDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for modifyDate
|
||||
*******************************************************************************/
|
||||
public void setModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for modifyDate
|
||||
*******************************************************************************/
|
||||
public AssistantRagSource withModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
+532
@@ -0,0 +1,532 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.model;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QField;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Scheduled assistant bookkeeping; credentials are references only.
|
||||
*******************************************************************************/
|
||||
public class AssistantScheduledRun extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "assistant_scheduled_run";
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@QField(isRequired = true)
|
||||
private Integer taskId;
|
||||
|
||||
@QField(isRequired = true, maxLength = 200)
|
||||
private String userId;
|
||||
|
||||
@QField()
|
||||
private Instant scheduledFor;
|
||||
|
||||
@QField()
|
||||
private Instant startedAt;
|
||||
|
||||
@QField()
|
||||
private Instant endedAt;
|
||||
|
||||
@QField(maxLength = 20)
|
||||
private String status;
|
||||
|
||||
@QField()
|
||||
private Integer conversationId;
|
||||
|
||||
@QField()
|
||||
private Integer turnId;
|
||||
|
||||
@QField(maxLength = 2000)
|
||||
private String summary;
|
||||
|
||||
@QField()
|
||||
private Long costMicros;
|
||||
|
||||
@QField(maxLength = 500)
|
||||
private String error;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant modifyDate;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Empty entity for framework mapping.
|
||||
*******************************************************************************/
|
||||
public AssistantScheduledRun()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Map a bookkeeping record.
|
||||
*******************************************************************************/
|
||||
public AssistantScheduledRun(QRecord record) throws QException
|
||||
{
|
||||
populateFromQRecord(record);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for id
|
||||
*******************************************************************************/
|
||||
public Integer getId()
|
||||
{
|
||||
return (id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for id
|
||||
*******************************************************************************/
|
||||
public void setId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for id
|
||||
*******************************************************************************/
|
||||
public AssistantScheduledRun withId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for taskId
|
||||
*******************************************************************************/
|
||||
public Integer getTaskId()
|
||||
{
|
||||
return (taskId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for taskId
|
||||
*******************************************************************************/
|
||||
public void setTaskId(Integer taskId)
|
||||
{
|
||||
this.taskId = taskId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for taskId
|
||||
*******************************************************************************/
|
||||
public AssistantScheduledRun withTaskId(Integer taskId)
|
||||
{
|
||||
this.taskId = taskId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for userId
|
||||
*******************************************************************************/
|
||||
public String getUserId()
|
||||
{
|
||||
return (userId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for userId
|
||||
*******************************************************************************/
|
||||
public void setUserId(String userId)
|
||||
{
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for userId
|
||||
*******************************************************************************/
|
||||
public AssistantScheduledRun withUserId(String userId)
|
||||
{
|
||||
this.userId = userId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for scheduledFor
|
||||
*******************************************************************************/
|
||||
public Instant getScheduledFor()
|
||||
{
|
||||
return (scheduledFor);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for scheduledFor
|
||||
*******************************************************************************/
|
||||
public void setScheduledFor(Instant scheduledFor)
|
||||
{
|
||||
this.scheduledFor = scheduledFor;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for scheduledFor
|
||||
*******************************************************************************/
|
||||
public AssistantScheduledRun withScheduledFor(Instant scheduledFor)
|
||||
{
|
||||
this.scheduledFor = scheduledFor;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for startedAt
|
||||
*******************************************************************************/
|
||||
public Instant getStartedAt()
|
||||
{
|
||||
return (startedAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for startedAt
|
||||
*******************************************************************************/
|
||||
public void setStartedAt(Instant startedAt)
|
||||
{
|
||||
this.startedAt = startedAt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for startedAt
|
||||
*******************************************************************************/
|
||||
public AssistantScheduledRun withStartedAt(Instant startedAt)
|
||||
{
|
||||
this.startedAt = startedAt;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for endedAt
|
||||
*******************************************************************************/
|
||||
public Instant getEndedAt()
|
||||
{
|
||||
return (endedAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for endedAt
|
||||
*******************************************************************************/
|
||||
public void setEndedAt(Instant endedAt)
|
||||
{
|
||||
this.endedAt = endedAt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for endedAt
|
||||
*******************************************************************************/
|
||||
public AssistantScheduledRun withEndedAt(Instant endedAt)
|
||||
{
|
||||
this.endedAt = endedAt;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for status
|
||||
*******************************************************************************/
|
||||
public String getStatus()
|
||||
{
|
||||
return (status);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for status
|
||||
*******************************************************************************/
|
||||
public void setStatus(String status)
|
||||
{
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for status
|
||||
*******************************************************************************/
|
||||
public AssistantScheduledRun withStatus(String status)
|
||||
{
|
||||
this.status = status;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for conversationId
|
||||
*******************************************************************************/
|
||||
public Integer getConversationId()
|
||||
{
|
||||
return (conversationId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for conversationId
|
||||
*******************************************************************************/
|
||||
public void setConversationId(Integer conversationId)
|
||||
{
|
||||
this.conversationId = conversationId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for conversationId
|
||||
*******************************************************************************/
|
||||
public AssistantScheduledRun withConversationId(Integer conversationId)
|
||||
{
|
||||
this.conversationId = conversationId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for turnId
|
||||
*******************************************************************************/
|
||||
public Integer getTurnId()
|
||||
{
|
||||
return (turnId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for turnId
|
||||
*******************************************************************************/
|
||||
public void setTurnId(Integer turnId)
|
||||
{
|
||||
this.turnId = turnId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for turnId
|
||||
*******************************************************************************/
|
||||
public AssistantScheduledRun withTurnId(Integer turnId)
|
||||
{
|
||||
this.turnId = turnId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for summary
|
||||
*******************************************************************************/
|
||||
public String getSummary()
|
||||
{
|
||||
return (summary);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for summary
|
||||
*******************************************************************************/
|
||||
public void setSummary(String summary)
|
||||
{
|
||||
this.summary = summary;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for summary
|
||||
*******************************************************************************/
|
||||
public AssistantScheduledRun withSummary(String summary)
|
||||
{
|
||||
this.summary = summary;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for costMicros
|
||||
*******************************************************************************/
|
||||
public Long getCostMicros()
|
||||
{
|
||||
return (costMicros);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for costMicros
|
||||
*******************************************************************************/
|
||||
public void setCostMicros(Long costMicros)
|
||||
{
|
||||
this.costMicros = costMicros;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for costMicros
|
||||
*******************************************************************************/
|
||||
public AssistantScheduledRun withCostMicros(Long costMicros)
|
||||
{
|
||||
this.costMicros = costMicros;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for error
|
||||
*******************************************************************************/
|
||||
public String getError()
|
||||
{
|
||||
return (error);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for error
|
||||
*******************************************************************************/
|
||||
public void setError(String error)
|
||||
{
|
||||
this.error = error;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for error
|
||||
*******************************************************************************/
|
||||
public AssistantScheduledRun withError(String error)
|
||||
{
|
||||
this.error = error;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for createDate
|
||||
*******************************************************************************/
|
||||
public Instant getCreateDate()
|
||||
{
|
||||
return (createDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for createDate
|
||||
*******************************************************************************/
|
||||
public void setCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for createDate
|
||||
*******************************************************************************/
|
||||
public AssistantScheduledRun withCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for modifyDate
|
||||
*******************************************************************************/
|
||||
public Instant getModifyDate()
|
||||
{
|
||||
return (modifyDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for modifyDate
|
||||
*******************************************************************************/
|
||||
public void setModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for modifyDate
|
||||
*******************************************************************************/
|
||||
public AssistantScheduledRun withModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
+805
@@ -0,0 +1,805 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.model;
|
||||
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QField;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Scheduled assistant bookkeeping; credentials are references only.
|
||||
*******************************************************************************/
|
||||
public class AssistantScheduledTask extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "assistant_scheduled_task";
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@QField(isRequired = true, maxLength = 200)
|
||||
private String name;
|
||||
|
||||
@QField()
|
||||
private Boolean enabled;
|
||||
|
||||
@QField(isRequired = true, maxLength = 4000)
|
||||
private String prompt;
|
||||
|
||||
@QField(isRequired = true, maxLength = 200)
|
||||
private String runAsUserId;
|
||||
|
||||
@QField(isRequired = true, maxLength = 200)
|
||||
private String credentialEnvName;
|
||||
|
||||
@QField(isEditable = false, maxLength = 200)
|
||||
private String credentialStatus;
|
||||
|
||||
@QField()
|
||||
private Integer profileId;
|
||||
|
||||
@QField(isRequired = true, maxLength = 200)
|
||||
private String cronExpression;
|
||||
|
||||
@QField(maxLength = 100)
|
||||
private String timezone;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant nextRunAt;
|
||||
|
||||
@QField(maxLength = 20)
|
||||
private String outputMode;
|
||||
|
||||
@QField()
|
||||
private Boolean writeModeRequested;
|
||||
|
||||
@QField(isEditable = false, maxLength = 200)
|
||||
private String writeModeApprovedBy;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant writeModeApprovedAt;
|
||||
|
||||
@QField(isEditable = false, maxLength = 64)
|
||||
private String writeModeApprovalHash;
|
||||
|
||||
@QField()
|
||||
private BigDecimal maxCostUsdPerRun;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant lastRunAt;
|
||||
|
||||
@QField(isEditable = false, maxLength = 20)
|
||||
private String lastRunStatus;
|
||||
|
||||
@QField(isEditable = false, maxLength = 500)
|
||||
private String lastRunError;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant modifyDate;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Empty entity for framework mapping.
|
||||
*******************************************************************************/
|
||||
public AssistantScheduledTask()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Map a bookkeeping record.
|
||||
*******************************************************************************/
|
||||
public AssistantScheduledTask(QRecord record) throws QException
|
||||
{
|
||||
populateFromQRecord(record);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for id
|
||||
*******************************************************************************/
|
||||
public Integer getId()
|
||||
{
|
||||
return (id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for id
|
||||
*******************************************************************************/
|
||||
public void setId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for id
|
||||
*******************************************************************************/
|
||||
public AssistantScheduledTask withId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for name
|
||||
*******************************************************************************/
|
||||
public String getName()
|
||||
{
|
||||
return (name);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for name
|
||||
*******************************************************************************/
|
||||
public void setName(String name)
|
||||
{
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for name
|
||||
*******************************************************************************/
|
||||
public AssistantScheduledTask withName(String name)
|
||||
{
|
||||
this.name = name;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for enabled
|
||||
*******************************************************************************/
|
||||
public Boolean getEnabled()
|
||||
{
|
||||
return (enabled);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for enabled
|
||||
*******************************************************************************/
|
||||
public void setEnabled(Boolean enabled)
|
||||
{
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for enabled
|
||||
*******************************************************************************/
|
||||
public AssistantScheduledTask withEnabled(Boolean enabled)
|
||||
{
|
||||
this.enabled = enabled;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for prompt
|
||||
*******************************************************************************/
|
||||
public String getPrompt()
|
||||
{
|
||||
return (prompt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for prompt
|
||||
*******************************************************************************/
|
||||
public void setPrompt(String prompt)
|
||||
{
|
||||
this.prompt = prompt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for prompt
|
||||
*******************************************************************************/
|
||||
public AssistantScheduledTask withPrompt(String prompt)
|
||||
{
|
||||
this.prompt = prompt;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for runAsUserId
|
||||
*******************************************************************************/
|
||||
public String getRunAsUserId()
|
||||
{
|
||||
return (runAsUserId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for runAsUserId
|
||||
*******************************************************************************/
|
||||
public void setRunAsUserId(String runAsUserId)
|
||||
{
|
||||
this.runAsUserId = runAsUserId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for runAsUserId
|
||||
*******************************************************************************/
|
||||
public AssistantScheduledTask withRunAsUserId(String runAsUserId)
|
||||
{
|
||||
this.runAsUserId = runAsUserId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for credentialEnvName
|
||||
*******************************************************************************/
|
||||
public String getCredentialEnvName()
|
||||
{
|
||||
return (credentialEnvName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for credentialEnvName
|
||||
*******************************************************************************/
|
||||
public void setCredentialEnvName(String credentialEnvName)
|
||||
{
|
||||
this.credentialEnvName = credentialEnvName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for credentialEnvName
|
||||
*******************************************************************************/
|
||||
public AssistantScheduledTask withCredentialEnvName(String credentialEnvName)
|
||||
{
|
||||
this.credentialEnvName = credentialEnvName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for credentialStatus
|
||||
*******************************************************************************/
|
||||
public String getCredentialStatus()
|
||||
{
|
||||
return (credentialStatus);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for credentialStatus
|
||||
*******************************************************************************/
|
||||
public void setCredentialStatus(String credentialStatus)
|
||||
{
|
||||
this.credentialStatus = credentialStatus;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for credentialStatus
|
||||
*******************************************************************************/
|
||||
public AssistantScheduledTask withCredentialStatus(String credentialStatus)
|
||||
{
|
||||
this.credentialStatus = credentialStatus;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for profileId
|
||||
*******************************************************************************/
|
||||
public Integer getProfileId()
|
||||
{
|
||||
return (profileId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for profileId
|
||||
*******************************************************************************/
|
||||
public void setProfileId(Integer profileId)
|
||||
{
|
||||
this.profileId = profileId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for profileId
|
||||
*******************************************************************************/
|
||||
public AssistantScheduledTask withProfileId(Integer profileId)
|
||||
{
|
||||
this.profileId = profileId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for cronExpression
|
||||
*******************************************************************************/
|
||||
public String getCronExpression()
|
||||
{
|
||||
return (cronExpression);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for cronExpression
|
||||
*******************************************************************************/
|
||||
public void setCronExpression(String cronExpression)
|
||||
{
|
||||
this.cronExpression = cronExpression;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for cronExpression
|
||||
*******************************************************************************/
|
||||
public AssistantScheduledTask withCronExpression(String cronExpression)
|
||||
{
|
||||
this.cronExpression = cronExpression;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for timezone
|
||||
*******************************************************************************/
|
||||
public String getTimezone()
|
||||
{
|
||||
return (timezone);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for timezone
|
||||
*******************************************************************************/
|
||||
public void setTimezone(String timezone)
|
||||
{
|
||||
this.timezone = timezone;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for timezone
|
||||
*******************************************************************************/
|
||||
public AssistantScheduledTask withTimezone(String timezone)
|
||||
{
|
||||
this.timezone = timezone;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for nextRunAt
|
||||
*******************************************************************************/
|
||||
public Instant getNextRunAt()
|
||||
{
|
||||
return (nextRunAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for nextRunAt
|
||||
*******************************************************************************/
|
||||
public void setNextRunAt(Instant nextRunAt)
|
||||
{
|
||||
this.nextRunAt = nextRunAt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for nextRunAt
|
||||
*******************************************************************************/
|
||||
public AssistantScheduledTask withNextRunAt(Instant nextRunAt)
|
||||
{
|
||||
this.nextRunAt = nextRunAt;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for outputMode
|
||||
*******************************************************************************/
|
||||
public String getOutputMode()
|
||||
{
|
||||
return (outputMode);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for outputMode
|
||||
*******************************************************************************/
|
||||
public void setOutputMode(String outputMode)
|
||||
{
|
||||
this.outputMode = outputMode;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for outputMode
|
||||
*******************************************************************************/
|
||||
public AssistantScheduledTask withOutputMode(String outputMode)
|
||||
{
|
||||
this.outputMode = outputMode;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for writeModeRequested
|
||||
*******************************************************************************/
|
||||
public Boolean getWriteModeRequested()
|
||||
{
|
||||
return (writeModeRequested);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for writeModeRequested
|
||||
*******************************************************************************/
|
||||
public void setWriteModeRequested(Boolean writeModeRequested)
|
||||
{
|
||||
this.writeModeRequested = writeModeRequested;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for writeModeRequested
|
||||
*******************************************************************************/
|
||||
public AssistantScheduledTask withWriteModeRequested(Boolean writeModeRequested)
|
||||
{
|
||||
this.writeModeRequested = writeModeRequested;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for writeModeApprovedBy
|
||||
*******************************************************************************/
|
||||
public String getWriteModeApprovedBy()
|
||||
{
|
||||
return (writeModeApprovedBy);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for writeModeApprovedBy
|
||||
*******************************************************************************/
|
||||
public void setWriteModeApprovedBy(String writeModeApprovedBy)
|
||||
{
|
||||
this.writeModeApprovedBy = writeModeApprovedBy;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for writeModeApprovedBy
|
||||
*******************************************************************************/
|
||||
public AssistantScheduledTask withWriteModeApprovedBy(String writeModeApprovedBy)
|
||||
{
|
||||
this.writeModeApprovedBy = writeModeApprovedBy;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for writeModeApprovedAt
|
||||
*******************************************************************************/
|
||||
public Instant getWriteModeApprovedAt()
|
||||
{
|
||||
return (writeModeApprovedAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for writeModeApprovedAt
|
||||
*******************************************************************************/
|
||||
public void setWriteModeApprovedAt(Instant writeModeApprovedAt)
|
||||
{
|
||||
this.writeModeApprovedAt = writeModeApprovedAt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for writeModeApprovedAt
|
||||
*******************************************************************************/
|
||||
public AssistantScheduledTask withWriteModeApprovedAt(Instant writeModeApprovedAt)
|
||||
{
|
||||
this.writeModeApprovedAt = writeModeApprovedAt;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for writeModeApprovalHash
|
||||
*******************************************************************************/
|
||||
public String getWriteModeApprovalHash()
|
||||
{
|
||||
return (writeModeApprovalHash);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for writeModeApprovalHash
|
||||
*******************************************************************************/
|
||||
public void setWriteModeApprovalHash(String writeModeApprovalHash)
|
||||
{
|
||||
this.writeModeApprovalHash = writeModeApprovalHash;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for writeModeApprovalHash
|
||||
*******************************************************************************/
|
||||
public AssistantScheduledTask withWriteModeApprovalHash(String writeModeApprovalHash)
|
||||
{
|
||||
this.writeModeApprovalHash = writeModeApprovalHash;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for maxCostUsdPerRun
|
||||
*******************************************************************************/
|
||||
public BigDecimal getMaxCostUsdPerRun()
|
||||
{
|
||||
return (maxCostUsdPerRun);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for maxCostUsdPerRun
|
||||
*******************************************************************************/
|
||||
public void setMaxCostUsdPerRun(BigDecimal maxCostUsdPerRun)
|
||||
{
|
||||
this.maxCostUsdPerRun = maxCostUsdPerRun;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for maxCostUsdPerRun
|
||||
*******************************************************************************/
|
||||
public AssistantScheduledTask withMaxCostUsdPerRun(BigDecimal maxCostUsdPerRun)
|
||||
{
|
||||
this.maxCostUsdPerRun = maxCostUsdPerRun;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for lastRunAt
|
||||
*******************************************************************************/
|
||||
public Instant getLastRunAt()
|
||||
{
|
||||
return (lastRunAt);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for lastRunAt
|
||||
*******************************************************************************/
|
||||
public void setLastRunAt(Instant lastRunAt)
|
||||
{
|
||||
this.lastRunAt = lastRunAt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for lastRunAt
|
||||
*******************************************************************************/
|
||||
public AssistantScheduledTask withLastRunAt(Instant lastRunAt)
|
||||
{
|
||||
this.lastRunAt = lastRunAt;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for lastRunStatus
|
||||
*******************************************************************************/
|
||||
public String getLastRunStatus()
|
||||
{
|
||||
return (lastRunStatus);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for lastRunStatus
|
||||
*******************************************************************************/
|
||||
public void setLastRunStatus(String lastRunStatus)
|
||||
{
|
||||
this.lastRunStatus = lastRunStatus;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for lastRunStatus
|
||||
*******************************************************************************/
|
||||
public AssistantScheduledTask withLastRunStatus(String lastRunStatus)
|
||||
{
|
||||
this.lastRunStatus = lastRunStatus;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for lastRunError
|
||||
*******************************************************************************/
|
||||
public String getLastRunError()
|
||||
{
|
||||
return (lastRunError);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for lastRunError
|
||||
*******************************************************************************/
|
||||
public void setLastRunError(String lastRunError)
|
||||
{
|
||||
this.lastRunError = lastRunError;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for lastRunError
|
||||
*******************************************************************************/
|
||||
public AssistantScheduledTask withLastRunError(String lastRunError)
|
||||
{
|
||||
this.lastRunError = lastRunError;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for createDate
|
||||
*******************************************************************************/
|
||||
public Instant getCreateDate()
|
||||
{
|
||||
return (createDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for createDate
|
||||
*******************************************************************************/
|
||||
public void setCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for createDate
|
||||
*******************************************************************************/
|
||||
public AssistantScheduledTask withCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for modifyDate
|
||||
*******************************************************************************/
|
||||
public Instant getModifyDate()
|
||||
{
|
||||
return (modifyDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for modifyDate
|
||||
*******************************************************************************/
|
||||
public void setModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for modifyDate
|
||||
*******************************************************************************/
|
||||
public AssistantScheduledTask withModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
+34
@@ -40,6 +40,9 @@ public class AssistantToolCall extends QRecordEntity
|
||||
@Deprecated(since = "0.1.0-RC.3")
|
||||
public static final String LEGACY_TABLE_NAME = "assistantToolCall";
|
||||
|
||||
@QField(isEditable = false, maxLength = 64)
|
||||
private String clientChannel;
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@@ -803,4 +806,35 @@ public class AssistantToolCall extends QRecordEntity
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for clientChannel
|
||||
*******************************************************************************/
|
||||
public String getClientChannel()
|
||||
{
|
||||
return (clientChannel);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for clientChannel
|
||||
*******************************************************************************/
|
||||
public void setClientChannel(String clientChannel)
|
||||
{
|
||||
this.clientChannel = clientChannel;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for clientChannel
|
||||
*******************************************************************************/
|
||||
public AssistantToolCall withClientChannel(String clientChannel)
|
||||
{
|
||||
this.clientChannel = clientChannel;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
+113
-12
@@ -17,8 +17,20 @@
|
||||
package com.kingsrook.qbits.assistant.persistence;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
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.Classifications;
|
||||
import com.kingsrook.qbits.assistant.engine.TurnClassification;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantBudget;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantBudgetEvent;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantBudgetOverride;
|
||||
@@ -32,18 +44,22 @@ import com.kingsrook.qbits.assistant.model.AssistantModelPrice;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantPolicy;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProvider;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantScheduledRun;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantScheduledTask;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantTurn;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantTurnEvent;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantUsage;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantUsageDaily;
|
||||
import com.kingsrook.qbits.assistant.provider.ModelUsage;
|
||||
import com.kingsrook.qbits.assistant.scheduled.ScheduledTasks;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.ActionFlag;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput;
|
||||
@@ -56,15 +72,6 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -773,7 +780,7 @@ public class AssistantStore
|
||||
/*******************************************************************************
|
||||
** Save the turn level and never lower persisted conversation history.
|
||||
*******************************************************************************/
|
||||
public synchronized void saveClassification(String userId, Integer conversationId, Integer turnId, com.kingsrook.qbits.assistant.engine.TurnClassification classification) throws QException
|
||||
public synchronized void saveClassification(String userId, Integer conversationId, Integer turnId, TurnClassification classification) throws QException
|
||||
{
|
||||
AssistantConversation current = getConversation(userId, conversationId);
|
||||
classification.inherit(current);
|
||||
@@ -781,8 +788,8 @@ public class AssistantStore
|
||||
.withValue("classification", classification.getClassification().name()).withValue("classificationTables", classification.getTables()));
|
||||
if(current != null)
|
||||
{
|
||||
var maximum = com.kingsrook.qbits.assistant.classification.DataClassification.max(
|
||||
com.kingsrook.qbits.assistant.classification.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()));
|
||||
@@ -934,4 +941,98 @@ public class AssistantStore
|
||||
updateFields(AssistantBudgetUsage.TABLE_NAME, projection.withValue("id", existing.get(0).getId()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Internal bookkeeping flag; never accepted from a UI request body.
|
||||
*******************************************************************************/
|
||||
public enum ScheduledWrite implements ActionFlag
|
||||
{
|
||||
INTERNAL
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Update computed task fields through QQQ under the caller's session.
|
||||
*******************************************************************************/
|
||||
public void scheduledUpdate(QRecord record) throws QException
|
||||
{
|
||||
UpdateOutput output = new UpdateAction().execute(new UpdateInput(AssistantScheduledTask.TABLE_NAME)
|
||||
.withRecord(record).withFlag(ScheduledWrite.INTERNAL));
|
||||
if(CollectionUtils.nullSafeHasContents(output.getRecords().get(0).getErrors()))
|
||||
{
|
||||
throw (new QException("Could not update scheduled task bookkeeping."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read a task definition, never application data.
|
||||
*******************************************************************************/
|
||||
public AssistantScheduledTask scheduledTask(Integer id) throws QException
|
||||
{
|
||||
return (first(query(AssistantScheduledTask.TABLE_NAME,
|
||||
new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, id), AssistantScheduledTask.class)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Optimistic slot claim. The database unique(taskId, scheduledFor) is the
|
||||
** cross-replica arbiter; the monitor also covers the fixture memory backend.
|
||||
** Advance before dispatch. A crash can skip a slot, never run it twice.
|
||||
*******************************************************************************/
|
||||
public ScheduledClaim claimScheduled(
|
||||
AssistantScheduledTask candidate, Instant now, Boolean manual) throws QException
|
||||
{
|
||||
synchronized(QContext.getQInstance())
|
||||
{
|
||||
var task = scheduledTask(candidate.getId());
|
||||
if(task == null || (!manual && (!Boolean.TRUE.equals(task.getEnabled()) || task.getNextRunAt() == null
|
||||
|| task.getNextRunAt().isAfter(now) || !Objects.equals(task.getNextRunAt(), candidate.getNextRunAt()))))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
Instant slot = manual ? now : task.getNextRunAt();
|
||||
Instant next = Boolean.TRUE.equals(task.getEnabled()) ? ScheduledTasks.next(task, now) : null;
|
||||
var filter = new QQueryFilter().withCriteria("taskId", QCriteriaOperator.EQUALS, task.getId()).withCriteria("scheduledFor", QCriteriaOperator.EQUALS, slot);
|
||||
if(!query(AssistantScheduledRun.TABLE_NAME, filter, AssistantScheduledRun.class).isEmpty())
|
||||
{
|
||||
if(!manual)
|
||||
{
|
||||
scheduledUpdate(new QRecord().withValue("id", task.getId()).withValue("nextRunAt", next));
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
AssistantScheduledRun run;
|
||||
try
|
||||
{
|
||||
run = insert(new AssistantScheduledRun().withTaskId(task.getId()).withUserId(task.getRunAsUserId())
|
||||
.withScheduledFor(slot).withStartedAt(now).withStatus("RUNNING").withCostMicros(0L));
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
if(!query(AssistantScheduledRun.TABLE_NAME, filter, AssistantScheduledRun.class).isEmpty())
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
throw (e);
|
||||
}
|
||||
scheduledUpdate(new QRecord().withValue("id", task.getId()).withValue("nextRunAt", next));
|
||||
return (new ScheduledClaim(task, run));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The claimed task snapshot and its run owner must remain consistent.
|
||||
*******************************************************************************/
|
||||
public record ScheduledClaim(AssistantScheduledTask task, AssistantScheduledRun run)
|
||||
{
|
||||
}
|
||||
|
||||
}
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.processes;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.assistant.AssistantRuntime;
|
||||
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;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Standard Review/Result record actions. No scheduler or service-user runner.
|
||||
*******************************************************************************/
|
||||
public final class KnowledgeIndexProcess
|
||||
{
|
||||
public static final String INDEX = "assistantKnowledgeIndex";
|
||||
public static final String CLEAR = "assistantKnowledgeClear";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private KnowledgeIndexProcess()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Both processes are table actions, never navigation entries.
|
||||
*******************************************************************************/
|
||||
public static List<QProcessMetaData> processes()
|
||||
{
|
||||
List<QProcessMetaData> processes = new ArrayList<>();
|
||||
for(Boolean clear : List.of(false, true))
|
||||
{
|
||||
Map<String, Serializable> values = new HashMap<>();
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, AssistantRagSource.TABLE_NAME);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, AssistantRagSource.TABLE_NAME);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, clear ? "Clear vectors for the selected knowledge sources." : "Read content through the UI API as you and embed changed chunks. Embedding usage counts toward budgets.");
|
||||
QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(ExtractViaQueryStep.class, clear ? ClearReview.class : Review.class, clear ? Clear.class : Index.class, values);
|
||||
process.setName(clear ? CLEAR : INDEX);
|
||||
process.setLabel(clear ? "Clear index" : "Index now");
|
||||
process.setIcon(new QIcon(clear ? "delete_sweep" : "manage_search"));
|
||||
process.setTableName(AssistantRagSource.TABLE_NAME);
|
||||
process.setPermissionRules(AssistantMetaDataBuilder.adminRules());
|
||||
processes.add(process);
|
||||
}
|
||||
return (processes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Review validates configuration without sending content to any provider.
|
||||
*******************************************************************************/
|
||||
public static class Review extends AbstractTransformStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Source records only; application records are read at Result time.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
AssistantRuntime runtime = AssistantRuntime.of(QContext.getQInstance());
|
||||
for(QRecord record : input.getRecords())
|
||||
{
|
||||
AssistantRagSource source = new AssistantRagSource(record);
|
||||
List<String> errors = problems(runtime, source);
|
||||
lines.add(TestConnectionProcess.single(errors.isEmpty() ? Status.INFO : Status.ERROR,
|
||||
source.getName() + (errors.isEmpty() ? ": source selected; no content has been sent." : ": " + String.join("; ", errors)), source.getId()));
|
||||
output.getRecords().add(record);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Standard streamed ETL Review lines.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
return (lines);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Review reports PHI/model setup blocks before any indexing dispatch.
|
||||
*******************************************************************************/
|
||||
protected List<String> problems(AssistantRuntime runtime, AssistantRagSource source) throws QException
|
||||
{
|
||||
List<String> errors = runtime.getRagService().validate(source);
|
||||
if(errors.isEmpty())
|
||||
{
|
||||
try
|
||||
{
|
||||
runtime.getProviderService().resolveEmbedding(source.getEmbeddingModelId(),
|
||||
runtime.getConfig().getClassificationSource().highest(source.getTableName()) == DataClassification.PHI);
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
errors.add(e.getMessage());
|
||||
}
|
||||
}
|
||||
return (errors);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Broken or disabled source configuration must never prevent clearing it.
|
||||
*******************************************************************************/
|
||||
public static class ClearReview extends Review
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Clearing has no model or source-content prerequisites.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected List<String> problems(AssistantRuntime runtime, AssistantRagSource source)
|
||||
{
|
||||
return (List.of());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Result performs indexing under the clicking administrator's QContext.
|
||||
*******************************************************************************/
|
||||
public static class Index extends AbstractLoadStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
protected final ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Page of source configuration records, not application content.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
AssistantRuntime runtime = AssistantRuntime.of(QContext.getQInstance());
|
||||
for(QRecord record : input.getRecords())
|
||||
{
|
||||
AssistantRagSource source = new AssistantRagSource(record);
|
||||
RagService.Result result = runtime.getRagService().index(source);
|
||||
lines.add(TestConnectionProcess.single(result.errors() == 0 ? Status.OK : Status.ERROR, result.summary(), source.getId()));
|
||||
output.getRecords().add(record);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Counts from this run, suitable for the standard Result screen.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Clear shares the process presentation but makes no embedding calls.
|
||||
*******************************************************************************/
|
||||
public static class Clear extends Index
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Remove only the selected sources' vectors.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
for(QRecord record : input.getRecords())
|
||||
{
|
||||
AssistantRagSource source = new AssistantRagSource(record);
|
||||
AssistantRuntime.of(QContext.getQInstance()).getRagService().clear(source);
|
||||
lines.add(TestConnectionProcess.single(Status.OK, source.getName() + ": index cleared.", source.getId()));
|
||||
output.getRecords().add(record);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.processes;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
import com.kingsrook.qbits.assistant.AssistantRuntime;
|
||||
import com.kingsrook.qbits.assistant.AssistantSecurity;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantScheduledTask;
|
||||
import com.kingsrook.qbits.assistant.scheduled.ScheduledTaskRunner;
|
||||
import com.kingsrook.qbits.assistant.scheduled.ScheduledTasks;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
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;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaData;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Admin record actions and the scheduler's bookkeeping step. No nav processes.
|
||||
*******************************************************************************/
|
||||
public final class ScheduledTaskProcess
|
||||
{
|
||||
public static final String RUNNER = "assistantScheduledTasksRunner";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private ScheduledTaskProcess()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** QQQ supplies the scheduler session. No session is created here.
|
||||
*******************************************************************************/
|
||||
public static List<QProcessMetaData> processes(AssistantQBitConfig config)
|
||||
{
|
||||
List<QProcessMetaData> result = new ArrayList<>();
|
||||
QProcessMetaData runner = new QProcessMetaData().withName(RUNNER).withLabel("Run due assistant tasks")
|
||||
.withPermissionRules(AssistantMetaDataBuilder.adminRules())
|
||||
.withStep(new QBackendStepMetaData().withName("runDue").withCode(new QCodeReference(Runner.class)));
|
||||
if(config.getScheduledTasksSchedulerName() != null && !config.getScheduledTasksSchedulerName().isBlank())
|
||||
{
|
||||
runner.withSchedule(new QScheduleMetaData().withSchedulerName(config.getScheduledTasksSchedulerName()).withRepeatSeconds(60));
|
||||
}
|
||||
result.add(runner);
|
||||
for(String action : List.of("Approve", "Revoke", "RunNow"))
|
||||
{
|
||||
String label = "Approve".equals(action) ? "Approve write mode" : "Revoke".equals(action) ? "Revoke write mode" : "Run now";
|
||||
Map<String, Serializable> values = new HashMap<>();
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, AssistantScheduledTask.TABLE_NAME);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, AssistantScheduledTask.TABLE_NAME);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, label + " for the selected tasks.");
|
||||
values.put("scheduledAction", action);
|
||||
QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(ExtractViaQueryStep.class, Review.class, Result.class, values);
|
||||
process.setName("assistantScheduledTask" + action);
|
||||
process.setLabel(label);
|
||||
process.setIcon(new QIcon("schedule"));
|
||||
process.setTableName(AssistantScheduledTask.TABLE_NAME);
|
||||
process.setPermissionRules(AssistantMetaDataBuilder.adminRules());
|
||||
result.add(process);
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Actions must enforce permission even when called outside the UI executor.
|
||||
*******************************************************************************/
|
||||
private static AssistantRuntime admin() throws QException
|
||||
{
|
||||
if(QContext.getQSession() == null || !QContext.getQSession().hasPermission(AssistantQBitConfig.ADMIN_PERMISSION))
|
||||
{
|
||||
throw (new QException("assistant.admin is required for scheduled task actions."));
|
||||
}
|
||||
return (AssistantRuntime.of(QContext.getQInstance()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Background scheduler step uses only the context supplied by QQQ.
|
||||
*******************************************************************************/
|
||||
public static class Runner implements BackendStep
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Dispatch the due page once.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
output.addValue("runs", new ScheduledTaskRunner(AssistantRuntime.of(QContext.getQInstance())).runDue());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Review binds the displayed prompt hash and user to the exact settings.
|
||||
*******************************************************************************/
|
||||
public static class Review extends AbstractTransformStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** No login or model call on Review.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
admin();
|
||||
for(QRecord record : input.getRecords())
|
||||
{
|
||||
AssistantScheduledTask task = new AssistantScheduledTask(record);
|
||||
record.setValue("reviewedHash", ScheduledTasks.approvalHash(task));
|
||||
lines.add(TestConnectionProcess.single(Status.INFO, "Run as " + task.getRunAsUserId() + "; prompt SHA-256 " + ScheduledTasks.hash(task.getPrompt())
|
||||
+ ". Write mode permits policy-allowed create, update and delete proposals; writes wait for this user's panel confirmation.", task.getId()));
|
||||
output.getRecords().add(record);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Standard streamed ETL Review lines.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Result rereads tasks; a change since Review requires a fresh review.
|
||||
*******************************************************************************/
|
||||
public static class Result extends AbstractLoadStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Approval records the actual administrator's session user id.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
AssistantRuntime runtime = admin();
|
||||
String action = input.getValueString("scheduledAction");
|
||||
for(QRecord record : input.getRecords())
|
||||
{
|
||||
AssistantScheduledTask task = runtime.getStore().scheduledTask(record.getValueInteger("id"));
|
||||
if(task == null || !ScheduledTasks.approvalHash(task).equals(record.getValueString("reviewedHash")))
|
||||
{
|
||||
throw (new QException("The task changed after Review. Review it again."));
|
||||
}
|
||||
String summary;
|
||||
if("RunNow".equals(action))
|
||||
{
|
||||
var run = new ScheduledTaskRunner(runtime).run(task, true);
|
||||
summary = run == null ? "Task already claimed." : "Run " + run.getId() + ": " + run.getStatus();
|
||||
}
|
||||
else if("Approve".equals(action) || "Revoke".equals(action))
|
||||
{
|
||||
Boolean approve = "Approve".equals(action);
|
||||
runtime.getStore().scheduledUpdate(new QRecord().withValue("id", task.getId())
|
||||
.withValue("writeModeApprovedBy", approve ? AssistantSecurity.userId(QContext.getQSession()) : null)
|
||||
.withValue("writeModeApprovedAt", approve ? runtime.getClock().instant() : null)
|
||||
.withValue("writeModeApprovalHash", approve ? ScheduledTasks.approvalHash(task) : null));
|
||||
summary = approve ? "Write mode approved." : "Write mode revoked.";
|
||||
}
|
||||
else
|
||||
{
|
||||
throw (new QException("Unknown scheduled task action."));
|
||||
}
|
||||
lines.add(TestConnectionProcess.single(Status.INFO, summary, task.getId()));
|
||||
output.getRecords().add(record);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Standard streamed ETL Result lines.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -58,4 +58,14 @@ public interface AssistantModelProvider
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional embedding capability. Unsupported adapters fail before transport.
|
||||
*******************************************************************************/
|
||||
default EmbeddingResponse embed(EmbeddingRequest request) throws ModelProviderException
|
||||
{
|
||||
throw (new ModelProviderException(ModelProviderException.Kind.INVALID_REQUEST, null, "Embeddings are unsupported by this provider."));
|
||||
}
|
||||
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.provider;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One embedding batch. Providers accept at most 64 inputs per request.
|
||||
*******************************************************************************/
|
||||
public record EmbeddingRequest(String model, List<String> input)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Snapshot the inputs and reject empty or oversized batches before transport.
|
||||
*******************************************************************************/
|
||||
public EmbeddingRequest
|
||||
{
|
||||
input = List.copyOf(input);
|
||||
if(model == null || model.isBlank() || input.isEmpty() || input.size() > 64)
|
||||
{
|
||||
throw (new IllegalArgumentException("An embedding request needs a model and 1 to 64 inputs."));
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.provider;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Ordered vectors and billable usage. No input text is retained in the response.
|
||||
*******************************************************************************/
|
||||
public record EmbeddingResponse(List<float[]> vectors, ModelUsage usage, String requestId)
|
||||
{
|
||||
}
|
||||
+11
@@ -94,4 +94,15 @@ public class RegistryModelProvider implements AssistantModelProvider
|
||||
{
|
||||
return (delegate.countTokens(request));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve the delegate's optional embedding capability.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public EmbeddingResponse embed(EmbeddingRequest request) throws ModelProviderException
|
||||
{
|
||||
return (delegate.embed(request));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* 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.rag;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Content-free index entry. The embedding is copied on entry and on read.
|
||||
*******************************************************************************/
|
||||
public record ChunkVector(Integer source, String table, String pk, String field, Integer chunkIndex, String contentHash, float[] embedding)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** No mutable vector can alter an indexed entry after insertion.
|
||||
*******************************************************************************/
|
||||
public ChunkVector
|
||||
{
|
||||
if(source == null || table == null || pk == null || field == null || chunkIndex == null || chunkIndex < 0
|
||||
|| contentHash == null || !contentHash.matches("[a-f0-9]{64}") || embedding == null || embedding.length == 0)
|
||||
{
|
||||
throw (new IllegalArgumentException("Incomplete chunk vector metadata."));
|
||||
}
|
||||
embedding = embedding.clone();
|
||||
for(float value : embedding)
|
||||
{
|
||||
if(!Float.isFinite(value))
|
||||
{
|
||||
throw (new IllegalArgumentException("Embedding coordinates must be finite."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Return a defensive copy.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public float[] embedding()
|
||||
{
|
||||
return (embedding.clone());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
* 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.rag;
|
||||
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
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.engine.BudgetService;
|
||||
import com.kingsrook.qbits.assistant.engine.PolicyService;
|
||||
import com.kingsrook.qbits.assistant.engine.ProviderService;
|
||||
import com.kingsrook.qbits.assistant.engine.UsageRecorder;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantDataAudit;
|
||||
import com.kingsrook.qbits.assistant.provider.EmbeddingRequest;
|
||||
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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Embedding dispatch uses fresh eligibility, kill-switch and budget checks per batch.
|
||||
*******************************************************************************/
|
||||
public class EmbeddingService
|
||||
{
|
||||
private final AssistantRuntime runtime;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bind the per-instance accounting and provider services.
|
||||
*******************************************************************************/
|
||||
public EmbeddingService(AssistantRuntime runtime)
|
||||
{
|
||||
this.runtime = runtime;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Batch at most 64 inputs. Every dispatched batch records EMBED usage,
|
||||
** including failures, and updates the existing daily budget rollups.
|
||||
*******************************************************************************/
|
||||
public List<float[]> embed(Integer modelId, List<String> texts, DataClassification classification, String tables, Integer conversationId, Integer turnId) throws QException
|
||||
{
|
||||
List<float[]> vectors = new ArrayList<>();
|
||||
for(Integer offset = 0; offset < texts.size(); offset += 64)
|
||||
{
|
||||
vectors.addAll(batch(modelId, texts.subList(offset, Math.min(offset + 64, texts.size())), classification, tables, conversationId, turnId));
|
||||
}
|
||||
return (vectors);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Audit protected dispatch refusals without any text, before transport.
|
||||
*******************************************************************************/
|
||||
public ProviderService.Active resolve(Integer modelId, DataClassification classification, String tables, Integer conversationId, Integer turnId) throws QException
|
||||
{
|
||||
try
|
||||
{
|
||||
return (runtime.getProviderService().resolveEmbedding(modelId, classification == DataClassification.PHI));
|
||||
}
|
||||
catch(ProviderService.ProviderSetupException e)
|
||||
{
|
||||
if(classification == DataClassification.PHI || Boolean.TRUE.equals(runtime.getConfig().getHipaaMode()))
|
||||
{
|
||||
var model = runtime.getStore().listModels().stream().filter(m -> Objects.equals(m.getId(), modelId)).findFirst().orElse(null);
|
||||
var provider = model == null ? null : runtime.getStore().getProvider(model.getProviderId());
|
||||
runtime.getStore().insert(new AssistantDataAudit().withUserId(AssistantSecurity.userId(QContext.getQSession())).withConversationId(conversationId).withTurnId(turnId)
|
||||
.withProviderId(provider == null ? null : provider.getId()).withProviderName(provider == null ? null : provider.getName())
|
||||
.withModel(model == null ? null : model.getModelId()).withHipaaEligible(provider != null && Boolean.TRUE.equals(provider.getHipaaEligible()))
|
||||
.withClassification(classification.name()).withTables(tables).withDecision("BLOCKED").withReason(e.getMessage()).withCreateDate(runtime.getClock().instant()));
|
||||
}
|
||||
throw (e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Never downgrade an embedding call into a different vector space.
|
||||
*******************************************************************************/
|
||||
private List<float[]> batch(Integer modelId, List<String> texts, DataClassification classification, String tables, Integer conversationId, Integer turnId) throws QException
|
||||
{
|
||||
String userId = AssistantSecurity.userId(QContext.getQSession());
|
||||
ScheduledTurn.checkCost(runtime, conversationId, turnId);
|
||||
ProviderService.Active active = resolve(modelId, classification, tables, conversationId, turnId);
|
||||
PolicyService.EffectivePolicy policy = runtime.getPolicyService().current(true);
|
||||
BudgetService.Decision budget = runtime.getBudgetService().evaluate(userId, active, classification == DataClassification.PHI, false);
|
||||
Long cap = ProviderService.capMicros(active.row());
|
||||
if(policy.killSwitch())
|
||||
{
|
||||
throw (new QException("The assistant kill switch is on; embeddings were not sent."));
|
||||
}
|
||||
if(budget.stopped() || !Objects.equals(active.model(), budget.active().model()) || !Objects.equals(active.providerId(), budget.active().providerId())
|
||||
|| runtime.getPolicyService().checkConfiguredBudget(policy, userId, 0, 0).hardExceeded()
|
||||
|| (cap != null && runtime.getProviderService().monthToDateCostMicros(active.providerId()) >= cap))
|
||||
{
|
||||
throw (new QException("The embedding budget has been reached; embeddings were not sent."));
|
||||
}
|
||||
if(classification.isAtLeast(DataClassification.PII))
|
||||
{
|
||||
runtime.getStore().insert(new AssistantDataAudit().withUserId(userId).withConversationId(conversationId).withTurnId(turnId)
|
||||
.withProviderId(active.providerId()).withProviderName(active.row().getName()).withModel(active.model())
|
||||
.withHipaaEligible(Boolean.TRUE.equals(active.row().getHipaaEligible())).withClassification(classification.name())
|
||||
.withTables(tables).withDecision("ALLOWED").withReason("Embedding provider permitted for this classification.").withCreateDate(runtime.getClock().instant()));
|
||||
}
|
||||
Semaphore gate = runtime.getProviderService().gate(active.row());
|
||||
Boolean acquired = gate == null || gate.tryAcquire();
|
||||
if(!acquired)
|
||||
{
|
||||
throw (new QException("Embedding provider concurrency limit reached."));
|
||||
}
|
||||
Long started = System.nanoTime();
|
||||
ModelResponse response = null;
|
||||
ModelProviderException error = null;
|
||||
try
|
||||
{
|
||||
EmbeddingResponse embedded = active.provider().embed(new EmbeddingRequest(active.model(), texts));
|
||||
response = new ModelResponse().withModel(active.model()).withRequestId(embedded.requestId()).withUsage(embedded.usage());
|
||||
if(embedded.vectors().size() != texts.size())
|
||||
{
|
||||
throw (new ModelProviderException(ModelProviderException.Kind.OTHER, null, "The embedding provider returned the wrong number of vectors."));
|
||||
}
|
||||
return (embedded.vectors());
|
||||
}
|
||||
catch(ModelProviderException e)
|
||||
{
|
||||
error = e;
|
||||
throw (new QException("Embedding request failed (" + e.getKind() + "). Check provider settings and retry."));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if(gate != null)
|
||||
{
|
||||
gate.release();
|
||||
}
|
||||
var pricing = runtime.getProviderService().pricing(active, active.model());
|
||||
runtime.getStore().insert(UsageRecorder.row(new UsageRecorder.Call(userId, conversationId, turnId, active, UsageRecorder.PURPOSE_EMBED),
|
||||
response, error, (System.nanoTime() - started) / 1_000_000, pricing));
|
||||
var usage = response == null ? (error == null ? null : error.getPartialUsage()) : response.getUsage();
|
||||
if(usage != null)
|
||||
{
|
||||
runtime.getStore().addUsage(userId, LocalDate.now(runtime.getClock().withZone(ZoneOffset.UTC)), usage, pricing.costMicros(usage), false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
* 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.rag;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Exact cosine search for tests and small hosts. Never stores source text.
|
||||
*******************************************************************************/
|
||||
public class InMemoryVectorStore implements VectorStore
|
||||
{
|
||||
private final Map<List<Object>, ChunkVector> vectors = new LinkedHashMap<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Upsert immutable entries.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public synchronized void upsert(List<ChunkVector> chunks)
|
||||
{
|
||||
for(ChunkVector chunk : chunks)
|
||||
{
|
||||
vectors.put(List.of(chunk.source(), chunk.table(), chunk.pk(), chunk.field(), chunk.chunkIndex()), chunk);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Remove a record within its source only.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public synchronized void deleteRecord(Integer source, String table, String pk)
|
||||
{
|
||||
vectors.values().removeIf(v -> v.source().equals(source) && v.table().equals(table) && v.pk().equals(pk));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A source can be cleared independently of other sources.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public synchronized void deleteSource(Integer source)
|
||||
{
|
||||
vectors.values().removeIf(v -> v.source().equals(source));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Search only the named tables and vectors with compatible dimensions.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public synchronized List<Hit> search(float[] query, Integer k, Set<String> tables)
|
||||
{
|
||||
return (search(query, k, tables, Set.of()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Different embedding models are different spaces even at equal dimensions.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public synchronized List<Hit> search(float[] query, Integer k, Set<String> tables, Set<Integer> sources)
|
||||
{
|
||||
List<Hit> hits = new ArrayList<>();
|
||||
for(ChunkVector vector : vectors.values())
|
||||
{
|
||||
if(tables.contains(vector.table()) && (sources.isEmpty() || sources.contains(vector.source())) && query.length == vector.embedding().length)
|
||||
{
|
||||
Double score = cosine(query, vector.embedding());
|
||||
if(Double.isFinite(score))
|
||||
{
|
||||
hits.add(new Hit(vector.source(), vector.table(), vector.pk(), vector.field(), vector.chunkIndex(), score));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (hits.stream().sorted(Comparator.comparing(Hit::score).reversed()).limit(Math.max(0, k)).toList());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Zero vectors have no direction and never match.
|
||||
*******************************************************************************/
|
||||
private static Double cosine(float[] a, float[] b)
|
||||
{
|
||||
Double dot = 0.0;
|
||||
Double left = 0.0;
|
||||
Double right = 0.0;
|
||||
for(Integer i = 0; i < a.length; i++)
|
||||
{
|
||||
dot += (double) a[i] * b[i];
|
||||
left += (double) a[i] * a[i];
|
||||
right += (double) b[i] * b[i];
|
||||
}
|
||||
return (left == 0 || right == 0 ? Double.NaN : dot / Math.sqrt(left * right));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Count by source.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public synchronized Long count(Integer source)
|
||||
{
|
||||
return (vectors.values().stream().filter(v -> v.source().equals(source)).count());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Snapshot the content-free entries for reconciliation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public synchronized List<ChunkVector> chunks(Integer source)
|
||||
{
|
||||
return (vectors.values().stream().filter(v -> v.source().equals(source)).toList());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Readers see either complete version of this record.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public synchronized void replaceRecord(Integer source, String table, String pk, List<ChunkVector> chunks)
|
||||
{
|
||||
deleteRecord(source, table, pk);
|
||||
upsert(chunks);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
/*
|
||||
* 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.rag;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
import com.kingsrook.qbits.assistant.AssistantRuntime;
|
||||
import com.kingsrook.qbits.assistant.engine.PolicyService;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantModel;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantRagSource;
|
||||
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
|
||||
import com.kingsrook.qbits.assistant.uiapi.UiApiCall;
|
||||
import com.kingsrook.qbits.assistant.uiapi.UiApiResult;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiExposure;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiToolOutcome;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.exposure.AiExposurePolicy;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.output.AiRedactor;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
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.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Manual indexing as the clicking administrator, and shared safe UI API reads.
|
||||
*******************************************************************************/
|
||||
public class RagService
|
||||
{
|
||||
private final AssistantRuntime runtime;
|
||||
private final VectorStore vectors;
|
||||
private final AiExposurePolicy exposure;
|
||||
private final UiApiToolbox reader;
|
||||
private final EmbeddingService embeddings;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Reuse get_record's helpers through its public execution path, without
|
||||
** imposing the chat cell-length cap on indexing or snippet boundaries.
|
||||
*******************************************************************************/
|
||||
public RagService(AssistantRuntime runtime, VectorStore vectors)
|
||||
{
|
||||
this.runtime = runtime;
|
||||
this.vectors = vectors;
|
||||
var settings = AssistantRuntime.kernelSettings(runtime.getConfig());
|
||||
exposure = new AiExposurePolicy(settings);
|
||||
reader = new UiApiToolbox(runtime.getQInstance(), runtime.getDataAccessService(), exposure, new AiRedactor(settings.getCustomRedactionPatterns()), Integer.MAX_VALUE);
|
||||
embeddings = new EmbeddingService(runtime);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configuration only; never query application data through AssistantStore.
|
||||
*******************************************************************************/
|
||||
public List<AssistantRagSource> sources() throws QException
|
||||
{
|
||||
return (runtime.getStore().query(AssistantRagSource.TABLE_NAME, new QQueryFilter(), AssistantRagSource.class));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Source definitions only narrow existing AI table/field exposure.
|
||||
*******************************************************************************/
|
||||
public List<String> validate(AssistantRagSource source) throws QException
|
||||
{
|
||||
List<String> errors = new ArrayList<>();
|
||||
QTableMetaData table = runtime.getQInstance().getTable(source.getTableName());
|
||||
if(table == null || source.getTableName().startsWith("assistant_") || !runtime.getDataAccessService().forTable(source.getTableName()).mode().allowsRead())
|
||||
{
|
||||
errors.add("Knowledge sources require an application table with AI data access READ or READ_WRITE, never NONE or assistant_*.");
|
||||
return (errors);
|
||||
}
|
||||
if(fields(source).isEmpty())
|
||||
{
|
||||
errors.add("Select at least one text field.");
|
||||
}
|
||||
Set<String> selected = new LinkedHashSet<>(fields(source));
|
||||
if(source.getTitleFieldName() != null && !source.getTitleFieldName().isBlank())
|
||||
{
|
||||
selected.add(source.getTitleFieldName());
|
||||
}
|
||||
for(String name : selected)
|
||||
{
|
||||
QFieldMetaData field = table.getFields().get(name);
|
||||
AiExposure level = field == null ? AiExposure.NONE : exposure.getFieldExposure(table, field);
|
||||
if(field == null || !Set.of(QFieldType.STRING, QFieldType.TEXT).contains(field.getType()) || !level.allowsValues())
|
||||
{
|
||||
errors.add("Knowledge fields must be text fields with VALUES or MASKED exposure: " + name);
|
||||
}
|
||||
}
|
||||
if(size(source) < 100 || size(source) > 16000 || overlap(source) < 0 || overlap(source) >= size(source))
|
||||
{
|
||||
errors.add("Chunk size must be 100 to 16000; overlap must be nonnegative and smaller than chunk size.");
|
||||
}
|
||||
AssistantModel model = runtime.getStore().listModels().stream().filter(m -> Objects.equals(m.getId(), source.getEmbeddingModelId())).findFirst().orElse(null);
|
||||
if(model == null || !"EMBEDDING".equals(model.getModelKind()) || !Boolean.TRUE.equals(model.getEnabled()) || model.getEmbeddingDimensions() == null || model.getEmbeddingDimensions() < 1)
|
||||
{
|
||||
errors.add("Select an enabled EMBEDDING model with positive embedding dimensions.");
|
||||
}
|
||||
try
|
||||
{
|
||||
filter(source);
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
errors.add("The query filter must be valid QQQ query filter JSON.");
|
||||
}
|
||||
return (errors);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Transient safe values from get_record, with the asking user's session.
|
||||
*******************************************************************************/
|
||||
public UiApiToolbox.Outcome read(UiApiToolbox.Context context, String table, String pk)
|
||||
{
|
||||
PolicyService.ToolPolicy policy = context.policy();
|
||||
UiApiToolbox.Context internal = new UiApiToolbox.Context(context.sessionUUID(), context.userTimezone(), context.gateway(),
|
||||
new PolicyService.ToolPolicy(true, Set.of(), policy == null ? 50 : policy.maxRowsPerCall(), policy == null || policy.redactPii()), null);
|
||||
return (reader.execute(internal, "knowledge-read", UiApiToolbox.GET_RECORD, Map.of("table", table, "id", pk)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read already masked/redacted fields from the existing record render.
|
||||
*******************************************************************************/
|
||||
public static Map<String, String> values(UiApiToolbox.Outcome outcome)
|
||||
{
|
||||
Map<String, String> values = new LinkedHashMap<>();
|
||||
if(outcome.getResult().getRender() instanceof Map<?, ?> render && render.get("fields") instanceof List<?> fields)
|
||||
{
|
||||
for(Object item : fields)
|
||||
{
|
||||
if(item instanceof Map<?, ?> field && field.get("name") instanceof String name && field.get("value") != null)
|
||||
{
|
||||
values.put(name, String.valueOf(field.get("value")));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (values);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Index result counters contain no record values or provider diagnostics.
|
||||
*******************************************************************************/
|
||||
public record Result(Integer recordsSeen, Integer embedded, Integer unchanged, Integer removed, Integer errors, String reason)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Standard process summary wording.
|
||||
*******************************************************************************/
|
||||
public String summary()
|
||||
{
|
||||
return ("Records seen: " + recordsSeen + "; chunks embedded: " + embedded + "; unchanged: " + unchanged + "; removed: " + removed + "; errors: " + errors
|
||||
+ (reason == null ? "" : ". " + reason));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Complete scans alone remove disappeared records. Failed or denied pages
|
||||
** never erase previously indexed records. Re-indexing is deliberately manual.
|
||||
*******************************************************************************/
|
||||
public synchronized Result index(AssistantRagSource source) throws QException
|
||||
{
|
||||
requireAdmin();
|
||||
Integer seen = 0;
|
||||
Integer embedded = 0;
|
||||
Integer unchanged = 0;
|
||||
Integer removed = 0;
|
||||
String reason = null;
|
||||
try
|
||||
{
|
||||
List<String> errors = validate(source);
|
||||
if(!errors.isEmpty())
|
||||
{
|
||||
throw (new QException(errors.getFirst()));
|
||||
}
|
||||
var classification = runtime.getConfig().getClassificationSource().highest(source.getTableName());
|
||||
embeddings.resolve(source.getEmbeddingModelId(), classification, source.getTableName(), null, null);
|
||||
Map<String, List<ChunkVector>> old = new LinkedHashMap<>();
|
||||
vectors.chunks(source.getId()).forEach(v -> old.computeIfAbsent(v.pk(), key -> new ArrayList<>()).add(v));
|
||||
Set<String> seenIds = new LinkedHashSet<>();
|
||||
QTableMetaData table = runtime.getQInstance().getTable(source.getTableName());
|
||||
QQueryFilter filter = filter(source);
|
||||
filter.setOrderBys(List.of(new QFilterOrderBy(table.getPrimaryKeyField(), true)));
|
||||
filter.setLimit(100);
|
||||
UiApiToolbox.Context context = context();
|
||||
for(Integer skip = 0; ; skip += 100)
|
||||
{
|
||||
checkKillSwitch();
|
||||
filter.setSkip(skip);
|
||||
UiApiResult page = runtime.getUiApiGateway().invoke(new UiApiCall("POST", "/table/{tableName}/query").withPathParam("tableName", source.getTableName())
|
||||
.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."));
|
||||
}
|
||||
if(!(page.json() instanceof Map<?, ?> json) || !(json.get("records") instanceof List<?> records))
|
||||
{
|
||||
throw (new QException("The UI API returned an invalid query page; existing chunks were preserved."));
|
||||
}
|
||||
for(Object item : records)
|
||||
{
|
||||
if(!(item instanceof Map<?, ?> record) || !(record.get("values") instanceof Map<?, ?> raw) || raw.get(table.getPrimaryKeyField()) == null)
|
||||
{
|
||||
throw (new QException("The UI API returned a record without its primary key."));
|
||||
}
|
||||
String pk = String.valueOf(raw.get(table.getPrimaryKeyField()));
|
||||
if(!seenIds.add(pk))
|
||||
{
|
||||
throw (new QException("Records moved during paging. Retry indexing; disappeared-record cleanup was skipped."));
|
||||
}
|
||||
seen++;
|
||||
checkKillSwitch();
|
||||
UiApiToolbox.Outcome read = read(context, source.getTableName(), pk);
|
||||
if(read.getResult().getOutcome() != AiToolOutcome.OK)
|
||||
{
|
||||
throw (new QException("Index record read was refused; existing chunks were preserved."));
|
||||
}
|
||||
List<ChunkVector> previous = old.getOrDefault(pk, List.of());
|
||||
List<ChunkVector> next = new ArrayList<>();
|
||||
List<String> texts = new ArrayList<>();
|
||||
List<String> names = new ArrayList<>();
|
||||
List<Integer> indexes = new ArrayList<>();
|
||||
Map<String, String> values = values(read);
|
||||
for(String field : fields(source))
|
||||
{
|
||||
List<String> chunks = TextChunks.split(values.getOrDefault(field, ""), size(source), overlap(source));
|
||||
for(Integer i = 0; i < chunks.size(); i++)
|
||||
{
|
||||
Integer index = i;
|
||||
String hash = TextChunks.hash(chunks.get(i));
|
||||
ChunkVector same = previous.stream().filter(v -> v.field().equals(field) && v.chunkIndex().equals(index) && v.contentHash().equals(hash)).findFirst().orElse(null);
|
||||
if(same != null)
|
||||
{
|
||||
next.add(same);
|
||||
unchanged++;
|
||||
}
|
||||
else
|
||||
{
|
||||
texts.add(chunks.get(i));
|
||||
names.add(field);
|
||||
indexes.add(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
List<float[]> generated = embeddings.embed(source.getEmbeddingModelId(), texts, classification, source.getTableName(), null, null);
|
||||
Integer dimensions = runtime.getStore().listModels().stream().filter(m -> m.getId().equals(source.getEmbeddingModelId())).findFirst().orElseThrow().getEmbeddingDimensions();
|
||||
for(Integer i = 0; i < generated.size(); i++)
|
||||
{
|
||||
if(generated.get(i).length != dimensions)
|
||||
{
|
||||
throw (new QException("Embedding dimensions differ from the model registry; no record replacement was made."));
|
||||
}
|
||||
next.add(new ChunkVector(source.getId(), source.getTableName(), pk, names.get(i), indexes.get(i), TextChunks.hash(texts.get(i)), generated.get(i)));
|
||||
}
|
||||
embedded += generated.size();
|
||||
removed += (int) previous.stream().filter(v -> next.stream().noneMatch(n -> n.field().equals(v.field()) && n.chunkIndex().equals(v.chunkIndex()))).count();
|
||||
if(!texts.isEmpty() || next.size() != previous.size())
|
||||
{
|
||||
vectors.replaceRecord(source.getId(), source.getTableName(), pk, next);
|
||||
}
|
||||
}
|
||||
if(records.size() < 100)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
checkKillSwitch();
|
||||
for(var entry : old.entrySet())
|
||||
{
|
||||
if(!seenIds.contains(entry.getKey()))
|
||||
{
|
||||
vectors.deleteRecord(source.getId(), source.getTableName(), entry.getKey());
|
||||
removed += entry.getValue().size();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
reason = e.getMessage();
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
reason = "Indexing failed. Check the source and vector store configuration.";
|
||||
}
|
||||
Integer chunkCount = source.getIndexedChunkCount();
|
||||
try
|
||||
{
|
||||
chunkCount = vectors.count(source.getId()).intValue();
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
reason = "The vector store is unavailable; the indexed chunk count could not be refreshed.";
|
||||
}
|
||||
Result result = new Result(seen, embedded, unchanged, removed, reason == null ? 0 : 1, reason);
|
||||
runtime.getStore().updateFields(AssistantRagSource.TABLE_NAME, new QRecord().withValue("id", source.getId())
|
||||
.withValue("lastIndexedAt", runtime.getClock().instant()).withValue("lastIndexStatus", reason == null ? "OK" : "ERROR")
|
||||
.withValue("lastIndexError", reason == null ? null : reason.substring(0, Math.min(500, reason.length())))
|
||||
.withValue("indexedRecordCount", seen).withValue("indexedChunkCount", chunkCount));
|
||||
return (result);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Clear is an admin record action, independent of the current model setup.
|
||||
*******************************************************************************/
|
||||
public synchronized void clear(AssistantRagSource source) throws QException
|
||||
{
|
||||
requireAdmin();
|
||||
vectors.deleteSource(source.getId());
|
||||
runtime.getStore().updateFields(AssistantRagSource.TABLE_NAME, new QRecord().withValue("id", source.getId()).withValue("indexedRecordCount", 0)
|
||||
.withValue("indexedChunkCount", 0).withValue("lastIndexStatus", "CLEARED").withValue("lastIndexError", null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Only a real clicking administrator may launch manual indexing.
|
||||
*******************************************************************************/
|
||||
private static void requireAdmin() throws QException
|
||||
{
|
||||
if(QContext.getQSession() == null || !QContext.getQSession().hasPermission(AssistantQBitConfig.ADMIN_PERMISSION))
|
||||
{
|
||||
throw (new QException("Knowledge indexing requires assistant.admin."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stop between UI API reads as well as before every embedding dispatch.
|
||||
*******************************************************************************/
|
||||
private void checkKillSwitch() throws QException
|
||||
{
|
||||
if(runtime.getPolicyService().current(true).killSwitch())
|
||||
{
|
||||
throw (new QException("The assistant kill switch is on."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Current caller context, never a stored or service session.
|
||||
*******************************************************************************/
|
||||
public UiApiToolbox.Context context() throws QException
|
||||
{
|
||||
return (new UiApiToolbox.Context(QContext.getQSession().getUuid(), QContext.getQSession().getValue("userTimezone"), runtime.getUiApiGateway(), runtime.getPolicyService().current(true).tools(), null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configured text field names in stable order.
|
||||
*******************************************************************************/
|
||||
public static List<String> fields(AssistantRagSource source)
|
||||
{
|
||||
return (source.getFieldNames() == null ? List.of() : java.util.Arrays.stream(source.getFieldNames().split(",")).map(String::trim).filter(s -> !s.isEmpty()).distinct().toList());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Default chunk size.
|
||||
*******************************************************************************/
|
||||
public static Integer size(AssistantRagSource source)
|
||||
{
|
||||
return (source.getChunkChars() == null ? 1500 : source.getChunkChars());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Default overlap.
|
||||
*******************************************************************************/
|
||||
public static Integer overlap(AssistantRagSource source)
|
||||
{
|
||||
return (source.getChunkOverlapChars() == null ? 200 : source.getChunkOverlapChars());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Source filter is QQQ filter JSON, passed unchanged apart from paging/order.
|
||||
*******************************************************************************/
|
||||
private static QQueryFilter filter(AssistantRagSource source) throws QException
|
||||
{
|
||||
try
|
||||
{
|
||||
QQueryFilter filter = source.getFilterJson() == null || source.getFilterJson().isBlank() ? new QQueryFilter() : JsonUtils.toObject(source.getFilterJson(), QQueryFilter.class);
|
||||
if(filter == null)
|
||||
{
|
||||
throw (new IllegalArgumentException("null filter"));
|
||||
}
|
||||
return (filter);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new QException("The query filter must be valid QQQ query filter JSON."));
|
||||
}
|
||||
}
|
||||
/*******************************************************************************
|
||||
** Remove a deleted source without attempting to update its missing row.
|
||||
*******************************************************************************/
|
||||
public synchronized void remove(AssistantRagSource source) throws QException
|
||||
{
|
||||
requireAdmin();
|
||||
vectors.deleteSource(source.getId());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.rag;
|
||||
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Deterministic overlapping chunks. Text exists only during indexing/retrieval.
|
||||
*******************************************************************************/
|
||||
public final class TextChunks
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private TextChunks()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fixed character boundaries are reproduced on the fresh UI API value.
|
||||
*******************************************************************************/
|
||||
public static List<String> split(String text, Integer size, Integer overlap)
|
||||
{
|
||||
if(size == null || overlap == null || size < 1 || overlap < 0 || overlap >= size)
|
||||
{
|
||||
throw (new IllegalArgumentException("Chunk overlap must be nonnegative and smaller than chunk size."));
|
||||
}
|
||||
List<String> chunks = new ArrayList<>();
|
||||
for(Integer start = 0; start < text.length(); start += size - overlap)
|
||||
{
|
||||
Integer end = Math.min(text.length(), start + size);
|
||||
chunks.add(text.substring(start, end));
|
||||
if(end == text.length())
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
return (chunks);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SHA-256 permits unchanged detection without retaining any text.
|
||||
*******************************************************************************/
|
||||
public static String hash(String text)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(text.getBytes(StandardCharsets.UTF_8))));
|
||||
}
|
||||
catch(NoSuchAlgorithmException e)
|
||||
{
|
||||
throw (new IllegalStateException("SHA-256 is required by the Java runtime.", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* 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.rag;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Per-instance vectors only. Every hit must be re-read through the UI API.
|
||||
*******************************************************************************/
|
||||
public interface VectorStore
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Source identity disambiguates field/chunk layouts on the same table.
|
||||
*******************************************************************************/
|
||||
record Hit(Integer source, String table, String pk, String field, Integer chunkIndex, Double score)
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Upsert by source/table/key/field/chunk, without storing content.
|
||||
*******************************************************************************/
|
||||
void upsert(List<ChunkVector> chunks) throws QException;
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete all chunks for a record within a source.
|
||||
*******************************************************************************/
|
||||
void deleteRecord(Integer source, String table, String pk) throws QException;
|
||||
|
||||
/*******************************************************************************
|
||||
** Remove a source's entire index.
|
||||
*******************************************************************************/
|
||||
void deleteSource(Integer source) throws QException;
|
||||
|
||||
/*******************************************************************************
|
||||
** Cosine similarity, highest first; an empty table set returns no hits.
|
||||
*******************************************************************************/
|
||||
List<Hit> search(float[] query, Integer k, Set<String> tables) throws QException;
|
||||
|
||||
/*******************************************************************************
|
||||
** Restrict ranking to compatible enabled sources, before applying the limit.
|
||||
*******************************************************************************/
|
||||
List<Hit> search(float[] query, Integer k, Set<String> tables, Set<Integer> sources) throws QException;
|
||||
|
||||
/*******************************************************************************
|
||||
** Count indexed chunks.
|
||||
*******************************************************************************/
|
||||
Long count(Integer source) throws QException;
|
||||
|
||||
/*******************************************************************************
|
||||
** Content-free reconciliation seam for unchanged and disappeared chunks.
|
||||
*******************************************************************************/
|
||||
List<ChunkVector> chunks(Integer source) throws QException;
|
||||
|
||||
/*******************************************************************************
|
||||
** Atomically replace one record, removing shortened or removed fields.
|
||||
*******************************************************************************/
|
||||
void replaceRecord(Integer source, String table, String pk, List<ChunkVector> chunks) throws QException;
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.scheduled;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.assistant.model.AssistantScheduledRun;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional Alerts notification SPI bridge. The audience is exactly one user;
|
||||
** notification bodies contain only status and a link, never answer text.
|
||||
*******************************************************************************/
|
||||
public class ScheduledAlerts
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(ScheduledAlerts.class);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Reflection keeps the optional notification implementation out of the ABI.
|
||||
*******************************************************************************/
|
||||
public void raise(AssistantScheduledRun run)
|
||||
{
|
||||
LOG.info("Scheduled assistant result", logPair("runId", run.getId()), logPair("status", run.getStatus()));
|
||||
try
|
||||
{
|
||||
String prefix = "com.kingsrook.qbits.alerts.spi.";
|
||||
Class<?> type = Class.forName(prefix + "NotificationSignal");
|
||||
Object signal = type.getConstructor(String.class).newInstance("Scheduled assistant task finished");
|
||||
type.getMethod("withSourceName", String.class).invoke(signal, "assistant");
|
||||
type.getMethod("withSourceKey", String.class).invoke(signal, "assistant/scheduled/run/" + run.getId());
|
||||
type.getMethod("withBody", String.class).invoke(signal, "Status: " + run.getStatus() + ". See conversation.");
|
||||
Class<?> audienceType = Class.forName(prefix + "NotificationAudience");
|
||||
Object audience = audienceType.getMethod("user", String.class).invoke(null, run.getUserId());
|
||||
type.getMethod("withAudience", audienceType).invoke(signal, audience);
|
||||
if(run.getConversationId() != null)
|
||||
{
|
||||
Class<?> linkType = Class.forName(prefix + "AlertLink");
|
||||
Object link = linkType.getMethod("toPath", String.class, String.class).invoke(null,
|
||||
"/app/assistantAdmin/assistant_conversation/" + run.getConversationId(), "See conversation");
|
||||
type.getMethod("withLink", linkType).invoke(signal, link);
|
||||
}
|
||||
Object runtime = Class.forName("com.kingsrook.qbits.alerts.AlertsRuntime").getMethod("get").invoke(null);
|
||||
Object sink = runtime.getClass().getMethod("sink").invoke(runtime);
|
||||
sink.getClass().getMethod("notify", type).invoke(sink, signal);
|
||||
}
|
||||
catch(Exception | LinkageError e)
|
||||
{
|
||||
LOG.info("Scheduled assistant notification unavailable", logPair("runId", run.getId()));
|
||||
}
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.scheduled;
|
||||
|
||||
|
||||
import java.net.HttpCookie;
|
||||
import java.net.URI;
|
||||
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.Map;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qbits.assistant.AssistantRuntime;
|
||||
import com.kingsrook.qbits.assistant.AssistantSecurity;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantScheduledTask;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleDispatcher;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Log in exactly as a UI client, then resolve the returned session UUID with
|
||||
** the host's authentication module. Never authenticate directly with a token
|
||||
** in process, create an automated session, or manufacture session permissions.
|
||||
*******************************************************************************/
|
||||
public class ScheduledLogin
|
||||
{
|
||||
/*******************************************************************************
|
||||
** No response body or transport exception can leak an access token.
|
||||
*******************************************************************************/
|
||||
public QSession login(AssistantRuntime runtime, AssistantScheduledTask task) throws QException
|
||||
{
|
||||
String token = ScheduledTasks.credential(runtime.getConfig(), task);
|
||||
String base = runtime.uiApiBaseUrl();
|
||||
if(base == null || base.isBlank())
|
||||
{
|
||||
throw (new QException("Configure uiApiBaseUrl before scheduling assistant tasks."));
|
||||
}
|
||||
String uuid;
|
||||
try
|
||||
{
|
||||
HttpRequest request = HttpRequest.newBuilder(URI.create(base.replaceAll("/+$", "") + "/qqq/v1/manageSession"))
|
||||
.timeout(Duration.ofSeconds(30)).header("Content-Type", "application/json")
|
||||
.header("X-QQQ-Client", "assistant/scheduled/" + task.getId())
|
||||
.POST(HttpRequest.BodyPublishers.ofString(new ObjectMapper().writeValueAsString(Map.of("accessToken", token)), StandardCharsets.UTF_8)).build();
|
||||
HttpResponse<String> response = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build()
|
||||
.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
if(response.statusCode() != 200)
|
||||
{
|
||||
throw (new QException("Scheduled login manageSession answered HTTP " + response.statusCode() + "."));
|
||||
}
|
||||
uuid = response.headers().allValues("Set-Cookie").stream().flatMap(value -> HttpCookie.parse(value).stream())
|
||||
.filter(cookie -> "sessionUUID".equals(cookie.getName())).map(HttpCookie::getValue).findFirst().orElse("");
|
||||
if(uuid.isBlank())
|
||||
{
|
||||
uuid = new ObjectMapper().readTree(response.body()).path("uuid").asText();
|
||||
}
|
||||
if(uuid.isBlank())
|
||||
{
|
||||
throw (new QException("Scheduled login returned no session UUID."));
|
||||
}
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
throw (e);
|
||||
}
|
||||
catch(InterruptedException e)
|
||||
{
|
||||
Thread.currentThread().interrupt();
|
||||
throw (new QException("Scheduled login interrupted."));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new QException("Scheduled login failed."));
|
||||
}
|
||||
QSession session;
|
||||
try
|
||||
{
|
||||
var instance = runtime.getQInstance();
|
||||
String key = QAuthenticationType.MOCK.equals(instance.getAuthentication().getType()) ? "sessionId" : "sessionUUID";
|
||||
session = new QAuthenticationModuleDispatcher().getQModule(instance.getAuthentication()).createSession(instance, Map.of(key, uuid));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new QException("Scheduled login session could not be resolved."));
|
||||
}
|
||||
if(!task.getRunAsUserId().equals(AssistantSecurity.userId(session)))
|
||||
{
|
||||
throw (new QException("The credential belongs to another user."));
|
||||
}
|
||||
if(!uuid.equals(session.getUuid()))
|
||||
{
|
||||
throw (new QException("The authentication module did not preserve the logged-in session UUID."));
|
||||
}
|
||||
AssistantSecurity.applySessionKeys(session, runtime.getConfig());
|
||||
return (session);
|
||||
}
|
||||
}
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.scheduled;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
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.engine.MessageParts;
|
||||
import com.kingsrook.qbits.assistant.engine.ProviderService;
|
||||
import com.kingsrook.qbits.assistant.engine.RunningTurn;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantConversation;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantScheduledRun;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantScheduledTask;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.invoke.UiApiCredentials;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Scheduler bookkeeping uses its supplied QContext. Each actual turn switches
|
||||
** to the designated user's freshly authenticated UI session, restoring context
|
||||
** afterwards. No user data is fetched by the scheduler or the store.
|
||||
*******************************************************************************/
|
||||
public class ScheduledTaskRunner
|
||||
{
|
||||
private final AssistantRuntime runtime;
|
||||
private final ScheduledLogin login;
|
||||
private final ScheduledAlerts alerts;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Default production collaborators.
|
||||
*******************************************************************************/
|
||||
public ScheduledTaskRunner(AssistantRuntime runtime)
|
||||
{
|
||||
this(runtime, new ScheduledLogin(), new ScheduledAlerts());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Notification seam for hosts and tests; authentication stays explicit.
|
||||
*******************************************************************************/
|
||||
public ScheduledTaskRunner(AssistantRuntime runtime, ScheduledLogin login, ScheduledAlerts alerts)
|
||||
{
|
||||
this.runtime = runtime;
|
||||
this.login = login;
|
||||
this.alerts = alerts;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One bounded page; each slot is advanced before any authentication/model.
|
||||
*******************************************************************************/
|
||||
public Integer runDue() throws QException
|
||||
{
|
||||
Instant now = runtime.getClock().instant();
|
||||
Integer count = 0;
|
||||
for(AssistantScheduledTask task : runtime.getStore().query(AssistantScheduledTask.TABLE_NAME, new QQueryFilter()
|
||||
.withCriteria("enabled", QCriteriaOperator.EQUALS, true).withCriteria("nextRunAt", QCriteriaOperator.LESS_THAN_OR_EQUALS, now)
|
||||
.withOrderBy(new QFilterOrderBy("nextRunAt", true)).withLimit(100), AssistantScheduledTask.class))
|
||||
{
|
||||
if(run(task, false) != null)
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return (count);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Claim, authenticate, start the normal API turn, and record its result.
|
||||
*******************************************************************************/
|
||||
public AssistantScheduledRun run(AssistantScheduledTask candidate, Boolean manual) throws QException
|
||||
{
|
||||
var store = runtime.getStore();
|
||||
var claim = store.claimScheduled(candidate, runtime.getClock().instant(), manual);
|
||||
if(claim == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
AssistantScheduledRun run = claim.run();
|
||||
AssistantScheduledTask task = claim.task();
|
||||
var original = QContext.capture();
|
||||
QSession session = null;
|
||||
Boolean attached = false;
|
||||
try
|
||||
{
|
||||
session = login.login(runtime, task);
|
||||
QContext.init(runtime.getQInstance(), 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)
|
||||
.withValue("scheduledTaskId", task.getId()).withValue("channel", "assistant/scheduled"));
|
||||
runtime.getScheduledTurn().attach(conversationId, session.getUuid(), task);
|
||||
attached = true;
|
||||
RunningTurn running = api.startTurn(conversationId, Map.of("text", task.getPrompt()));
|
||||
run.setTurnId(running.getTurnId());
|
||||
while(runtime.getRunningTurn(running.getTurnId()) != null)
|
||||
{
|
||||
Thread.sleep(20);
|
||||
}
|
||||
var turn = store.getTurn(task.getRunAsUserId(), run.getTurnId());
|
||||
if(turn == null || "RUNNING".equals(turn.getStatus()))
|
||||
{
|
||||
throw (new QException("The scheduled turn did not persist a terminal result."));
|
||||
}
|
||||
run.setCostMicros(ScheduledTurn.cost(runtime, turn.getId()));
|
||||
run.setStatus("COMPLETE".equals(turn.getStatus()) ? "OK" : blocked(turn.getErrorCode()) ? "BLOCKED" : "FAILED");
|
||||
run.setError("OK".equals(run.getStatus()) ? null : "Turn stopped: " + (turn.getErrorCode() == null ? turn.getStatus() : turn.getErrorCode()));
|
||||
String summary = MessageParts.fromJson(store.getMessage(task.getRunAsUserId(), turn.getAssistantMessageId()).getPartsJson()).text();
|
||||
Boolean pending = !store.query(AssistantPendingAction.TABLE_NAME, new QQueryFilter().withCriteria("turnId", QCriteriaOperator.EQUALS, turn.getId())
|
||||
.withCriteria("userId", QCriteriaOperator.EQUALS, task.getRunAsUserId()).withCriteria("status", QCriteriaOperator.EQUALS, "PENDING").withLimit(1), AssistantPendingAction.class).isEmpty();
|
||||
run.setSummary(Boolean.TRUE.equals(runtime.getConfig().getHipaaMode()) ? "see conversation"
|
||||
: truncate((pending ? "Waiting for your confirmation in the panel. " : "") + summary, 2000));
|
||||
}
|
||||
catch(AssistantApiException e)
|
||||
{
|
||||
run.withStatus(blocked(e.getCode()) ? "BLOCKED" : "FAILED").withError("Turn could not start: " + e.getCode());
|
||||
}
|
||||
catch(InterruptedException e)
|
||||
{
|
||||
if(run.getTurnId() != null && runtime.getRunningTurn(run.getTurnId()) != null)
|
||||
{
|
||||
runtime.getRunningTurn(run.getTurnId()).cancel("SCHEDULER_INTERRUPTED");
|
||||
}
|
||||
Thread.currentThread().interrupt();
|
||||
run.withStatus("FAILED").withError("Scheduled runner interrupted.");
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
if(e instanceof ProviderService.ProviderSetupException && blocked(e.getMessage()))
|
||||
{
|
||||
run.withStatus("BLOCKED").withError("Provider blocked by PHI/HIPAA policy.");
|
||||
}
|
||||
else
|
||||
{
|
||||
run.withStatus("FAILED").withError(run.getConversationId() == null ? truncate(e.getMessage(), 500) : "Scheduled turn failed; see conversation.");
|
||||
}
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
run.withStatus("FAILED").withError("Scheduled task failed.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if(attached && (run.getTurnId() == null || runtime.getRunningTurn(run.getTurnId()) == null))
|
||||
{
|
||||
runtime.getScheduledTurn().detach(run.getConversationId(), session.getUuid());
|
||||
}
|
||||
QContext.init(original);
|
||||
}
|
||||
run.setEndedAt(runtime.getClock().instant());
|
||||
if(Boolean.TRUE.equals(runtime.getConfig().getHipaaMode()))
|
||||
{
|
||||
run.setSummary("see conversation");
|
||||
}
|
||||
store.update(run);
|
||||
store.scheduledUpdate(new QRecord().withValue("id", task.getId()).withValue("lastRunAt", run.getStartedAt())
|
||||
.withValue("lastRunStatus", run.getStatus()).withValue("lastRunError", run.getError()));
|
||||
if(!"RECORD".equals(task.getOutputMode()))
|
||||
{
|
||||
alerts.raise(run);
|
||||
}
|
||||
return (run);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Policy denials are distinct from operational failures.
|
||||
*******************************************************************************/
|
||||
private static Boolean blocked(String code)
|
||||
{
|
||||
return (code != null && (code.contains("BUDGET") || code.contains("PHI") || code.contains("HIPAA") || code.contains("KILL_SWITCH")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bound stored summaries and sanitized errors.
|
||||
*******************************************************************************/
|
||||
private static String truncate(String value, Integer limit)
|
||||
{
|
||||
return (value == null || value.length() <= limit ? value : value.substring(0, limit));
|
||||
}
|
||||
}
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.scheduled;
|
||||
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Date;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.TimeZone;
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantScheduledTask;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import org.quartz.CronExpression;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Schedule and credential validation shared by saves, reviews and execution.
|
||||
*******************************************************************************/
|
||||
public final class ScheduledTasks
|
||||
{
|
||||
public static final List<String> APPROVAL_FIELDS = List.of("prompt", "runAsUserId", "credentialEnvName", "profileId", "writeModeRequested");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private ScheduledTasks()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Quartz computes wall-clock schedules in a validated IANA timezone.
|
||||
*******************************************************************************/
|
||||
public static Instant next(AssistantScheduledTask task, Instant after) throws QException
|
||||
{
|
||||
try
|
||||
{
|
||||
CronExpression cron = new CronExpression(task.getCronExpression());
|
||||
cron.setTimeZone(TimeZone.getTimeZone(ZoneId.of(task.getTimezone() == null ? "UTC" : task.getTimezone())));
|
||||
Date next = cron.getNextValidTimeAfter(Date.from(after));
|
||||
return (next == null ? null : next.toInstant());
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new QException("Invalid Quartz cron expression or timezone."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Refuse a pasted token or an unrelated host secret without echoing it.
|
||||
*******************************************************************************/
|
||||
public static void checkReference(AssistantQBitConfig config, String name) throws QException
|
||||
{
|
||||
if(name == null || !name.matches("[A-Z_][A-Z0-9_]*"))
|
||||
{
|
||||
throw (new QException("The scheduled credential must be an environment variable name."));
|
||||
}
|
||||
if(config.getAllowedScheduledCredentialEnvNames() == null || !config.getAllowedScheduledCredentialEnvNames().contains(name))
|
||||
{
|
||||
throw (new QException("The scheduled credential environment variable is not allowed."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve only after the reference passes the dedicated allowlist.
|
||||
*******************************************************************************/
|
||||
public static String credential(AssistantQBitConfig config, AssistantScheduledTask task) throws QException
|
||||
{
|
||||
checkReference(config, task.getCredentialEnvName());
|
||||
String token = config.getSecretResolver().apply(task.getCredentialEnvName());
|
||||
if(token == null || token.isBlank())
|
||||
{
|
||||
throw (new QException("The scheduled credential environment variable is not set."));
|
||||
}
|
||||
return (token);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Presence only; no token characters are exposed in record screens.
|
||||
*******************************************************************************/
|
||||
public static String credentialStatus(AssistantQBitConfig config, AssistantScheduledTask task)
|
||||
{
|
||||
try
|
||||
{
|
||||
credential(config, task);
|
||||
return ("Set (from " + task.getCredentialEnvName() + ")");
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
return (e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bind approval to all security-relevant settings, including null values.
|
||||
*******************************************************************************/
|
||||
public static String approvalHash(AssistantScheduledTask task) throws QException
|
||||
{
|
||||
return (hash(JsonUtils.toJson(APPROVAL_FIELDS.stream().map(name -> task.toQRecord().getValue(name)).toList())));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SHA-256 displayed on Review and checked again on Result.
|
||||
*******************************************************************************/
|
||||
public static String hash(String text) throws QException
|
||||
{
|
||||
try
|
||||
{
|
||||
return (HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(text.getBytes(StandardCharsets.UTF_8))));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new QException("Could not hash the scheduled task."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An approval for an older task definition never authorizes writes.
|
||||
*******************************************************************************/
|
||||
public static Boolean writesApproved(AssistantScheduledTask task) throws QException
|
||||
{
|
||||
return (Boolean.TRUE.equals(task.getWriteModeRequested()) && task.getWriteModeApprovedAt() != null
|
||||
&& task.getWriteModeApprovedBy() != null && Objects.equals(task.getWriteModeApprovalHash(), approvalHash(task)));
|
||||
}
|
||||
}
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.scheduled;
|
||||
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.stream.Collectors;
|
||||
import com.kingsrook.qbits.assistant.AssistantRuntime;
|
||||
import com.kingsrook.qbits.assistant.engine.BudgetService;
|
||||
import com.kingsrook.qbits.assistant.engine.PolicyService;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantScheduledTask;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantUsage;
|
||||
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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Server-owned per-conversation execution settings. They never come from a
|
||||
** panel request or prompt. Scoped to one runtime and removed after completion.
|
||||
*******************************************************************************/
|
||||
public class ScheduledTurn
|
||||
{
|
||||
private final Map<Integer, AssistantScheduledTask> tasks = new ConcurrentHashMap<>();
|
||||
private final Map<String, String> channels = new ConcurrentHashMap<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Attach a snapshot before the unchanged API starts its normal turn.
|
||||
*******************************************************************************/
|
||||
public void attach(Integer conversationId, String uuid, AssistantScheduledTask task) throws QException
|
||||
{
|
||||
if(channels.putIfAbsent(uuid, "assistant/scheduled/" + task.getId()) != null)
|
||||
{
|
||||
throw (new QException("This login session already has a scheduled task in progress."));
|
||||
}
|
||||
tasks.put(conversationId, task);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Remove references to the completed run's context.
|
||||
*******************************************************************************/
|
||||
public void detach(Integer conversationId, String uuid)
|
||||
{
|
||||
if(conversationId != null)
|
||||
{
|
||||
AssistantScheduledTask task = tasks.remove(conversationId);
|
||||
if(task != null)
|
||||
{
|
||||
channels.remove(uuid, "assistant/scheduled/" + task.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Snapshot of task settings for this conversation.
|
||||
*******************************************************************************/
|
||||
public AssistantScheduledTask task(Integer conversationId)
|
||||
{
|
||||
return (tasks.get(conversationId));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Client attribution also covers metadata and knowledge retrieval calls.
|
||||
*******************************************************************************/
|
||||
public String channel(String uuid)
|
||||
{
|
||||
return (uuid == null ? null : channels.get(uuid));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Narrow the normal policy. Scheduled writes always require confirmation.
|
||||
*******************************************************************************/
|
||||
public static PolicyService.ToolPolicy tools(AssistantRuntime runtime, AssistantScheduledTask task, PolicyService.ToolPolicy policy) throws QException
|
||||
{
|
||||
Set<String> allowed = runtime.getContextBuilder().toolNames(policy).stream()
|
||||
.filter(name -> !Set.of("create_record", "update_record", "delete_record").contains(name) || approved(task))
|
||||
.collect(Collectors.toSet());
|
||||
if(allowed.isEmpty())
|
||||
{
|
||||
allowed = Set.of("__no_tools__");
|
||||
}
|
||||
return (new PolicyService.ToolPolicy(true, allowed, policy.maxRowsPerCall(), policy.redactPii()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Approval failure closes write mode.
|
||||
*******************************************************************************/
|
||||
private static Boolean approved(AssistantScheduledTask task)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (ScheduledTasks.writesApproved(task));
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Extra STOP cap at dispatch boundaries; a completed call can cross it.
|
||||
*******************************************************************************/
|
||||
public static void checkCost(AssistantScheduledTask task, Long costMicros) throws BudgetService.Blocked
|
||||
{
|
||||
if(task != null && task.getMaxCostUsdPerRun() != null
|
||||
&& BigDecimal.valueOf(costMicros, 6).compareTo(task.getMaxCostUsdPerRun()) >= 0)
|
||||
{
|
||||
throw (new BudgetService.Blocked("The scheduled task reached its per-run cost cap."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Include chat, classifier, failed attempts and knowledge-query embeddings.
|
||||
*******************************************************************************/
|
||||
public static Long cost(AssistantRuntime runtime, Integer turnId) throws QException
|
||||
{
|
||||
return (runtime.getStore().query(AssistantUsage.TABLE_NAME,
|
||||
new QQueryFilter().withCriteria("turnId",
|
||||
QCriteriaOperator.EQUALS, turnId),
|
||||
AssistantUsage.class).stream().mapToLong(row -> row.getCostMicros() == null ? 0 : row.getCostMicros()).sum());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Check persisted accounting before each embedding and model dispatch.
|
||||
*******************************************************************************/
|
||||
public static void checkCost(AssistantRuntime runtime, Integer conversationId, Integer turnId) throws QException
|
||||
{
|
||||
AssistantScheduledTask task = conversationId == null ? null : runtime.getScheduledTurn().task(conversationId);
|
||||
if(task != null)
|
||||
{
|
||||
checkCost(task, cost(runtime, turnId));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
/*
|
||||
* 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.tools;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.assistant.AssistantRuntime;
|
||||
import com.kingsrook.qbits.assistant.engine.PolicyService;
|
||||
import com.kingsrook.qbits.assistant.engine.TurnEngine;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantRagSource;
|
||||
import com.kingsrook.qbits.assistant.provider.ModelToolDefinition;
|
||||
import com.kingsrook.qbits.assistant.rag.EmbeddingService;
|
||||
import com.kingsrook.qbits.assistant.rag.RagService;
|
||||
import com.kingsrook.qbits.assistant.rag.TextChunks;
|
||||
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;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Permission-filtered semantic retrieval. Every candidate is re-read as the caller.
|
||||
*******************************************************************************/
|
||||
public class KnowledgeTool
|
||||
{
|
||||
public static final String NAME = "search_knowledge";
|
||||
private final AssistantRuntime runtime;
|
||||
private final RagService rag;
|
||||
private final VectorStore vectors;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bind the existing services; the vector store contains no record content.
|
||||
*******************************************************************************/
|
||||
public KnowledgeTool(AssistantRuntime runtime, RagService rag, VectorStore vectors)
|
||||
{
|
||||
this.runtime = runtime;
|
||||
this.rag = rag;
|
||||
this.vectors = vectors;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A source must be enabled and readable in the UI before offering the tool.
|
||||
*******************************************************************************/
|
||||
public Boolean offered(PolicyService.ToolPolicy policy, QSession session)
|
||||
{
|
||||
if(session == null || (policy != null && !policy.allows(NAME)))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
try
|
||||
{
|
||||
Set<String> tables = new LinkedHashSet<>();
|
||||
for(AssistantRagSource source : rag.sources())
|
||||
{
|
||||
if(Boolean.TRUE.equals(source.getEnabled()) && runtime.getDataAccessService().forTable(source.getTableName()).mode().allowsRead() && rag.validate(source).isEmpty())
|
||||
{
|
||||
tables.add(source.getTableName());
|
||||
}
|
||||
}
|
||||
if(tables.isEmpty())
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
return (runtime.getAccessExplainer().explain(runtime.getUiApiGateway(), session.getUuid(), policy, null, null).stream()
|
||||
.anyMatch(t -> tables.contains(t.table()) && t.uiRead() && t.mode().allowsRead()));
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable, small schema in the same sorted tool list as other tools.
|
||||
*******************************************************************************/
|
||||
public ModelToolDefinition definition()
|
||||
{
|
||||
return (new ModelToolDefinition().withName(NAME).withDescription("Search indexed application knowledge. Cite returned records using their links.").withStrict(false)
|
||||
.withInputSchema(Map.of("type", "object", "properties", Map.of("query", Map.of("type", "string"),
|
||||
"tables", Map.of("type", "array", "items", Map.of("type", "string")), "limit", Map.of("type", "integer", "minimum", 1, "maximum", 8)), "required", List.of("query"))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Expected denials disclose only a filtered count, never hit identifiers.
|
||||
*******************************************************************************/
|
||||
public AiToolResult execute(UiApiToolbox.Context context, String callId, Map<String, Object> input, DataClassification classification, Integer conversationId, Integer turnId)
|
||||
{
|
||||
AiToolResult result = new AiToolResult().withToolName(NAME).withToolCallId(callId);
|
||||
Long started = System.nanoTime();
|
||||
try
|
||||
{
|
||||
if(context.policy() != null && !context.policy().allows(NAME))
|
||||
{
|
||||
return (result.withOutcome(AiToolOutcome.DENIED).withSummary("Not allowed").withModelContent("The policy does not allow search_knowledge."));
|
||||
}
|
||||
if(input == null || !(input.get("query") instanceof String query) || query.isBlank() || query.length() > 8000
|
||||
|| (input.containsKey("tables") && (!(input.get("tables") instanceof List<?> requested) || requested.stream().anyMatch(t -> !(t instanceof String))))
|
||||
|| (input.containsKey("limit") && (!(input.get("limit") instanceof Number n) || n.doubleValue() != n.intValue() || n.intValue() < 1)))
|
||||
{
|
||||
return (result.withOutcome(AiToolOutcome.INVALID_INPUT).withSummary("Invalid search").withModelContent("Supply a nonempty query (at most 8000 characters), optional table names and a positive integer limit."));
|
||||
}
|
||||
Integer limit = input.get("limit") instanceof Number n ? Math.min(8, n.intValue()) : 5;
|
||||
Set<?> requested = input.get("tables") instanceof List<?> list ? new LinkedHashSet<>(list) : Set.of();
|
||||
Map<Integer, AssistantRagSource> sources = new LinkedHashMap<>();
|
||||
Map<Integer, Set<String>> modelTables = new LinkedHashMap<>();
|
||||
for(AssistantRagSource source : rag.sources())
|
||||
{
|
||||
if(Boolean.TRUE.equals(source.getEnabled()) && (requested.isEmpty() || requested.contains(source.getTableName()))
|
||||
&& runtime.getDataAccessService().forTable(source.getTableName()).mode().allowsRead() && rag.validate(source).isEmpty())
|
||||
{
|
||||
sources.put(source.getId(), source);
|
||||
modelTables.computeIfAbsent(source.getEmbeddingModelId(), key -> new LinkedHashSet<>()).add(source.getTableName());
|
||||
}
|
||||
}
|
||||
List<VectorStore.Hit> hits = new ArrayList<>();
|
||||
EmbeddingService embeddings = new EmbeddingService(runtime);
|
||||
for(var model : modelTables.entrySet())
|
||||
{
|
||||
float[] vector = embeddings.embed(model.getKey(), List.of(query), classification, String.join(", ", model.getValue()), conversationId, turnId).getFirst();
|
||||
Set<Integer> compatible = new LinkedHashSet<>();
|
||||
sources.values().stream().filter(s -> model.getKey().equals(s.getEmbeddingModelId())).forEach(s -> compatible.add(s.getId()));
|
||||
for(VectorStore.Hit hit : vectors.search(vector, limit * 4, model.getValue(), compatible))
|
||||
{
|
||||
AssistantRagSource source = sources.get(hit.source());
|
||||
if(source != null && model.getKey().equals(source.getEmbeddingModelId()) && source.getTableName().equals(hit.table()) && RagService.fields(source).contains(hit.field()))
|
||||
{
|
||||
hits.add(hit);
|
||||
}
|
||||
}
|
||||
}
|
||||
hits.sort(Comparator.comparing(VectorStore.Hit::score).reversed());
|
||||
List<Map<String, Object>> records = new ArrayList<>();
|
||||
Set<List<String>> returned = new LinkedHashSet<>();
|
||||
Integer filtered = 0;
|
||||
for(VectorStore.Hit hit : hits)
|
||||
{
|
||||
if(records.size() >= limit)
|
||||
{
|
||||
break;
|
||||
}
|
||||
if(runtime.getPolicyService().current(true).killSwitch())
|
||||
{
|
||||
throw (new QException("The assistant kill switch is on."));
|
||||
}
|
||||
UiApiToolbox.Outcome read = rag.read(context, hit.table(), hit.pk());
|
||||
if(read.getResult().getOutcome() == AiToolOutcome.DENIED || read.getResult().getOutcome() == AiToolOutcome.NOT_FOUND)
|
||||
{
|
||||
filtered++;
|
||||
continue;
|
||||
}
|
||||
if(read.getResult().getOutcome() != AiToolOutcome.OK)
|
||||
{
|
||||
throw (new QException("A knowledge record could not be read through the UI API. Retry the search."));
|
||||
}
|
||||
Map<String, String> values = RagService.values(read);
|
||||
AssistantRagSource source = sources.get(hit.source());
|
||||
List<String> chunks = TextChunks.split(values.getOrDefault(hit.field(), ""), RagService.size(source), RagService.overlap(source));
|
||||
if(hit.chunkIndex() >= chunks.size() || !returned.add(List.of(hit.table(), hit.pk(), hit.field(), String.valueOf(hit.chunkIndex()))))
|
||||
{
|
||||
filtered++;
|
||||
continue;
|
||||
}
|
||||
String href = String.valueOf(((Map<?, ?>) read.getResult().getRender()).get("href"));
|
||||
if(!TurnEngine.isAppHref(href))
|
||||
{
|
||||
filtered++;
|
||||
continue;
|
||||
}
|
||||
String title = values.getOrDefault(source.getTitleFieldName(), hit.pk());
|
||||
title = title.substring(0, Math.min(300, title.length()));
|
||||
String snippet = chunks.get(hit.chunkIndex());
|
||||
snippet = snippet.substring(0, Math.min(1500, snippet.length()));
|
||||
records.add(Map.of("table", hit.table(), "recordId", hit.pk(), "title", title, "snippet", snippet, "score", hit.score(), "href", href));
|
||||
result.getRecordRefs().add(new AiRecordRef().withTableName(hit.table()).withId(hit.pk()).withLabel(title).withHref(href));
|
||||
result.getRedactions().addAll(read.getResult().getRedactions());
|
||||
}
|
||||
Map<String, Object> content = Map.of("results", records, "filtered", filtered);
|
||||
return (result.withOutcome(AiToolOutcome.OK).withRowCount(records.size()).withSummary("Found " + records.size() + " knowledge results; filtered " + filtered)
|
||||
.withPermissionCheck("uiApi:GET /table/{tableName}/{primaryKey}").withModelContent(AiDataEnvelope.wrap(NAME, null, JsonUtils.toJson(content))));
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
return (result.withOutcome(AiToolOutcome.ERROR).withSummary("Knowledge search failed").withModelContent(e.getMessage()));
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
return (result.withOutcome(AiToolOutcome.ERROR).withSummary("Knowledge search failed").withModelContent("Knowledge search could not complete. Check the source and embedding configuration."));
|
||||
}
|
||||
finally
|
||||
{
|
||||
result.withDurationMs((System.nanoTime() - started) / 1_000_000);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -203,6 +235,21 @@ public class UiApiToolbox
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An outcome built by another tool family (e.g. the bulk-load tools), so
|
||||
** TurnEngine and the confirm route log it the same way.
|
||||
*******************************************************************************/
|
||||
public static Outcome of(AiToolResult result, String operation, Integer httpStatus, Integer pendingActionId)
|
||||
{
|
||||
Outcome outcome = new Outcome(result);
|
||||
outcome.operation = operation;
|
||||
outcome.httpStatus = httpStatus;
|
||||
outcome.pendingActionId = pendingActionId;
|
||||
return (outcome);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for result
|
||||
*******************************************************************************/
|
||||
@@ -313,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).
|
||||
@@ -643,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;
|
||||
}
|
||||
|
||||
@@ -1240,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();
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* 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.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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** How bulk_load_run starts a load (design spec #1171 section 3.4): one seam,
|
||||
** so the run can move from today's path to durable jobs without the tool
|
||||
** changing. Every implementation goes through the /qqq/v1 UI API as the user.
|
||||
*******************************************************************************/
|
||||
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, 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What started: status uses the durable job status names (jobs_job.status:
|
||||
** AWAITING_CONFIRMATION, QUEUED, RUNNING, COMPLETED,
|
||||
** COMPLETED_WITH_ERRORS, FAILED, ...); jobId when it runs as a job,
|
||||
** processUUID when it ran synchronously; a short summary; the HTTP status.
|
||||
*******************************************************************************/
|
||||
record RunHandle(String status, Long jobId, String processUUID, String summary, String operation, Integer httpStatus)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Start the load. Throws when it cannot start at all (the handle's status
|
||||
** reports a load that started and failed).
|
||||
*******************************************************************************/
|
||||
RunHandle start(Request request) throws QException;
|
||||
}
|
||||
+883
@@ -0,0 +1,883 @@
|
||||
/*
|
||||
* 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.tools.bulkload;
|
||||
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.assistant.access.DataAccessService;
|
||||
import com.kingsrook.qbits.assistant.engine.PolicyService;
|
||||
import com.kingsrook.qbits.assistant.provider.ModelToolDefinition;
|
||||
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
|
||||
import com.kingsrook.qbits.assistant.uiapi.UiApiCall;
|
||||
import com.kingsrook.qbits.assistant.uiapi.UiApiResult;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiToolOutcome;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiToolResult;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.output.AiDataEnvelope;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.tools.AiJsonSchema;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.tools.AiToolException;
|
||||
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.backend.core.utils.StringUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The assistant's bulk-load tools (design spec #1171 section 3.4): use and
|
||||
** create saved bulk load profiles from a file the user attached in the chat.
|
||||
** Every call goes through the /qqq/v1 UI API with the user's own session, so
|
||||
** table and process permissions, record locks and the PHI rule are enforced
|
||||
** by the server, never by the model:
|
||||
** - bulk_profiles_list / bulk_profile_describe: the user's (and shared)
|
||||
** saved profiles, through the savedBulkLoadProfile table (record locks);
|
||||
** - bulk_file_attach: an upload the panel already made (by its ref), with
|
||||
** its column profiles (samples only where the server allows);
|
||||
** - bulk_mapping_suggest: core's suggester, then the model refines it;
|
||||
** - bulk_load_dry_run: the table's real bulk-insert validation, no store;
|
||||
** - bulk_profile_save (write): storeSavedBulkLoadProfile, and shares through
|
||||
** insertSharedRecord, after the user confirms;
|
||||
** - bulk_load_run (write): through {@link BulkLoadRunner}, after the user
|
||||
** confirms (and, as a durable job, again on its job record).
|
||||
**
|
||||
** The AI route header (X-QQQ-AI-Route: hipaa|standard) is set here from the
|
||||
** turn's provider, never from the model's input. Tool-call logs keep
|
||||
** profiles and counts, never sample values (see {@link #forLog}).
|
||||
**
|
||||
** STAND-IN(core=#1179, removeBy=RC.8): the profile, suggestMapping and
|
||||
** validate operations ship in a QQQ RC after 4.1.0-RC.5; until then the
|
||||
** tools report that the server does not offer them.
|
||||
*******************************************************************************/
|
||||
public class BulkLoadTools
|
||||
{
|
||||
public static final String LIST = "bulk_profiles_list";
|
||||
public static final String DESCRIBE = "bulk_profile_describe";
|
||||
public static final String ATTACH = "bulk_file_attach";
|
||||
public static final String SUGGEST = "bulk_mapping_suggest";
|
||||
public static final String DRY_RUN = "bulk_load_dry_run";
|
||||
public static final String SAVE = "bulk_profile_save";
|
||||
public static final String RUN = "bulk_load_run";
|
||||
|
||||
public static final Set<String> NAMES = Set.of(LIST, DESCRIBE, ATTACH, SUGGEST, DRY_RUN, SAVE, RUN);
|
||||
public static final Set<String> WRITE_TOOLS = Set.of(SAVE, RUN);
|
||||
|
||||
public static final String PROFILE_TABLE = "savedBulkLoadProfile";
|
||||
public static final String ROUTE_HEADER = "X-QQQ-AI-Route";
|
||||
|
||||
private static final Set<String> LOG_STRIPPED_KEYS = Set.of("sampleValues", "bulletsOfText", "valueMappings");
|
||||
|
||||
private final QInstance qInstance;
|
||||
private final DataAccessService dataAccess;
|
||||
private final BulkLoadRunner runner;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
public BulkLoadTools(QInstance qInstance, DataAccessService dataAccess, BulkLoadRunner runner)
|
||||
{
|
||||
this.qInstance = qInstance;
|
||||
this.dataAccess = dataAccess;
|
||||
this.runner = runner == null ? new JobsLaunchBulkLoadRunner() : runner;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Offered when the instance has saved bulk load profiles.
|
||||
*******************************************************************************/
|
||||
public boolean offered()
|
||||
{
|
||||
return (qInstance != null && qInstance.getTable(PROFILE_TABLE) != null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The definitions the policy allows (writes only when anyWritable).
|
||||
*******************************************************************************/
|
||||
public List<ModelToolDefinition> definitions(PolicyService.ToolPolicy policy, boolean anyWritable)
|
||||
{
|
||||
List<ModelToolDefinition> definitions = new ArrayList<>();
|
||||
if(!offered())
|
||||
{
|
||||
return (definitions);
|
||||
}
|
||||
|
||||
Map<String, Object> table = AiJsonSchema.string("The table to load into (from list_tables).");
|
||||
Map<String, Object> upload = AiJsonSchema.string("The upload ref of the file the user attached (from the conversation's attachments).");
|
||||
Map<String, Object> profile = new LinkedHashMap<>(Map.of("type", "object", "description", """
|
||||
A v1 bulk load profile: {"version":"v1","layout":"FLAT","hasHeaderRow":true,"fieldList":[{"fieldName":"...","columnIndex":0, \
|
||||
"headerName":"...","defaultValue":null,"doValueMapping":false,"valueMappings":{"file value":"field value"}}]}."""));
|
||||
|
||||
add(definitions, policy, LIST, "List the saved bulk load profiles (the user's own and those shared with them) for a table.",
|
||||
AiJsonSchema.object(AiJsonSchema.properties("tableName", table), List.of("tableName")));
|
||||
add(definitions, policy, DESCRIBE, "Read one saved bulk load profile: its table, owner and column mapping.",
|
||||
AiJsonSchema.object(AiJsonSchema.properties("id", AiJsonSchema.integer("The profile id (from bulk_profiles_list).")), List.of("id")));
|
||||
add(definitions, policy, ATTACH, """
|
||||
Look at a file the user attached for a bulk load: whether row 1 is a header, and each column's type, blanks, distinct count, \
|
||||
pattern and (only where the server allows) a few sample values.""",
|
||||
AiJsonSchema.object(AiJsonSchema.properties("uploadRef", upload, "tableName", table), List.of("uploadRef", "tableName")));
|
||||
add(definitions, policy, SUGGEST, """
|
||||
Suggest a bulk load profile for an attached file: the server's header matching, with the columns it left unmapped, required \
|
||||
fields it did not fill, and fields whose values need mapping. Refine the profile yourself from the headers and column \
|
||||
profiles, then check it with bulk_load_dry_run.""",
|
||||
AiJsonSchema.object(AiJsonSchema.properties("uploadRef", upload, "tableName", table), List.of("uploadRef", "tableName")));
|
||||
add(definitions, policy, DRY_RUN, """
|
||||
Dry-run a bulk load profile over an attached file through the table's real bulk insert validation. Nothing is stored. \
|
||||
Returns counts, summary lines and error groups with row numbers. SAMPLE checks the first 100 rows; FULL the whole file. \
|
||||
Fix the profile and run it again until there are no errors (or explain the rest to the user).""",
|
||||
AiJsonSchema.object(AiJsonSchema.properties("uploadRef", upload, "tableName", table, "profile", profile,
|
||||
"mode", AiJsonSchema.enumOf(List.of("SAMPLE", "FULL"), "SAMPLE (default) or FULL.")), List.of("uploadRef", "tableName", "profile")));
|
||||
|
||||
if(anyWritable)
|
||||
{
|
||||
Map<String, Object> share = AiJsonSchema.object(AiJsonSchema.properties(
|
||||
"audienceType", AiJsonSchema.enumOf(List.of("user", "group"), "Share with a user (email) or a group."),
|
||||
"audienceId", AiJsonSchema.string("The user's email or the group's name."),
|
||||
"scope", AiJsonSchema.enumOf(List.of("READ_ONLY", "READ_WRITE"), "The share's scope (default READ_ONLY).")), List.of("audienceType", "audienceId"));
|
||||
add(definitions, policy, SAVE, """
|
||||
Save a bulk load profile (after a clean dry run) under a name, optionally sharing it. The user confirms before it is saved; \
|
||||
it is saved as the user, and shares are limited to what the user may share.""",
|
||||
AiJsonSchema.object(AiJsonSchema.properties("tableName", table, "label", AiJsonSchema.string("The profile's name."), "profile", profile,
|
||||
"shares", AiJsonSchema.array(share, "Optional shares.")), List.of("tableName", "label", "profile")));
|
||||
add(definitions, policy, RUN, """
|
||||
Load an attached file into a table with a saved profile. The user confirms first; a large load runs as a background job that \
|
||||
the user starts from its job record.""",
|
||||
AiJsonSchema.object(AiJsonSchema.properties("tableName", table, "uploadRef", upload, "profileId", AiJsonSchema.integer("The saved profile id.")),
|
||||
List.of("tableName", "uploadRef", "profileId")));
|
||||
}
|
||||
return (definitions);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void add(List<ModelToolDefinition> definitions, PolicyService.ToolPolicy policy, String name, String description, Map<String, Object> schema)
|
||||
{
|
||||
if(policy == null || policy.allows(name))
|
||||
{
|
||||
definitions.add(new ModelToolDefinition().withName(name).withDescription(description).withInputSchema(schema).withStrict(false));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run a tool. hipaaRoute is the turn's provider's HIPAA eligibility (sent
|
||||
** as X-QQQ-AI-Route; the model cannot set it).
|
||||
*******************************************************************************/
|
||||
public UiApiToolbox.Outcome execute(UiApiToolbox.Context context, String toolCallId, String toolName, Map<String, Object> input, boolean hipaaRoute)
|
||||
{
|
||||
AiToolResult result = new AiToolResult().withToolCallId(toolCallId).withToolName(toolName);
|
||||
Map<String, Object> in = input == null ? Map.of() : input;
|
||||
Call call = new Call(context, hipaaRoute);
|
||||
Integer actionId = null;
|
||||
long started = System.nanoTime();
|
||||
try
|
||||
{
|
||||
if(context.policy() != null && !context.policy().allows(toolName))
|
||||
{
|
||||
deny(result, "policy:tool", "The administrator has not allowed the " + toolName + " tool.");
|
||||
return (UiApiToolbox.Outcome.of(result, null, null, null));
|
||||
}
|
||||
|
||||
String tableName = in.get("tableName") instanceof String t ? t.strip() : null;
|
||||
if(tableName != null)
|
||||
{
|
||||
result.setTableName(tableName);
|
||||
if(!dataAccess.forTable(tableName).mode().allowsRead())
|
||||
{
|
||||
deny(result, "dataAccess:" + dataAccess.forTable(tableName).mode(), "The assistant may not use the " + tableName + " table.");
|
||||
return (UiApiToolbox.Outcome.of(result, null, null, null));
|
||||
}
|
||||
}
|
||||
|
||||
switch(toolName)
|
||||
{
|
||||
case LIST -> list(call, result, required(in, "tableName"));
|
||||
case DESCRIBE -> describe(call, result, requiredInteger(in, "id"));
|
||||
case ATTACH -> attach(call, result, required(in, "tableName"), required(in, "uploadRef"));
|
||||
case SUGGEST -> suggest(call, result, required(in, "tableName"), required(in, "uploadRef"));
|
||||
case DRY_RUN -> dryRun(call, result, required(in, "tableName"), required(in, "uploadRef"), requiredMap(in, "profile"), in.get("mode") instanceof String m ? m : "SAMPLE");
|
||||
case SAVE, RUN -> actionId = propose(context, result, toolName, required(in, "tableName"), in);
|
||||
default -> throw (AiToolException.invalidInput("Unknown tool \"" + toolName + "\"."));
|
||||
}
|
||||
}
|
||||
catch(AiToolException e)
|
||||
{
|
||||
result.withOutcome(e.getOutcome() == null ? AiToolOutcome.INVALID_INPUT : e.getOutcome()).withSummary(shortText(e.getMessage())).withModelContent(e.getMessage());
|
||||
}
|
||||
catch(QException | RuntimeException e)
|
||||
{
|
||||
result.withOutcome(AiToolOutcome.ERROR).withSummary("Failed").withModelContent("The tool failed: " + shortText(e.getMessage()));
|
||||
}
|
||||
finally
|
||||
{
|
||||
result.setDurationMs((System.nanoTime() - started) / 1_000_000);
|
||||
}
|
||||
return (UiApiToolbox.Outcome.of(result, call.operation, call.httpStatus, actionId));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run a confirmed write (from a pending action) with the confirming user's
|
||||
** session.
|
||||
*******************************************************************************/
|
||||
public UiApiToolbox.Outcome executeConfirmed(UiApiToolbox.Context context, String toolCallId, String toolName, String tableName, Map<String, Object> values)
|
||||
{
|
||||
AiToolResult result = new AiToolResult().withToolCallId(toolCallId).withToolName(toolName).withTableName(tableName);
|
||||
Call call = new Call(context, false);
|
||||
long started = System.nanoTime();
|
||||
try
|
||||
{
|
||||
if(!WRITE_TOOLS.contains(toolName))
|
||||
{
|
||||
throw (AiToolException.invalidInput("Not a write tool: " + toolName));
|
||||
}
|
||||
DataAccessService.Decision decision = dataAccess.forTable(SAVE.equals(toolName) ? PROFILE_TABLE : tableName);
|
||||
if(!decision.mode().allowsWrite())
|
||||
{
|
||||
deny(result, "dataAccess:" + decision.mode(), "The assistant may not write here (data access " + decision.mode() + ", " + decision.source() + ").");
|
||||
return (UiApiToolbox.Outcome.of(result, null, null, null));
|
||||
}
|
||||
if(SAVE.equals(toolName))
|
||||
{
|
||||
save(call, result, tableName, values);
|
||||
}
|
||||
else
|
||||
{
|
||||
run(call, result, context, tableName, values);
|
||||
}
|
||||
}
|
||||
catch(AiToolException e)
|
||||
{
|
||||
result.withOutcome(e.getOutcome() == null ? AiToolOutcome.INVALID_INPUT : e.getOutcome()).withSummary(shortText(e.getMessage())).withModelContent(e.getMessage());
|
||||
}
|
||||
catch(QException | RuntimeException e)
|
||||
{
|
||||
result.withOutcome(AiToolOutcome.ERROR).withSummary("Failed").withModelContent("The write failed: " + shortText(e.getMessage()));
|
||||
}
|
||||
finally
|
||||
{
|
||||
result.setDurationMs((System.nanoTime() - started) / 1_000_000);
|
||||
}
|
||||
return (UiApiToolbox.Outcome.of(result, call.operation, call.httpStatus, null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// read tools //
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** POST /table/savedBulkLoadProfile/query: the profiles the record locks let
|
||||
** the user see, for one table.
|
||||
*******************************************************************************/
|
||||
private void list(Call call, AiToolResult result, String tableName) throws QException
|
||||
{
|
||||
Map<String, Object> filter = Map.of(
|
||||
"criteria", List.of(Map.of("fieldName", "tableName", "operator", "EQUALS", "values", List.of(tableName))),
|
||||
"orderBys", List.of(Map.of("fieldName", "label", "isAscending", true)));
|
||||
UiApiResult response = call.invoke(new UiApiCall("POST", "/table/{tableName}/query").withPathParam("tableName", PROFILE_TABLE)
|
||||
.withJsonBody(Map.of("filter", filter)));
|
||||
if(response == null || !response.isSuccess())
|
||||
{
|
||||
fail(result, call, response);
|
||||
return;
|
||||
}
|
||||
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(Object record : list(response.json().get("records")))
|
||||
{
|
||||
Map<String, Object> values = map(map(record).get("values"));
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("id", values.get("id"));
|
||||
row.put("label", values.get("label"));
|
||||
row.put("owner", values.get("userId"));
|
||||
row.put("fieldCount", fieldList(values.get("mappingJson")).size());
|
||||
rows.add(row);
|
||||
}
|
||||
result.withOutcome(AiToolOutcome.OK).withRowCount(rows.size()).withSummary("Found " + rows.size() + " saved profile" + (rows.size() == 1 ? "" : "s"))
|
||||
.withPermissionCheck(call.permissionCheck())
|
||||
.withModelContent(AiDataEnvelope.wrap(LIST, PROFILE_TABLE, JsonUtils.toJson(Map.of("tableName", tableName, "profiles", rows))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** GET /table/savedBulkLoadProfile/{id}: one profile (when the locks allow).
|
||||
*******************************************************************************/
|
||||
private void describe(Call call, AiToolResult result, Integer id) throws QException
|
||||
{
|
||||
UiApiResult response = call.invoke(new UiApiCall("GET", "/table/{tableName}/{primaryKey}").withPathParam("tableName", PROFILE_TABLE).withPathParam("primaryKey", id));
|
||||
if(response == null || !response.isSuccess())
|
||||
{
|
||||
fail(result, call, response);
|
||||
return;
|
||||
}
|
||||
Map<String, Object> values = map(map(response.json().get("record")).get("values"));
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("id", values.get("id"));
|
||||
out.put("label", values.get("label"));
|
||||
out.put("tableName", values.get("tableName"));
|
||||
out.put("owner", values.get("userId"));
|
||||
out.put("profile", values.get("mappingJson") == null ? null : new JSONObject(String.valueOf(values.get("mappingJson"))).toMap());
|
||||
result.setTableName(String.valueOf(values.get("tableName")));
|
||||
result.withOutcome(AiToolOutcome.OK).withRowCount(1).withSummary("Read profile " + values.get("label"))
|
||||
.withPermissionCheck(call.permissionCheck())
|
||||
.withModelContent(AiDataEnvelope.wrap(DESCRIBE, PROFILE_TABLE, JsonUtils.toJson(out)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** GET /bulkLoad/uploads/{uploadRef}/profile.
|
||||
*******************************************************************************/
|
||||
private void attach(Call call, AiToolResult result, String tableName, String uploadRef) throws QException
|
||||
{
|
||||
UiApiResult response = call.invoke(new UiApiCall("GET", "/bulkLoad/uploads/{uploadRef}/profile").withPathParam("uploadRef", uploadRef)
|
||||
.withQueryParam("tableName", tableName).withQueryParam("sampleCount", 5));
|
||||
if(response == null || !response.isSuccess())
|
||||
{
|
||||
fail(result, call, response);
|
||||
return;
|
||||
}
|
||||
Map<String, Object> json = response.json();
|
||||
int columns = list(json.get("columns")).size();
|
||||
result.withOutcome(AiToolOutcome.OK).withRowCount(columns).withSummary("Read " + json.get("fileName") + ": " + columns + " columns")
|
||||
.withPermissionCheck(call.permissionCheck())
|
||||
.withModelContent(AiDataEnvelope.wrap(ATTACH, tableName, response.body()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** POST /bulkLoad/{tableName}/suggestMapping.
|
||||
*******************************************************************************/
|
||||
private void suggest(Call call, AiToolResult result, String tableName, String uploadRef) throws QException
|
||||
{
|
||||
UiApiResult response = call.invoke(new UiApiCall("POST", "/bulkLoad/{tableName}/suggestMapping").withPathParam("tableName", tableName)
|
||||
.withJsonBody(Map.of("uploadRef", uploadRef)));
|
||||
if(response == null || !response.isSuccess())
|
||||
{
|
||||
fail(result, call, response);
|
||||
return;
|
||||
}
|
||||
Map<String, Object> json = response.json();
|
||||
int mapped = list(map(json.get("profile")).get("fieldList")).size();
|
||||
result.withOutcome(AiToolOutcome.OK).withRowCount(mapped).withSummary("Suggested a mapping of " + mapped + " field" + (mapped == 1 ? "" : "s"))
|
||||
.withPermissionCheck(call.permissionCheck())
|
||||
.withModelContent(AiDataEnvelope.wrap(SUGGEST, tableName, response.body()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** POST /bulkLoad/{tableName}/validate (nothing is stored).
|
||||
*******************************************************************************/
|
||||
private void dryRun(Call call, AiToolResult result, String tableName, String uploadRef, Map<String, Object> profile, String mode) throws QException
|
||||
{
|
||||
UiApiResult response = call.invoke(new UiApiCall("POST", "/bulkLoad/{tableName}/validate").withPathParam("tableName", tableName)
|
||||
.withJsonBody(Map.of("uploadRef", uploadRef, "profile", profile, "mode", "FULL".equalsIgnoreCase(mode) ? "FULL" : "SAMPLE"))
|
||||
.withTimeout(Duration.ofSeconds(120)));
|
||||
if(response == null || !response.isSuccess())
|
||||
{
|
||||
fail(result, call, response);
|
||||
return;
|
||||
}
|
||||
Map<String, Object> json = response.json();
|
||||
Integer ok = ValueUtils.getValueAsInteger(json.get("okCount"));
|
||||
Integer errors = ValueUtils.getValueAsInteger(json.get("errorCount"));
|
||||
result.withOutcome(AiToolOutcome.OK).withRowCount(ok)
|
||||
.withSummary("Dry run (" + json.get("mode") + "): " + ok + " ok, " + errors + " with errors")
|
||||
.withPermissionCheck(call.permissionCheck())
|
||||
.withRender(render("bulk_load_dry_run", tableName, json))
|
||||
.withModelContent(AiDataEnvelope.wrap(DRY_RUN, tableName, response.body()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// write tools //
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Propose a save or a run as a pending action (the user confirms in the
|
||||
** panel; nothing happens until then).
|
||||
*******************************************************************************/
|
||||
private Integer propose(UiApiToolbox.Context context, AiToolResult result, String toolName, String tableName, Map<String, Object> in) throws QException
|
||||
{
|
||||
DataAccessService.Decision decision = dataAccess.forTable(SAVE.equals(toolName) ? PROFILE_TABLE : tableName);
|
||||
if(!decision.mode().allowsWrite())
|
||||
{
|
||||
deny(result, "dataAccess:" + decision.mode(), "The assistant may only read here (data access " + decision.mode() + ", " + decision.source() + "). Writes need READ_WRITE.");
|
||||
return (null);
|
||||
}
|
||||
|
||||
Map<String, Object> values = new LinkedHashMap<>();
|
||||
String summary;
|
||||
if(SAVE.equals(toolName))
|
||||
{
|
||||
String label = required(in, "label");
|
||||
values.put("label", label);
|
||||
values.put("profile", requiredMap(in, "profile"));
|
||||
if(in.get("shares") instanceof List<?> shares && !shares.isEmpty())
|
||||
{
|
||||
values.put("shares", shares);
|
||||
}
|
||||
summary = "Save the bulk load profile \"" + label + "\" for " + tableName + (values.containsKey("shares") ? " and share it" : "");
|
||||
}
|
||||
else
|
||||
{
|
||||
values.put("uploadRef", required(in, "uploadRef"));
|
||||
values.put("profileId", requiredInteger(in, "profileId"));
|
||||
summary = "Load the attached file into " + tableName + " with saved profile " + values.get("profileId");
|
||||
}
|
||||
|
||||
if(context.pendingActions() == null)
|
||||
{
|
||||
throw (new QException("The " + toolName + " action could not be proposed."));
|
||||
}
|
||||
String operation = SAVE.equals(toolName) ? "POST /processes/{processName}/init" : "POST " + JobsLaunchBulkLoadRunner.LAUNCH_OPERATION_PATH;
|
||||
Integer actionId = context.pendingActions().propose(new UiApiToolbox.ProposedWrite(result.getToolCallId(), toolName, operation, tableName, null, values, summary));
|
||||
if(actionId == null)
|
||||
{
|
||||
throw (new QException("The " + toolName + " action could not be proposed."));
|
||||
}
|
||||
|
||||
Map<String, Object> render = new LinkedHashMap<>();
|
||||
render.put("type", "pending_action");
|
||||
render.put("actionId", actionId);
|
||||
render.put("toolName", toolName);
|
||||
render.put("tableName", tableName);
|
||||
render.put("summary", summary);
|
||||
result.withOutcome(AiToolOutcome.OK).withSummary("Waiting for your confirmation").withRender(render)
|
||||
.withModelContent("Proposed (pending action " + actionId + "): " + summary + ". Nothing happens until the user confirms it in the panel.");
|
||||
return (actionId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Confirmed save: storeSavedBulkLoadProfile, then insertSharedRecord per
|
||||
** share (core lets only the owner share).
|
||||
*******************************************************************************/
|
||||
private void save(Call call, AiToolResult result, String tableName, Map<String, Object> values) throws QException
|
||||
{
|
||||
Map<String, Object> processValues = new LinkedHashMap<>();
|
||||
processValues.put("tableName", tableName);
|
||||
processValues.put("label", values.get("label"));
|
||||
processValues.put("mappingJson", JsonUtils.toJson(values.get("profile")));
|
||||
processValues.put("isBulkEdit", false);
|
||||
UiApiResult response = call.invoke(new UiApiCall("POST", "/processes/{processName}/init").withPathParam("processName", "storeSavedBulkLoadProfile")
|
||||
.withFormParam("values", JsonUtils.toJson(processValues)));
|
||||
String error = processError(response);
|
||||
if(error != null)
|
||||
{
|
||||
result.withOutcome(response == null || response.isSuccess() ? AiToolOutcome.ERROR : outcomeFor(response.status())).withSummary(shortText(error)).withPermissionCheck(call.permissionCheck())
|
||||
.withModelContent("The profile was not saved: " + error);
|
||||
return;
|
||||
}
|
||||
Integer id = ValueUtils.getValueAsInteger(map(map(list(map(response.json().get("values")).get("savedBulkLoadProfileList")).stream().findFirst().orElse(null)).get("values")).get("id"));
|
||||
|
||||
List<String> shared = new ArrayList<>();
|
||||
List<String> failed = new ArrayList<>();
|
||||
for(Object share : list(values.get("shares")))
|
||||
{
|
||||
Map<String, Object> s = map(share);
|
||||
Map<String, Object> shareValues = new LinkedHashMap<>();
|
||||
shareValues.put("tableName", PROFILE_TABLE);
|
||||
shareValues.put("recordId", id);
|
||||
shareValues.put("audienceType", s.get("audienceType"));
|
||||
shareValues.put("audienceId", s.get("audienceId"));
|
||||
shareValues.put("scopeId", s.get("scope") == null ? "READ_ONLY" : s.get("scope"));
|
||||
UiApiResult shareResponse = call.invoke(new UiApiCall("POST", "/processes/{processName}/init").withPathParam("processName", "insertSharedRecord")
|
||||
.withFormParam("values", JsonUtils.toJson(shareValues)));
|
||||
String shareError = processError(shareResponse);
|
||||
(shareError == null ? shared : failed).add(s.get("audienceType") + " " + s.get("audienceId") + (shareError == null ? "" : " (" + shortText(shareError) + ")"));
|
||||
}
|
||||
|
||||
String summary = "Saved profile \"" + values.get("label") + "\" (id " + id + ")" + (shared.isEmpty() ? "" : ", shared with " + String.join(", ", shared))
|
||||
+ (failed.isEmpty() ? "" : "; not shared with " + String.join(", ", failed));
|
||||
result.withOutcome(AiToolOutcome.OK).withRowCount(1).withSummary(shortText(summary)).withPermissionCheck(call.permissionCheck())
|
||||
.withModelContent(JsonUtils.toJson(Map.of("id", id == null ? "" : id, "label", values.get("label"), "shared", shared, "notShared", failed)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Confirmed run: through the runner seam.
|
||||
*******************************************************************************/
|
||||
private void run(Call call, AiToolResult result, UiApiToolbox.Context context, String tableName, Map<String, Object> 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")), context.credentials(), context.turnId()));
|
||||
call.operation = handle.operation();
|
||||
call.httpStatus = handle.httpStatus();
|
||||
boolean ok = !"FAILED".equals(handle.status()) && !"NOT_AVAILABLE".equals(handle.status());
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("status", handle.status());
|
||||
out.put("jobId", handle.jobId());
|
||||
out.put("processUUID", handle.processUUID());
|
||||
out.put("summary", handle.summary());
|
||||
result.withOutcome(ok ? AiToolOutcome.OK : AiToolOutcome.ERROR).withSummary(shortText(handle.summary())).withPermissionCheck(call.permissionCheck())
|
||||
.withModelContent(JsonUtils.toJson(out));
|
||||
}
|
||||
|
||||
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// helpers //
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A tool's input or result as kept in the tool-call log: profiles and
|
||||
** counts, never sample values, example bullets or value mappings.
|
||||
*******************************************************************************/
|
||||
public static String forLog(String json)
|
||||
{
|
||||
if(json == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
try
|
||||
{
|
||||
int start = json.indexOf('{');
|
||||
int end = json.lastIndexOf('}');
|
||||
if(start < 0 || end < start)
|
||||
{
|
||||
return (json);
|
||||
}
|
||||
JSONObject object = new JSONObject(json.substring(start, end + 1));
|
||||
strip(object);
|
||||
return (object.toString());
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
return ("{\"note\":\"not logged\"}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void strip(Object node)
|
||||
{
|
||||
if(node instanceof JSONObject object)
|
||||
{
|
||||
for(String key : new ArrayList<>(object.keySet()))
|
||||
{
|
||||
if(LOG_STRIPPED_KEYS.contains(key))
|
||||
{
|
||||
object.remove(key);
|
||||
}
|
||||
else
|
||||
{
|
||||
strip(object.get(key));
|
||||
}
|
||||
}
|
||||
}
|
||||
else if(node instanceof JSONArray array)
|
||||
{
|
||||
for(int i = 0; i < array.length(); i++)
|
||||
{
|
||||
strip(array.get(i));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A compact render for the panel (counts and error groups, no values).
|
||||
*******************************************************************************/
|
||||
private static Map<String, Object> render(String type, String tableName, Map<String, Object> json)
|
||||
{
|
||||
Map<String, Object> render = new LinkedHashMap<>();
|
||||
render.put("type", type);
|
||||
render.put("tableName", tableName);
|
||||
render.put("mode", json.get("mode"));
|
||||
render.put("okCount", json.get("okCount"));
|
||||
render.put("errorCount", json.get("errorCount"));
|
||||
List<Map<String, Object>> errors = new ArrayList<>();
|
||||
for(Object error : list(json.get("errors")))
|
||||
{
|
||||
Map<String, Object> e = map(error);
|
||||
errors.add(new LinkedHashMap<>(Map.of("rows", String.valueOf(e.get("rowNumbers")), "field", String.valueOf(e.get("fieldName")), "message", String.valueOf(e.get("message")))));
|
||||
}
|
||||
render.put("errors", errors);
|
||||
return (render);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The error of a process init call (HTTP or the process's ERROR type), or
|
||||
** null when it completed.
|
||||
*******************************************************************************/
|
||||
private static String processError(UiApiResult response)
|
||||
{
|
||||
if(response == null)
|
||||
{
|
||||
return ("No response.");
|
||||
}
|
||||
if(!response.isSuccess())
|
||||
{
|
||||
return (response.errorMessage() == null ? "HTTP " + response.status() : response.errorMessage());
|
||||
}
|
||||
Map<String, Object> json = response.json();
|
||||
if("ERROR".equals(json.get("type")))
|
||||
{
|
||||
return (String.valueOf(json.getOrDefault("userFacingError", json.getOrDefault("error", "The process failed."))));
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void fail(AiToolResult result, Call call, UiApiResult response)
|
||||
{
|
||||
if(response == null)
|
||||
{
|
||||
result.withOutcome(AiToolOutcome.ERROR).withSummary("Not available")
|
||||
.withModelContent("This server does not offer " + call.operation + " yet (it needs a newer QQQ). Use the table's Bulk Load screen instead.");
|
||||
return;
|
||||
}
|
||||
AiToolOutcome kind = outcomeFor(response.status());
|
||||
String message = response.errorMessage() == null ? "HTTP " + response.status() : response.errorMessage();
|
||||
result.withOutcome(kind).withSummary(kind == AiToolOutcome.DENIED ? "Not allowed" : shortText(message)).withPermissionCheck(call.permissionCheck())
|
||||
.withModelContent(call.operation + " answered HTTP " + response.status() + ": " + shortText(message));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static AiToolOutcome outcomeFor(int status)
|
||||
{
|
||||
return (switch(status)
|
||||
{
|
||||
case 401, 403 -> AiToolOutcome.DENIED;
|
||||
case 404 -> AiToolOutcome.NOT_FOUND;
|
||||
case 400, 409, 422 -> AiToolOutcome.INVALID_INPUT;
|
||||
default -> AiToolOutcome.ERROR;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void deny(AiToolResult result, String check, String message)
|
||||
{
|
||||
result.withOutcome(AiToolOutcome.DENIED).withSummary("Not allowed").withPermissionCheck(check).withModelContent(message);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String required(Map<String, Object> in, String name) throws AiToolException
|
||||
{
|
||||
if(in.get(name) instanceof String s && StringUtils.hasContent(s))
|
||||
{
|
||||
return (s.strip());
|
||||
}
|
||||
throw (AiToolException.invalidInput(name + " is required."));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Integer requiredInteger(Map<String, Object> in, String name) throws AiToolException
|
||||
{
|
||||
try
|
||||
{
|
||||
Integer value = ValueUtils.getValueAsInteger(in.get(name));
|
||||
if(value != null)
|
||||
{
|
||||
return (value);
|
||||
}
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
// fall through
|
||||
}
|
||||
throw (AiToolException.invalidInput(name + " must be a number."));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Map<String, Object> requiredMap(Map<String, Object> in, String name) throws AiToolException
|
||||
{
|
||||
Object value = in.get(name);
|
||||
if(value instanceof String s && s.strip().startsWith("{"))
|
||||
{
|
||||
return (new JSONObject(s).toMap());
|
||||
}
|
||||
if(value instanceof Map<?, ?> m && !m.isEmpty())
|
||||
{
|
||||
return ((Map<String, Object>) m);
|
||||
}
|
||||
throw (AiToolException.invalidInput(name + " is required (a JSON object)."));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static List<Object> fieldList(Object mappingJson)
|
||||
{
|
||||
if(mappingJson == null)
|
||||
{
|
||||
return (List.of());
|
||||
}
|
||||
try
|
||||
{
|
||||
return (new JSONObject(String.valueOf(mappingJson)).optJSONArray("fieldList") == null ? List.of()
|
||||
: new JSONObject(String.valueOf(mappingJson)).getJSONArray("fieldList").toList());
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
return (List.of());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Map<String, Object> map(Object value)
|
||||
{
|
||||
return (value instanceof Map<?, ?> m ? (Map<String, Object>) m : Map.of());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("unchecked")
|
||||
private static List<Object> list(Object value)
|
||||
{
|
||||
return (value instanceof List<?> l ? (List<Object>) l : List.of());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String shortText(String text)
|
||||
{
|
||||
if(text == null)
|
||||
{
|
||||
return ("");
|
||||
}
|
||||
return (text.length() > 300 ? text.substring(0, 297) + "..." : text);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One tool call's UI API invocations: the user's session, the route header,
|
||||
** and the last operation and status (for the log).
|
||||
*******************************************************************************/
|
||||
private static class Call
|
||||
{
|
||||
private final UiApiToolbox.Context context;
|
||||
private final boolean hipaaRoute;
|
||||
private String operation;
|
||||
private Integer httpStatus;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
Call(UiApiToolbox.Context context, boolean hipaaRoute)
|
||||
{
|
||||
this.context = context;
|
||||
this.hipaaRoute = hipaaRoute;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Invoke, or null when the server does not offer the operation (an
|
||||
** older QQQ).
|
||||
*******************************************************************************/
|
||||
UiApiResult invoke(UiApiCall uiApiCall) throws QException
|
||||
{
|
||||
operation = uiApiCall.operation();
|
||||
if(!context.gateway().isInvocable(uiApiCall.getMethod(), uiApiCall.getPathTemplate()))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
context.prepare(uiApiCall).withInvokedBy("assistant")
|
||||
.withHeader(ROUTE_HEADER, hipaaRoute ? "hipaa" : "standard");
|
||||
UiApiResult response = context.gateway().invoke(uiApiCall);
|
||||
httpStatus = response.status();
|
||||
return (response);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
String permissionCheck()
|
||||
{
|
||||
return ("uiApi:" + operation + ":" + httpStatus);
|
||||
}
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* 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.tools.bulkload;
|
||||
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.assistant.uiapi.UiApiCall;
|
||||
import com.kingsrook.qbits.assistant.uiapi.UiApiResult;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The default {@link BulkLoadRunner}: the durable jobs launch,
|
||||
** POST /qqq/v1/processes/{table}.bulkInsert/jobs with the upload ref and the
|
||||
** saved profile (design spec #1171 sections 2.4 and 3.4). From the assistant
|
||||
** channel core creates the job AWAITING_CONFIRMATION, so it starts only when
|
||||
** the user confirms it outside the assistant.
|
||||
**
|
||||
** STAND-IN(core=#1178, removeBy=RC.8): until the jobs kernel ships in a QQQ
|
||||
** RC, the launch is not a spec'd operation on the server; the runner then
|
||||
** says so instead of guessing at another path (a load needs the upload ref,
|
||||
** which the bulk-insert process endpoints cannot take).
|
||||
*******************************************************************************/
|
||||
public class JobsLaunchBulkLoadRunner implements BulkLoadRunner
|
||||
{
|
||||
public static final String LAUNCH_OPERATION_PATH = "/processes/{processName}/jobs";
|
||||
|
||||
public static final String NOT_AVAILABLE = "Running a bulk load from the assistant needs the durable jobs launch "
|
||||
+ "(POST /qqq/v1/processes/{process}/jobs), which this server does not have yet. Open the table's Bulk Load "
|
||||
+ "screen and choose the saved profile instead.";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public RunHandle start(Request request) throws QException
|
||||
{
|
||||
String processName = request.tableName() + ".bulkInsert";
|
||||
if(!request.gateway().isInvocable("POST", LAUNCH_OPERATION_PATH))
|
||||
{
|
||||
return (new RunHandle("NOT_AVAILABLE", null, null, NOT_AVAILABLE, "POST " + LAUNCH_OPERATION_PATH, null));
|
||||
}
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("uploadRefs", List.of(request.uploadRef()));
|
||||
body.put("savedBulkLoadProfileId", request.savedBulkLoadProfileId());
|
||||
body.put("mode", "EXECUTE");
|
||||
body.put("label", "Bulk load into " + request.tableName() + " (assistant)");
|
||||
|
||||
UiApiResult response = request.gateway().invoke(new UiApiCall("POST", LAUNCH_OPERATION_PATH)
|
||||
.withPathParam("processName", processName)
|
||||
.withJsonBody(body)
|
||||
.withSessionUUID(request.sessionUUID())
|
||||
.withCredentials(request.credentials())
|
||||
.withClientDetail(request.turnId())
|
||||
.withUserTimezone(request.userTimezone())
|
||||
.withInvokedBy("assistant"));
|
||||
|
||||
if(!response.isSuccess())
|
||||
{
|
||||
return (new RunHandle("FAILED", null, null, response.errorMessage() == null ? "HTTP " + response.status() : response.errorMessage(),
|
||||
"POST " + LAUNCH_OPERATION_PATH, response.status()));
|
||||
}
|
||||
|
||||
Map<String, Object> json = response.json();
|
||||
Long jobId = ValueUtils.getValueAsLong(json.get("jobId"));
|
||||
String status = json.get("status") == null ? "QUEUED" : String.valueOf(json.get("status"));
|
||||
String summary = "AWAITING_CONFIRMATION".equals(status)
|
||||
? "Job " + jobId + " is waiting for your confirmation on its job record before it starts."
|
||||
: "Job " + jobId + " is " + status.toLowerCase() + ".";
|
||||
return (new RunHandle(status, jobId, null, summary, "POST " + LAUNCH_OPERATION_PATH, response.status()));
|
||||
}
|
||||
}
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.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<String> 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<String> 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()));
|
||||
}
|
||||
|
||||
}
|
||||
-230
@@ -1,230 +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.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";
|
||||
|
||||
private static final Set<String> SPEC_OPERATIONS = specOperations();
|
||||
|
||||
private final Supplier<String> 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<String> 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<String> specOperations()
|
||||
{
|
||||
Set<String> 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<String> 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<String> 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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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<String, String> 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());
|
||||
}
|
||||
|
||||
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"));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -33,9 +34,13 @@ public class UiApiCall
|
||||
private final String pathTemplate;
|
||||
private final Map<String, String> pathParams = new LinkedHashMap<>();
|
||||
private final Map<String, String> queryParams = new LinkedHashMap<>();
|
||||
private final Map<String, String> formParams = new LinkedHashMap<>();
|
||||
private final Map<String, String> 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);
|
||||
|
||||
@@ -128,6 +133,58 @@ public class UiApiCall
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add a multipart form field (ignored when null). A call with form fields
|
||||
** is sent as multipart/form-data (the /processes endpoints), mirroring
|
||||
** core's UiApiRequest.withFormParam.
|
||||
*******************************************************************************/
|
||||
public UiApiCall withFormParam(String name, Object value)
|
||||
{
|
||||
if(value != null)
|
||||
{
|
||||
formParams.put(name, String.valueOf(value));
|
||||
}
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for formParams
|
||||
*******************************************************************************/
|
||||
public Map<String, String> getFormParams()
|
||||
{
|
||||
return (formParams);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add a request header the invoker sets (e.g. X-QQQ-AI-Route); ignored
|
||||
** when null. The session cookie and the invoked-by marker cannot be
|
||||
** overridden this way.
|
||||
*******************************************************************************/
|
||||
public UiApiCall withHeader(String name, Object value)
|
||||
{
|
||||
if(name != null && value != null)
|
||||
{
|
||||
headers.put(name, String.valueOf(value));
|
||||
}
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for headers
|
||||
*******************************************************************************/
|
||||
public Map<String, String> getHeaders()
|
||||
{
|
||||
return (headers);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for jsonBody
|
||||
*******************************************************************************/
|
||||
@@ -161,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
|
||||
*******************************************************************************/
|
||||
@@ -192,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)
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.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<String> 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<String> 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<String, String> 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<String, String> getHeaders()
|
||||
{
|
||||
return (headers);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
-9
@@ -17,7 +17,10 @@
|
||||
package com.kingsrook.qbits.assistant;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantAdminPermissionChecker;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantInstanceMetaData;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantConversation;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantPolicy;
|
||||
@@ -26,8 +29,8 @@ import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
|
||||
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionCheckResult;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
@@ -44,9 +47,8 @@ import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QUser;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
@@ -88,7 +90,7 @@ class AssistantQBitProducerTest extends BaseAssistantTest
|
||||
// the nav: the home plus the main tables; processes are actions //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
assertThat(qInstance.getApp(AssistantMetaDataBuilder.APP_NAME).getChildren()).extracting(c -> c.getName())
|
||||
.containsExactly("assistant_budget", "assistant_provider", "assistant_profile", "assistant_skill", "assistant_data_access", "assistant_conversation", "assistant_model_price", "assistant_policy");
|
||||
.containsExactly("assistant_scheduled_task", "assistant_rag_source", "assistant_budget", "assistant_provider", "assistant_profile", "assistant_skill", "assistant_data_access", "assistant_conversation", "assistant_model_price", "assistant_policy");
|
||||
assertThat(qInstance.getApp(AssistantMetaDataBuilder.APP_NAME).getWidgets()).containsExactly("assistantHome", "assistantAccess");
|
||||
assertThat(qInstance.getProcess(AssistantMetaDataBuilder.RETENTION_PROCESS_NAME).getTableName()).isEqualTo("assistant_conversation");
|
||||
assertThat(qInstance.getProcess(AssistantMetaDataBuilder.RETENTION_PROCESS_NAME)).isNotNull();
|
||||
@@ -96,9 +98,9 @@ class AssistantQBitProducerTest extends BaseAssistantTest
|
||||
assertThat(qInstance.getPossibleValueSource(AssistantConversation.TABLE_NAME)).isNotNull();
|
||||
assertThat(qInstance.getQBits().values()).anyMatch(q -> "assistant".equals(q.getArtifactId()));
|
||||
|
||||
assertThat(com.kingsrook.qbits.assistant.metadata.AssistantInstanceMetaData.of(qInstance)).isNotNull();
|
||||
assertThat(com.kingsrook.qbits.assistant.metadata.AssistantInstanceMetaData.of(qInstance).getVersion()).isEqualTo(1);
|
||||
assertThat(com.kingsrook.qbits.assistant.metadata.AssistantInstanceMetaData.of(qInstance).getBasePath()).isEqualTo("/qqq/v1/assistant");
|
||||
assertThat(AssistantInstanceMetaData.of(qInstance)).isNotNull();
|
||||
assertThat(AssistantInstanceMetaData.of(qInstance).getVersion()).isEqualTo(1);
|
||||
assertThat(AssistantInstanceMetaData.of(qInstance).getBasePath()).isEqualTo("/qqq/v1/assistant");
|
||||
assertThat(config.getRuntime()).isNotNull();
|
||||
assertThat(AssistantRuntime.of(qInstance)).isSameAs(config.getRuntime());
|
||||
assertThat(AssistantQBitProducer.getVersion()).isNotBlank();
|
||||
@@ -192,7 +194,7 @@ class AssistantQBitProducerTest extends BaseAssistantTest
|
||||
assertThat(editor.getSecurityKeyValues(AssistantQBitConfig.DEFAULT_OWNER_KEY_TYPE)).containsExactly(AssistantTestFixture.EDITOR);
|
||||
assertThat(editor.getSecurityKeyValues("assistant_user_id_all_access")).isNullOrEmpty();
|
||||
|
||||
QSession admin = new QSession().withUser(new com.kingsrook.qqq.backend.core.model.session.QUser().withIdReference("boss"));
|
||||
QSession admin = new QSession().withUser(new QUser().withIdReference("boss"));
|
||||
admin.withPermission(AssistantQBitConfig.ADMIN_PERMISSION);
|
||||
AssistantSecurity.applySessionKeys(admin, config);
|
||||
assertThat(admin.getSecurityKeyValues("assistant_user_id_all_access")).containsExactly(true);
|
||||
@@ -277,7 +279,7 @@ class AssistantQBitProducerTest extends BaseAssistantTest
|
||||
AssistantQBitConfig hostConfig = new AssistantQBitConfig().withBackendName("memory").withOwnerSecurityKeyType("userKey").withRetentionSchedulerName("quartz").withRegisterRoutes(false);
|
||||
hostConfig.setEnabled(false);
|
||||
new AssistantQBitProducer().withQBitConfig(hostConfig).produce(instance);
|
||||
assertThat(com.kingsrook.qbits.assistant.metadata.AssistantInstanceMetaData.of(instance)).as("not advertised when disabled").isNull();
|
||||
assertThat(AssistantInstanceMetaData.of(instance)).as("not advertised when disabled").isNull();
|
||||
assertThat(instance.getSecurityKeyType("userKey").getAllAccessKeyName()).isEqualTo("userKeyAll");
|
||||
assertThat(instance.getProcess(AssistantMetaDataBuilder.RETENTION_PROCESS_NAME).getSchedule().getSchedulerName()).isEqualTo("quartz");
|
||||
hostConfig.getRuntime().close();
|
||||
|
||||
+4
-7
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
+73
@@ -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<String> problems = UiApiContract.check(generated, Files.readString(COMMITTED, StandardCharsets.UTF_8),
|
||||
Files.exists(BASELINE) ? Files.readString(BASELINE, StandardCharsets.UTF_8) : null, 1, "./mvnw test -pl qbit-assistant -Dtest=AssistantSpecsContractTest -Dassistant.regenerateOpenApi=true");
|
||||
assertThat(problems).isEmpty();
|
||||
}
|
||||
|
||||
}
|
||||
+4
-3
@@ -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);
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+2
-2
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -90,7 +90,11 @@ class RedTeamTest extends BaseAssistantTest
|
||||
|
||||
for(Map<String, Object> 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");
|
||||
}
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user