Compare commits

...
Author SHA1 Message Date
james.maesandClaude Opus 5.5 87835961f4 chore(bulk-load): tag the jobs-launch stand-in with the kernel PR (QRun-IO/qqq#1178)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:11:32 -05:00
james.maesandClaude Opus 5.5 c3a828c1c3 feat(bulk-load): assistant tools to use and create saved bulk load profiles
The owner asked (2026-10-13) that the assistant use and create bulk load
profiles. This adds the spec #1171 section 3.4 tools, all through the
/qqq/v1 UI API with the user's own session:

- bulk_profiles_list / bulk_profile_describe: the user's and shared
  profiles, read through the savedBulkLoadProfile table (record locks).
- bulk_file_attach: an upload the panel already made (pageContext
  attachments, refs only), with its masked column profiles.
- bulk_mapping_suggest: core's suggester. The model then refines the
  profile from the headers and the permitted samples.
- bulk_load_dry_run: the table's real bulk-insert validation (SAMPLE or
  FULL), with nothing stored. Error groups come with row numbers, so the
  model can fix the profile and run again.
- bulk_profile_save (write): storeSavedBulkLoadProfile, plus
  insertSharedRecord for each share. It runs only after the user confirms
  the pending action, and only as far as the user may share.
- bulk_load_run (write): goes through a BulkLoadRunner seam after
  confirmation. The default runner is the durable jobs launch, where core
  creates assistant jobs AWAITING_CONFIRMATION. Until that ships, it
  answers that the launch is not available yet (STAND-IN).

PHI: the X-QQQ-AI-Route header (hipaa|standard) comes from the turn's
provider HIPAA eligibility, never the model. The server masks samples and
values. Tool-call logs drop sampleValues, example bullets and value
mappings. Data access READ_WRITE is required to propose and to confirm
writes.

Supporting changes, all additive:
- UiApiCall: form params and headers.
- LoopbackUiApiGateway: multipart bodies.
- UiApiToolbox.Outcome.of.
- One dispatch branch each in ContextBuilder, TurnEngine and
  AssistantApi.confirmAction.
- ToolLabels entries.
- The config seam.

The core operations are QRun-IO/qqq#1179 (STAND-IN until an RC). Until
then the tools say the server does not offer them.

Tests: BulkLoadToolsTest (6), including the golden eval. It uploads a CSV
with messy headers. The assistant proposes a profile, the dry run finds
the missing required field, the assistant defaults it, the dry run
passes, it saves and shares the profile after confirmation, and in a
second turn runs the load after confirmation. Full ./mvnw verify is green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:47:00 -05:00
james.maes 5e1c213fd6 Merge pull request #34 from QRun-IO/feature/presentation-demo-shots
test(demo): answer presentation demo turn and panel screenshots
2026-10-13 20:29:45 -05:00
james.maes 1a65aa2257 Merge pull request #33 from QRun-IO/feature/p6-rag
feat: RAG over instance content, permission-filtered through the UI API (P-f)
2026-10-13 20:29:40 -05:00
james.maesandClaude Opus 5.5 17c376c5b1 feat: contingent possible values for provider model and kind (and price model)
assistant_provider.model and .kind, and assistant_model_price.model,
use CUSTOM possible-value providers that read otherValues.providerType:
the type's own models (plus the provider's enabled chat models from the
registry) and kinds. Without a providerType they return the union, so
saved rows and API callers keep working. possibleValueSourceDependsOn
("providerType") follows when the next QQQ RC has it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:26:22 -05:00
james.maes 2e90fbdc5d Merge remote-tracking branch 'origin/develop' into feature/p6-rag
# Conflicts:
#	CHANGELOG.md
#	README.md
#	qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/ContextBuilder.java
#	qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/SystemPrompt.java
#	qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/TurnEngine.java
2026-10-13 20:06:13 -05:00
james.maesandClaude Opus 5.5 6a26a7d4fa test(demo): answer presentation demo turn; panel screenshots
The demo's mock host answers "How many images do I have by type?" with
count_records through the UI API and then present_results, so the
panel shows the owner's example (summary, metric, table with shares and
row links, pie chart, filter links, action). Screenshots light/dark at
390/1440 px with the Next UI build of qqq-frontend-next#111.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:03:20 -05:00
james.maes 1d66154bd2 Merge pull request #32 from QRun-IO/feature/answer-presentation
feat: answer presentation: present_results, presentation parts, default presentation skill
2026-10-13 19:59:57 -05:00
james.maes 57673131ff Merge remote-tracking branch 'origin/develop' into feature/answer-presentation 2026-10-13 19:54:07 -05:00
james.maesandClaude Opus 5.5 c27994c432 feat: answer presentation: present_results tool, presentation parts and the default presentation skill
Owner priority: answers must be easy to read and act on.

- present_results tool: summary, metrics, table (computed percent
  columns, per-row filtered-list links), bar/pie/line chart from the
  table, filter and record links, follow-up actions. Every table and
  field is checked against what the user can see through the UI API;
  hrefs (/app/<table>?filter=<QQQ filter JSON>, records) and number
  formats are built server-side from metadata; the model never writes
  URLs.
- presentation message part (persisted, SSE event) with a server-built
  multi-line GFM markdown fallback for clients without blocks.
- Static system-prompt Presentation section (built-in default skill):
  answer first, no restating, tables for 3+ items, charts for shares and
  trends, filterLinks whenever a list is offered.
- Golden eval of the owner's example: summary, 2-row table with 80.2% /
  19.8%, a 2-point pie and two mimeType filter links; negative cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:54:05 -05:00
james.maes 1947688c34 Merge remote-tracking branch 'origin/develop' into feature/p6-rag 2026-10-13 19:51:42 -05:00
james.maesandClaude Opus 5.5 c487c87051 feat: RAG over instance content, permission-filtered through the UI API (P-f)
- Embeddings: EmbeddingProvider SPI, OpenAI-compatible /embeddings
  (batching, Azure path), assistant_model.model_kind CHAT/EMBEDDING and
  embedding_dimensions; chat selection ignores embedding models.
- VectorStore SPI: in-memory store, and the new qbit-assistant-rag-pgvector
  module (one schema per instance, HNSW cosine index, parameterized SQL).
  The index holds NO record content: table, key, field, chunk, hash and
  vector only.
- assistant_rag_source (Knowledge sources) with Index now / Clear index
  record actions that read through the UI API as the clicking admin,
  skip unchanged chunks and drop removed records.
- search_knowledge tool: vector search, then every hit re-read through the
  UI API with the asking user's session (403/404 hits dropped), AI field
  exposure and masking applied, /app citations; PHI rule for embeddings;
  classification raised by results.
- pgvector container test (pgvector/pgvector:pg16) passes with Docker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:51:40 -05:00
james.maes 14bd4e377c Merge pull request #31 from QRun-IO/fix/core-alerts-rc5
refactor: budget alerts through core QAlerts on QQQ RC.5 (#28)
2026-10-13 19:34:21 -05:00
james.maesandClaude Opus 5.5 cb7d5d4d5a refactor: budget alerts through core QAlerts (QQQ 4.1.0-RC.5), alert types declared
Closes #28 (first half; the DataClassification and loopback gateway
stand-ins go with RC.6).

- Default QQQ is 4.1.0-RC.5.
- BudgetAlerts calls QAlerts.raise(AlertSignal...) directly instead of
  binding by reflection.
- The producer declares assistant.budget.warning, .downgraded and .stopped
  as QAlertTypeMetaData (assistant.admin, subject assistant_budget).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:30:01 -05:00
james.maes 51e0692f46 Merge pull request #27 from QRun-IO/feature/p5-routing-fallback
feat: simple/complex model routing and fallback chains (P-e)
2026-10-13 19:13:26 -05:00
james.maes 6f80dcaff4 Merge remote-tracking branch 'origin/develop' into feature/p5-routing-fallback 2026-10-13 19:09:28 -05:00
james.maesandClaude Opus 5.5 55804d9059 feat: simple/complex model routing and fallback chains across providers (P-e)
- Profiles gain routing (OFF, HEURISTIC, CLASSIFIER) with simple, complex
  and classifier models. The heuristic is a small documented rule set; the
  classifier is one 4-token call on the question text only (ROUTE usage,
  3 s timeout, heuristic on any failure, skipped for PHI on a
  non-eligible provider).
- assistant_model.fallback_model_id chains (cycle-checked, max 4 hops)
  across providers; retryable failures before any output fall back.
  Candidates that are disabled, down, over a STOP budget or fail the PHI
  rule are skipped; without a chain, LOCAL falls back only to LOCAL.
- Circuit breaker (provider health UP/DOWN, down_until) and a cached
  health probe for stale LOCAL providers.
- A mid-turn provider switch rebuilds history from neutral parts and drops
  adapter state (Anthropic thinking signatures).
- Turn route decision, source and fallback count; home shows health and
  fallbacks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:09:26 -05:00
james.maes 73159e536e Merge pull request #26 from QRun-IO/feature/p4-budgets
feat: budgets with warn, downgrade, stop and admin overrides (P-d)
2026-10-13 18:46:19 -05:00
97 changed files with 10852 additions and 230 deletions

No files matched your search

+33
View File
@@ -1,7 +1,40 @@
# Changelog
## 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,
charts and validated in-app filter/record/action links.
- Persist and stream presentation parts with a multi-line GFM fallback and
record-data retention; add a static default presentation skill.
- Add the owner's 81 JPEG / 20 PNG golden example, shared live eval coverage,
permission/formatting/input tests and strict-schema budget coverage.
## Unreleased - P-e
- Add per-profile heuristic/classifier model routing, with budgeted ROUTE usage.
- Add bounded cross-provider fallback chains, PHI-safe selection, neutral history
rebuilding, and a 60-second connection circuit breaker with cached local probes.
- Show provider health, fallback totals and turn routing; include additive schema
migration and admin record-screen controls.
## Unreleased - P-d
- Require QQQ RC.5 and send budget alerts directly through core QAlerts, with admin-visible alert type metadata and core sink tests.
- Admin budgets, expiring overrides, deduplicated warning/downgrade/stop events.
- Per-dispatch limits preserving PHI routing and existing caps.
- Home budget cards, 30-day cost/token bars and provider breakdowns.
+5 -1
View File
@@ -46,6 +46,8 @@ 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:**
@@ -53,6 +55,7 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
- 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.
@@ -69,6 +72,7 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
- **Links:** only `/app` or `/app/...` hrefs are emitted (`TurnEngine.isAppHref`).
- **PHI routing:** PHI only to `hipaaEligible` providers; checked before every model call. Conversation classification never decreases.
- **HIPAA storage:** Conversation storage encrypted in HIPAA mode, only in `AssistantStore`; encryption keys are environment references and never logged or stored in tables.
- **Fallback:** fallback never crosses the PHI rule; never local-to-cloud by default. No retry after partial output from that call; provider switches rebuild neutral history without providerState.
- **Budgets:** budgets are checked before every model call; downgrade never crosses the PHI rule.
- **Kill switch:** read fresh before every model call and every tool step.
@@ -98,4 +102,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}`.
+106 -3
View File
@@ -9,7 +9,69 @@ 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. |
The data access itself is done by the AI tool kernel in QQQ core (`com.kingsrook.qqq.backend.core.actions.ai.AiToolKernel`). The kernel checks table permissions and exposure rules, applies record security, and caps and redacts what the model sees. Every tool result reaches the model wrapped as untrusted data.
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
@@ -203,6 +265,7 @@ Base path `/qqq/v1/assistant`. Every request is authenticated through the app's
The stream sends these events, each with a `seq`:
- `message.start`, `text.delta`
- `tool.start`, `tool.result` (with `render` and `recordRefs`)
- `presentation` (`messageId`, `part`: summary, blocks and server-built GFM markdown)
- `ui.navigate`, `notice`
- `usage`, `message.end`
- `error`
@@ -385,7 +448,7 @@ Apache 2.0.
## Data classification and HIPAA mode
On QQQ 4.1.0-RC.4, classify application tables/fields in the host configuration:
On QQQ 4.1.0-RC.5, classify application tables/fields in the host configuration:
```java
import com.kingsrook.qbits.assistant.classification.DataClassification;
@@ -455,8 +518,48 @@ extra allowance or temporary unlimited use from the budget's Overrides child lis
with a required reason and expiry; the session sets the grantor.
Budget state changes appear once per scope/period/state in the Events child list,
as panel notices and, when the optional QQQ Alerts SPI is installed, bell alerts.
as panel notices and, through core QAlerts, bell alerts when an alerts sink is installed (logging otherwise).
The Assistant home shows budgets, overrides, daily cost and tokens, and breakdowns
by user, model, provider and tool. Existing `AssistantBudgets`/policy limits and
provider monthly caps continue to apply independently of these rows and overrides.
See [UPGRADE.md](UPGRADE.md) for the required additive schema.
### Profile routing and provider fallback
On an Assistant Profile, **Model routing** defaults to OFF. HEURISTIC selects the
complex slot for long questions (>600 characters), two mentioned app tables,
analysis/comparison/explanation/trend/planning requests, pending writes, more than
eight previous turns, or a previous tool limit; otherwise it selects the simple
slot. CLASSIFIER uses an optional registry model with just the question and a
fixed instruction, four output tokens and a three-second timeout. An invalid reply,
error, unavailable classifier or PHI-ineligible classifier uses HEURISTIC. ROUTE
usage counts toward budgets. Empty model slots keep the provider/policy default;
budgets may downgrade either choice. Without a profile, enabled code-level
`AssistantModelRouting.escalation*` supplies the complex default.
A model's **Fallback model** points to its next candidate, including another
provider. Chains are cycle-free and bounded to four hops. Only retryable failures
before any text or tool start from that call permit fallback. Disabled, down,
PHI-ineligible or budget-blocked candidates are skipped. Without explicit links,
only another LOCAL model in the same adapter family is eligible; local requests
never automatically move to cloud. Provider state/signatures are removed when
switching, preserving neutral tool history. Each attempted call is audited as usage.
NETWORK/TIMEOUT marks a provider DOWN for 60 seconds; success marks it UP. Local
health is probed when unknown/stale with a two-second timeout. Provider cards show
health and down-until, failures show fallback totals, and turn records show the
route and final destination. See [UPGRADE.md](UPGRADE.md) for the additive schema.
### Answer presentation
The always-available `present_results` tool turns retrieved results into metrics,
tables, charts and actionable in-app links. The server validates the user's UI API
visibility, formats field values, computes shares and builds all links. It emits
and stores a `presentation` part with a complete GFM Markdown fallback. Profile
skills can extend the built-in presentation instructions. See the
[part, stream and input contract](docs/answer-presentation.md).
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.
+59
View File
@@ -1,7 +1,66 @@
# Upgrading qbit-assistant
## 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
[docs/schema-postgres.sql](docs/schema-postgres.sql) before deploying. Existing
profiles default to OFF; optional model references and health columns start null.
No routing or fallback configuration is seeded. Existing turns retain unknown
routing/final-provider values; fallback_count defaults to zero.
Profiles now have a **Model routing** section. Select HEURISTIC or CLASSIFIER,
then optional simple/complex registry models and a classifier model. Empty route
slots retain provider/policy selection. CLASSIFIER with no usable classifier
uses HEURISTIC. OFF preserves prior provider selection. With no effective profile,
code-configured escalation supplies the complex model/effort when enabled.
Set a model's **Fallback model** to opt into a cross-provider chain. Saves reject
cycles, more than four edges, or disabled/missing targets. Provider health is
read-only; NETWORK/TIMEOUT opens a 60-second circuit, and successful calls close
it. Local endpoints with stale health get a bounded two-second probe. Automatic
fallback without an explicit link is confined to local models of the same adapter
family. PHI/HIPAA eligibility and budgets apply to every destination.
Classifier usage is recorded with purpose ROUTE and included in budgets. Reports
that filter usage purpose should include ROUTE. Turn screens show route decision,
source, fallback count and final provider; the home shows health and fallback totals.
All configuration remains assistant.admin-only, with AI exposure NONE.
## P-d: data-driven budgets
Requires QQQ 4.1.0-RC.5 for the core Alerts API and alert type metadata.
Create `assistant_budget`, `assistant_budget_override`, `assistant_budget_event`
and `assistant_budget_usage`
using the P-d DDL in [schema-postgres.sql](docs/schema-postgres.sql) before deploying.
+109 -2
View File
@@ -387,14 +387,88 @@ AI exposure NONE is not enough, because the assistant acts as the user and an ad
reason, session grantor and expiry; they never waive the other limit layers.
- Start-turn checks use the existing 429 BUDGET path through PolicyService.
Every dispatch, including strict retries, evaluates current usage and overrides.
Events deduplicate WARNED, DOWNGRADED and STOPPED; optional reflective QAlerts
signals accompany panel notices without a compile-time Alerts dependency.
Events deduplicate WARNED, DOWNGRADED and STOPPED.
- QQQ RC.5 core QAlerts sends budget signals alongside panel notices, with three
admin-visible alert types and a default logging sink. Resolution awaits active
alert/recurrence tracking so overrides cannot clear shared alerts incorrectly.
- Explicit registry model selection leaves provider defaults untouched. Automatic
downgrades prefer LOCAL, then price. PHI eligibility and destination provider caps
still apply; a missing eligible target stops the turn.
- The home owns the budget cards and 30-day cost/token bars, with user, model,
provider and tool breakdowns, inherited colors and empty states.
### P-e as built
- Profiles select OFF (the migration default), HEURISTIC or CLASSIFIER and optional
simple/complex/classifier registry models. Selection uses ProfileService's existing
specificity. Without a profile, enabled escalation config supplies the complex
model and effort. Empty slots retain provider row/default/policy selection.
- The documented heuristic is a pure rule table: >600 characters, two distinct app
table names, complexity keywords, pending write context, >8 previous turns or a
previous tool limit. This branch has no conversation write-mode field.
- Classifier transport is non-streaming, bounded to four output tokens/three seconds,
and contains only the fixed instruction and user question. PHI guards apply before
dispatch. Errors/unexpected replies use the heuristic. ROUTE usage enters budget
accounting; normal dispatch budgets may override the selected route.
- FallbackChain follows at most four edges on retryable failures, only before any
text/tool start of that call. Candidates are checked for enablement, health,
PHI/HIPAA eligibility and budgets. Without explicit links only the same LOCAL
adapter family is considered. Provider switches rebuild neutral messages and
discard providerState; failed attempts remain visible in usage.
- ProviderHealth persists UP/DOWN, checked-at and a 60-second connection-failure
circuit. Local stale/unknown health uses a bounded two-second probe. Successful
completions close the breaker. Turn rows persist route/source, fallback count and
final provider/model; notices name only the unavailable and answering providers.
- 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.
### Phases (each a green PR)
| | Scope | Depends on |
|---|---|---|
@@ -419,3 +493,36 @@ AI exposure NONE is not enough, because the assistant acts as the user and an ad
- an "Explain access" action (for a user, and a table or a prompt, which operations the assistant could call and which it would be denied, with the reason);
- a recent-denials widget on the app home.
- **(d) Spec'd assistant endpoints:** move `/qqq/v1/assistant/...` (conversations, turns, events, feedback, cancel, config, actions) onto `AbstractEndpointSpec`s through the qbit spec extension point (`MiddlewareSpecExtension`, established prefix `/assistant`, QRun-IO/qqq#1128). The SSE turn and events streams stay raw routes until the spec layer can describe a stream.
## Answer presentation (owner priority 2026-10-13)
`present_results` is always offered and reads no records. It takes a direct answer,
metrics, a table, an optional table-derived chart, filtered list links, record links
and next-step actions. The server validates metadata and the user's UI API table
visibility once per turn, formats values using QQQ field types/display formats,
computes percentages and builds every `/app` href. Unknown or denied items are
dropped with an explanation. The assistant must still retrieve every fact through
data tools before presenting it.
The [presentation part and SSE contract](answer-presentation.md) defines `summary`,
`blocks` and the complete server-built GFM `markdown` fallback. Each successful
call emits `presentation` with `{messageId, part}` and persists that part; the tool
response tells the model not to repeat its tables or links. Audit rows contain
only accepted presentation data. Normal record-data retention removes these parts.
The built-in Presentation skill is compact static prompt text in the cached rules
block. It asks for a one-sentence answer first, metrics for single numbers, tables
for breakdowns/comparisons, charts for useful shares/trends and actual filter links
whenever offering a list. Administrator profile skills append after it as before
and extend it; there is no replacement boolean or extra permission surface.
The owner's one-line table was a frontend parsing defect: Next's `AssistantMarkdown`
used react-markdown without `remark-gfm`, so table lines became a paragraph.
The renderer fix belongs to `qqq-frontend-next`; this worktree preserves real
newlines and provides structured blocks plus a proper multi-line GFM fallback.
The frontend change is maintained separately and is not verified by this build.
The shared golden task asks "How many images do I have by type?": five UI count
calls establish 81 JPEG, 20 PNG and zero other configured types, then a presentation
with two rows, 80.2%/19.8% shares, two pie points and working MIME filter links.
The same task is included in the opt-in `live` Anthropic eval suite.
+56
View File
@@ -0,0 +1,56 @@
# Assistant answer presentation contract (v1)
Owner priority (2026-10-13): answers must ALWAYS be easy to read and act on. Bad example the owner saw, rendered on ONE line:
"| Type | Count | |---|---| | JPEG (image/jpeg) | 81 | | PNG (image/png) | 20 | | WebP, GIF, SVG | 0 |" followed by "You can open the lists filtered by JPEG or PNG" with NO links.
Root cause found in Next: `AssistantMarkdown` uses react-markdown WITHOUT `remark-gfm`, so GFM tables are not parsed and the table's lines collapse into one paragraph (soft line breaks). Backend streaming preserves `\n` (events are JSON).
## New message part: `presentation`
An assistant message's `parts` (persisted in `assistant_message.parts_json`, streamed as an SSE event, returned by the conversation API) may contain, besides `text`, `notice`, `tool`/`render` parts etc., ONE part per `present_results` call:
```json
{
"type": "presentation",
"summary": "101 media files are images: 81 JPEG and 20 PNG.",
"markdown": "<server-built GFM fallback of the whole presentation, for clients that do not render blocks (CLI/TUI)>",
"blocks": [
{ "kind": "metrics", "items": [ { "label": "Images", "value": 101, "display": "101", "href": "/app/media?filter=..." } ] },
{ "kind": "table", "title": "Media by type",
"columns": [ { "key": "type", "label": "Type", "align": "left" },
{ "key": "count", "label": "Count", "align": "right" },
{ "key": "share", "label": "Share", "align": "right" } ],
"rows": [ { "cells": { "type": "JPEG (image/jpeg)", "count": 81, "share": 0.802 },
"display": { "type": "JPEG (image/jpeg)", "count": "81", "share": "80.2%" },
"href": "/app/media?filter=..." } ] },
{ "kind": "chart", "chartType": "pie", "title": "Share by type",
"points": [ { "label": "JPEG", "value": 81, "display": "81", "href": "/app/media?filter=..." },
{ "label": "PNG", "value": 20, "display": "20", "href": "/app/media?filter=..." } ] },
{ "kind": "filterLinks", "items": [ { "label": "Open JPEG media (81)", "tableName": "media", "href": "/app/media?filter=..." } ] },
{ "kind": "recordLinks", "items": [ { "label": "Sunset.jpg", "tableName": "media", "recordId": "12", "href": "/app/media/12" } ] },
{ "kind": "actions", "items": [ { "label": "Show the PNG files", "prompt": "Show me the PNG media files" },
{ "label": "Open media", "href": "/app/media" } ] }
]
}
```
Rules:
- `summary` is required, one sentence, the direct answer first.
- `chartType` ∈ `bar`, `pie`, `line`; ≤ 12 points; values are numbers.
- Every `href` is built SERVER-SIDE from metadata and is an `/app` path (same rules as `TurnEngine.isAppHref`). Filtered list links are `/app/<tableName>?filter=<URL-encoded JSON {"criteria":[{"fieldName":..,"operator":..,"values":[..]}],"booleanOperator":"AND"}>`, which Next's `deserializeFilter` accepts (JSON starting with `{`). The model never writes URLs.
- `display` strings are formatted server-side from field metadata (`displayFormat`, e.g. `%,d`, `$%,.2f`; percent columns as `80.2%`); clients show `display` when present, else the raw value.
- `actions` items have either `prompt` (send as the user's next message) or `href` (navigate), never both.
- Unknown `kind`s must be ignored by clients (forward compatible); clients that do not render blocks render `markdown`.
- The SSE event is `presentation` with `{ "messageId": <id>, "part": <the part above> }`; Next's reducer appends it to the assistant message's parts.
## Tool input and implementation
The input schema is [present-results.schema.json](../qbit-assistant/src/main/resources/present-results.schema.json). `table.rowKeys` supplies one raw filter value per row; `table.rowFilter` names the table, field and optional operator (EQUALS by default). Columns receive stable server keys `c0`, `c1`, etc. Input cells remain raw scalars; each row has a parallel display map. The example keys above are illustrative.
`PresentResultsTool` is instantiated per turn. It caches `GET /metaData` with that turn's session, checks table read visibility and instance fields, validates QQQ criteria operators and builds paths using RC.5 `AiLinkBuilder`. Filter links use URL-encoded JSON accepted by Next. Record ids are encoded as opaque path segments. Metadata denial or invalid item drops that item (an invalid table drops its table/chart), with a short explanation to the model; other items survive. No data query or permission elevation happens here.
Limits: summary 300 characters, display text 2,000 characters, table 20 columns/200 rows, chart 12 points, item lists 100 entries, filters 20 criteria/200 values. Chart truncation is reported to the model. Percent columns reference a raw numeric column; a zero total yields 0.0%. Numbers still must originate in earlier data tool results; presentation is formatting, not a new source of facts.
The schema uses small named, nonrecursive definitions to avoid deeply inlined structures. Each object component is at most two levels of inline containers; the resolved table/criteria structure necessarily has the nesting shown in the owner contract. Strict is off by default. Opting `present_results` into strict mode requires all properties (empty arrays/strings, empty table and -1 for unused percentage indexes), removes provider-unsupported length constraints, and retains server validation. This has zero optional parameters and one shared scalar union in the existing strict-budget test.
Presentation parts are persisted with the assistant message and replayed as `presentation` events with `{messageId, part}` (plus the standard event sequence). They are removed by record-data retention along with tool renders. Model history and text-only consumers can use the server-built Markdown. Unknown block kinds should be ignored. Plain labels must be rendered as text, never parsed as links or HTML; fallback Markdown escapes them. Tool audit input records the accepted presentation only, excluding dropped values.
+63
View File
@@ -461,3 +461,66 @@ INSERT INTO assistant_budget_usage
SELECT id, user_id, provider_id, model, create_date, cost_micros, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens
FROM assistant_usage
ON CONFLICT (usage_id) DO NOTHING;
-- P-e: profile routing, bounded fallback chains, provider health and turn outcome.
ALTER TABLE assistant_profile ADD COLUMN routing_mode VARCHAR(16) DEFAULT 'OFF'
CHECK (routing_mode IN ('OFF', 'HEURISTIC', 'CLASSIFIER'));
ALTER TABLE assistant_profile ADD COLUMN simple_model_id INTEGER REFERENCES assistant_model(id);
ALTER TABLE assistant_profile ADD COLUMN complex_model_id INTEGER REFERENCES assistant_model(id);
ALTER TABLE assistant_profile ADD COLUMN classifier_model_id INTEGER REFERENCES assistant_model(id);
ALTER TABLE assistant_model ADD COLUMN fallback_model_id INTEGER REFERENCES assistant_model(id);
ALTER TABLE assistant_provider ADD COLUMN health_state VARCHAR(8) CHECK (health_state IN ('UP', 'DOWN'));
ALTER TABLE assistant_provider ADD COLUMN health_checked_at TIMESTAMP WITH TIME ZONE;
ALTER TABLE assistant_provider ADD COLUMN down_until TIMESTAMP WITH TIME ZONE;
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);
+5
View File
@@ -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

+3 -2
View File
@@ -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,7 +194,7 @@
</build>
<profiles>
<!-- The assistant needs QQQ 4.1.0-RC.4: the AI tool kernel (package -->
<!-- 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 -->
<!-- qbit-build-parent imports the 4.1 BOM, this -->
<!-- profile is active by default and imports qqq-bom-pom ahead of the -->
@@ -205,7 +206,7 @@
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.4</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.5</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -278,6 +278,26 @@ public class AnthropicModelProvider implements AssistantModelProvider
return (handle);
}
if(Boolean.FALSE.equals(request.getStreaming()))
{
java.util.concurrent.Future<?> completion = executor.submit(() ->
{
try
{
handle.complete(AnthropicMapping.toResponse(client.messages().create(params)));
}
catch(Throwable error)
{
if(!handle.isCancelled())
{
handle.fail(AnthropicMapping.toProviderException(error));
}
}
});
handle.setOnCancel(() -> completion.cancel(true));
return (handle);
}
AtomicReference<ModelUsage> seen = new AtomicReference<>();
handle.setPartialUsage(seen::get);
@@ -51,6 +51,39 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
*******************************************************************************/
class AnthropicModelProviderTest
{
/*******************************************************************************
** Routing requests use a single JSON reply without text deltas.
*******************************************************************************/
@Test
void nonStreamingClassifier() throws Exception
{
server.thenError(200, "classifier-response.json");
ModelRequest request = new ModelRequest().withModel("claude-sonnet-5-5").withMaxTokens(4).withStreaming(false)
.withMessages(List.of(ModelMessage.user("hello")));
List<String> deltas = new ArrayList<>();
ModelResponse response = provider.stream(request, deltas::add).await(Duration.ofSeconds(3));
assertThat(response.getText()).isEqualTo("SIMPLE");
assertThat(response.getUsage().getOutputTokens()).isEqualTo(1L);
assertThat(deltas).isEmpty();
assertThat(sentBody(0).get("stream")).isIn(null, false);
assertThat(sentBody(0).get("max_tokens")).isEqualTo(4);
}
/*******************************************************************************
** Non-streaming errors retain the normal exception mapping.
*******************************************************************************/
@Test
void nonStreamingError() throws Exception
{
server.thenError(401, "unauthorized.json");
assertThatThrownBy(() -> provider.stream(request().withStreaming(false), new StringBuilder()::append).await(Duration.ofSeconds(3)))
.isInstanceOfSatisfying(ModelProviderException.class, error -> assertThat(error.getKind()).isEqualTo(ModelProviderException.Kind.AUTHENTICATION));
}
private static final String TEST_KEY = "sk-ant-test-0000000000000000000000";
private RecordedAnthropicServer server;
@@ -108,6 +108,24 @@ class AnthropicRequestContractTest
/*******************************************************************************
** Presentation remains inside the actual serialized strict request budget.
*******************************************************************************/
@Test
void testPresentationStrictOptIn() throws Exception
{
Map<String, Object> body = requestBody(new AssistantQBitConfig().withModelProvider(new ScriptedModelProvider()).withStrictToolNames(Set.of("present_results")));
assertThat(violations(body)).isEmpty();
assertThat(strictBudgetViolations(body)).isEmpty();
assertThat(tools(body)).anySatisfy(tool ->
{
assertThat(tool.get("name")).isEqualTo("present_results");
assertThat(tool.get("strict")).isEqualTo(true);
});
}
/*******************************************************************************
** The RC.3 shape (every tool strict) is recognized as over budget, and a
** host can still opt a few tools in.
@@ -0,0 +1 @@
{"id":"msg_classify","type":"message","role":"assistant","model":"claude-sonnet-5-5","content":[{"type":"text","text":"SIMPLE"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":18,"output_tokens":1}}
@@ -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.
*******************************************************************************/
@@ -157,7 +237,7 @@ public class OpenAiCompatibleModelProvider implements AssistantModelProvider
{
throw (http.error(response.statusCode(), new String(response.body().readNBytes(64 * 1024), StandardCharsets.UTF_8), result.requestId, strict));
}
if(Boolean.TRUE.equals(caps.getStreaming()))
if(!Boolean.FALSE.equals(request.getStreaming()) && Boolean.TRUE.equals(caps.getStreaming()))
{
readEvents(response.body(), lastRead, result, strict, handle);
}
@@ -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");
@@ -52,7 +52,7 @@ final class OpenAiMapping
static ObjectNode request(ModelRequest request, String kind, ModelCapabilities capabilities) throws JsonProcessingException
{
ObjectNode body = OpenAiHttp.JSON.createObjectNode();
boolean streaming = Boolean.TRUE.equals(capabilities.getStreaming());
boolean streaming = !Boolean.FALSE.equals(request.getStreaming()) && Boolean.TRUE.equals(capabilities.getStreaming());
boolean nativeOpenAi = List.of("OPENAI", "AZURE_OPENAI").contains(kind);
String model = request.getModel();
body.put("model", "VERTEX".equals(kind) && model != null && !model.contains("/") ? "google/" + model : model);
@@ -45,6 +45,27 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
*******************************************************************************/
class OpenAiContractTest
{
/*******************************************************************************
** Classifiers opt out of streaming even when the model supports it.
*******************************************************************************/
@Test
void nonStreamingClassifier() throws Exception
{
try(MockOpenAiServer server = new MockOpenAiServer())
{
server.enqueue(200, "{\"model\":\"test\",\"choices\":[{\"message\":{\"content\":\"SIMPLE\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":18,\"completion_tokens\":1}}");
var provider = new OpenAiCompatibleModelProvider(settings(server, "OPENAI"));
ModelResponse response = provider.stream(request().withStreaming(false).withMaxTokens(4), new StringBuilder()::append).await(Duration.ofSeconds(3));
assertThat(response.getText()).isEqualTo("SIMPLE");
JsonNode body = OpenAiHttp.JSON.readTree(server.requests.getFirst().body());
assertThat(body.path("stream").asBoolean()).isFalse();
assertThat(body.path("max_completion_tokens").asInt()).isEqualTo(4);
assertThat(body.has("stream_options")).isFalse();
}
}
static final String TEXT = "data: {\"model\":\"test\",\"choices\":[{\"delta\":{\"content\":\"OK\"},\"finish_reason\":\"stop\"}]}\r\n\r\ndata: [DONE]\r\n\r\n";
@@ -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);
}
}
}
@@ -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))));
}
}
@@ -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.
+130
View File
@@ -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>
@@ -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));
}
}
@@ -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();
}
}
@@ -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();
}
}
@@ -28,9 +28,9 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
** from the provider (no date suffix). Effort is sent explicitly on every
** request, because the models' defaults differ (Sonnet 5.5 defaults to high).
**
** Phase 1 uses only the main slot. Escalation (Opus) and the summary model
** (Haiku: titles, compaction) arrive in phase 2; they are configurable now
** so the admin policy shape is stable.
** With no effective profile, enabled escalation supplies the complex route's
** model and effort. A profile's routing mode takes precedence. The summary
** slot remains available for titles and compaction.
*******************************************************************************/
public class AssistantModelRouting implements Serializable
{
@@ -17,7 +17,6 @@
package com.kingsrook.qbits.assistant;
import javax.net.ssl.SSLContext;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -29,6 +28,7 @@ import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.regex.Pattern;
import javax.net.ssl.SSLContext;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.kingsrook.qbits.assistant.classification.ClassificationSource;
import com.kingsrook.qbits.assistant.classification.ConfiguredClassificationSource;
@@ -37,6 +37,7 @@ 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.model.metadata.QInstance;
@@ -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";
@@ -130,6 +133,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;
@@ -1199,6 +1203,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 +1824,35 @@ 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);
}
}
@@ -26,8 +26,12 @@ import com.kingsrook.qbits.assistant.home.AssistantHome;
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
import com.kingsrook.qbits.assistant.metadata.AssistantRecordScreens;
import com.kingsrook.qbits.assistant.processes.DiscoverModelsProcess;
import com.kingsrook.qbits.assistant.processes.KnowledgeIndexProcess;
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;
@@ -155,6 +159,9 @@ public class AssistantQBitProducer implements QBitMetaDataProducer<AssistantQBit
qInstance.addTable(customized);
tables.add(customized);
}
qInstance.add(budgetAlertType("warning", "Assistant budget warning", "Assistant usage has reached the budget warning threshold.", AlertSeverity.WARNING));
qInstance.add(budgetAlertType("downgraded", "Assistant budget downgraded", "Assistant usage has reached the budget limit and switched to a cheaper model.", AlertSeverity.WARNING));
qInstance.add(budgetAlertType("stopped", "Assistant budget stopped", "Assistant dispatch has stopped because a budget limit was reached.", AlertSeverity.ERROR));
qInstance.addPossibleValueSource(builder.buildConversationPossibleValueSource());
AssistantRecordScreens.buildPossibleValueSources().forEach(qInstance::addPossibleValueSource);
AssistantRecordScreens.buildSettingsPossibleValueSources(qBitConfig).forEach(qInstance::addPossibleValueSource);
@@ -173,6 +180,12 @@ public class AssistantQBitProducer implements QBitMetaDataProducer<AssistantQBit
qInstance.addProcess(process);
}
for(QProcessMetaData process : KnowledgeIndexProcess.processes())
{
process.setSourceQBitName(qBitMetaData.getName());
qInstance.addProcess(process);
}
qInstance.addWidget(AssistantHome.widget());
qInstance.addWidget(AssistantAccessWidget.widget());
@@ -185,6 +198,18 @@ public class AssistantQBitProducer implements QBitMetaDataProducer<AssistantQBit
/*******************************************************************************
** Declare budget alerts as top-level core metadata for admin visibility.
*******************************************************************************/
private QAlertTypeMetaData budgetAlertType(String state, String label, String description, AlertSeverity severity)
{
return (new QAlertTypeMetaData().withName("assistant.budget." + state).withLabel(label).withDescription(description)
.withDefaultSeverity(severity).withHowToFix("Raise the budget or grant an override in Assistant → Budgets")
.withSourceQBitName(ARTIFACT_ID).withVisibilityPermission("assistant.admin").withSubjectTableName(AssistantBudget.TABLE_NAME));
}
/*******************************************************************************
** Build the runtime and register the routes.
*******************************************************************************/
@@ -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,6 +33,11 @@ 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.tools.KnowledgeTool;
import com.kingsrook.qbits.assistant.tools.bulkload.BulkLoadTools;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qbits.assistant.uiapi.LoopbackUiApiGateway;
import com.kingsrook.qbits.assistant.uiapi.UiApiGateway;
@@ -37,12 +48,6 @@ import com.kingsrook.qqq.backend.core.actions.ai.output.AiRedactor;
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 +57,10 @@ import java.util.regex.Pattern;
*******************************************************************************/
public class AssistantRuntime
{
private final RagService ragService;
private final KnowledgeTool knowledgeTool;
private final BulkLoadTools bulkLoadTools;
private final QInstance qInstance;
private final AssistantQBitConfig config;
private final Clock clock;
@@ -106,7 +115,11 @@ public class AssistantRuntime
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);
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 +430,34 @@ 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);
}
/*******************************************************************************
** The bulk-load tools (saved profiles, attached files, dry runs, loads).
*******************************************************************************/
public BulkLoadTools getBulkLoadTools()
{
return (bulkLoadTools);
}
}
@@ -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;
@@ -598,7 +599,9 @@ public class AssistantApi
}
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.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;
@@ -17,23 +17,16 @@
package com.kingsrook.qbits.assistant.engine;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import java.lang.reflect.Method;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
/*******************************************************************************
** Optional Alerts producer SPI, with log-only fallback and no dependency.
** Budget signals through core's non-throwing Alerts API and default logging sink.
*******************************************************************************/
public final class BudgetAlerts
{
private static final QLogger LOG = QLogger.getLogger(BudgetAlerts.class);
private static final String QALERTS = "com.kingsrook.qqq.backend.core.alerts.QAlerts";
private static final String ALERT_SIGNAL = "com.kingsrook.qqq.backend.core.alerts.AlertSignal";
private static final String SEVERITY = "com.kingsrook.qqq.backend.core.alerts.AlertSeverity";
/*******************************************************************************
** Utility class.
*******************************************************************************/
@@ -44,55 +37,10 @@ public final class BudgetAlerts
/*******************************************************************************
** True when the Alerts SPI is on the classpath.
** Raise one deduplicated budget transition through the configured core sink.
*******************************************************************************/
public static boolean available()
public static void raise(String type, String dedupKey, AlertSeverity severity, String title, String summary)
{
try
{
Class.forName(QALERTS, false, BudgetAlerts.class.getClassLoader());
return (true);
}
catch(ClassNotFoundException | LinkageError e)
{
return (false);
}
QAlerts.raise(new AlertSignal(type).withDedupKey(dedupKey).withSeverity(severity).withTitle(title).withSummary(summary));
}
/*******************************************************************************
** QAlerts.raise(new AlertSignal(type).withDedupKey(..).withSeverity(..)
** .withTitle(..).withSummary(..)), or a log line.
*******************************************************************************/
public static void raise(String type, String dedupKey, String severity, String title, String summary)
{
LOG.info("Assistant budget alert", logPair("type", type), logPair("dedupKey", dedupKey), logPair("title", title));
if(!available())
{
return;
}
try
{
ClassLoader loader = BudgetAlerts.class.getClassLoader();
Class<?> signalType = Class.forName(ALERT_SIGNAL, true, loader);
Object signal = signalType.getConstructor(String.class).newInstance(type);
signal = signalType.getMethod("withDedupKey", String.class).invoke(signal, dedupKey);
Class<?> severityType = Class.forName(SEVERITY, true, loader);
@SuppressWarnings({"unchecked", "rawtypes"})
Object level = Enum.valueOf((Class<? extends Enum>) severityType.asSubclass(Enum.class), severity);
signal = signalType.getMethod("withSeverity", severityType).invoke(signal, level);
signal = signalType.getMethod("withTitle", String.class).invoke(signal, title);
signal = signalType.getMethod("withSummary", String.class).invoke(signal, summary);
Method raise = Class.forName(QALERTS, true, loader).getMethod("raise", signalType);
raise.invoke(null, signal);
}
catch(Exception | LinkageError e)
{
LOG.info("Could not raise a assistant budget alert through the Alerts SPI", logPair("type", type), logPair("reason", e.getClass().getSimpleName()));
}
}
}
@@ -24,6 +24,7 @@ import com.kingsrook.qbits.assistant.model.AssistantBudgetUsage;
import com.kingsrook.qbits.assistant.model.AssistantModel;
import com.kingsrook.qbits.assistant.model.AssistantUsage;
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
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;
@@ -48,21 +49,6 @@ public class BudgetService
private final AssistantStore store;
private final ProviderService providers;
private final Clock clock;
private final AlertRaiser alerts;
/*******************************************************************************
** Injectable alert seam; production uses the optional Alerts producer SPI.
*******************************************************************************/
@FunctionalInterface
public interface AlertRaiser
{
/*******************************************************************************
** Attempt to raise one deduplicated transition.
*******************************************************************************/
void raise(String type, String key, String severity, String title, String summary);
}
@@ -104,21 +90,10 @@ public class BudgetService
** Production constructor.
*******************************************************************************/
public BudgetService(AssistantStore store, ProviderService providers, Clock clock)
{
this(store, providers, clock, BudgetAlerts::raise);
}
/*******************************************************************************
** Constructor with an alert seam for tests.
*******************************************************************************/
public BudgetService(AssistantStore store, ProviderService providers, Clock clock, AlertRaiser alerts)
{
this.store = store;
this.providers = providers;
this.clock = clock;
this.alerts = alerts;
}
@@ -318,6 +293,10 @@ public class BudgetService
/*******************************************************************************
** The persisted unique key is also the warning/alert deduplication record.
** Resolution on period rollover or override recovery needs an active-alert
** lifecycle: these immutable events cannot re-raise a resolved state within
** the same period, and user-specific overrides must not resolve shared alerts.
** Track active keys and recurrence before calling QAlerts.resolve here.
*******************************************************************************/
private boolean transition(Snapshot snapshot, String state, String userId, String message) throws QException
{
@@ -333,8 +312,8 @@ public class BudgetService
case "DOWNGRADED" -> "downgraded";
default -> "stopped";
};
alerts.raise(type, snapshot.budget().getId() + ":" + snapshot.scopeKey() + ":" + snapshot.periodStart(),
"STOPPED".equals(state) ? "ERROR" : "WARNING", "Assistant budget " + snapshot.budget().getName(), message);
BudgetAlerts.raise(type, snapshot.budget().getId() + ":" + snapshot.scopeKey() + ":" + snapshot.periodStart(),
"STOPPED".equals(state) ? AlertSeverity.ERROR : AlertSeverity.WARNING, "Assistant budget " + snapshot.budget().getName(), message);
return (true);
}
@@ -21,6 +21,7 @@ import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.classification.DataClassification;
import com.kingsrook.qbits.assistant.model.AssistantModel;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
@@ -31,6 +32,7 @@ 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.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,9 +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;
@@ -63,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;
@@ -116,6 +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()));
}
@@ -343,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");
@@ -370,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);
}
}
@@ -0,0 +1,362 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.engine;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
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;
import com.kingsrook.qbits.assistant.provider.ModelEventListener;
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.exceptions.QException;
/*******************************************************************************
** At most four fallback edges per call, never after that call emitted output.
** Explicit chains may cross providers. Implicit alternatives stay local and in
** the same adapter family; PHI, enablement, health and budgets always narrow them.
*******************************************************************************/
public class FallbackChain
{
public static final Integer MAX_HOPS = 4;
private final AssistantRuntime runtime;
private final TurnClassification classification;
private final AssistantTurn turn;
private final Consumer<String> notice;
private final AtomicBoolean output = new AtomicBoolean();
private final ProviderHealth health;
/*******************************************************************************
** The engine owns dispatch, usage, cancellation and strict-schema retries.
*******************************************************************************/
@FunctionalInterface
public interface Dispatch
{
/*******************************************************************************
** Dispatch one selected model through the existing guards.
*******************************************************************************/
ModelResponse call(ProviderService.Active selected) throws QException, ModelProviderException, InterruptedException;
}
/*******************************************************************************
** One chain tracker per turn.
*******************************************************************************/
public FallbackChain(AssistantRuntime runtime, TurnClassification classification, AssistantTurn turn, Consumer<String> notice)
{
this.runtime = runtime;
this.classification = classification;
this.turn = turn;
this.notice = notice;
this.health = new ProviderHealth(runtime);
turn.setFallbackCount(0);
}
/*******************************************************************************
** Start a distinct attempt, including a strict-schema retry.
*******************************************************************************/
public void beforeCall(ProviderService.Active active) throws QException, ModelProviderException, InterruptedException
{
output.set(false);
health.beforeCall(active);
}
/*******************************************************************************
** Count text and tool starts conservatively, even when text is still buffered.
*******************************************************************************/
public ModelEventListener listener(ModelEventListener delegate)
{
return (new ModelEventListener()
{
/*******************************************************************************
** Visible text prevents another provider from replacing this attempt.
*******************************************************************************/
@Override
public void onTextDelta(String text)
{
if(text != null && !text.isEmpty())
{
output.set(true);
}
delegate.onTextDelta(text);
}
/*******************************************************************************
** A partial tool call also prevents retry.
*******************************************************************************/
@Override
public void onToolUseStart(String id, String name)
{
output.set(true);
delegate.onToolUseStart(id, name);
}
});
}
/*******************************************************************************
** Used by strict retries as well as provider fallback.
*******************************************************************************/
public Boolean hasOutput()
{
return (output.get());
}
/*******************************************************************************
** Retain the original route's budget scopes across downgrades and retries.
** A fallback may change the destination, never evade an original STOP.
*******************************************************************************/
public BudgetService.Decision budget(ProviderService.Active origin, ProviderService.Active candidate) throws QException
{
Boolean phi = classification.getClassification() == DataClassification.PHI;
BudgetService.Decision original = runtime.getBudgetService().evaluate(turn.getUserId(), origin, phi, false);
if(original.stopped() || (Objects.equals(origin.providerId(), candidate.providerId()) && Objects.equals(origin.model(), candidate.model())))
{
return (original);
}
BudgetService.Decision destination = runtime.getBudgetService().evaluate(turn.getUserId(), candidate, phi, false);
List<String> notices = new ArrayList<>(original.notices());
notices.addAll(destination.notices());
return (new BudgetService.Decision(destination.stopped(), destination.reason(), destination.active(), notices));
}
/*******************************************************************************
** Resolve candidates lazily after a failure; refresh rows before every attempt.
*******************************************************************************/
public ModelResponse call(ModelRequest request, Supplier<ProviderService.Active> current, Dispatch dispatch) throws QException, ModelProviderException, InterruptedException
{
ProviderService.Active selected = current.get();
String unavailable = null;
Set<Integer> visited = new HashSet<>();
Integer hops = 0;
Boolean explicitChain = null;
while(true)
{
try
{
ModelResponse response = dispatch.call(selected);
if(!ModelResponse.STOP_CANCELLED.equals(response.getStopReason()))
{
health.succeeded(current.get());
if(unavailable != null)
{
notice.accept("Provider " + unavailable + " is unavailable; answered with " + name(current.get()) + ".");
}
}
return (response);
}
catch(ModelProviderException error)
{
ProviderService.Active failed = current.get();
health.failed(failed, error);
if(!error.isRetryable() || output.get() || hops >= MAX_HOPS)
{
throw (error);
}
List<AssistantModel> models = runtime.getStore().listModels();
AssistantModel origin = models.stream().filter(m -> Objects.equals(m.getProviderId(), failed.providerId()) && Objects.equals(m.getModelId(), failed.model())).findFirst().orElse(null);
if(origin != null)
{
visited.add(origin.getId());
}
if(explicitChain == null)
{
explicitChain = origin != null && origin.getFallbackModelId() != null;
}
selected = null;
List<AssistantModel> candidates = explicitChain && (origin == null || origin.getFallbackModelId() == null) ? List.of() : candidates(origin, failed, models);
for(AssistantModel candidate : candidates)
{
if(hops >= MAX_HOPS)
{
break;
}
if(!visited.add(candidate.getId()))
{
continue;
}
hops++;
ProviderService.Active resolved = runtime.getProviderService().resolveModel(candidate.getId(), classification.getClassification() == DataClassification.PHI);
if(resolved == null || health.isDown(resolved.row()) || PhiRoutingGuard.check(classification, resolved.row(), runtime.getConfig().getHipaaMode()).isPresent())
{
continue;
}
BudgetService.Decision budget = runtime.getBudgetService().evaluate(turn.getUserId(), resolved, classification.getClassification() == DataClassification.PHI, false);
if(budget.stopped())
{
continue;
}
if(!explicitChain && !"LOCAL".equals(runtime.getProviderService().location(budget.active().row())))
{
continue;
}
Long cap = ProviderService.capMicros(budget.active().row());
if(health.isDown(budget.active().row()) || (cap != null && runtime.getProviderService().monthToDateCostMicros(budget.active().providerId()) >= cap))
{
continue;
}
selected = budget.active();
budget.notices().forEach(notice);
break;
}
if(selected == null)
{
throw (error);
}
if(unavailable == null)
{
unavailable = name(failed);
}
turn.setFallbackCount(turn.getFallbackCount() + 1);
neutralHistory(request);
}
}
}
/*******************************************************************************
** Explicit edges win. The automatic pool is LOCAL only, same adapter family.
*******************************************************************************/
private List<AssistantModel> candidates(AssistantModel origin, ProviderService.Active failed, List<AssistantModel> models) throws QException
{
List<AssistantModel> result = new ArrayList<>();
if(origin != null && origin.getFallbackModelId() != null)
{
Integer next = origin.getFallbackModelId();
Set<Integer> seen = new HashSet<>();
while(next != null && seen.add(next) && result.size() < MAX_HOPS)
{
Integer id = next;
AssistantModel row = models.stream().filter(m -> id.equals(m.getId())).findFirst().orElse(null);
if(row == null)
{
break;
}
result.add(row);
next = row.getFallbackModelId();
}
}
else if(failed.row() != null && "LOCAL".equals(runtime.getProviderService().location(failed.row())))
{
for(AssistantProvider provider : runtime.getStore().listProviders())
{
if("LOCAL".equals(runtime.getProviderService().location(provider)) && Objects.equals(provider.getProviderType(), failed.providerType()))
{
models.stream().filter(m -> Objects.equals(m.getProviderId(), provider.getId())).forEach(result::add);
}
}
}
return (result);
}
/*******************************************************************************
** Rebuild messages from neutral parts; signatures never cross provider boundaries.
*******************************************************************************/
public static void neutralHistory(ModelRequest request)
{
List<ModelMessage> messages = new ArrayList<>();
for(ModelMessage message : request.getMessages())
{
messages.add(new ModelMessage().withRole(message.getRole()).withParts(new ArrayList<>(message.getParts())).withCacheHint(message.getCacheHint()));
}
request.setMessages(messages);
request.getTools().forEach(tool -> tool.setStrict(false));
}
/*******************************************************************************
** User-visible names only.
*******************************************************************************/
private static String name(ProviderService.Active active)
{
return (active.row() == null ? active.providerType() : active.row().getName());
}
/*******************************************************************************
** Validate the proposed graph (including every row of a batch update).
*******************************************************************************/
public static String validate(AssistantModel origin, Map<Integer, AssistantModel> models)
{
return (validate(origin, models, true));
}
/*******************************************************************************
** Recheck ancestor lengths without preventing an admin from disabling a
** destination. Runtime selection skips disabled models and follows the chain.
*******************************************************************************/
public static String validate(AssistantModel origin, Map<Integer, AssistantModel> models, Boolean requireEnabled)
{
Set<Integer> seen = new HashSet<>();
seen.add(origin.getId());
Integer next = origin.getFallbackModelId();
Integer hops = 0;
while(next != null)
{
if(!seen.add(next))
{
return ("Fallback chain must not contain a cycle.");
}
if(++hops > MAX_HOPS)
{
return ("Fallback chain must have at most four hops.");
}
AssistantModel candidate = models.get(next);
if(candidate == null || (requireEnabled && !Boolean.TRUE.equals(candidate.getEnabled())))
{
return ("Fallback model must exist and be enabled.");
}
next = candidate.getFallbackModelId();
}
return (null);
}
}
@@ -31,7 +31,7 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
** assistantMessage.partsJson and returned by the API:
** {type:"text", text} | {type:"tool", toolCallId, name, summary, outcome, render?}
** | {type:"record_refs", refs:[...]} | {type:"navigate", href}
** | {type:"notice", level, text}
** | {type:"notice", level, text} | {type:"presentation", summary, blocks, markdown}
** Consecutive text is merged into one part. Thread-safe.
*******************************************************************************/
public class MessageParts
@@ -110,6 +110,17 @@ public class MessageParts
/*******************************************************************************
** Append a server-built presentation, one part per successful tool call.
*******************************************************************************/
public synchronized MessageParts addPresentation(Map<String, Object> presentation)
{
parts.add(new LinkedHashMap<>(presentation));
return (this);
}
/*******************************************************************************
** Append record references.
*******************************************************************************/
@@ -150,12 +161,12 @@ public class MessageParts
/*******************************************************************************
** Remove record data (retention): the render payloads of tool parts and
** the record_refs parts. Tool summaries, text, navigation and notices
** the record_refs and presentation parts. Tool summaries, text, navigation and notices
** stay. Returns whether anything was removed.
*******************************************************************************/
public synchronized boolean stripRecordData()
{
boolean changed = parts.removeIf(p -> "record_refs".equals(p.get("type")));
boolean changed = parts.removeIf(p -> ("record_refs".equals(p.get("type")) || "presentation".equals(p.get("type"))));
for(Map<String, Object> part : parts)
{
if("tool".equals(part.get("type")) && part.remove("render") != null)
@@ -169,7 +180,7 @@ public class MessageParts
/*******************************************************************************
** All text parts joined (for the model's history and titles).
** Text and presentation fallbacks joined (for model history and titles).
*******************************************************************************/
public synchronized String text()
{
@@ -180,6 +191,10 @@ public class MessageParts
{
builder.append(part.get("text"));
}
else if("presentation".equals(part.get("type")))
{
builder.append("\n").append(part.get("markdown")).append("\n");
}
}
return (builder.toString());
}
@@ -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);
}
}
@@ -0,0 +1,281 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.engine;
import java.util.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.Set;
import java.util.concurrent.Semaphore;
import java.util.function.Consumer;
import java.util.regex.Pattern;
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.AssistantTurn;
import com.kingsrook.qbits.assistant.provider.CacheHint;
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.qbits.assistant.provider.ModelUsage;
import com.kingsrook.qbits.assistant.provider.StreamHandle;
import com.kingsrook.qbits.assistant.provider.SystemBlock;
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.session.QSession;
/*******************************************************************************
** Selects the initial model from the effective profile. Classifier content is
** strictly the fixed instruction plus the question; no context is attached.
*******************************************************************************/
public class ModelRouter
{
public static final String CLASSIFIER_PROMPT = "Classify the user's request as SIMPLE or COMPLEX. Reply with one word.";
private static final Pattern COMPLEX_WORDS = Pattern.compile("\\b(analyze|compare|why|trend|explain|summarize\\s+across|plan)\\b", Pattern.CASE_INSENSITIVE);
private final AssistantRuntime runtime;
private final ModelUsage usage = new ModelUsage();
private Long costMicros = 0L;
private Integer calls = 0;
/*******************************************************************************
** Routing returns accounting as well as the selected model.
*******************************************************************************/
public record Route(ProviderService.Active active, String effort, ModelUsage usage, Long costMicros, Integer calls)
{
}
/*******************************************************************************
** One router per turn; accounting is never shared between users.
*******************************************************************************/
public ModelRouter(AssistantRuntime runtime)
{
this.runtime = runtime;
}
/*******************************************************************************
** Preserve classifier accounting even when later route resolution fails.
*******************************************************************************/
public Route accounting()
{
return (new Route(null, null, usage, costMicros, calls));
}
/*******************************************************************************
** Each rule is independently sufficient. Boundaries are >600 characters,
** at least two distinct table names, and >8 previous turns. Match words,
** not substrings (for example, planet does not imply plan).
*******************************************************************************/
public static String heuristic(String question, Set<String> tableNames, Boolean writeContext, Integer priorTurns, Boolean previousToolLimit)
{
String text = question == null ? "" : question;
Long mentioned = tableNames.stream().filter(name -> Pattern.compile("(?<![\\p{L}\\p{N}_])" + Pattern.quote(name) + "(?![\\p{L}\\p{N}_])", Pattern.CASE_INSENSITIVE).matcher(text).find()).count();
return (Boolean.TRUE.equals(writeContext) || text.length() > 600 || mentioned >= 2 || COMPLEX_WORDS.matcher(text).find()
|| priorTurns > 8 || Boolean.TRUE.equals(previousToolLimit) ? "COMPLEX" : "SIMPLE");
}
/*******************************************************************************
** OFF preserves row/default/policy selection. With no profile, configured
** escalation supplies the complex slot and its effort when enabled.
*******************************************************************************/
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));
String mode = profile == null || profile.getRoutingMode() == null ? "OFF" : profile.getRoutingMode();
Boolean escalation = profile == null && Boolean.TRUE.equals(runtime.getConfig().getModels().getEscalationEnabled());
String decision = "OFF";
String source = null;
Integer selectedId = null;
String effort = policy.mainEffort();
if(!"OFF".equals(mode) || escalation)
{
List<AssistantTurn> previous = runtime.getStore().query(AssistantTurn.TABLE_NAME, new QQueryFilter()
.withCriteria("userId", QCriteriaOperator.EQUALS, turn.getUserId()).withCriteria("conversationId", QCriteriaOperator.EQUALS, conversation.getId())
.withCriteria("id", QCriteriaOperator.LESS_THAN, turn.getId()).withOrderBy(new QFilterOrderBy("id", false)).withLimit(9), AssistantTurn.class);
Boolean pending = !runtime.getStore().query(AssistantPendingAction.TABLE_NAME, new QQueryFilter()
.withCriteria("userId", QCriteriaOperator.EQUALS, turn.getUserId()).withCriteria("conversationId", QCriteriaOperator.EQUALS, conversation.getId())
.withCriteria("status", QCriteriaOperator.EQUALS, AssistantPendingAction.STATUS_PENDING).withLimit(1), AssistantPendingAction.class).isEmpty();
String digest = runtime.getContextBuilder().appDigest();
Set<String> tables = runtime.getQInstance().getTables().keySet().stream().filter(digest::contains).collect(Collectors.toSet());
decision = heuristic(question, tables, pending, previous.size(), !previous.isEmpty() && "tool_limit".equals(previous.get(0).getStopReason()));
source = "HEURISTIC";
if("CLASSIFIER".equals(mode) && profile.getClassifierModelId() != null)
{
String classified = classify(profile.getClassifierModelId(), question, turn, policy, classification, running, deadline, notice);
if(classified != null)
{
decision = classified;
source = "CLASSIFIER";
}
}
selectedId = profile == null ? null : ("COMPLEX".equals(decision) ? profile.getComplexModelId() : profile.getSimpleModelId());
}
turn.withRouteDecision(decision).withRouteSource(source);
ProviderService.Active active = selectedId == null ? null : runtime.getProviderService().resolveModel(selectedId, classification.getClassification() == DataClassification.PHI);
if(selectedId != null && active == null)
{
throw (new ProviderService.ProviderSetupException("The routed model is disabled, unavailable or not eligible for this conversation."));
}
if(active == null)
{
TurnDataRouting.beforeResolve(runtime, classification, turn, policy.mainModel());
active = runtime.getProviderService().resolve(policy.mainModel());
if(active != null && escalation && "COMPLEX".equals(decision))
{
String model = runtime.getConfig().getModels().getEscalationModel();
if(active.row() == null)
{
active = new ProviderService.Active(null, active.providerType(), model, active.provider());
}
else
{
active = runtime.getProviderService().forRow(new AssistantProvider(active.row().toQRecord()).withModel(model), model);
}
effort = runtime.getConfig().getModels().getEscalationEffort();
}
}
return (new Route(active, effort, usage, costMicros, calls));
}
/*******************************************************************************
** Buffer a classifier reply, with a three-second deadline and four output
** tokens. Errors and non-exact answers leave the heuristic decision intact.
*******************************************************************************/
private String classify(Integer modelId, String question, AssistantTurn turn, PolicyService.EffectivePolicy policy, TurnClassification classification,
RunningTurn running, Instant deadline, Consumer<String> notice) throws QException, InterruptedException
{
classification.inherit(runtime.getStore().getConversation(turn.getUserId(), turn.getConversationId()));
ProviderService.Active active = runtime.getProviderService().resolveModel(modelId, classification.getClassification() == DataClassification.PHI, false);
if(active == null || PhiRoutingGuard.check(classification, active.row(), runtime.getConfig().getHipaaMode()).isPresent())
{
return (null);
}
BudgetService.Decision budget = runtime.getBudgetService().evaluate(turn.getUserId(), active, classification.getClassification() == DataClassification.PHI, false);
budget.notices().forEach(notice);
PolicyService.BudgetStatus configured = runtime.getPolicyService().checkConfiguredBudget(policy, turn.getUserId(), 0, 0);
if(budget.stopped() || configured.hardExceeded() || !Objects.equals(budget.active().model(), active.model()) || !Objects.equals(budget.active().providerId(), active.providerId()))
{
return (null);
}
Long cap = ProviderService.capMicros(active.row());
if((cap != null && runtime.getProviderService().monthToDateCostMicros(active.providerId()) >= cap) || running.isCancelled() || runtime.getPolicyService().isKillSwitchOn() || !runtime.getClock().instant().isBefore(deadline))
{
return (null);
}
try
{
TurnDataRouting.beforeModelCall(runtime, classification, turn, active);
}
catch(TurnDataRouting.Blocked e)
{
return (null);
}
ModelRequest request = new ModelRequest().withModel(active.model()).withMaxTokens(4).withStreaming(false)
.withSystem(List.of(new SystemBlock(CLASSIFIER_PROMPT, CacheHint.NONE))).withMessages(List.of(ModelMessage.user(question)));
ModelResponse response = null;
ModelProviderException error = null;
StreamHandle handle = null;
Long started = System.nanoTime();
ProviderHealth health = new ProviderHealth(runtime);
Semaphore gate = runtime.getProviderService().gate(active.row());
Boolean acquired = false;
try
{
health.beforeCall(active);
if(gate != null)
{
acquired = gate.tryAcquire();
if(!acquired)
{
throw (new ModelProviderException(ModelProviderException.Kind.RATE_LIMITED, null, "Classifier concurrency limit reached."));
}
}
calls++;
handle = active.provider().stream(request, text ->
{
});
running.setCurrentHandle(handle);
Duration remaining = Duration.between(runtime.getClock().instant(), deadline);
response = handle.await(remaining.compareTo(Duration.ofSeconds(3)) < 0 ? remaining : Duration.ofSeconds(3));
if(!ModelResponse.STOP_CANCELLED.equals(response.getStopReason()))
{
health.succeeded(active);
String answer = response.getText().strip().toUpperCase(Locale.ROOT);
if(List.of("SIMPLE", "COMPLEX").contains(answer) && response.getToolUses().isEmpty())
{
return (answer);
}
}
return (null);
}
catch(ModelProviderException e)
{
error = e;
health.failed(active, e);
return (null);
}
catch(RuntimeException e)
{
error = new ModelProviderException(ModelProviderException.Kind.OTHER, null, "Classifier did not return a usable answer.");
return (null);
}
finally
{
if(acquired)
{
gate.release();
}
if(handle != null)
{
handle.cancel();
}
running.setCurrentHandle(null);
ModelUsage measured = response == null ? (error == null ? null : error.getPartialUsage()) : response.getUsage();
if(measured != null)
{
usage.add(measured);
costMicros += runtime.getProviderService().pricing(active, active.model()).costMicros(measured);
}
UsageRecorder.save(runtime.getStore(), UsageRecorder.row(new UsageRecorder.Call(turn.getUserId(), turn.getConversationId(), turn.getId(), active, UsageRecorder.PURPOSE_ROUTE),
response, error, (System.nanoTime() - started) / 1_000_000, runtime.getProviderService().pricing(active, active.model())));
}
}
}
@@ -215,7 +215,7 @@ public class ProfileService
/*******************************************************************************
**
*******************************************************************************/
private AssistantProfile pick(QSession session, String appName)
public AssistantProfile pick(QSession session, String appName)
{
List<AssistantProfile> enabled = load().profiles().stream().filter(p -> !Boolean.FALSE.equals(p.getEnabled())).toList();
return (enabled.stream()
@@ -0,0 +1,144 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.engine;
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.FutureTask;
import java.util.concurrent.TimeUnit;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qbits.assistant.provider.HealthResult;
import com.kingsrook.qbits.assistant.provider.ModelProviderException;
import com.kingsrook.qbits.assistant.provider.ProviderSettings;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Persisted sixty-second circuit breaker; only local endpoints are probed.
*******************************************************************************/
public class ProviderHealth
{
private final AssistantRuntime runtime;
/*******************************************************************************
** Use the runtime clock for all cache and breaker boundaries.
*******************************************************************************/
public ProviderHealth(AssistantRuntime runtime)
{
this.runtime = runtime;
}
/*******************************************************************************
** Refuse an open circuit; probe unknown or stale local endpoints within two seconds.
*******************************************************************************/
public void beforeCall(ProviderService.Active active) throws QException, ModelProviderException, InterruptedException
{
if(active.providerId() == null)
{
return;
}
AssistantProvider row = runtime.getStore().getProvider(active.providerId());
if(row == null || !Boolean.TRUE.equals(row.getEnabled()))
{
throw (new ModelProviderException(ModelProviderException.Kind.AUTHENTICATION, null, "Provider is disabled."));
}
if(isDown(row))
{
throw (new ModelProviderException(ModelProviderException.Kind.OVERLOADED, null, "Provider circuit is open."));
}
Instant now = runtime.getClock().instant();
if(!"LOCAL".equals(runtime.getProviderService().location(row)) || (row.getHealthCheckedAt() != null && row.getHealthCheckedAt().plusSeconds(60).isAfter(now)))
{
return;
}
ProviderSettings settings = runtime.getProviderService().settings(row).withConnectTimeoutSeconds(2).withRequestTimeoutSeconds(2);
FutureTask<HealthResult> probe = new FutureTask<>(() -> runtime.getConfig().getProviderTypes().get(row.getProviderType()).checkHealth(settings));
Thread.ofVirtual().name("assistant-health").start(probe);
try
{
HealthResult result = probe.get(Duration.ofSeconds(2).toMillis(), TimeUnit.MILLISECONDS);
if(Boolean.FALSE.equals(result.healthy()))
{
throw (new ModelProviderException(ModelProviderException.Kind.NETWORK, null, "Provider health check failed."));
}
save(row.getId(), Boolean.TRUE.equals(result.healthy()) ? "UP" : null, null);
}
catch(java.util.concurrent.ExecutionException | java.util.concurrent.TimeoutException e)
{
throw (new ModelProviderException(ModelProviderException.Kind.TIMEOUT, null, "Provider health check did not complete."));
}
finally
{
probe.cancel(true);
}
}
/*******************************************************************************
** A future downUntil alone keeps the circuit open; expiry permits recovery.
*******************************************************************************/
public Boolean isDown(AssistantProvider row)
{
return (row != null && row.getDownUntil() != null && row.getDownUntil().isAfter(runtime.getClock().instant()));
}
/*******************************************************************************
** Connection errors open the breaker; model errors do not poison the endpoint.
*******************************************************************************/
public void failed(ProviderService.Active active, ModelProviderException error) throws QException
{
if(active.providerId() != null && (error.getKind() == ModelProviderException.Kind.NETWORK || error.getKind() == ModelProviderException.Kind.TIMEOUT))
{
save(active.providerId(), "DOWN", runtime.getClock().instant().plusSeconds(60));
}
}
/*******************************************************************************
** A successful completion closes the breaker.
*******************************************************************************/
public void succeeded(ProviderService.Active active) throws QException
{
if(active.providerId() != null)
{
save(active.providerId(), "UP", null);
}
}
/*******************************************************************************
** Only health columns are changed, under the requesting session.
*******************************************************************************/
private void save(Integer providerId, String state, Instant downUntil) throws QException
{
runtime.getStore().updateFields(AssistantProvider.TABLE_NAME, new QRecord().withValue("id", providerId)
.withValue("healthState", state).withValue("healthCheckedAt", runtime.getClock().instant()).withValue("downUntil", downUntil));
runtime.getProviderService().invalidate();
}
}
@@ -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."));
}
@@ -740,9 +750,19 @@ public class ProviderService
** the default. Copy the row so the shared provider cache is never mutated.
*******************************************************************************/
public Active resolveModel(Integer modelId, boolean requiresHipaa) throws QException
{
return (resolveModel(modelId, requiresHipaa, true));
}
/*******************************************************************************
** Classifiers may use models without tool support.
*******************************************************************************/
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);
}
@@ -755,7 +775,7 @@ public class ProviderService
try
{
Active active = forRow(selected, model.getModelId());
return (active.provider().getCapabilities(active.model()) != null && Boolean.FALSE.equals(active.provider().getCapabilities(active.model()).getTools()) ? null : active);
return (requireTools && active.provider().getCapabilities(active.model()) != null && Boolean.FALSE.equals(active.provider().getCapabilities(active.model()).getTools()) ? null : active);
}
catch(ProviderSetupException e)
{
@@ -806,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,7 +40,10 @@ 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.
Answer style: brief and specific. Use short Markdown (lists, small tables). Refer to records with the links the tools return.
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.
""";
public static final String QQQ_PRIMER = """
@@ -27,6 +27,7 @@ import java.util.Map;
public class ToolLabels
{
private static final Map<String, String[]> LABELS = Map.ofEntries(
Map.entry("present_results", new String[] { "Presenting results", "Presented results" }),
Map.entry("list_tables", new String[] { "Listing tables", "Listed tables" }),
Map.entry("describe_table", new String[] { "Reading table details", "Read table details" }),
Map.entry("query_records", new String[] { "Querying records", "Queried records" }),
@@ -36,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" })
);
@@ -42,7 +42,7 @@ import com.kingsrook.qqq.backend.core.utils.JsonUtils;
*******************************************************************************/
public class TurnClassification
{
private static final Set<String> METADATA_TOOLS = Set.of("list_tables", "describe_table", "navigate", "search_help");
private static final Set<String> METADATA_TOOLS = Set.of("list_tables", "describe_table", "navigate", "search_help", "present_results");
private final ClassificationSource source;
private final Set<String> tables = new LinkedHashSet<>();
private DataClassification classification;
@@ -65,7 +65,8 @@ public class TurnClassification
/*******************************************************************************
** Observe before appending any result to a model request. Metadata tools carry no record values.
** Observe before appending results. Metadata reads no records; presentation
** only reuses values whose originating data tools were already classified.
*******************************************************************************/
public void observe(ModelPart use, AiToolResult result)
{
@@ -74,7 +75,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)
@@ -97,6 +98,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();
@@ -17,6 +17,21 @@
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.Set;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.Future;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.model.AssistantConversation;
@@ -36,6 +51,9 @@ 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.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.AiRecordRef;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolCall;
@@ -49,21 +67,6 @@ 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 static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
@@ -101,6 +104,7 @@ public class TurnEngine implements Runnable
private static final Set<AiToolOutcome> ERROR_OUTCOMES = Set.of(AiToolOutcome.DENIED, AiToolOutcome.NOT_FOUND, AiToolOutcome.INVALID_INPUT, AiToolOutcome.ERROR);
private final PresentResultsTool presenter;
private final AssistantRuntime runtime;
private final AssistantQBitConfig config;
private final AssistantStore store;
@@ -130,11 +134,10 @@ public class TurnEngine implements Runnable
private long costMicros = 0;
private TurnClassification classification;
private FallbackChain fallback;
private ProviderService.Active active;
private String model;
private ProviderService.Active budgetOrigin;
private Long capMicros;
private long monthCostAtStart = 0;
private volatile boolean finished = false;
@@ -147,6 +150,7 @@ public class TurnEngine implements Runnable
AssistantTurn turn, String userText, Map<String, Object> pageContext, PolicyService.EffectivePolicy policy, RunningTurn running, String softBudgetNotice)
{
this.runtime = runtime;
this.presenter = new PresentResultsTool(runtime.getQInstance());
this.config = runtime.getConfig();
this.store = runtime.getStore();
this.session = session;
@@ -193,19 +197,26 @@ public class TurnEngine implements Runnable
persistPendingEvents();
model = policy.mainModel();
TurnDataRouting.beforeResolve(runtime, classification, turn, policy.mainModel());
active = runtime.getProviderService().resolve(policy.mainModel());
ModelRouter router = new ModelRouter(runtime);
ModelRouter.Route route;
try
{
route = router.route(session, conversation, turn, userText, pageContext, policy, classification, running, deadline, text -> notice("warning", text));
}
finally
{
usage.add(router.accounting().usage());
costMicros += router.accounting().costMicros();
modelCallCount += router.accounting().calls();
}
active = route.active();
fallback = new FallbackChain(runtime, classification, turn, text -> notice("warning", text));
if(active == null)
{
throw (new ModelProviderException(ModelProviderException.Kind.AUTHENTICATION, null, "No usable assistant provider is configured."));
}
model = active.model();
budgetOrigin = active;
capMicros = ProviderService.capMicros(active.row());
if(capMicros != null)
{
monthCostAtStart = runtime.getProviderService().monthToDateCostMicros(active.providerId());
}
AssistantModelProvider provider = active.provider();
ModelCapabilities caps = provider.getCapabilities(model);
@@ -221,7 +232,7 @@ public class TurnEngine implements Runnable
String appName = runtime.appNameFor(pageContext);
ProfileService.Persona persona = runtime.getProfileService().persona(session, appName);
String profileText = ProfileService.instructions(persona, runtime.getProfileService().relevantSkills(persona.profileId(), userText, appName));
ModelRequest request = runtime.getContextBuilder().build(session, model, policy.mainEffort(), history, userText, pageContext, policy.tools(), profileText);
ModelRequest request = runtime.getContextBuilder().build(session, model, route.effort(), history, userText, pageContext, policy.tools(), profileText);
Integer maxPerRequest = active.row() == null ? null : active.row().getMaxOutputTokensPerRequest();
if(maxPerRequest != null && (request.getMaxTokens() == null || request.getMaxTokens() > maxPerRequest))
{
@@ -246,15 +257,7 @@ public class TurnEngine implements Runnable
break;
}
if(capMicros != null && monthCostAtStart + costMicros >= capMicros)
{
status = "ERROR";
errorCode = "BUDGET";
errorText = "The assistant has reached this provider's monthly cost cap. Ask an administrator.";
break;
}
ModelResponse response = callModelWithStrictFallback(provider, request);
ModelResponse response = fallback.call(request, () -> active, selected -> callModelWithStrictFallback(selected, request));
stopReason = response.getStopReason();
if(ModelResponse.STOP_CANCELLED.equals(stopReason))
@@ -351,11 +354,6 @@ public class TurnEngine implements Runnable
}
catch(ModelProviderException e)
{
if(e.getPartialUsage() != null)
{
usage.add(e.getPartialUsage());
costMicros += pricing().costMicros(e.getPartialUsage());
}
status = "ERROR";
errorCode = e.getKind() == ModelProviderException.Kind.TIMEOUT ? "TIMEOUT" : "PROVIDER";
errorText = e.getKind() == ModelProviderException.Kind.TIMEOUT
@@ -369,7 +367,7 @@ public class TurnEngine implements Runnable
errorText += " The provider said: " + UsageRecorder.errorText(e);
}
LOG.warn("Assistant model call failed", logPair("turnId", turn.getId()), logPair("kind", e.getKind()), logPair("httpStatus", e.getHttpStatus()),
logPair("errorType", hipaaSafe(e).getProviderErrorType()), logPair("errorMessage", hipaaSafe(e).getProviderMessage()), logPair("requestId", hipaaSafe(e).getRequestId()));
logPair("errorType", hipaaSafe(e.getProviderErrorType())), logPair("errorMessage", hipaaSafe(e.getProviderMessage())), logPair("requestId", hipaaSafe(e.getRequestId())));
}
catch(InterruptedException e)
{
@@ -514,23 +512,23 @@ public class TurnEngine implements Runnable
** tool schemas, retry once (and for the rest of the turn) with no strict
** tools. The kernel validates every tool input either way.
*******************************************************************************/
private ModelResponse callModelWithStrictFallback(AssistantModelProvider provider, ModelRequest request) throws QException, ModelProviderException, InterruptedException
private ModelResponse callModelWithStrictFallback(ProviderService.Active candidate, ModelRequest request) throws QException, ModelProviderException, InterruptedException
{
try
{
return (callModel(provider, request));
return (callModel(candidate, request));
}
catch(ModelProviderException e)
{
boolean anyStrict = request.getTools().stream().anyMatch(tool -> Boolean.TRUE.equals(tool.getStrict()));
if(!e.isStrictSchemaRejected() || !anyStrict)
if(!e.isStrictSchemaRejected() || !anyStrict || fallback.hasOutput())
{
throw (e);
}
LOG.warn("Assistant provider refused the strict tool schemas; retrying without strict tools", logPair("turnId", turn.getId()), logPair("model", model),
logPair("errorMessage", hipaaSafe(e).getProviderMessage()), logPair("requestId", hipaaSafe(e).getRequestId()));
logPair("errorMessage", hipaaSafe(e.getProviderMessage())), logPair("requestId", hipaaSafe(e.getRequestId())));
request.getTools().forEach(tool -> tool.setStrict(false));
return (callModel(provider, request));
return (callModel(candidate, request));
}
}
@@ -539,11 +537,19 @@ public class TurnEngine implements Runnable
/*******************************************************************************
** One streamed model call.
*******************************************************************************/
private ModelResponse callModel(AssistantModelProvider provider, ModelRequest request) throws QException, ModelProviderException, InterruptedException
private ModelResponse callModel(ProviderService.Active candidate, ModelRequest request) throws QException, ModelProviderException, InterruptedException
{
if(checkStop() != null)
{
return (new ModelResponse().withStopReason(ModelResponse.STOP_CANCELLED));
}
PolicyService.BudgetStatus configuredBudget = runtime.getPolicyService().checkConfiguredBudget(policy, userId, usage.billableTokens(), costMicros);
if(configuredBudget.hardExceeded())
{
throw (new BudgetService.Blocked(configuredBudget.message()));
}
classification.inherit(store.getConversation(userId, conversation.getId()));
BudgetService.Decision budgetDecision = runtime.getBudgetService().evaluate(userId, budgetOrigin,
classification.getClassification() == com.kingsrook.qbits.assistant.classification.DataClassification.PHI, false);
BudgetService.Decision budgetDecision = fallback.budget(budgetOrigin, candidate);
budgetDecision.notices().forEach(text -> notice("warning", text));
if(budgetDecision.stopped())
{
@@ -555,7 +561,7 @@ public class TurnEngine implements Runnable
active = selected;
model = active.model();
request.setModel(model);
request.getMessages().forEach(message -> message.withProviderState(null));
FallbackChain.neutralHistory(request);
request.getTools().forEach(tool -> tool.setStrict(false));
ModelCapabilities capabilities = active.provider().getCapabilities(model);
if(capabilities != null && Boolean.FALSE.equals(capabilities.getParallelToolCalls()))
@@ -567,9 +573,8 @@ public class TurnEngine implements Runnable
{
request.setMaxTokens(maxTokens);
}
capMicros = null;
}
provider = active.provider();
AssistantModelProvider provider = active.provider();
Long selectedCap = ProviderService.capMicros(active.row());
if(selectedCap != null && runtime.getProviderService().monthToDateCostMicros(active.providerId()) >= selectedCap)
{
@@ -599,8 +604,9 @@ public class TurnEngine implements Runnable
try
{
TurnDataRouting.beforeModelCall(runtime, classification, turn, active);
fallback.beforeCall(active);
modelCallCount++;
StreamHandle handle = provider.stream(request.copy(), listener);
StreamHandle handle = provider.stream(request.copy(), fallback.listener(listener));
running.setCurrentHandle(handle);
try
{
@@ -616,6 +622,11 @@ public class TurnEngine implements Runnable
catch(ModelProviderException e)
{
recordUsage(null, e, started);
if(e.getPartialUsage() != null)
{
usage.add(e.getPartialUsage());
costMicros += pricing().costMicros(e.getPartialUsage());
}
throw (e);
}
finally
@@ -683,7 +694,11 @@ public class TurnEngine implements Runnable
{
if(!running.isCancelled())
{
if(ContextBuilder.KERNEL_TOOLS.contains(call.getName()))
if(PresentResultsTool.NAME.equals(call.getName()))
{
results[index] = presenter.execute(toolContext(), call.getId(), call.getInput());
}
else if(ContextBuilder.KERNEL_TOOLS.contains(call.getName()))
{
///////////////////////////////////////////////////
// navigate / search_help: no records are read //
@@ -698,8 +713,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
{
@@ -792,7 +819,8 @@ public class TurnEngine implements Runnable
}
Map<String, Object> data = map("messageId", assistantMessage.getId(), "toolCallId", use.getToolUseId(), "name", use.getToolName(), "outcome", outcome, "summary", summary);
if(result.getRender() != null)
Boolean presentation = PresentResultsTool.NAME.equals(use.getToolName()) && AiToolOutcome.OK.equals(result.getOutcome()) && result.getRender() != null;
if(result.getRender() != null && !presentation)
{
data.put("render", result.getRender());
}
@@ -802,7 +830,12 @@ public class TurnEngine implements Runnable
}
emit(TurnEvent.TOOL_RESULT, data);
parts.addTool(use.getToolUseId(), use.getToolName(), summary, outcome, result.getRender());
parts.addTool(use.getToolUseId(), use.getToolName(), summary, outcome, presentation ? null : result.getRender());
if(presentation)
{
parts.addPresentation(result.getRender());
emit(TurnEvent.PRESENTATION, map("messageId", assistantMessage.getId(), "part", result.getRender()));
}
parts.addRecordRefs(refs);
String href = result.getNavigateHref();
@@ -820,7 +853,7 @@ public class TurnEngine implements Runnable
.withUserId(userId)
.withToolCallId(use.getToolUseId())
.withToolName(use.getToolName())
.withInputJson(truncate(redactInput(use.getInput()), MAX_INPUT_JSON_CHARS))
.withInputJson(truncate(redactInput(PresentResultsTool.NAME.equals(use.getToolName()) ? (presentation ? result.getRender() : Map.of()) : use.getInput()), MAX_INPUT_JSON_CHARS))
.withOutcome(outcome)
.withPermissionCheck(result.getPermissionCheck())
.withTableName(result.getTableName())
@@ -829,9 +862,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()));
@@ -849,6 +882,21 @@ 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));
@@ -904,7 +952,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("\\"));
}
@@ -982,6 +1030,10 @@ public class TurnEngine implements Runnable
.withValue("errorCode", errorCode)
.withValue("cancelReason", running.getCancelReason())
.withValue("model", model)
.withValue("providerId", active == null ? null : active.providerId())
.withValue("routeDecision", turn.getRouteDecision())
.withValue("routeSource", turn.getRouteSource())
.withValue("fallbackCount", turn.getFallbackCount())
.withValue("endedAt", now)
.withValue("toolCallCount", toolCallCount)
.withValue("modelCallCount", modelCallCount)
@@ -33,6 +33,7 @@ public class TurnEvent
public static final String TEXT_DELTA = "text.delta";
public static final String TOOL_START = "tool.start";
public static final String TOOL_RESULT = "tool.result";
public static final String PRESENTATION = "presentation";
public static final String UI_NAVIGATE = "ui.navigate";
public static final String NOTICE = "notice";
public static final String USAGE = "usage";
@@ -39,6 +39,8 @@ 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";
public static final String STATUS_ERROR = "ERROR";
@@ -17,6 +17,7 @@
package com.kingsrook.qbits.assistant.home;
import com.kingsrook.qbits.assistant.model.AssistantTurn;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.AssistantSecurity;
import com.kingsrook.qbits.assistant.engine.BudgetService;
@@ -73,6 +74,7 @@ public class AssistantHomeData
private final List<BudgetService.Snapshot> budgets = new ArrayList<>();
private final List<Totals> byProvider = new ArrayList<>();
private Long fallbackCount = 0L;
private Boolean hipaaMode;
private Integer hipaaRetentionDays;
private LocalDate today;
@@ -239,6 +241,16 @@ public class AssistantHomeData
// conversations started per day //
////////////////////////////////////////
Instant dayOneAt = firstDay.atStartOfDay().toInstant(ZoneOffset.UTC);
List<QRecord> routedTurns = runtime.getStore().queryRecords(AssistantTurn.TABLE_NAME, new QQueryFilter()
.withCriteria("startedAt", QCriteriaOperator.GREATER_THAN_OR_EQUALS, dayOneAt), max);
data.truncated |= routedTurns.size() >= max;
for(QRecord row : routedTurns)
{
if(row.getValueInteger("fallbackCount") != null)
{
data.fallbackCount += row.getValueInteger("fallbackCount");
}
}
List<QRecord> conversations = runtime.getStore().queryRecords(AssistantConversation.TABLE_NAME, new QQueryFilter()
.withCriteria("createDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, dayOneAt), max);
data.truncated |= conversations.size() >= max;
@@ -556,4 +568,14 @@ public class AssistantHomeData
{
return (byProvider);
}
/*******************************************************************************
** Provider switches in the displayed period, including recovered failures.
*******************************************************************************/
public Long getFallbackCount()
{
return (fallbackCount);
}
}
@@ -193,6 +193,11 @@ public final class AssistantHomeView
}
html.append("</div><div style=\"").append(CELLS).append("\">");
cell(html, "Type", nvl(p.typeLabel(), "Not set"));
cell(html, "Health", nvl(row.getHealthState(), "Unknown"));
if(row.getDownUntil() != null)
{
cell(html, "Down until", when(row.getDownUntil()));
}
cell(html, "Kind", AssistantRecordScreens.providerKindLabel(p.kind()));
cell(html, "Location", "LOCAL".equals(p.location()) ? "Local" : "Cloud");
if(Boolean.TRUE.equals(row.getHipaaEligible()))
@@ -423,6 +428,7 @@ public final class AssistantHomeView
private static void failures(StringBuilder html, AssistantHomeData data, String prefix)
{
open(html, "Recent failures");
html.append("<p>").append(esc(number(data.getFallbackCount()) + " fallbacks in the last " + AssistantHomeData.DAYS + " days.")).append("</p>");
if(data.getFailedCalls().isEmpty() && data.getFailedTools().isEmpty())
{
empty(html, "No failed model calls or tool calls in the last " + AssistantHomeData.DAYS + " days.");
@@ -30,6 +30,7 @@ import com.kingsrook.qbits.assistant.model.AssistantDataAudit;
import com.kingsrook.qbits.assistant.model.AssistantFeedback;
import com.kingsrook.qbits.assistant.model.AssistantMessage;
import com.kingsrook.qbits.assistant.model.AssistantModel;
import com.kingsrook.qbits.assistant.model.AssistantRagSource;
import com.kingsrook.qbits.assistant.model.AssistantModelPrice;
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
import com.kingsrook.qbits.assistant.model.AssistantPolicy;
@@ -110,7 +111,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(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 +187,13 @@ public class AssistantMetaDataBuilder
.withUniqueKey(new UniqueKey("name"))
.withRecordLabelFormat("%s").withRecordLabelFields("name"));
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"));
@@ -92,6 +92,15 @@ public class AssistantProfileCustomizer implements TableCustomizerInterface
*******************************************************************************/
private static void checkProfile(QRecord record, QRecord merged)
{
String routingMode = merged.getValueString("routingMode");
if(routingMode == null)
{
record.setValue("routingMode", "OFF");
}
else if(!List.of("OFF", "HEURISTIC", "CLASSIFIER").contains(routingMode))
{
record.addError(new BadInputStatusMessage("Routing mode must be OFF, HEURISTIC or CLASSIFIER."));
}
String personality = merged.getValueString("personality");
if(personality != null && personality.length() > ProfileService.MAX_PERSONALITY_CHARS)
{
@@ -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);
}
}
@@ -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,7 @@ 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.AssistantSkill;
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
import com.kingsrook.qbits.assistant.model.AssistantTurn;
@@ -47,6 +53,9 @@ 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.metadata.QInstance;
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.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 +73,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;
/*******************************************************************************
@@ -128,6 +133,7 @@ public final class AssistantRecordScreens
return (List.of(
QPossibleValueSource.newForTable(AssistantBudget.TABLE_NAME),
QPossibleValueSource.newForTable(AssistantModel.TABLE_NAME).withName("assistant_registry_model"),
QPossibleValueSource.newForTable(AssistantModel.TABLE_NAME).withName("assistant_budget_model"),
budgetEnum("scopeType", "INSTANCE", "USER", "ROLE", "PROVIDER", "MODEL"),
budgetEnum("period", "DAY", "MONTH"),
@@ -183,6 +189,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);
@@ -285,16 +292,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.
@@ -330,6 +351,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");
@@ -365,6 +391,7 @@ public final class AssistantRecordScreens
section("connection", "Connection", "key", Tier.T2, "apiKeyEnvName", ProviderService.KEY_STATUS_FIELD, "baseUrl", "tlsCaBundle", "cloudProject"),
section("limits", "Limits", "speed", Tier.T2, "monthlyCostCapUsd", "maxOutputTokensPerRequest", "maxConcurrentRequests"),
section("registry", "Provider settings", "settings", Tier.T2, "kind", "location", "hipaaEligible", "region", "apiVersion", "connectTimeoutSeconds", "requestTimeoutSeconds"),
section("health", "Health", "network_check", Tier.T2, "healthState", "healthCheckedAt", "downUntil"),
section("discovery", "Last discovery", "memory", Tier.T2, "lastDiscoveryAt", "lastDiscoveryStatus", "lastDiscoveryError"),
section("lastTest", "Last connection test", "network_check", Tier.T2, "lastTestAt", "lastTestStatus", "lastTestLatencyMs", "lastTestModel", "lastTestError"));
}
@@ -377,6 +404,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.");
@@ -406,7 +438,7 @@ public final class AssistantRecordScreens
micros(table, "costMicros");
counts(table, "inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "latencyMs");
table.getField("latencyMs").withLabel("Latency (ms)");
help(table, "purpose", "TURN: a step of an answer. TEST: a Test connection call.");
help(table, "purpose", "TURN: a step of an answer. ROUTE: a routing classifier call. TEST: a Test connection call.");
sections(table,
section("call", "Model Call", "receipt_long", Tier.T1, "id", "purpose", "status", "model", "providerId", "providerType", "userId", "conversationId", "turnId"),
section("result", "Result", "fact_check", Tier.T2, "latencyMs", "errorKind", "httpStatus", "requestId"),
@@ -467,11 +499,13 @@ public final class AssistantRecordScreens
*******************************************************************************/
private static void turn(QTableMetaData table)
{
link(table, "providerId", AssistantProvider.TABLE_NAME, "Final provider");
link(table, "userMessageId", AssistantMessage.TABLE_NAME, "User Message");
link(table, "assistantMessageId", AssistantMessage.TABLE_NAME, "Assistant Message");
micros(table, "costMicros");
counts(table, "inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "toolCallCount", "modelCallCount");
sections(table,
section("routing", "Routing", "route", Tier.T2, "routeDecision", "routeSource", "fallbackCount", "providerId"),
section("turn", "Turn", "sync_alt", Tier.T1, "id", "conversationId", "status", "model", "startedAt", "endedAt", "userMessageId", "assistantMessageId", "userId"),
section("outcome", "Outcome", "flag", Tier.T2, "stopReason", "cancelReason", "errorCode"),
section("usage", "Usage", "query_stats", Tier.T2, "inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "costMicros", "toolCallCount", "modelCallCount"),
@@ -520,6 +554,10 @@ public final class AssistantRecordScreens
help(table, "name", "An internal name for this profile.");
help(table, "displayName", "The assistant's name in the panel header and its messages, e.g. Ada.");
help(table, "icon", "A Material icon name for the panel, e.g. auto_awesome, support_agent, school.");
pvs(table, "routingMode", "assistant_routing_mode", "Routing mode");
pvs(table, "simpleModelId", "assistant_registry_model", "Simple model");
pvs(table, "complexModelId", "assistant_registry_model", "Complex model");
pvs(table, "classifierModelId", "assistant_registry_model", "Classifier model");
help(table, "personality", "Tone and style instructions, at most " + ProfileService.MAX_PERSONALITY_CHARS + " characters, e.g. \"Friendly and brief. Use the user's first name.\" Added to the assistant's instructions; it never changes its rules, tools or what the user may do.");
help(table, "greeting", "Shown when a new conversation opens.");
help(table, "starterPrompts", "Suggested first questions, one per line (at most " + ProfileService.MAX_STARTER_PROMPTS + ").");
@@ -528,6 +566,7 @@ public final class AssistantRecordScreens
help(table, "permissionName", "Optional: use this profile only for users who hold this permission (a role).");
sections(table,
section("profile", "Profile", "face", Tier.T1, "id", "name", "displayName", "icon", "enabled", "isDefault"),
section("routing", "Model routing", "route", Tier.T2, "routingMode", "simpleModelId", "complexModelId", "classifierModelId"),
section("voice", "Personality", "record_voice_over", Tier.T2, "personality", "greeting", "starterPrompts"),
section("scope", "Where it applies", "filter_alt", Tier.T2, "appName", "permissionName"));
}
@@ -878,6 +917,8 @@ 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");
table.getField("supportsReasoningEffort").withLabel("Supports reasoning effort");
@@ -885,7 +926,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"),
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"));
@@ -925,4 +966,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"));
}
}
@@ -17,6 +17,8 @@
package com.kingsrook.qbits.assistant.metadata;
import com.kingsrook.qbits.assistant.model.AssistantModel;
import com.kingsrook.qbits.assistant.engine.FallbackChain;
import java.io.Serializable;
import java.util.List;
import java.util.Map;
@@ -77,10 +79,22 @@ public class AssistantSettingsCustomizer implements TableCustomizerInterface
public List<QRecord> preInsertOrUpdate(AbstractActionInput input, List<QRecord> records, boolean isPreview, Optional<List<QRecord>> oldRecordList) throws QException
{
ProviderService service = service();
if(com.kingsrook.qbits.assistant.model.AssistantModel.TABLE_NAME.equals(tableName(input)))
if(AssistantModel.TABLE_NAME.equals(tableName(input)))
{
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)
@@ -93,6 +107,7 @@ public class AssistantSettingsCustomizer implements TableCustomizerInterface
}
}
}
validateFallbacks(records, oldRecordList);
return (records);
}
if(service == null || !AssistantProvider.TABLE_NAME.equals(tableName(input)))
@@ -168,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);
}
@@ -204,6 +238,7 @@ public class AssistantSettingsCustomizer implements TableCustomizerInterface
{
String name = entry.getKey();
boolean system = name.equals("id") || name.equals("modifyDate") || name.startsWith("lastTest") || name.startsWith("lastDiscovery")
|| List.of("healthState", "healthCheckedAt", "downUntil").contains(name)
|| (name.equals("isDefault") && Boolean.FALSE.equals(entry.getValue()));
if(!system)
{
@@ -242,4 +277,54 @@ public class AssistantSettingsCustomizer implements TableCustomizerInterface
return (runtime == null ? null : runtime.getProviderService());
}
/*******************************************************************************
** Validate the resulting graph, including all changes in this save batch.
*******************************************************************************/
private void validateFallbacks(List<QRecord> records, Optional<List<QRecord>> oldRecords) throws QException
{
AssistantRuntime runtime = AssistantRuntime.of(QContext.getQInstance());
if(runtime == null)
{
return;
}
Map<Integer, AssistantModel> models = new java.util.LinkedHashMap<>();
for(AssistantModel model : runtime.getStore().listModels())
{
models.put(model.getId(), model);
}
Optional<Map<Serializable, QRecord>> old = oldRecordListToMap("id", oldRecords);
List<AssistantModel> proposed = new java.util.ArrayList<>();
for(QRecord record : records)
{
QRecord merged = new QRecord();
old.map(m -> m.get(record.getValue("id"))).ifPresent(o -> merged.setValues(new java.util.LinkedHashMap<>(o.getValues())));
record.getValues().forEach(merged::setValue);
AssistantModel model = new AssistantModel(merged);
proposed.add(model);
if(model.getId() != null)
{
models.put(model.getId(), model);
}
}
for(Integer i = 0; i < records.size(); i++)
{
String error = FallbackChain.validate(proposed.get(i), models);
if(error != null)
{
records.get(i).addError(new BadInputStatusMessage(error));
}
}
for(AssistantModel model : models.values())
{
String error = FallbackChain.validate(model, models, false);
if(error != null)
{
records.forEach(record -> record.addError(new BadInputStatusMessage(error)));
break;
}
}
}
}
@@ -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())));
}
}
@@ -32,6 +32,15 @@ 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;
@QField(isEditable = false, isPrimaryKey = true)
private Integer id;
@@ -764,4 +773,107 @@ public class AssistantModel extends QRecordEntity
return (this);
}
/*******************************************************************************
** Get fallbackModelId.
*******************************************************************************/
public Integer getFallbackModelId()
{
return (this.fallbackModelId);
}
/*******************************************************************************
** Set fallbackModelId.
*******************************************************************************/
public void setFallbackModelId(Integer fallbackModelId)
{
this.fallbackModelId = fallbackModelId;
}
/*******************************************************************************
** With fallbackModelId.
*******************************************************************************/
public AssistantModel withFallbackModelId(Integer fallbackModelId)
{
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));
}
}
@@ -32,6 +32,18 @@ public class AssistantProfile extends QRecordEntity
{
public static final String TABLE_NAME = "assistant_profile";
@QField(maxLength = 16)
private String routingMode = "OFF";
@QField()
private Integer simpleModelId;
@QField()
private Integer complexModelId;
@QField()
private Integer classifierModelId;
@QField(isEditable = false, isPrimaryKey = true)
private Integer id;
@@ -481,4 +493,128 @@ public class AssistantProfile extends QRecordEntity
return (this);
}
/*******************************************************************************
** Get routingMode.
*******************************************************************************/
public String getRoutingMode()
{
return (this.routingMode);
}
/*******************************************************************************
** Set routingMode.
*******************************************************************************/
public void setRoutingMode(String routingMode)
{
this.routingMode = routingMode;
}
/*******************************************************************************
** With routingMode.
*******************************************************************************/
public AssistantProfile withRoutingMode(String routingMode)
{
this.routingMode = routingMode;
return (this);
}
/*******************************************************************************
** Get simpleModelId.
*******************************************************************************/
public Integer getSimpleModelId()
{
return (this.simpleModelId);
}
/*******************************************************************************
** Set simpleModelId.
*******************************************************************************/
public void setSimpleModelId(Integer simpleModelId)
{
this.simpleModelId = simpleModelId;
}
/*******************************************************************************
** With simpleModelId.
*******************************************************************************/
public AssistantProfile withSimpleModelId(Integer simpleModelId)
{
this.simpleModelId = simpleModelId;
return (this);
}
/*******************************************************************************
** Get complexModelId.
*******************************************************************************/
public Integer getComplexModelId()
{
return (this.complexModelId);
}
/*******************************************************************************
** Set complexModelId.
*******************************************************************************/
public void setComplexModelId(Integer complexModelId)
{
this.complexModelId = complexModelId;
}
/*******************************************************************************
** With complexModelId.
*******************************************************************************/
public AssistantProfile withComplexModelId(Integer complexModelId)
{
this.complexModelId = complexModelId;
return (this);
}
/*******************************************************************************
** Get classifierModelId.
*******************************************************************************/
public Integer getClassifierModelId()
{
return (this.classifierModelId);
}
/*******************************************************************************
** Set classifierModelId.
*******************************************************************************/
public void setClassifierModelId(Integer classifierModelId)
{
this.classifierModelId = classifierModelId;
}
/*******************************************************************************
** With classifierModelId.
*******************************************************************************/
public AssistantProfile withClassifierModelId(Integer classifierModelId)
{
this.classifierModelId = classifierModelId;
return (this);
}
}
@@ -71,6 +71,15 @@ public class AssistantProvider extends QRecordEntity
public static final String TABLE_NAME = "assistant_provider";
@QField(isEditable = false, maxLength = 8)
private String healthState;
@QField(isEditable = false)
private Instant healthCheckedAt;
@QField(isEditable = false)
private Instant downUntil;
@QField(isEditable = false, isPrimaryKey = true)
private Integer id;
@@ -1116,4 +1125,97 @@ public class AssistantProvider extends QRecordEntity
this.cloudProject = cloudProject;
return (this);
}
/*******************************************************************************
** Get healthState.
*******************************************************************************/
public String getHealthState()
{
return (this.healthState);
}
/*******************************************************************************
** Set healthState.
*******************************************************************************/
public void setHealthState(String healthState)
{
this.healthState = healthState;
}
/*******************************************************************************
** With healthState.
*******************************************************************************/
public AssistantProvider withHealthState(String healthState)
{
this.healthState = healthState;
return (this);
}
/*******************************************************************************
** Get healthCheckedAt.
*******************************************************************************/
public Instant getHealthCheckedAt()
{
return (this.healthCheckedAt);
}
/*******************************************************************************
** Set healthCheckedAt.
*******************************************************************************/
public void setHealthCheckedAt(Instant healthCheckedAt)
{
this.healthCheckedAt = healthCheckedAt;
}
/*******************************************************************************
** With healthCheckedAt.
*******************************************************************************/
public AssistantProvider withHealthCheckedAt(Instant healthCheckedAt)
{
this.healthCheckedAt = healthCheckedAt;
return (this);
}
/*******************************************************************************
** Get downUntil.
*******************************************************************************/
public Instant getDownUntil()
{
return (this.downUntil);
}
/*******************************************************************************
** Set downUntil.
*******************************************************************************/
public void setDownUntil(Instant downUntil)
{
this.downUntil = downUntil;
}
/*******************************************************************************
** With downUntil.
*******************************************************************************/
public AssistantProvider withDownUntil(Instant downUntil)
{
this.downUntil = downUntil;
return (this);
}
}
@@ -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);
}
}
@@ -44,6 +44,18 @@ public class AssistantTurn extends QRecordEntity
@QField(isEditable = false, maxLength = 500)
private String classificationTables;
@QField(isEditable = false, maxLength = 16)
private String routeDecision;
@QField(isEditable = false, maxLength = 16)
private String routeSource;
@QField(isEditable = false)
private Integer fallbackCount;
@QField(isEditable = false)
private Integer providerId;
@QField(isEditable = false, isPrimaryKey = true)
private Integer id;
@@ -875,4 +887,128 @@ public class AssistantTurn extends QRecordEntity
/*******************************************************************************
** Get routeDecision.
*******************************************************************************/
public String getRouteDecision()
{
return (this.routeDecision);
}
/*******************************************************************************
** Set routeDecision.
*******************************************************************************/
public void setRouteDecision(String routeDecision)
{
this.routeDecision = routeDecision;
}
/*******************************************************************************
** With routeDecision.
*******************************************************************************/
public AssistantTurn withRouteDecision(String routeDecision)
{
this.routeDecision = routeDecision;
return (this);
}
/*******************************************************************************
** Get routeSource.
*******************************************************************************/
public String getRouteSource()
{
return (this.routeSource);
}
/*******************************************************************************
** Set routeSource.
*******************************************************************************/
public void setRouteSource(String routeSource)
{
this.routeSource = routeSource;
}
/*******************************************************************************
** With routeSource.
*******************************************************************************/
public AssistantTurn withRouteSource(String routeSource)
{
this.routeSource = routeSource;
return (this);
}
/*******************************************************************************
** Get fallbackCount.
*******************************************************************************/
public Integer getFallbackCount()
{
return (this.fallbackCount);
}
/*******************************************************************************
** Set fallbackCount.
*******************************************************************************/
public void setFallbackCount(Integer fallbackCount)
{
this.fallbackCount = fallbackCount;
}
/*******************************************************************************
** With fallbackCount.
*******************************************************************************/
public AssistantTurn withFallbackCount(Integer fallbackCount)
{
this.fallbackCount = fallbackCount;
return (this);
}
/*******************************************************************************
** Get providerId.
*******************************************************************************/
public Integer getProviderId()
{
return (this.providerId);
}
/*******************************************************************************
** Set providerId.
*******************************************************************************/
public void setProviderId(Integer providerId)
{
this.providerId = providerId;
}
/*******************************************************************************
** With providerId.
*******************************************************************************/
public AssistantTurn withProviderId(Integer providerId)
{
this.providerId = providerId;
return (this);
}
}
@@ -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.classification.DataClassification;
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
import com.kingsrook.qbits.assistant.model.AssistantRagSource;
import com.kingsrook.qbits.assistant.rag.RagService;
import com.kingsrook.qqq.backend.core.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);
}
}
}
}
@@ -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."));
}
}
@@ -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."));
}
}
}
@@ -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)
{
}
@@ -36,6 +36,7 @@ public class ModelRequest
private ModelCapabilities capabilities;
private Boolean streaming = true;
private String model;
private String effort;
private Integer maxTokens;
@@ -52,6 +53,7 @@ public class ModelRequest
public ModelRequest copy()
{
return (new ModelRequest()
.withStreaming(streaming)
.withModel(model)
.withCapabilities(capabilities)
.withEffort(effort)
@@ -311,4 +313,25 @@ public class ModelRequest
return (this);
}
/*******************************************************************************
** Whether to request streaming transport (classifiers use one JSON reply).
*******************************************************************************/
public Boolean getStreaming()
{
return (streaming);
}
/*******************************************************************************
** Select streaming transport independently of model capability.
*******************************************************************************/
public ModelRequest withStreaming(Boolean streaming)
{
this.streaming = streaming;
return (this);
}
}
@@ -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,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.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.classification.DataClassification;
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.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());
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);
}
}
}
}
@@ -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()).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;
}
@@ -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.classification.DataClassification;
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.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);
}
}
}
@@ -0,0 +1,727 @@
/*
* 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.io.InputStream;
import java.math.BigDecimal;
import java.math.MathContext;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.function.Function;
import com.fasterxml.jackson.core.type.TypeReference;
import com.kingsrook.qbits.assistant.provider.ModelToolDefinition;
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.AiLinkBuilder;
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.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
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.ValueUtils;
/*******************************************************************************
** Builds presentations from previously retrieved values, never reading records.
** One instance belongs to one turn: UI metadata, including denials, is cached
** only for that turn and session. All output links are built by the server.
*******************************************************************************/
public class PresentResultsTool
{
public static final String NAME = "present_results";
private final QInstance qInstance;
private UiApiResult metadata;
/*******************************************************************************
** Create a turn-scoped presenter.
*******************************************************************************/
public PresentResultsTool(QInstance qInstance)
{
this.qInstance = qInstance;
}
/*******************************************************************************
** Stable schema. Strict opt-in requires empty placeholders instead of many
** optional productions, keeping the existing strict grammar budget small.
*******************************************************************************/
public static ModelToolDefinition definition(Boolean strict)
{
try(InputStream stream = PresentResultsTool.class.getResourceAsStream("/present-results.schema.json"))
{
Map<String, Object> schema = JsonUtils.toObject(new String(stream.readAllBytes(), StandardCharsets.UTF_8), new TypeReference<Map<String, Object>>() {});
if(strict)
{
requireProperties(schema);
}
return (new ModelToolDefinition().withName(NAME).withDescription("Present an answer as metrics, a table, a chart and actionable links. Use only data from tool results; never supply URLs.")
.withInputSchema(schema).withStrict(strict));
}
catch(Exception e)
{
throw (new IllegalStateException("Cannot load presentation schema", e));
}
}
/*******************************************************************************
** Eliminate optional productions for strict mode without adding unions.
*******************************************************************************/
private static void requireProperties(Object node)
{
if(node instanceof Map<?, ?>)
{
Map<String, Object> schema = object(node);
schema.remove("maxLength");
if(schema.get("properties") instanceof Map<?, ?> properties)
{
schema.put("required", new ArrayList<>(properties.keySet()));
}
schema.values().forEach(PresentResultsTool::requireProperties);
}
else if(node instanceof List<?> list)
{
list.forEach(PresentResultsTool::requireProperties);
}
}
/*******************************************************************************
** Preserve valid items when others are dropped; never echo rejected values.
*******************************************************************************/
public synchronized AiToolResult execute(UiApiToolbox.Context context, String callId, Map<String, Object> input)
{
AiToolResult result = new AiToolResult().withToolCallId(callId).withToolName(NAME);
try
{
require(input != null && input.get("summary") instanceof String, "summary required");
String summary = text(input.get("summary"));
require(!summary.isBlank() && summary.length() <= 300, "summary must contain 1 to 300 characters");
List<String> notes = new ArrayList<>();
List<Map<String, Object>> blocks = new ArrayList<>();
addItems(blocks, "metrics", input.get("metrics"), notes, item -> metric(context, item));
Map<String, Object> table = null;
if(input.get("table") != null)
{
try
{
table = table(context, object(input.get("table")));
if(!list(table.get("columns")).isEmpty())
{
blocks.add(table);
}
}
catch(IllegalArgumentException e)
{
notes.add("Dropped table: " + e.getMessage());
}
}
if(input.get("chart") != null && table != null && !list(table.get("rows")).isEmpty())
{
try
{
blocks.add(chart(object(input.get("chart")), table, notes));
}
catch(IllegalArgumentException e)
{
notes.add("Dropped chart: " + e.getMessage());
}
}
if(input.get("chart") != null && (table == null || list(table.get("rows")).isEmpty()))
{
notes.add("Dropped chart: requires a valid nonempty table.");
}
addItems(blocks, "filterLinks", input.get("filterLinks"), notes, item -> filterLink(context, item));
addItems(blocks, "recordLinks", input.get("recordLinks"), notes, item -> recordLink(context, item));
addItems(blocks, "actions", input.get("actions"), notes, item -> action(context, item));
Map<String, Object> part = map("type", "presentation", "summary", summary, "blocks", blocks, "markdown", markdown(summary, blocks));
return (result.withOutcome(AiToolOutcome.OK).withSummary("Presented results").withRender(part)
.withPermissionCheck(metadata == null ? "presentation:no table references" : "uiApi:GET /metaData:" + metadata.status())
.withModelContent("Presented to the user: " + summary + (summary.endsWith(".") ? "" : ".") + " Do not repeat the table or links in text." + (notes.isEmpty() ? "" : "\n" + String.join("\n", notes))));
}
catch(IllegalArgumentException e)
{
return (result.withOutcome(AiToolOutcome.INVALID_INPUT).withSummary("Invalid presentation").withModelContent(e.getMessage()));
}
}
/*******************************************************************************
** Resolve tables through the same metadata operation and session as the UI.
*******************************************************************************/
private QTableMetaData visibleTable(UiApiToolbox.Context context, String name)
{
QTableMetaData table = qInstance.getTable(name);
require(table != null, "unknown table " + name);
if(metadata == null)
{
try
{
metadata = context.gateway().invoke(new UiApiCall("GET", "/metaData").withSessionUUID(context.sessionUUID()).withUserTimezone(context.userTimezone()));
}
catch(QException e)
{
metadata = new UiApiResult(503, "{}");
}
}
require(metadata.isSuccess(), "GET /metaData answered HTTP " + metadata.status());
Map<String, Object> tables = object(object(metadata.json()).get("tables"));
require(tables.containsKey(name) && Boolean.TRUE.equals(object(tables.get(name)).get("readPermission")), "table not visible through UI API: " + name);
return (table);
}
/*******************************************************************************
** A field reference must be paired with a visible table reference.
*******************************************************************************/
private QFieldMetaData field(UiApiToolbox.Context context, Map<String, Object> item)
{
String tableName = text(item.get("tableName"));
String fieldName = text(item.get("fieldName"));
require(fieldName.isEmpty() || !tableName.isEmpty(), "fieldName requires tableName");
QTableMetaData table = tableName.isEmpty() ? null : visibleTable(context, tableName);
QFieldMetaData field = fieldName.isEmpty() ? null : table.getFields().get(fieldName);
require(fieldName.isEmpty() || field != null, "unknown field " + fieldName);
return (field);
}
/*******************************************************************************
** A metric preserves its numeric value and adds the metadata display.
*******************************************************************************/
private Map<String, Object> metric(UiApiToolbox.Context context, Map<String, Object> item)
{
QFieldMetaData field = field(context, item);
number(item.get("value"));
return (map("label", label(item), "value", item.get("value"), "display", display(item.get("value"), field)));
}
/*******************************************************************************
** Build table cells and percentages from raw values, never model displays.
*******************************************************************************/
private Map<String, Object> table(UiApiToolbox.Context context, Map<String, Object> input)
{
List<?> columns = list(input.get("columns"));
List<?> rows = list(input.get("rows"));
require(columns.size() <= 20 && rows.size() <= 200, "table exceeds 20 columns or 200 rows");
require(rows.isEmpty() || !columns.isEmpty(), "rows require columns");
List<QFieldMetaData> fields = new ArrayList<>();
List<Map<String, Object>> outputColumns = new ArrayList<>();
List<BigDecimal> totals = new ArrayList<>();
for(Integer c = 0; c < columns.size(); c++)
{
Map<String, Object> column = object(columns.get(c));
fields.add(field(context, column));
BigDecimal total = BigDecimal.ZERO;
Boolean numeric = !rows.isEmpty();
for(Object row : rows)
{
require(list(row).size() == columns.size(), "row width must match columns");
Object value = list(row).get(c);
scalar(value);
if(value instanceof Number)
{
total = total.add(number(value));
}
else
{
numeric = false;
}
}
totals.add(numeric ? total : null);
outputColumns.add(map("key", "c" + c, "label", label(column), "align", numeric || percentColumn(column) >= 0 ? "right" : "left"));
}
Map<String, Object> rowFilter = input.get("rowFilter") == null ? Map.of() : object(input.get("rowFilter"));
List<?> rowKeys = input.get("rowKeys") == null ? List.of() : list(input.get("rowKeys"));
Boolean hasFilter = !text(rowFilter.get("tableName")).isEmpty();
require(!hasFilter || rowKeys.size() == rows.size(), "rowKeys must match rows");
QFieldMetaData filterField = field(context, rowFilter);
require(!hasFilter || filterField != null, "rowFilter requires a field");
List<Map<String, Object>> outputRows = new ArrayList<>();
for(Integer r = 0; r < rows.size(); r++)
{
Map<String, Object> cells = new LinkedHashMap<>();
Map<String, Object> displays = new LinkedHashMap<>();
for(Integer c = 0; c < columns.size(); c++)
{
Integer source = percentColumn(object(columns.get(c)));
Object value = list(rows.get(r)).get(c);
String formatted;
if(source >= 0)
{
require(source < columns.size() && totals.get(source) != null && percentColumn(object(columns.get(source))) < 0, "percentOfColumn must reference a numeric raw column");
BigDecimal share = totals.get(source).signum() == 0 ? BigDecimal.ZERO : number(list(rows.get(r)).get(source)).divide(totals.get(source), MathContext.DECIMAL64);
value = share;
formatted = String.format(Locale.US, "%.1f%%", share.multiply(BigDecimal.valueOf(100)));
}
else
{
formatted = display(value, fields.get(c));
}
cells.put("c" + c, value);
displays.put("c" + c, formatted);
}
Map<String, Object> row = map("cells", cells, "display", displays);
if(hasFilter)
{
Object key = rowKeys.get(r);
scalar(key);
Map<String, Object> criterion = map("fieldName", rowFilter.get("fieldName"), "operator", rowFilter.getOrDefault("operator", "EQUALS"), "values", java.util.Collections.singletonList(key));
row.put("href", filterHref(context, text(rowFilter.get("tableName")), List.of(criterion)));
}
outputRows.add(row);
}
return (map("kind", "table", "title", text(input.get("title")), "columns", outputColumns, "rows", outputRows));
}
/*******************************************************************************
** A chart uses the table's raw values, formatted values and row links.
*******************************************************************************/
private Map<String, Object> chart(Map<String, Object> input, Map<String, Object> table, List<String> notes)
{
String type = text(input.get("chartType"));
require(List.of("bar", "pie", "line").contains(type), "invalid chartType");
Integer labelColumn = index(input.get("labelColumn"));
Integer valueColumn = index(input.get("valueColumn"));
Integer width = list(table.get("columns")).size();
require(labelColumn >= 0 && valueColumn >= 0 && labelColumn < width && valueColumn < width, "chart column index out of range");
List<Map<String, Object>> points = new ArrayList<>();
List<?> rows = list(table.get("rows"));
for(Object entry : rows)
{
Map<String, Object> row = object(entry);
Object value = object(row.get("cells")).get("c" + valueColumn);
number(value);
if(points.size() < 12)
{
Map<String, Object> point = map("label", object(row.get("display")).get("c" + labelColumn), "value", value, "display", object(row.get("display")).get("c" + valueColumn));
if(row.containsKey("href"))
{
point.put("href", row.get("href"));
}
points.add(point);
}
}
if(rows.size() > 12)
{
notes.add("Chart truncated to 12 points; the table retains all rows.");
}
return (map("kind", "chart", "chartType", type, "title", text(input.get("title")), "points", points));
}
/*******************************************************************************
** A filtered list link, including only validated metadata and raw values.
*******************************************************************************/
private Map<String, Object> filterLink(UiApiToolbox.Context context, Map<String, Object> item)
{
String tableName = text(item.get("tableName"));
return (map("label", label(item), "tableName", tableName, "href", filterHref(context, tableName, list(item.get("criteria")))));
}
/*******************************************************************************
** Serialize the UI's JSON filter format. The table path uses core navigate's
** helper; filter values stay raw inside the URL-encoded JSON.
*******************************************************************************/
private String filterHref(UiApiToolbox.Context context, String tableName, List<?> criteria)
{
visibleTable(context, tableName);
require(!criteria.isEmpty() && criteria.size() <= 20, "filter requires 1 to 20 criteria");
List<Map<String, Object>> output = new ArrayList<>();
for(Object entry : criteria)
{
Map<String, Object> criterion = object(entry);
String fieldName = text(criterion.get("fieldName"));
require(!fieldName.isBlank(), "filter requires fieldName");
field(context, map("tableName", tableName, "fieldName", fieldName));
String operator = text(criterion.get("operator"));
operator = operator.isEmpty() ? "EQUALS" : operator;
try
{
QCriteriaOperator.valueOf(operator);
}
catch(IllegalArgumentException e)
{
throw (new IllegalArgumentException("invalid criteria operator"));
}
List<?> values = list(criterion.get("values"));
require(values.size() <= 200, "too many filter values");
values.forEach(PresentResultsTool::scalar);
output.add(map("fieldName", fieldName, "operator", operator, "values", values));
}
return (safeHref(AiLinkBuilder.objectHref(tableName) + "?filter=" + URLEncoder.encode(JsonUtils.toJson(map("criteria", output, "booleanOperator", "AND")), StandardCharsets.UTF_8)));
}
/*******************************************************************************
** Record ids are opaque path segments, encoded by core's link helper.
*******************************************************************************/
private Map<String, Object> recordLink(UiApiToolbox.Context context, Map<String, Object> item)
{
String tableName = text(item.get("tableName"));
visibleTable(context, tableName);
Object id = item.get("recordId");
require(id instanceof String || id instanceof Number, "recordId must be a string or number");
String recordId = text(id);
require(!recordId.isBlank() && !List.of(".", "..").contains(recordId), "invalid recordId");
return (map("label", label(item), "tableName", tableName, "recordId", recordId, "href", safeHref(AiLinkBuilder.recordHref(tableName, recordId))));
}
/*******************************************************************************
** Actions are either a next-message prompt or a visible table destination.
*******************************************************************************/
private Map<String, Object> action(UiApiToolbox.Context context, Map<String, Object> item)
{
String prompt = text(item.get("prompt"));
String destination = text(item.get("navigateTo"));
require(prompt.isBlank() != destination.isBlank(), "action requires exactly one of prompt or navigateTo");
if(!destination.isBlank())
{
visibleTable(context, destination);
return (map("label", label(item), "href", safeHref(AiLinkBuilder.objectHref(destination))));
}
return (map("label", label(item), "prompt", prompt));
}
/*******************************************************************************
** Preserve independently valid items and report dropped positions.
*******************************************************************************/
private static void addItems(List<Map<String, Object>> blocks, String kind, Object input, List<String> notes, Function<Map<String, Object>, Map<String, Object>> build)
{
if(input == null)
{
return;
}
List<?> entries = list(input);
require(entries.size() <= 100, "too many " + kind);
List<Map<String, Object>> items = new ArrayList<>();
for(Integer i = 0; i < entries.size(); i++)
{
try
{
items.add(build.apply(object(entries.get(i))));
}
catch(IllegalArgumentException e)
{
notes.add("Dropped " + kind + "[" + i + "]: " + e.getMessage());
}
}
if(!items.isEmpty())
{
blocks.add(map("kind", kind, "items", items));
}
}
/*******************************************************************************
** Metadata formats use QQQ field coercion and Java display format strings.
*******************************************************************************/
private static String display(Object value, QFieldMetaData field)
{
if(value == null)
{
return ("");
}
if(field != null)
{
try
{
Object typed = ValueUtils.getValueAsFieldType(field.getType(), value);
if(field.getDisplayFormat() != null)
{
return (String.format(Locale.US, field.getDisplayFormat(), typed));
}
value = typed;
}
catch(Exception e)
{
throw (new IllegalArgumentException("value does not match field format"));
}
}
if(value instanceof Number)
{
BigDecimal decimal = number(value);
return (decimal.stripTrailingZeros().scale() <= 0 ? String.format(Locale.US, "%,d", decimal.toBigIntegerExact()) : decimal.toPlainString());
}
return (text(value));
}
/*******************************************************************************
** Complete GFM fallback with real newlines; chart data is already in table.
*******************************************************************************/
private static String markdown(String summary, List<Map<String, Object>> blocks)
{
StringBuilder markdown = new StringBuilder(escape(summary)).append("\n");
for(Map<String, Object> block : blocks)
{
if("table".equals(block.get("kind")))
{
markdown.append("\n").append(escape(text(block.get("title")))).append("\n\n|");
List<?> columns = list(block.get("columns"));
for(Object column : columns)
{
markdown.append(' ').append(escape(text(object(column).get("label")))).append(" |");
}
markdown.append("\n|");
for(Object column : columns)
{
markdown.append("right".equals(object(column).get("align")) ? " ---: |" : " --- |");
}
for(Object entry : list(block.get("rows")))
{
Map<String, Object> row = object(entry);
markdown.append("\n|");
for(Integer c = 0; c < columns.size(); c++)
{
String value = escape(text(object(row.get("display")).get("c" + c)));
markdown.append(' ').append(c == 0 && row.containsKey("href") ? "[" + value + "](" + row.get("href") + ")" : value).append(" |");
}
}
markdown.append("\n");
}
else if(block.get("items") instanceof List<?> items)
{
markdown.append("\n");
for(Object entry : items)
{
Map<String, Object> item = object(entry);
String label = escape(text(item.get("label")));
markdown.append("- ");
if(item.containsKey("href"))
{
markdown.append('[').append(label).append("](").append(item.get("href")).append(')');
}
else
{
markdown.append(label);
if(item.containsKey("display") || item.containsKey("prompt"))
{
markdown.append(": ").append(escape(text(item.getOrDefault("display", item.get("prompt")))));
}
}
markdown.append("\n");
}
}
}
return (markdown.toString());
}
/*******************************************************************************
** Literal text only: no Markdown links, HTML or GFM URL autolinks from labels.
*******************************************************************************/
private static String escape(String value)
{
StringBuilder output = new StringBuilder();
for(char character : value.toCharArray())
{
if("\\`*_{}[]()<>|!&#:.@~".indexOf(character) >= 0)
{
output.append("&#").append((int) character).append(';');
}
else
{
output.append(character);
}
}
return (output.toString());
}
/*******************************************************************************
** Reject unsafe paths even if metadata contains unexpected names.
*******************************************************************************/
private static String safeHref(String href)
{
require(AiLinkBuilder.isSafeAppPath(href), "unsafe application path");
return (href);
}
/*******************************************************************************
** A column with no percentage uses -1 in strict mode.
*******************************************************************************/
private static Integer percentColumn(Map<String, Object> column)
{
return (column.get("percentOfColumn") == null ? -1 : index(column.get("percentOfColumn")));
}
/*******************************************************************************
** Indexes must be integral; never silently truncate model input.
*******************************************************************************/
private static Integer index(Object value)
{
try
{
return (number(value).intValueExact());
}
catch(ArithmeticException e)
{
throw (new IllegalArgumentException("index must be an integer"));
}
}
/*******************************************************************************
** Finite numeric input only.
*******************************************************************************/
private static BigDecimal number(Object value)
{
require(value instanceof Number, "numeric value required");
try
{
return (new BigDecimal(value.toString()));
}
catch(NumberFormatException e)
{
throw (new IllegalArgumentException("finite numeric value required"));
}
}
/*******************************************************************************
** Cells and filter values remain raw JSON scalars.
*******************************************************************************/
private static void scalar(Object value)
{
require(value == null || value instanceof String || value instanceof Number || value instanceof Boolean, "scalar value required");
if(value instanceof Number)
{
number(value);
}
if(value instanceof String)
{
text(value);
}
}
/*******************************************************************************
** Bounded single-line display text.
*******************************************************************************/
private static String text(Object value)
{
require(value == null || value instanceof String || value instanceof Number || value instanceof Boolean, "text or scalar required");
String text = value == null ? "" : String.valueOf(value);
require(text.length() <= 2000, "text exceeds 2000 characters");
return (text.replaceAll("[\\p{Cntrl}\\u2028\\u2029]", " "));
}
/*******************************************************************************
** Labels are mandatory plain text.
*******************************************************************************/
private static String label(Map<String, Object> item)
{
String label = text(item.get("label"));
require(!label.isBlank(), "label required");
return (label);
}
/*******************************************************************************
** Validate a JSON object without converting arbitrary values to strings.
*******************************************************************************/
@SuppressWarnings("unchecked")
private static Map<String, Object> object(Object value)
{
require(value instanceof Map<?, ?>, "object required");
return ((Map<String, Object>) value);
}
/*******************************************************************************
** Validate a JSON array.
*******************************************************************************/
private static List<?> list(Object value)
{
require(value instanceof List<?>, "array required");
return ((List<?>) value);
}
/*******************************************************************************
** Ordered output maps allow null raw cells.
*******************************************************************************/
private static Map<String, Object> map(Object... pairs)
{
Map<String, Object> map = new LinkedHashMap<>();
for(Integer i = 0; i < pairs.length; i += 2)
{
map.put((String) pairs[i], pairs[i + 1]);
}
return (map);
}
/*******************************************************************************
** Validation failures contain only server-authored explanations.
*******************************************************************************/
private static void require(Boolean condition, String message)
{
if(!condition)
{
throw (new IllegalArgumentException(message));
}
}
}
@@ -203,6 +203,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
*******************************************************************************/
@@ -0,0 +1,57 @@
/*
* 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;
/*******************************************************************************
** 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)
{
}
/*******************************************************************************
** 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;
}
@@ -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"))));
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);
}
uiApiCall.withSessionUUID(context.sessionUUID()).withUserTimezone(context.userTimezone()).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);
}
}
}
@@ -0,0 +1,90 @@
/*
* 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())
.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()));
}
}
@@ -30,6 +30,7 @@ import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import java.util.StringJoiner;
import java.util.UUID;
import java.util.function.Supplier;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
@@ -64,6 +65,9 @@ public class LoopbackUiApiGateway implements UiApiGateway
public static final String INVOKED_BY_HEADER = "X-QQQ-Invoked-By";
public static final String TIMEZONE_HEADER = "X-QQQ-UserTimezone";
/** Headers a call cannot set itself (lower case). */
private static final Set<String> RESERVED_HEADERS = Set.of("cookie", "authorization", "x-api-key", "content-type", "accept", INVOKED_BY_HEADER.toLowerCase(), TIMEZONE_HEADER.toLowerCase());
private static final Set<String> SPEC_OPERATIONS = specOperations();
private final Supplier<String> baseUrlSupplier;
@@ -204,8 +208,31 @@ public class LoopbackUiApiGateway implements UiApiGateway
{
builder.header(TIMEZONE_HEADER, call.getUserTimezone());
}
call.getHeaders().forEach((name, value) ->
{
if(!RESERVED_HEADERS.contains(name.toLowerCase()))
{
builder.header(name, value);
}
});
if(call.getJsonBody() != null)
if(!call.getFormParams().isEmpty())
{
////////////////////////////////////////////////////////////////
// multipart/form-data text fields (the /processes endpoints) //
////////////////////////////////////////////////////////////////
String boundary = "qqq-assistant-" + UUID.randomUUID();
StringBuilder body = new StringBuilder();
String lineEnd = "\r\n";
call.getFormParams().forEach((name, value) -> body.append("--").append(boundary).append(lineEnd)
.append("Content-Disposition: form-data; name=\"").append(name.replace("\"", "")).append("\"").append(lineEnd)
.append("Content-Type: text/plain; charset=UTF-8").append(lineEnd).append(lineEnd)
.append(value).append(lineEnd));
body.append("--").append(boundary).append("--").append(lineEnd);
builder.header("Content-Type", "multipart/form-data; boundary=" + boundary);
builder.method(call.getMethod(), HttpRequest.BodyPublishers.ofString(body.toString(), StandardCharsets.UTF_8));
}
else if(call.getJsonBody() != null)
{
builder.header("Content-Type", "application/json");
builder.method(call.getMethod(), HttpRequest.BodyPublishers.ofString(call.getJsonBody() instanceof String s ? s : JsonUtils.toJson(call.getJsonBody()), StandardCharsets.UTF_8));
@@ -33,6 +33,8 @@ 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 String userTimezone;
@@ -128,6 +130,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
*******************************************************************************/
@@ -0,0 +1,280 @@
{
"type": "object",
"properties": {
"summary": {
"type": "string",
"maxLength": 300
},
"metrics": {
"type": "array",
"items": {
"$ref": "#/$defs/metric"
}
},
"table": {
"$ref": "#/$defs/table"
},
"chart": {
"$ref": "#/$defs/chart"
},
"filterLinks": {
"type": "array",
"items": {
"$ref": "#/$defs/filterLink"
}
},
"recordLinks": {
"type": "array",
"items": {
"$ref": "#/$defs/recordLink"
}
},
"actions": {
"type": "array",
"items": {
"$ref": "#/$defs/action"
}
}
},
"required": [
"summary"
],
"additionalProperties": false,
"$defs": {
"cell": {
"type": [
"string",
"number",
"boolean",
"null"
]
},
"metric": {
"type": "object",
"properties": {
"label": {
"type": "string"
},
"value": {
"type": "number"
},
"tableName": {
"type": "string"
},
"fieldName": {
"type": "string"
}
},
"required": [
"label",
"value"
],
"additionalProperties": false
},
"column": {
"type": "object",
"properties": {
"label": {
"type": "string"
},
"tableName": {
"type": "string"
},
"fieldName": {
"type": "string"
},
"percentOfColumn": {
"type": "integer"
}
},
"required": [
"label"
],
"additionalProperties": false
},
"rowFilter": {
"type": "object",
"properties": {
"tableName": {
"type": "string"
},
"fieldName": {
"type": "string"
},
"operator": {
"type": "string"
}
},
"required": [
"tableName",
"fieldName"
],
"additionalProperties": false
},
"criterion": {
"type": "object",
"properties": {
"fieldName": {
"type": "string"
},
"operator": {
"type": "string"
},
"values": {
"type": "array",
"items": {
"$ref": "#/$defs/cell"
}
}
},
"required": [
"fieldName",
"operator",
"values"
],
"additionalProperties": false
},
"filterLink": {
"type": "object",
"properties": {
"label": {
"type": "string"
},
"tableName": {
"type": "string"
},
"criteria": {
"$ref": "#/$defs/criteria"
}
},
"required": [
"label",
"tableName",
"criteria"
],
"additionalProperties": false
},
"recordLink": {
"type": "object",
"properties": {
"label": {
"type": "string"
},
"tableName": {
"type": "string"
},
"recordId": {
"$ref": "#/$defs/cell"
}
},
"required": [
"label",
"tableName",
"recordId"
],
"additionalProperties": false
},
"action": {
"type": "object",
"properties": {
"label": {
"type": "string"
},
"prompt": {
"type": "string"
},
"navigateTo": {
"type": "string"
}
},
"required": [
"label"
],
"additionalProperties": false
},
"chart": {
"type": "object",
"properties": {
"chartType": {
"type": "string",
"enum": [
"bar",
"pie",
"line"
]
},
"title": {
"type": "string"
},
"labelColumn": {
"type": "integer"
},
"valueColumn": {
"type": "integer"
}
},
"required": [
"chartType",
"labelColumn",
"valueColumn"
],
"additionalProperties": false
},
"row": {
"type": "array",
"items": {
"$ref": "#/$defs/cell"
}
},
"columns": {
"type": "array",
"items": {
"$ref": "#/$defs/column"
}
},
"rows": {
"type": "array",
"items": {
"$ref": "#/$defs/row"
}
},
"rowKeys": {
"type": "array",
"items": {
"$ref": "#/$defs/cell"
}
},
"criteria": {
"type": "array",
"items": {
"$ref": "#/$defs/criterion"
}
},
"table": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"columns": {
"$ref": "#/$defs/columns"
},
"rows": {
"$ref": "#/$defs/rows"
},
"rowFilter": {
"$ref": "#/$defs/rowFilter"
},
"rowKeys": {
"$ref": "#/$defs/rowKeys"
}
},
"required": [
"columns",
"rows"
],
"additionalProperties": false
}
},
"description": "Present only facts from previous tool results. Optional sections may be omitted. In strict mode use empty arrays, empty strings, empty table rows/columns and -1 for unused indexes; every property must be supplied. rowKeys belongs to table, one raw key per row. Percent columns may use null cells; the server computes them."
}
@@ -26,6 +26,9 @@ 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.QAlertTypeMetaData;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
@@ -85,7 +88,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_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();
@@ -103,6 +106,28 @@ class AssistantQBitProducerTest extends BaseAssistantTest
/*******************************************************************************
** Budget types are discoverable through core with admin-only visibility.
*******************************************************************************/
@Test
void budgetAlertTypes()
{
var types = AlertsInstanceMetaData.of(qInstance).getAlertTypes();
assertThat(types).containsOnlyKeys("assistant.budget.warning", "assistant.budget.downgraded", "assistant.budget.stopped");
for(QAlertTypeMetaData type : types.values())
{
assertThat(type.getLabel()).isNotBlank();
assertThat(type.getDescription()).isNotBlank();
assertThat(type.getDefaultSeverity()).isEqualTo(type.getName().endsWith("stopped") ? AlertSeverity.ERROR : AlertSeverity.WARNING);
assertThat(type.getHowToFix()).isEqualTo("Raise the budget or grant an override in Assistant → Budgets");
assertThat(type.getSourceQBitName()).isEqualTo("assistant");
assertThat(type.getVisibilityPermission()).isEqualTo("assistant.admin");
assertThat(type.getSubjectTableName()).isEqualTo("assistant_budget");
}
}
/*******************************************************************************
** The admin app and tables: visible only with assistant.admin.
*******************************************************************************/
@@ -93,6 +93,10 @@ class ClassificationTest extends BaseAssistantTest
assertThat(record.getClassification()).isEqualTo(DataClassification.PHI);
page.observe(ModelPart.toolUse("1", "describe_table", Map.of("table", "page")), new AiToolResult());
assertThat(page.getClassification()).isEqualTo(DataClassification.PUBLIC);
page.observe(ModelPart.toolUse("present", "present_results", Map.of("summary", "Available results")), new AiToolResult());
assertThat(page.getClassification()).isEqualTo(DataClassification.PUBLIC);
record.observe(ModelPart.toolUse("present", "present_results", Map.of("summary", "Available results")), new AiToolResult());
assertThat(record.getClassification()).isEqualTo(DataClassification.PHI);
page.observe(ModelPart.toolUse("2", "search_records", Map.of()), new AiToolResult().withModelContent("<qqq_data>{\"results\":[{\"table\":\"media\"}]}</qqq_data>"));
assertThat(page.getClassification()).isEqualTo(DataClassification.PII);
page.observe(ModelPart.toolUse("3", "search_records", Map.of()), new AiToolResult());
@@ -35,6 +35,13 @@ import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
import com.kingsrook.qbits.assistant.provider.ModelUsage;
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
import com.kingsrook.qqq.backend.core.alerts.LoggingAlertSink;
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.code.InitializableViaCodeReference;
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;
@@ -172,12 +179,45 @@ class BudgetServiceTest extends BaseAssistantTest
/*******************************************************************************
** Fixed-clock evaluator and alert seam.
** Fixed-clock evaluator with the production core sink configuration.
*******************************************************************************/
private BudgetService service(List<String> alerts)
private BudgetService service(List<AlertSignal> alerts)
{
return (new BudgetService(store(), config.getRuntime().getProviderService(), Clock.fixed(NOW, ZoneOffset.UTC),
(type, key, severity, title, summary) -> alerts.add(type + ":" + severity)));
AlertsInstanceMetaData.ofOrWithNew(qInstance).withSink(new QCodeReferenceWithProperties(TestAlertSink.class, Map.of("signals", (ArrayList<AlertSignal>) alerts)));
return (new BudgetService(store(), config.getRuntime().getProviderService(), Clock.fixed(NOW, ZoneOffset.UTC)));
}
/*******************************************************************************
** Core loads a separate sink per instance; captured signals stay test-local.
*******************************************************************************/
public static class TestAlertSink extends LoggingAlertSink implements InitializableViaCodeReference
{
private List<AlertSignal> signals;
/*******************************************************************************
** Receive this test's capture list from the configured code reference.
*******************************************************************************/
@Override
@SuppressWarnings("unchecked")
public void initialize(QCodeReference codeReference)
{
signals = (List<AlertSignal>) ((QCodeReferenceWithProperties) codeReference).getProperties().get("signals");
}
/*******************************************************************************
** Capture the signal delivered through core.
*******************************************************************************/
@Override
public void raise(AlertSignal signal)
{
signals.add(signal);
}
}
@@ -188,15 +228,22 @@ class BudgetServiceTest extends BaseAssistantTest
@Test
void warningBoundaryAndDedup() throws Exception
{
budget();
AssistantBudget budget = budget();
usage(790_000, NOW);
List<String> alerts = new ArrayList<>();
List<AlertSignal> alerts = new ArrayList<>();
BudgetService service = service(alerts);
assertThat(service.evaluate(USER, null, false, false).notices()).isEmpty();
usage(10_000, NOW);
assertThat(service.evaluate(USER, null, false, false).notices()).singleElement().asString().contains("80%", "Per user daily");
assertThat(service.evaluate(USER, null, false, false).notices()).isEmpty();
assertThat(alerts).containsExactly("assistant.budget.warning:WARNING");
assertThat(alerts).singleElement().satisfies(signal ->
{
assertThat(signal.getAlertTypeName()).isEqualTo("assistant.budget.warning");
assertThat(signal.getDedupKey()).isEqualTo(budget.getId() + ":USER:" + USER + ":2026-10-13T00:00:00Z");
assertThat(signal.getSeverity()).isEqualTo(AlertSeverity.WARNING);
assertThat(signal.getTitle()).isEqualTo("Assistant budget Per user daily");
assertThat(signal.getSummary()).contains("80%", "Per user daily");
});
assertThat(store().query(AssistantBudgetEvent.TABLE_NAME, new QQueryFilter(), AssistantBudgetEvent.class)).singleElement()
.extracting(AssistantBudgetEvent::getState).isEqualTo("WARNED");
}
@@ -215,10 +262,14 @@ class BudgetServiceTest extends BaseAssistantTest
AssistantModel cheap = store().insert(new AssistantModel().withProviderId(local.getId()).withModelId("cheap").withEnabled(true).withInputUsdPerMTok(BigDecimal.ONE));
store().insert(new AssistantModel().withProviderId(cloud.getId()).withModelId("main").withEnabled(true).withInputUsdPerMTok(BigDecimal.ZERO));
usage(1_000_000, NOW);
BudgetService service = service(new ArrayList<>());
List<AlertSignal> alerts = new ArrayList<>();
BudgetService service = service(alerts);
ProviderService.Active active = config.getRuntime().getProviderService().resolve("main");
assertThat(service.evaluate(USER, active, false, false).active().providerId()).isEqualTo(local.getId());
assertThat(service.evaluate(USER, active, true, false).stopped()).isTrue();
assertThat(alerts).extracting(AlertSignal::getAlertTypeName).containsExactly("assistant.budget.downgraded", "assistant.budget.stopped");
assertThat(alerts).extracting(AlertSignal::getSeverity).containsExactly(AlertSeverity.WARNING, AlertSeverity.ERROR);
assertThat(alerts).extracting(AlertSignal::getDedupKey).containsOnly(budget.getId() + ":USER:" + USER + ":2026-10-13T00:00:00Z");
store().updateFields(AssistantBudget.TABLE_NAME, new QRecord().withValue("id", budget.getId()).withValue("downgradeModelId", cheap.getId()));
assertThat(service.evaluate(USER, active, false, false).active().model()).isEqualTo("cheap");
}
@@ -0,0 +1,784 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.assistant.engine;
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
import java.math.BigDecimal;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
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.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.AssistantProfile;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qbits.assistant.model.AssistantTurn;
import com.kingsrook.qbits.assistant.model.AssistantUsage;
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.CompletableStreamHandle;
import com.kingsrook.qbits.assistant.provider.HealthResult;
import com.kingsrook.qbits.assistant.provider.ModelEventListener;
import com.kingsrook.qbits.assistant.provider.ModelPart;
import com.kingsrook.qbits.assistant.provider.ModelProviderException;
import com.kingsrook.qbits.assistant.provider.ModelRequest;
import com.kingsrook.qbits.assistant.provider.ModelResponse;
import com.kingsrook.qbits.assistant.provider.ProviderSettings;
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.qbits.assistant.testing.TurnRunner;
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;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.EnumSource;
import org.junit.jupiter.params.provider.ValueSource;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Multi-provider dispatch through the fixture with persisted rows and fixed time.
*******************************************************************************/
class RoutingFallbackTest extends BaseAssistantTest
{
private static final String USER = "editor-routing";
private static final Instant NOW = Instant.now();
private final Map<String, ScriptedModelProvider> scripts = new LinkedHashMap<>();
private Integer probes = 0;
private Boolean healthy = true;
/*******************************************************************************
** Keyless adapter with distinct scripts for each endpoint.
*******************************************************************************/
@Override
protected void customizeConfig(AssistantQBitConfig value)
{
value.withProviderType(new RoutingType()).withAllowedBaseUrls(Set.of("https://a.invalid", "https://b.invalid", "https://c.invalid"));
}
/*******************************************************************************
** Fix time for budgets, routing and health.
*******************************************************************************/
@BeforeEach
void fixedClock()
{
config.getRuntime().close();
config.setRuntime(new AssistantRuntime(qInstance, config, Clock.fixed(NOW, ZoneOffset.UTC)));
}
/*******************************************************************************
** One adapter family with separate recording instances.
*******************************************************************************/
class RoutingType implements AssistantProviderType
{
/*******************************************************************************
** Type identifier.
*******************************************************************************/
public String getType()
{
return ("routing-test");
}
/*******************************************************************************
** Display label.
*******************************************************************************/
public String getLabel()
{
return ("Routing test");
}
/*******************************************************************************
** Registry supplies actual models.
*******************************************************************************/
public List<String> getModels()
{
return (List.of("a", "b", "c"));
}
/*******************************************************************************
** No secret needed.
*******************************************************************************/
public String getDefaultApiKeyEnvName()
{
return (null);
}
/*******************************************************************************
** No network traffic.
*******************************************************************************/
public String getDefaultBaseUrl()
{
return ("https://a.invalid");
}
/*******************************************************************************
** Resolve the endpoint's own script.
*******************************************************************************/
public AssistantModelProvider create(String key, String url)
{
return (scripts.get(url == null ? "https://a.invalid" : url));
}
/*******************************************************************************
** Count probes and assert the transport timeout.
*******************************************************************************/
public HealthResult checkHealth(ProviderSettings settings)
{
probes++;
assertThat(settings.getRequestTimeoutSeconds()).isEqualTo(2);
return (new HealthResult(healthy, 1L, "Test probe"));
}
}
/*******************************************************************************
** Admin setup; actual turns use the editor session.
*******************************************************************************/
private AssistantStore store()
{
QContext.init(qInstance, AssistantTestFixture.ownerSession("admin-routing", config));
return (config.getRuntime().getStore());
}
/*******************************************************************************
** One enabled registry model and endpoint.
*******************************************************************************/
private AssistantModel model(String name, String location, Boolean eligible) throws Exception
{
scripts.put("https://" + name + ".invalid", new ScriptedModelProvider(name));
AssistantProvider row = store().insert(new AssistantProvider().withName(name.toUpperCase()).withProviderType("routing-test")
.withBaseUrl("https://" + name + ".invalid").withModel(name).withEnabled(true).withIsDefault("a".equals(name)).withLocation(location).withHipaaEligible(eligible));
return (store().insert(new AssistantModel().withProviderId(row.getId()).withModelId(name).withEnabled(true)));
}
/*******************************************************************************
** Retrieve a recording endpoint.
*******************************************************************************/
private ScriptedModelProvider script(String name)
{
return (scripts.get("https://" + name + ".invalid"));
}
/*******************************************************************************
** Save through the real validation path.
*******************************************************************************/
private void link(AssistantModel from, AssistantModel to) throws Exception
{
store().updateFields(AssistantModel.TABLE_NAME, new QRecord().withValue("id", from.getId()).withValue("fallbackModelId", to.getId()));
}
/*******************************************************************************
** Default profile for this fixture.
*******************************************************************************/
private void profile(String mode, AssistantModel simple, AssistantModel complex, AssistantModel classifier) throws Exception
{
store().insert(new AssistantProfile().withName("routing").withDisplayName("Assistant").withIsDefault(true).withEnabled(true).withRoutingMode(mode)
.withSimpleModelId(simple.getId()).withComplexModelId(complex.getId()).withClassifierModelId(classifier == null ? null : classifier.getId()));
}
/*******************************************************************************
** Read all terminal fields.
*******************************************************************************/
private AssistantTurn turn(TurnRunner.Outcome outcome) throws Exception
{
return (store().getTurn(USER, outcome.turnId()));
}
/*******************************************************************************
** Each keyword and negative word-boundary case.
*******************************************************************************/
@ParameterizedTest
@CsvSource({"analyze sales,COMPLEX", "compare months,COMPLEX", "why,COMPLEX", "trend,COMPLEX", "explain this,COMPLEX", "summarize across stores,COMPLEX", "plan work,COMPLEX", "planet,SIMPLE", "hello,SIMPLE", "blog_post page,COMPLEX", "blog_post blog_post,SIMPLE", "blog_posts,SIMPLE"})
void heuristicWords(String question, String expected)
{
assertThat(ModelRouter.heuristic(question, Set.of("blog_post", "page"), false, 8, false)).isEqualTo(expected);
}
/*******************************************************************************
** Exact length/turn boundaries, pending writes, and tool exhaustion.
*******************************************************************************/
@Test
void heuristicBoundaries()
{
assertThat(ModelRouter.heuristic("x".repeat(600), Set.of(), false, 8, false)).isEqualTo("SIMPLE");
assertThat(ModelRouter.heuristic("x".repeat(601), Set.of(), false, 8, false)).isEqualTo("COMPLEX");
assertThat(ModelRouter.heuristic("hi", Set.of(), true, 0, false)).isEqualTo("COMPLEX");
assertThat(ModelRouter.heuristic("hi", Set.of(), false, 9, false)).isEqualTo("COMPLEX");
assertThat(ModelRouter.heuristic("hi", Set.of(), false, 0, true)).isEqualTo("COMPLEX");
assertThat(ModelRouter.heuristic(null, Set.of(), false, 0, false)).isEqualTo("SIMPLE");
}
/*******************************************************************************
** Exact classifier replies select a route; garbage uses the heuristic.
*******************************************************************************/
@ParameterizedTest
@CsvSource({"SIMPLE,a,CLASSIFIER", "COMPLEX,b,CLASSIFIER", "garbage,b,HEURISTIC"})
void classifierAnswers(String answer, String selected, String source) throws Exception
{
AssistantModel a = model("a", "CLOUD", true);
AssistantModel b = model("b", "CLOUD", true);
AssistantModel c = model("c", "CLOUD", true);
store().updateFields(AssistantModel.TABLE_NAME, new QRecord().withValue("id", c.getId()).withValue("supportsTools", false));
profile("CLASSIFIER", a, b, c);
script("c").thenText(answer);
script(selected).thenText("Answer");
TurnRunner.Outcome result = runner.run(USER, "explain this");
assertThat(result.text()).isEqualTo("Answer");
assertThat(turn(result).getModel()).isEqualTo(selected);
assertThat(turn(result).getRouteSource()).isEqualTo(source);
ModelRequest request = script("c").getRequests().get(0);
assertThat(request.getMaxTokens()).isEqualTo(4);
assertThat(request.getStreaming()).isFalse();
assertThat(request.getTools()).isEmpty();
assertThat(request.getSystem()).hasSize(1);
assertThat(request.getSystem().get(0).getText()).isEqualTo(ModelRouter.CLASSIFIER_PROMPT);
assertThat(request.getMessages()).hasSize(1);
assertThat(request.getMessages().get(0).getParts()).hasSize(1);
assertThat(request.getMessages().get(0).getParts().get(0).getText()).isEqualTo("explain this");
List<AssistantUsage> rows = store().query(AssistantUsage.TABLE_NAME, new QQueryFilter(), AssistantUsage.class);
assertThat(rows).extracting(AssistantUsage::getPurpose).containsExactly("ROUTE", "TURN");
assertThat(turn(result).getInputTokens()).isEqualTo(2400L);
}
/*******************************************************************************
** Error categories leave the heuristic in charge and record ROUTE failures.
*******************************************************************************/
@ParameterizedTest
@EnumSource(value = ModelProviderException.Kind.class, names = {"TIMEOUT", "SERVER"})
void classifierErrors(ModelProviderException.Kind kind) throws Exception
{
AssistantModel a = model("a", "CLOUD", true);
AssistantModel b = model("b", "CLOUD", true);
AssistantModel c = model("c", "CLOUD", true);
profile("CLASSIFIER", a, b, c);
script("c").thenError(kind, "unavailable");
script("b").thenText("Answer");
TurnRunner.Outcome result = runner.run(USER, "why");
assertThat(turn(result).getRouteSource()).isEqualTo("HEURISTIC");
assertThat(result.text()).isEqualTo("Answer");
assertThat(store().query(AssistantUsage.TABLE_NAME, new QQueryFilter(), AssistantUsage.class)).anyMatch(row -> "ROUTE".equals(row.getPurpose()) && "ERROR".equals(row.getStatus()));
}
/*******************************************************************************
** Real wait timeout cancels the classifier and opens its circuit.
*******************************************************************************/
@Test
void classifierDeadline() throws Exception
{
AssistantModel a = model("a", "CLOUD", true);
AssistantModel b = model("b", "CLOUD", true);
AssistantModel c = model("c", "CLOUD", true);
profile("CLASSIFIER", a, b, c);
script("c").withDelay(Duration.ofSeconds(10)).thenText("SIMPLE");
script("b").thenText("Answer");
Long start = System.nanoTime();
TurnRunner.Outcome result = runner.run(USER, "why");
assertThat(Duration.ofNanos(System.nanoTime() - start)).isLessThan(Duration.ofSeconds(8));
assertThat(turn(result).getRouteSource()).isEqualTo("HEURISTIC");
assertThat(store().getProvider(c.getProviderId()).getHealthState()).isEqualTo("DOWN");
}
/*******************************************************************************
** PHI cannot reach an ineligible classifier.
*******************************************************************************/
@Test
void phiSkipsClassifier() throws Exception
{
AssistantModel a = model("a", "CLOUD", true);
AssistantModel b = model("b", "CLOUD", true);
AssistantModel c = model("c", "CLOUD", false);
profile("CLASSIFIER", a, b, c);
config.withDataClassification("blog_post", DataClassification.PHI);
script("b").thenText("Answer");
TurnRunner.Outcome result = runner.run(USER, null, "why", Map.of("tableName", "blog_post"));
assertThat(script("c").getRequests()).isEmpty();
assertThat(turn(result).getRouteSource()).isEqualTo("HEURISTIC");
}
/*******************************************************************************
** OFF preserves row selection even for a complex question.
*******************************************************************************/
@Test
void offKeepsDefault() throws Exception
{
AssistantModel a = model("a", "CLOUD", true);
AssistantModel b = model("b", "CLOUD", true);
profile("OFF", b, b, null);
script("a").thenText("Default");
TurnRunner.Outcome result = runner.run(USER, "why");
assertThat(result.text()).isEqualTo("Default");
assertThat(turn(result).getRouteDecision()).isEqualTo("OFF");
assertThat(turn(result).getRouteSource()).isNull();
}
/*******************************************************************************
** Every retryable category records both attempts and the final destination.
*******************************************************************************/
@ParameterizedTest
@EnumSource(value = ModelProviderException.Kind.class, names = {"NETWORK", "TIMEOUT", "OVERLOADED", "SERVER", "RATE_LIMITED"})
void fallbackBeforeOutput(ModelProviderException.Kind kind) throws Exception
{
AssistantModel a = model("a", "CLOUD", true);
AssistantModel b = model("b", "CLOUD", true);
link(a, b);
script("a").thenError(kind, "unavailable");
script("b").thenText("Recovered");
TurnRunner.Outcome result = runner.run(USER, "hello");
assertThat(result.text()).isEqualTo("Recovered");
assertThat(turn(result).getFallbackCount()).isEqualTo(1);
assertThat(turn(result).getProviderId()).isEqualTo(b.getProviderId());
assertThat(turn(result).getModel()).isEqualTo("b");
assertThat(result.events(TurnEvent.NOTICE).toString()).contains("Provider A is unavailable; answered with B.");
List<AssistantUsage> rows = store().query(AssistantUsage.TABLE_NAME, new QQueryFilter(), AssistantUsage.class);
assertThat(rows).extracting(AssistantUsage::getStatus).containsExactly("ERROR", "OK");
assertThat(rows.get(0).getErrorKind()).isEqualTo(kind.name());
assertThat(turn(result).getInputTokens()).isEqualTo(2400L);
}
/*******************************************************************************
** Permanent errors cannot use another provider.
*******************************************************************************/
@ParameterizedTest
@EnumSource(value = ModelProviderException.Kind.class, names = {"INVALID_REQUEST", "AUTHENTICATION", "OTHER"})
void noFallbackForPermanentError(ModelProviderException.Kind kind) throws Exception
{
AssistantModel a = model("a", "CLOUD", true);
AssistantModel b = model("b", "CLOUD", true);
link(a, b);
script("a").thenError(kind, "bad request");
TurnRunner.Outcome result = runner.run(USER, "hello");
assertThat(result.end()).containsEntry("status", "ERROR");
assertThat(script("b").getRequests()).isEmpty();
}
/*******************************************************************************
** Three models, two failures, one final answer.
*******************************************************************************/
@Test
void threeModelChain() throws Exception
{
AssistantModel a = model("a", "CLOUD", true);
AssistantModel b = model("b", "CLOUD", true);
AssistantModel c = model("c", "CLOUD", true);
link(a, b);
link(b, c);
script("a").thenError(ModelProviderException.Kind.OVERLOADED, "busy");
script("b").thenError(ModelProviderException.Kind.SERVER, "busy");
script("c").thenText("Recovered");
TurnRunner.Outcome result = runner.run(USER, "hello");
assertThat(result.text()).isEqualTo("Recovered");
assertThat(turn(result).getFallbackCount()).isEqualTo(2);
}
/*******************************************************************************
** Real save validation rejects cycles and disabled destinations.
*******************************************************************************/
@Test
void rejectsInvalidChains() throws Exception
{
AssistantModel a = model("a", "CLOUD", true);
AssistantModel b = model("b", "CLOUD", true);
link(a, b);
assertThatThrownBy(() -> link(b, a)).hasMessageContaining("cycle");
assertThatThrownBy(() -> link(a, a)).hasMessageContaining("cycle");
AssistantModel c = model("c", "CLOUD", true);
store().updateFields(AssistantModel.TABLE_NAME, new QRecord().withValue("id", c.getId()).withValue("enabled", false));
assertThatThrownBy(() -> link(a, c)).hasMessageContaining("enabled");
Map<Integer, AssistantModel> graph = new LinkedHashMap<>();
for(Integer i = 1; i <= 6; i++)
{
graph.put(i, new AssistantModel().withId(i).withEnabled(true).withFallbackModelId(i == 6 ? null : i + 1));
}
assertThat(FallbackChain.validate(graph.get(1), graph)).contains("four hops");
assertThat(FallbackChain.validate(graph.get(2), graph)).isNull();
}
/*******************************************************************************
** Skip unavailable intermediate endpoints but follow their next edge.
*******************************************************************************/
@ParameterizedTest
@ValueSource(strings = {"disabled", "down", "disabledModel"})
void skipsUnavailableCandidate(String state) throws Exception
{
AssistantModel a = model("a", "CLOUD", true);
AssistantModel b = model("b", "CLOUD", true);
AssistantModel c = model("c", "CLOUD", true);
link(a, b);
link(b, c);
if("disabledModel".equals(state))
{
store().updateFields(AssistantModel.TABLE_NAME, new QRecord().withValue("id", b.getId()).withValue("enabled", false));
}
else
{
store().updateFields(AssistantProvider.TABLE_NAME, "disabled".equals(state)
? new QRecord().withValue("id", b.getProviderId()).withValue("enabled", false)
: new QRecord().withValue("id", b.getProviderId()).withValue("healthState", "DOWN").withValue("downUntil", NOW.plusSeconds(60)));
}
script("a").thenError(ModelProviderException.Kind.SERVER, "busy");
script("c").thenText("Recovered");
assertThat(runner.run(USER, "hello").text()).isEqualTo("Recovered");
assertThat(script("b").getRequests()).isEmpty();
}
/*******************************************************************************
** Explicit links never override PHI eligibility.
*******************************************************************************/
@Test
void phiNeverFallsToIneligible() throws Exception
{
AssistantModel a = model("a", "CLOUD", true);
AssistantModel b = model("b", "CLOUD", false);
link(a, b);
config.withDataClassification("blog_post", DataClassification.PHI);
script("a").thenError(ModelProviderException.Kind.OVERLOADED, "busy");
TurnRunner.Outcome result = runner.run(USER, null, "hello", Map.of("tableName", "blog_post"));
assertThat(result.end()).containsEntry("status", "ERROR");
assertThat(script("b").getRequests()).isEmpty();
}
/*******************************************************************************
** Automatic alternatives stay local, even when cloud is available.
*******************************************************************************/
@ParameterizedTest
@ValueSource(strings = {"LOCAL", "CLOUD"})
void implicitLocalOnly(String destination) throws Exception
{
model("a", "LOCAL", true);
model("b", destination, true);
script("a").thenError(ModelProviderException.Kind.OVERLOADED, "busy");
script("b").thenText("Recovered");
TurnRunner.Outcome result = runner.run(USER, "hello");
assertThat(result.end()).containsEntry("status", "LOCAL".equals(destination) ? "COMPLETE" : "ERROR");
assertThat(script("b").getRequests()).hasSize("LOCAL".equals(destination) ? 1 : 0);
}
/*******************************************************************************
** Destination STOP skips a candidate and continues to its successor.
*******************************************************************************/
@Test
void budgetSkipsCandidate() throws Exception
{
AssistantModel a = model("a", "CLOUD", true);
AssistantModel b = model("b", "CLOUD", true);
AssistantModel c = model("c", "CLOUD", true);
link(a, b);
link(b, c);
store().insert(new AssistantBudget().withName("Stop B").withScopeType("MODEL").withScopeValue(String.valueOf(b.getId()))
.withPeriod("DAY").withMetric("TOKENS").withLimitValue(BigDecimal.ONE).withAtLimitAction("STOP"));
store().insert(new AssistantUsage().withUserId(USER).withProviderId(b.getProviderId()).withModel("b").withPurpose("TURN").withStatus("OK").withInputTokens(100L).withOutputTokens(0L).withCreateDate(NOW));
script("a").thenError(ModelProviderException.Kind.SERVER, "busy");
script("c").thenText("Recovered");
assertThat(runner.run(USER, "hello").text()).isEqualTo("Recovered");
assertThat(script("b").getRequests()).isEmpty();
}
/*******************************************************************************
** Circuit expiry, cached probes and successful recovery.
*******************************************************************************/
@Test
void healthCircuitAndProbeCache() throws Exception
{
AssistantModel a = model("a", "LOCAL", true);
ProviderService.Active active = config.getRuntime().getProviderService().resolveModel(a.getId(), false);
ProviderHealth health = new ProviderHealth(config.getRuntime());
health.beforeCall(active);
health.beforeCall(active);
assertThat(probes).isEqualTo(1);
health.failed(active, new ModelProviderException(ModelProviderException.Kind.NETWORK, null, "offline"));
assertThat(store().getProvider(a.getProviderId()).getDownUntil()).isEqualTo(NOW.plusSeconds(60));
assertThatThrownBy(() -> health.beforeCall(active)).isInstanceOf(ModelProviderException.class);
store().updateFields(AssistantProvider.TABLE_NAME, new QRecord().withValue("id", a.getProviderId()).withValue("downUntil", NOW).withValue("healthCheckedAt", NOW.minusSeconds(61)));
health.beforeCall(active);
assertThat(probes).isEqualTo(2);
assertThat(store().getProvider(a.getProviderId()).getHealthState()).isEqualTo("UP");
health.failed(active, new ModelProviderException(ModelProviderException.Kind.TIMEOUT, null, "offline"));
health.succeeded(active);
assertThat(store().getProvider(a.getProviderId()).getDownUntil()).isNull();
}
/*******************************************************************************
** Provider-private signatures from a successful tool round cannot reach B.
*******************************************************************************/
@Test
void midTurnSwitchRebuildsNeutralHistory() throws Exception
{
AssistantModel a = model("a", "CLOUD", true);
AssistantModel b = model("b", "CLOUD", true);
link(a, b);
script("a").thenRespond(request -> new ModelResponse().withParts(new ArrayList<>(List.of(ModelPart.toolUse("tool-1", "count_records", input("table", "blog_post")))))
.withProviderState("private-signature").withStopReason(ModelResponse.STOP_TOOL_USE))
.thenError(ModelProviderException.Kind.OVERLOADED, "busy");
script("b").thenRespond(request ->
{
assertThat(request.getMessages()).allMatch(message -> message.getProviderState() == null);
assertThat(request.getMessages()).anyMatch(message -> message.getParts().stream().anyMatch(part -> part.getType() == ModelPart.Type.TOOL_RESULT));
return (new ModelResponse().withParts(new ArrayList<>(List.of(ModelPart.text("Recovered after tool")))).withStopReason(ModelResponse.STOP_END_TURN));
});
TurnRunner.Outcome result = runner.run(USER, "count posts");
assertThat(result.text()).isEqualTo("Recovered after tool");
assertThat(result.toolCalls()).hasSize(1);
assertThat(turn(result).getFallbackCount()).isEqualTo(1);
assertThat(script("a").getRequests().get(1).getMessages()).anyMatch(message -> "private-signature".equals(message.getProviderState()));
}
/*******************************************************************************
** Even buffered text or a tool start makes the failure terminal.
*******************************************************************************/
@ParameterizedTest
@ValueSource(strings = {"text", "tool"})
void partialOutputNeverFallsBack(String output) throws Exception
{
AssistantModel a = model("a", "CLOUD", true);
AssistantModel b = model("b", "CLOUD", true);
link(a, b);
scripts.put("https://a.invalid", new ScriptedModelProvider("a")
{
/*******************************************************************************
** Emit a partial event, then fail the same call.
*******************************************************************************/
@Override
public StreamHandle stream(ModelRequest request, ModelEventListener listener)
{
if("text".equals(output))
{
listener.onTextDelta("partial");
}
else
{
listener.onToolUseStart("tool-1", "count_records");
}
CompletableStreamHandle handle = new CompletableStreamHandle();
handle.fail(new ModelProviderException(ModelProviderException.Kind.NETWORK, null, "offline"));
return (handle);
}
});
TurnRunner.Outcome result = runner.run(USER, "hello");
assertThat(result.end()).containsEntry("status", "ERROR");
assertThat(script("b").getRequests()).isEmpty();
assertThat(turn(result).getFallbackCount()).isZero();
assertThat(store().getProvider(a.getProviderId()).getHealthState()).isEqualTo("DOWN");
if("text".equals(output))
{
assertThat(result.text()).isEqualTo("partial");
}
}
/*******************************************************************************
** Failed local probes open the circuit without sending model requests.
*******************************************************************************/
@Test
void failedProbeAndHomeHealth() throws Exception
{
AssistantModel a = model("a", "LOCAL", true);
healthy = false;
TurnRunner.Outcome result = runner.run(USER, "hello");
assertThat(result.end()).containsEntry("status", "ERROR");
assertThat(script("a").getRequests()).isEmpty();
assertThat(store().getProvider(a.getProviderId()).getHealthState()).isEqualTo("DOWN");
String html = AssistantHomeView.render(AssistantHomeData.load(config.getRuntime()), "");
assertThat(html).contains("DOWN", "Down until", "0 fallbacks");
}
/*******************************************************************************
** Classifier tokens count before the answer call can dispatch.
*******************************************************************************/
@Test
void classifierConsumesBudget() throws Exception
{
AssistantModel a = model("a", "CLOUD", true);
AssistantModel b = model("b", "CLOUD", true);
AssistantModel c = model("c", "CLOUD", true);
profile("CLASSIFIER", a, b, c);
store().insert(new AssistantBudget().withName("Small allowance").withScopeType("USER").withScopeValue(USER)
.withPeriod("DAY").withMetric("TOKENS").withLimitValue(BigDecimal.valueOf(100)).withAtLimitAction("STOP"));
script("c").thenText("SIMPLE");
TurnRunner.Outcome result = runner.run(USER, "hello");
assertThat(result.end()).containsEntry("status", "ERROR");
assertThat(script("a").getRequests()).isEmpty();
assertThat(store().query(AssistantUsage.TABLE_NAME, new QQueryFilter(), AssistantUsage.class)).extracting(AssistantUsage::getPurpose).containsExactly("ROUTE");
}
/*******************************************************************************
** Conversation context supplies tool exhaustion, pending writes and history.
*******************************************************************************/
@ParameterizedTest
@ValueSource(strings = {"tool_limit", "pending", "history"})
void heuristicConversationContext(String context) throws Exception
{
AssistantModel a = model("a", "CLOUD", true);
AssistantModel b = model("b", "CLOUD", true);
profile("HEURISTIC", a, b, null);
script("a").thenText("Simple");
TurnRunner.Outcome first = runner.run(USER, "hello");
assertThat(turn(first).getRouteDecision()).isEqualTo("SIMPLE");
if("tool_limit".equals(context))
{
store().updateFields(AssistantTurn.TABLE_NAME, new QRecord().withValue("id", first.turnId()).withValue("stopReason", "tool_limit"));
}
else if("pending".equals(context))
{
store().insert(new AssistantPendingAction().withUserId(USER).withConversationId(first.conversationId()).withStatus("PENDING"));
}
else
{
for(Integer i = 0; i < 8; i++)
{
store().insert(new AssistantTurn().withUserId(USER).withConversationId(first.conversationId()).withStatus("COMPLETE"));
}
}
script("b").thenText("Complex");
TurnRunner.Outcome result = runner.run(USER, first.conversationId(), "hello", null);
assertThat(result.text()).isEqualTo("Complex");
assertThat(turn(result).getRouteDecision()).isEqualTo("COMPLEX");
}
/*******************************************************************************
** Without a profile, escalation supplies the complex model and effort.
*******************************************************************************/
@Test
void codeLevelEscalation() throws Exception
{
config.getModels().withEscalationEnabled(true).withEscalationModel("complex-default").withEscalationEffort("high");
provider.thenText("Complex");
TurnRunner.Outcome result = runner.run(USER, "why");
assertThat(result.text()).isEqualTo("Complex");
assertThat(provider.getRequests().get(0).getModel()).isEqualTo("complex-default");
assertThat(provider.getRequests().get(0).getEffort()).isEqualTo("high");
}
/*******************************************************************************
** Four edges are allowed; a fifth is rejected even when added at the tail.
*******************************************************************************/
@Test
void fourHopBoundary() throws Exception
{
AssistantModel a = model("a", "CLOUD", true);
AssistantModel b = model("b", "CLOUD", true);
AssistantModel c = model("c", "CLOUD", true);
AssistantModel d = store().insert(new AssistantModel().withProviderId(c.getProviderId()).withModelId("d").withEnabled(true));
AssistantModel e = store().insert(new AssistantModel().withProviderId(c.getProviderId()).withModelId("e").withEnabled(true));
AssistantModel f = store().insert(new AssistantModel().withProviderId(c.getProviderId()).withModelId("f").withEnabled(true));
link(a, b);
link(b, c);
link(c, d);
link(d, e);
assertThatThrownBy(() -> link(e, f)).hasMessageContaining("four hops");
script("a").thenError(ModelProviderException.Kind.OVERLOADED, "busy");
script("b").thenError(ModelProviderException.Kind.OVERLOADED, "busy");
script("c").thenError(ModelProviderException.Kind.OVERLOADED, "busy").thenError(ModelProviderException.Kind.OVERLOADED, "busy").thenText("Recovered");
TurnRunner.Outcome result = runner.run(USER, "hello");
assertThat(result.text()).isEqualTo("Recovered");
assertThat(turn(result).getModel()).isEqualTo("e");
assertThat(turn(result).getFallbackCount()).isEqualTo(4);
String html = AssistantHomeView.render(AssistantHomeData.load(config.getRuntime()), "");
assertThat(html).contains("4 fallbacks");
}
}
@@ -0,0 +1,578 @@
/*
* 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.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.classification.DataClassification;
import com.kingsrook.qbits.assistant.engine.PolicyService;
import com.kingsrook.qbits.assistant.engine.TurnClassification;
import com.kingsrook.qbits.assistant.model.AssistantConversation;
import com.kingsrook.qbits.assistant.model.AssistantDataAccess;
import com.kingsrook.qbits.assistant.model.AssistantModel;
import com.kingsrook.qbits.assistant.model.AssistantPolicy;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qbits.assistant.model.AssistantRagSource;
import com.kingsrook.qbits.assistant.model.AssistantUsage;
import com.kingsrook.qbits.assistant.processes.KnowledgeIndexProcess;
import com.kingsrook.qbits.assistant.provider.AssistantModelProvider;
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
import com.kingsrook.qbits.assistant.provider.EmbeddingRequest;
import com.kingsrook.qbits.assistant.provider.EmbeddingResponse;
import com.kingsrook.qbits.assistant.provider.ModelPart;
import com.kingsrook.qbits.assistant.provider.ModelUsage;
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.tools.KnowledgeTool;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qbits.assistant.uiapi.UiApiCall;
import com.kingsrook.qqq.backend.core.actions.ai.AiExposure;
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolOutcome;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolResult;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Real UI API indexing/retrieval, with deterministic local embeddings and no credentials.
*******************************************************************************/
class RagTest extends BaseAssistantTest
{
private final InMemoryVectorStore vectors = new InMemoryVectorStore();
private final Embeddings embeddings = new Embeddings();
private AssistantRagSource source;
private AssistantModel model;
private AssistantProvider embeddingProvider;
/*******************************************************************************
** A hashed bag of words; capture batches solely to assert routing and masking.
*******************************************************************************/
static class Embeddings extends ScriptedModelProvider
{
final List<EmbeddingRequest> batches = new ArrayList<>();
/*******************************************************************************
** Same text always yields the same 16-dimensional vector.
*******************************************************************************/
@Override
public EmbeddingResponse embed(EmbeddingRequest request)
{
batches.add(request);
List<float[]> vectors = new ArrayList<>();
for(String text : request.input())
{
float[] vector = new float[16];
for(String word : text.toLowerCase(java.util.Locale.ROOT).split("[^a-z0-9]+"))
{
vector[Math.floorMod(word.hashCode(), vector.length)]++;
}
vectors.add(vector);
}
return (new EmbeddingResponse(vectors, new ModelUsage().withInputTokens((long) request.input().size()), "embed-test"));
}
}
/*******************************************************************************
** Keyless local type with the same provider instance for chat and embeddings.
*******************************************************************************/
class Type implements AssistantProviderType
{
/*******************************************************************************
** Test type identity.
*******************************************************************************/
@Override
public String getType()
{
return ("rag-test");
}
/*******************************************************************************
** Test label.
*******************************************************************************/
@Override
public String getLabel()
{
return ("RAG test");
}
/*******************************************************************************
** Chat default deliberately differs from the embedding model.
*******************************************************************************/
@Override
public List<String> getModels()
{
return (List.of("chat", "bge-m3", "nomic-embed-text"));
}
/*******************************************************************************
** No credentials for this in-process fake.
*******************************************************************************/
@Override
public String getDefaultApiKeyEnvName()
{
return (null);
}
/*******************************************************************************
** Never contacted; satisfies provider configuration validation.
*******************************************************************************/
@Override
public String getDefaultBaseUrl()
{
return ("http://localhost:11434/v1");
}
/*******************************************************************************
** Local test location.
*******************************************************************************/
@Override
public String getDefaultLocation(String kind)
{
return ("LOCAL");
}
/*******************************************************************************
** Return the test recording provider.
*******************************************************************************/
@Override
public AssistantModelProvider create(String key, String base)
{
return (embeddings);
}
}
/*******************************************************************************
** Configure only local fakes and the optional PHI fixture table.
*******************************************************************************/
@Override
protected void customizeConfig(AssistantQBitConfig config)
{
config.withVectorStore(vectors).withProviderType(new Type()).withDataClassification("patient", DataClassification.PHI);
}
/*******************************************************************************
** Registered EMBEDDING model and a source in the real metadata-backed store.
*******************************************************************************/
@BeforeEach
void sources() throws Exception
{
as(AssistantTestFixture.ADMIN);
embeddingProvider = config.getRuntime().getStore().insert(new AssistantProvider().withName("Local embeddings").withProviderType("rag-test")
.withModel("chat").withEnabled(true).withHipaaEligible(false));
model = config.getRuntime().getStore().insert(new AssistantModel().withProviderId(embeddingProvider.getId()).withModelId("bge-m3")
.withModelKind("EMBEDDING").withEmbeddingDimensions(16).withEnabled(true));
config.getRuntime().getStore().insert(new AssistantModel().withProviderId(embeddingProvider.getId()).withModelId("chat").withEnabled(true));
source = config.getRuntime().getStore().insert(new AssistantRagSource().withName("Blog knowledge").withTableName("blog_post")
.withFieldNames("title,content").withTitleFieldName("title").withEmbeddingModelId(model.getId()));
assertThat(source.getChunkChars()).isEqualTo(1500);
}
/*******************************************************************************
** The session used both for configuration reads and UI API calls.
*******************************************************************************/
private void as(String user)
{
QContext.init(qInstance, AssistantTestFixture.ownerSession(user, config));
}
/*******************************************************************************
** Retrieval helper, retaining the caller's exact session UUID.
*******************************************************************************/
private AiToolResult search(String user, DataClassification classification) throws Exception
{
as(user);
return (config.getRuntime().getKnowledgeTool().execute(config.getRuntime().getRagService().context(), "search-1", Map.of("query", "Drain upgrade verify", "limit", 8), classification, null, null));
}
/*******************************************************************************
** Unchanged content performs no embedding; disappeared records are removed.
*******************************************************************************/
@Test
void indexesReconcilesAndReports() throws Exception
{
RagService rag = config.getRuntime().getRagService();
RunBackendStepInput input = new RunBackendStepInput().withRecords(List.of(source.toQRecord()));
RunBackendStepOutput output = new RunBackendStepOutput();
KnowledgeIndexProcess.Review review = new KnowledgeIndexProcess.Review();
review.runOnePage(input, output);
assertThat(review.getProcessSummary(output, false)).hasSize(1);
assertThat(embeddings.batches).isEmpty();
KnowledgeIndexProcess.Index index = new KnowledgeIndexProcess.Index();
index.runOnePage(input, new RunBackendStepOutput());
assertThat(index.getProcessSummary(output, true)).hasSize(1);
assertThat(vectors.count(source.getId())).isPositive();
Integer calls = embeddings.batches.size();
RagService.Result unchanged = rag.index(source);
assertThat(unchanged.errors()).as(unchanged.reason()).isZero();
assertThat(unchanged.embedded()).isZero();
assertThat(unchanged.unchanged()).isEqualTo(vectors.count(source.getId()).intValue());
assertThat(embeddings.batches).hasSize(calls);
assertThat(vectors.chunks(source.getId())).allSatisfy(v -> assertThat(v.contentHash()).matches("[a-f0-9]{64}"));
assertThat(ChunkVector.class.getRecordComponents()).extracting(java.lang.reflect.RecordComponent::getName)
.containsExactly("source", "table", "pk", "field", "chunkIndex", "contentHash", "embedding");
assertThat(config.getRuntime().getUiApiGateway().invoke(new UiApiCall("DELETE", "/table/{tableName}/{primaryKey}")
.withPathParam("tableName", "blog_post").withPathParam("primaryKey", "1").withSessionUUID(AssistantTestFixture.ADMIN)).status()).isEqualTo(200);
RagService.Result changed = rag.index(source);
assertThat(changed.errors()).as(changed.reason()).isZero();
assertThat(changed.removed()).isEqualTo(2);
assertThat(vectors.chunks(source.getId())).noneMatch(v -> v.pk().equals("1"));
new KnowledgeIndexProcess.Clear().runOnePage(input, new RunBackendStepOutput());
assertThat(vectors.count(source.getId())).isZero();
}
/*******************************************************************************
** UI read permission is checked again for every vector hit, with no identifiers
** or snippets leaking from denied records. Exposure is reused from get_record.
*******************************************************************************/
@Test
void retrievesAsUserAndFiltersUiDenials() throws Exception
{
assertThat(config.getRuntime().getRagService().index(source).errors()).isZero();
AiToolResult allowed = search(AssistantTestFixture.EDITOR, DataClassification.PUBLIC);
assertThat(allowed.getOutcome()).as(allowed.getModelContent()).isEqualTo(AiToolOutcome.OK);
assertThat(allowed.getRowCount()).isPositive();
assertThat(allowed.getModelContent()).contains("Drain, upgrade, verify.", "/app/blog_post/").doesNotContain(AssistantTestFixture.HIDDEN_SENTINEL, AssistantTestFixture.PASSWORD_SENTINEL);
assertThat(config.getRuntime().getKnowledgeTool().offered(PolicyService.ToolPolicy.defaults(), QContext.getQSession())).isTrue();
AiToolResult denied = search(AssistantTestFixture.ANALYST, DataClassification.PUBLIC);
assertThat(denied.getOutcome()).isEqualTo(AiToolOutcome.OK);
assertThat(denied.getRowCount()).isZero();
assertThat(denied.getSummary()).doesNotEndWith("filtered 0");
assertThat(denied.getRecordRefs()).isEmpty();
assertThat(denied.getModelContent()).doesNotContain("blog_post", "Drain", "/app/");
assertThat(config.getRuntime().getKnowledgeTool().offered(PolicyService.ToolPolicy.defaults(), QContext.getQSession())).isFalse();
assertThat(config.getRuntime().getUiApiGateway().invoke(new UiApiCall("GET", "/table/{tableName}/{primaryKey}")
.withPathParam("tableName", "blog_post").withPathParam("primaryKey", "1").withSessionUUID(AssistantTestFixture.ANALYST)).status()).isEqualTo(403);
}
/*******************************************************************************
** Fresh field/table restrictions narrow both index validation and tool offering.
*******************************************************************************/
@Test
void validatesExposureAndMasksBeforeEmbedding() throws Exception
{
RagService rag = config.getRuntime().getRagService();
assertThat(rag.validate(new AssistantRagSource(source.toQRecord()).withFieldNames("internalNotes"))).isNotEmpty();
assertThat(rag.validate(new AssistantRagSource(source.toQRecord()).withFieldNames("id"))).isNotEmpty();
assertThat(rag.validate(new AssistantRagSource(source.toQRecord()).withTableName("assistant_model"))).isNotEmpty();
assertThat(rag.validate(new AssistantRagSource(source.toQRecord()).withChunkChars(100).withChunkOverlapChars(100))).isNotEmpty();
qInstance.getTable("blog_post").getField("content").withSupplementalMetaData(new AiFieldMetaData().withExposure(AiExposure.MASKED));
assertThat(rag.index(source).errors()).isZero();
assertThat(embeddings.batches.stream().flatMap(b -> b.input().stream())).noneMatch(s -> s.contains("Drain, upgrade"));
assertThat(search(AssistantTestFixture.EDITOR, DataClassification.PUBLIC).getModelContent()).doesNotContain("Drain, upgrade");
as(AssistantTestFixture.ADMIN);
config.getRuntime().getStore().insert(new AssistantDataAccess().withObjectType("TABLE").withObjectName("blog_post").withMode("NONE"));
assertThat(rag.validate(source)).anyMatch(s -> s.contains("NONE"));
assertThat(config.getRuntime().getKnowledgeTool().offered(PolicyService.ToolPolicy.defaults(), QContext.getQSession())).isFalse();
assertThat(search(AssistantTestFixture.EDITOR, DataClassification.PUBLIC).getRowCount()).isZero();
}
/*******************************************************************************
** PHI dispatch fails before text leaves the instance; eligible retrieval
** contributes table classification through the existing record-ref mechanism.
*******************************************************************************/
@Test
void phiEmbeddingGuardAndClassification() throws Exception
{
AssistantRagSource patient = config.getRuntime().getStore().insert(new AssistantRagSource().withName("Patients").withTableName("patient")
.withFieldNames("diagnosis").withEmbeddingModelId(model.getId()));
RagService.Result blocked = config.getRuntime().getRagService().index(patient);
assertThat(blocked.errors()).isEqualTo(1);
assertThat(blocked.reason()).contains("HIPAA-eligible");
assertThat(embeddings.batches).isEmpty();
assertThat(search(AssistantTestFixture.EDITOR, DataClassification.PHI).getModelContent()).contains("HIPAA-eligible");
assertThat(embeddings.batches).isEmpty();
as(AssistantTestFixture.ADMIN);
config.getRuntime().getStore().updateFields(AssistantProvider.TABLE_NAME, new QRecord().withValue("id", embeddingProvider.getId()).withValue("hipaaEligible", true));
assertThat(config.getRuntime().getRagService().index(patient).errors()).isZero();
as(AssistantTestFixture.EDITOR);
AiToolResult result = config.getRuntime().getKnowledgeTool().execute(config.getRuntime().getRagService().context(), "phi", Map.of("query", "diagnosis", "tables", List.of("patient")), DataClassification.PUBLIC, null, null);
assertThat(result.getRowCount()).isPositive();
TurnClassification classification = new TurnClassification(config.getClassificationSource(), new AssistantConversation().withMaxClassification("PUBLIC"), null);
classification.observe(ModelPart.toolUse("phi", KnowledgeTool.NAME, Map.of()), result);
assertThat(classification.getClassification()).isEqualTo(DataClassification.PHI);
}
/*******************************************************************************
** Batches are capped, accounted as EMBED, and blocked by the kill switch.
*******************************************************************************/
@Test
void batchesUsageAndSelection() throws Exception
{
new EmbeddingService(config.getRuntime()).embed(model.getId(), java.util.Collections.nCopies(130, "word"), DataClassification.PUBLIC, "blog_post", null, null);
assertThat(embeddings.batches).extracting(b -> b.input().size()).containsExactly(64, 64, 2);
assertThat(config.getRuntime().getStore().query(AssistantUsage.TABLE_NAME, new QQueryFilter(), AssistantUsage.class))
.hasSize(3).allSatisfy(row -> assertThat(row.getPurpose()).isEqualTo("EMBED"));
assertThat(config.getRuntime().getProviderService().resolveModel(model.getId(), false)).isNull();
assertThat(config.getRuntime().getProviderService().forRow(new AssistantProvider(embeddingProvider.toQRecord()).withModel(null), "chat").model()).isEqualTo("chat");
assertThatThrownBy(() -> config.getRuntime().getProviderService().resolveEmbedding(-1, false)).hasMessageContaining("EMBEDDING");
assertThat(com.kingsrook.qbits.assistant.engine.ModelDiscovery.run(config.getRuntime(), embeddingProvider).ok()).isTrue();
assertThat(config.getRuntime().getStore().listModels()).filteredOn(m -> m.getModelId().equals("nomic-embed-text"))
.singleElement().satisfies(m ->
{
assertThat(m.getModelKind()).isEqualTo("EMBEDDING");
assertThat(m.getEmbeddingDimensions()).isEqualTo(768);
});
config.getRuntime().getStore().insert(new AssistantPolicy().withName("Stop").withKillSwitch(true));
assertThatThrownBy(() -> new EmbeddingService(config.getRuntime()).embed(model.getId(), List.of("blocked"), DataClassification.PUBLIC, "blog_post", null, null)).hasMessageContaining("kill switch");
assertThat(embeddings.batches).hasSize(3);
assertThat(com.kingsrook.qbits.assistant.engine.ConnectionTest.runModel(config.getRuntime(), model, AssistantTestFixture.ADMIN).status()).isEqualTo("ERROR");
}
/*******************************************************************************
** A full golden turn cites fresh knowledge and writes the standard tool log.
*******************************************************************************/
@Test
@Tag("golden")
void goldenAnswerCitesKnowledge() throws Exception
{
assertThat(config.getRuntime().getRagService().index(source).errors()).isZero();
embeddings.thenToolUse(KnowledgeTool.NAME, Map.of("query", "Drain upgrade verify"))
.thenRespond(com.kingsrook.qbits.assistant.testing.EvalSupport.answer(request ->
{
Map<String, Object> data = com.kingsrook.qbits.assistant.testing.EvalSupport.firstResult(request);
Map<?, ?> record = (Map<?, ?>) ((List<?>) data.get("results")).getFirst();
return (record.get("snippet") + " [Source](" + record.get("href") + ")");
}));
var outcome = runner.run(AssistantTestFixture.EDITOR, "Find the knowledge about safely upgrading");
assertThat(outcome.errorCode()).isNull();
assertThat(outcome.text()).contains("[Source](/app/blog_post/");
assertThat(outcome.toolCalls()).singleElement().satisfies(call ->
{
assertThat(call.getToolName()).isEqualTo(KnowledgeTool.NAME);
assertThat(call.getOutcome()).isEqualTo("OK");
assertThat(call.getUiApiOperation()).startsWith("GET /table/");
});
}
/*******************************************************************************
** Defensive vectors and chunk boundary behavior, including invalid inputs.
*******************************************************************************/
@Test
void vectorAndChunkContracts() throws Exception
{
float[] raw = { 1, 0 };
ChunkVector chunk = new ChunkVector(1, "table", "key", "body", 0, TextChunks.hash("text"), raw);
raw[0] = 9;
assertThat(chunk.embedding()).containsExactly(1, 0);
chunk.embedding()[0] = 5;
assertThat(chunk.embedding()).containsExactly(1, 0);
InMemoryVectorStore store = new InMemoryVectorStore();
store.upsert(List.of(chunk));
assertThat(store.search(new float[] { 1, 0 }, 1, Set.of("table"))).singleElement().satisfies(hit -> assertThat(hit.score()).isEqualTo(1.0));
assertThat(store.search(new float[] { 0, 0 }, 1, Set.of("table"))).isEmpty();
assertThat(store.search(new float[] { 1 }, 1, Set.of("table"))).isEmpty();
assertThat(store.search(raw, 1, Set.of())).isEmpty();
store.replaceRecord(1, "table", "key", List.of());
assertThat(store.count(1)).isZero();
assertThat(TextChunks.split("abcdefgh", 4, 1)).containsExactly("abcd", "defg", "gh");
assertThat(TextChunks.split("", 4, 0)).isEmpty();
assertThatThrownBy(() -> TextChunks.split("a", 1, 1)).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new ChunkVector(1, "t", "p", "f", 0, "invalid", raw)).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new ChunkVector(1, "t", "p", "f", 0, TextChunks.hash("x"), new float[] { Float.NaN })).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new ScriptedModelProvider().embed(new EmbeddingRequest("chat", List.of("text")))).hasMessageContaining("unsupported");
}
/*******************************************************************************
** Test auth supplies a record-lock key for each real user's UI API session.
*******************************************************************************/
public static class LockedSessions extends AssistantTestFixture.TestSessionCustomizer
{
/*******************************************************************************
** The admin can index all records; each editor can see only its own key.
*******************************************************************************/
@Override
public void customizeSession(com.kingsrook.qqq.backend.core.model.metadata.QInstance instance, com.kingsrook.qqq.backend.core.model.session.QSession session, Map<String, Object> context)
{
super.customizeSession(instance, session, context);
session.withSecurityKeyValue("rag_owner", session.getUuid());
if(session.getUuid().startsWith("admin"))
{
session.withSecurityKeyValue("rag_all", true);
}
}
}
/*******************************************************************************
** The owner and non-owner hit identical UI record-lock enforcement.
*******************************************************************************/
@Test
void recordLocksFilterNonOwner() throws Exception
{
qInstance.addSecurityKeyType(new com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType().withName("rag_owner").withAllAccessKeyName("rag_all"));
qInstance.getAuthentication().withCustomizer(new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(LockedSessions.class));
var table = qInstance.getTable("blog_post");
table.withRecordSecurityLock(new com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock().withFieldName("slug").withSecurityKeyType("rag_owner"));
assertThat(config.getRuntime().getUiApiGateway().invoke(new UiApiCall("PATCH", "/table/{tableName}/{primaryKey}")
.withPathParam("tableName", "blog_post").withPathParam("primaryKey", "1").withSessionUUID(AssistantTestFixture.ADMIN)
.withJsonBody(Map.of("slug", AssistantTestFixture.EDITOR))).status()).isEqualTo(200);
RagService.Result indexed = config.getRuntime().getRagService().index(source);
assertThat(indexed.errors()).as(indexed.reason()).isZero();
AiToolResult owner = search(AssistantTestFixture.EDITOR, DataClassification.PUBLIC);
assertThat(owner.getRowCount()).as(owner.getModelContent()).isPositive();
assertThat(owner.getRecordRefs()).allSatisfy(ref -> assertThat(String.valueOf(ref.getId())).isEqualTo("1"));
AiToolResult other = search(AssistantTestFixture.OTHER_EDITOR, DataClassification.PUBLIC);
assertThat(other.getRowCount()).isZero();
assertThat(other.getSummary()).doesNotEndWith("filtered 0");
Integer status = config.getRuntime().getUiApiGateway().invoke(new UiApiCall("GET", "/table/{tableName}/{primaryKey}")
.withPathParam("tableName", "blog_post").withPathParam("primaryKey", "1").withSessionUUID(AssistantTestFixture.OTHER_EDITOR)).status();
assertThat(status).isIn(403, 404);
}
/*******************************************************************************
** Filter paging and chunk shrinkage reconcile without holding record content.
*******************************************************************************/
@Test
void sourceFilterAndChangedChunks() throws Exception
{
source.setFilterJson("{\"criteria\":[{\"fieldName\":\"id\",\"operator\":\"EQUALS\",\"values\":[1]}]}");
source.setFieldNames("content");
source.setChunkChars(100);
source.setChunkOverlapChars(20);
config.getRuntime().getStore().update(source);
var gateway = config.getRuntime().getUiApiGateway();
var update = new UiApiCall("PATCH", "/table/{tableName}/{primaryKey}").withPathParam("tableName", "blog_post").withPathParam("primaryKey", "1").withSessionUUID(AssistantTestFixture.ADMIN);
assertThat(gateway.invoke(update.withJsonBody(Map.of("content", "Knowledge ".repeat(40)))).status()).isEqualTo(200);
RagService.Result first = config.getRuntime().getRagService().index(source);
assertThat(first.errors()).as(first.reason()).isZero();
assertThat(first.recordsSeen()).isEqualTo(1);
assertThat(first.embedded()).isGreaterThan(1);
assertThat(gateway.invoke(update.withJsonBody(Map.of("content", "Short updated knowledge"))).status()).isEqualTo(200);
assertThat(search(AssistantTestFixture.EDITOR, DataClassification.PUBLIC).getModelContent()).contains("Short updated knowledge").doesNotContain("Knowledge Knowledge");
as(AssistantTestFixture.ADMIN);
RagService.Result shortened = config.getRuntime().getRagService().index(source);
assertThat(shortened.errors()).as(shortened.reason()).isZero();
assertThat(shortened.embedded()).isEqualTo(1);
assertThat(shortened.removed()).isEqualTo(first.embedded() - 1);
source.setFieldNames("title");
config.getRuntime().getStore().update(source);
assertThat(vectors.count(source.getId())).isZero();
assertThat(config.getRuntime().getRagService().validate(new AssistantRagSource(source.toQRecord()).withFilterJson("{"))).isNotEmpty();
as(AssistantTestFixture.EDITOR);
assertThatThrownBy(() -> config.getRuntime().getRagService().index(source)).hasMessageContaining("assistant.admin");
}
/*******************************************************************************
** Standard process execution reaches both Review and Result backend steps.
*******************************************************************************/
@Test
void standardProcessAndPolicy() throws Exception
{
var input = new com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput();
input.setProcessName(KnowledgeIndexProcess.INDEX);
input.setCallback(com.kingsrook.qqq.backend.core.actions.processes.QProcessCallbackFactory.forPrimaryKey("id", source.getId()));
input.setFrontendStepBehavior(com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput.FrontendStepBehavior.SKIP);
new com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction().execute(input);
assertThat(vectors.count(source.getId())).isPositive();
assertThat(com.kingsrook.qbits.assistant.engine.ConnectionTest.runModel(config.getRuntime(), model, AssistantTestFixture.ADMIN).status()).isEqualTo("OK");
RunBackendStepOutput clearOutput = new RunBackendStepOutput();
KnowledgeIndexProcess.ClearReview clearReview = new KnowledgeIndexProcess.ClearReview();
clearReview.runOnePage(new RunBackendStepInput().withRecords(List.of(source.toQRecord())), clearOutput);
assertThat(clearReview.getProcessSummary(clearOutput, false)).hasSize(1);
var policy = new PolicyService.ToolPolicy(true, Set.of("search_help"), 50, true);
assertThat(config.getRuntime().getKnowledgeTool().offered(policy, QContext.getQSession())).isFalse();
var context = new UiApiToolbox.Context(AssistantTestFixture.ADMIN, null, config.getRuntime().getUiApiGateway(), policy, null);
assertThat(config.getRuntime().getKnowledgeTool().execute(context, "denied", Map.of("query", "x"), DataClassification.PUBLIC, null, null).getOutcome()).isEqualTo(AiToolOutcome.DENIED);
assertThat(config.getRuntime().getKnowledgeTool().execute(config.getRuntime().getRagService().context(), "invalid", Map.of(), DataClassification.PUBLIC, null, null).getOutcome()).isEqualTo(AiToolOutcome.INVALID_INPUT);
config.getBudgets().withUserDailyTokensHard(1L);
assertThatThrownBy(() -> new EmbeddingService(config.getRuntime()).embed(model.getId(), List.of("blocked"), DataClassification.PUBLIC, "blog_post", null, null)).hasMessageContaining("budget");
}
/*******************************************************************************
** A multi-page scan does not stop at the first UI API query page.
*******************************************************************************/
@Test
void indexesMultiplePages() throws Exception
{
List<QRecord> records = new ArrayList<>();
for(Integer i = 0; i < 105; i++)
{
records.add(new QRecord().withValue("title", "Extra page " + i).withValue("content", "Paged knowledge " + i));
}
new com.kingsrook.qqq.backend.core.actions.tables.InsertAction().execute(new com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput("page").withRecords(records));
source.setTableName("page");
source.setFieldNames("content");
config.getRuntime().getStore().update(source);
RagService.Result result = config.getRuntime().getRagService().index(source);
assertThat(result.errors()).as(result.reason()).isZero();
assertThat(result.recordsSeen()).isEqualTo(117);
assertThat(vectors.count(source.getId())).isEqualTo(117);
}
}
@@ -66,6 +66,13 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qbits.assistant.model.AssistantModel;
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
import com.kingsrook.qqq.backend.core.actions.values.SearchPossibleValueSourceAction;
import com.kingsrook.qqq.backend.core.actions.values.QCustomPossibleValueProvider;
import com.kingsrook.qqq.backend.core.model.actions.values.SearchPossibleValueSourceInput;
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.HashMap;
@@ -222,8 +229,9 @@ class AssistantSettingsTest extends BaseAssistantTest
** Tables, nav, actions, child lists and exposure.
*******************************************************************************/
@Test
void testMetaData()
void testMetaData() throws Exception
{
asAdmin();
for(String name : List.of(AssistantProvider.TABLE_NAME, AssistantModelPrice.TABLE_NAME, AssistantUsage.TABLE_NAME))
{
QTableMetaData table = qInstance.getTable(name);
@@ -237,7 +245,7 @@ class AssistantSettingsTest extends BaseAssistantTest
assertThat(qInstance.getProcess(TestConnectionProcess.NAME).getLabel()).isEqualTo("Test connection");
assertThat(qInstance.getWidget(AssistantHome.WIDGET_NAME)).isNotNull();
assertThat(qInstance.getPossibleValueSource(AssistantRecordScreens.PROVIDER_TYPE_PVS_NAME).getEnumValues()).extracting(v -> String.valueOf(v.getId())).containsExactly(FAKE_TYPE);
assertThat(qInstance.getPossibleValueSource(AssistantRecordScreens.MODEL_PVS_NAME).getEnumValues()).extracting(v -> String.valueOf(v.getId())).contains("fake-model-1", "claude-sonnet-5-5");
assertThat(new SearchPossibleValueSourceAction().execute(new SearchPossibleValueSourceInput().withPossibleValueSourceName(AssistantRecordScreens.MODEL_PVS_NAME)).getResults()).extracting(v -> String.valueOf(v.getId())).contains("fake-model-1", "claude-sonnet-5-5");
assertThat(qInstance.getTable(AssistantProvider.TABLE_NAME).getSections()).anyMatch(s -> s.getWidgetName() != null);
assertThat(qInstance.getTable(AssistantProvider.TABLE_NAME).getSections()).anyMatch(s -> s.getFieldNames() != null && s.getFieldNames().contains("apiKeyStatus"));
}
@@ -570,4 +578,112 @@ class AssistantSettingsTest extends BaseAssistantTest
return (((RawHTML) output.getWidgetData()).getHtml());
}
/*******************************************************************************
** Provider models are scoped, with only this row's enabled chat models added.
*******************************************************************************/
@Test
void testContingentProviderModels() throws Exception
{
asAdmin();
Integer id = providerId();
AssistantStore store = new AssistantStore();
store.insert(new AssistantModel().withProviderId(id).withModelId("registered-chat").withEnabled(true).withModelKind("CHAT"));
store.insert(new AssistantModel().withProviderId(id).withModelId("disabled-chat").withEnabled(false).withModelKind("CHAT"));
store.insert(new AssistantModel().withProviderId(id).withModelId("embedding").withEnabled(true).withModelKind("EMBEDDING"));
Integer otherId = insertProvider(Map.of("name", "Other")).getRecords().get(0).getValueInteger("id");
store.insert(new AssistantModel().withProviderId(otherId).withModelId("other-chat").withEnabled(true).withModelKind("CHAT"));
assertThat(settingsValues(AssistantRecordScreens.MODEL_PVS_NAME, Map.of("providerType", FAKE_TYPE, "id", id.toString())))
.extracting(value -> String.valueOf(value.getId())).containsExactly("fake-model-1", "fake-model-2", "registered-chat");
assertThat(settingsValues(AssistantRecordScreens.MODEL_PVS_NAME, Map.of("providerType", FAKE_TYPE)))
.extracting(value -> String.valueOf(value.getId())).containsExactly("fake-model-1", "fake-model-2");
assertThat(new SearchPossibleValueSourceAction().execute(new SearchPossibleValueSourceInput()
.withPossibleValueSourceName(AssistantRecordScreens.MODEL_PVS_NAME).withOtherValues(Map.of("providerType", FAKE_TYPE)).withSearchTerm("fake-model-2")).getResults())
.extracting(value -> String.valueOf(value.getId())).containsExactly("fake-model-2");
}
/*******************************************************************************
** Kinds use the selected type and keep their existing labels.
*******************************************************************************/
@Test
void testContingentProviderKinds() throws Exception
{
assertThat(settingsValues("assistant_provider_kind", Map.of("providerType", FAKE_TYPE)))
.extracting(value -> String.valueOf(value.getId())).containsExactly("FAKE");
assertThat(settingsValues("assistant_provider_kind", Map.of())).anySatisfy(value ->
{
assertThat(value.getId()).isEqualTo("AZURE_OPENAI");
assertThat(value.getLabel()).isEqualTo("Azure OpenAI");
});
}
/*******************************************************************************
** A price id must not be interpreted as a provider id.
*******************************************************************************/
@Test
void testContingentPriceModels() throws Exception
{
asAdmin();
Integer id = providerId();
new AssistantStore().insert(new AssistantModel().withProviderId(id).withModelId("registered-chat").withEnabled(true).withModelKind("CHAT"));
String source = qInstance.getTable(AssistantModelPrice.TABLE_NAME).getField("model").getPossibleValueSourceName();
assertThat(settingsValues(source, Map.of("providerType", FAKE_TYPE, "id", id)))
.extracting(value -> String.valueOf(value.getId())).containsExactly("fake-model-1", "fake-model-2");
}
/*******************************************************************************
** Missing and unknown types retain the original union, including pricing.
*******************************************************************************/
@Test
void testContingentUnionFallback() throws Exception
{
for(String source : List.of(AssistantRecordScreens.MODEL_PVS_NAME, "assistant_price_model", "assistant_provider_kind"))
{
List<QPossibleValue<?>> union = settingsValues(source, null);
assertThat(settingsValues(source, Map.of())).usingRecursiveComparison().isEqualTo(union);
assertThat(settingsValues(source, Map.of("providerType", "unknown"))).usingRecursiveComparison().isEqualTo(union);
}
assertThat(settingsValues(AssistantRecordScreens.MODEL_PVS_NAME, null)).extracting(value -> String.valueOf(value.getId()))
.contains("fake-model-1", "claude-sonnet-5-5");
}
/*******************************************************************************
** Saved values resolve without a selected type, even disabled registry ids.
*******************************************************************************/
@Test
void testSavedPossibleValue() throws Exception
{
asAdmin();
Integer id = providerId();
new AssistantStore().insert(new AssistantModel().withProviderId(id).withModelId("saved-model").withEnabled(false).withModelKind("CHAT"));
for(String source : List.of(AssistantRecordScreens.MODEL_PVS_NAME, "assistant_price_model", "assistant_provider_kind"))
{
QCustomPossibleValueProvider<?> custom = QCodeLoader.getAdHoc(QCustomPossibleValueProvider.class, qInstance.getPossibleValueSource(source).getCustomCodeReference());
String saved = source.equals("assistant_provider_kind") ? "OPENAI" : "saved-model";
assertThat(custom.getPossibleValue(saved).getId()).isEqualTo(saved);
assertThat(custom.getPossibleValue("not-known")).isNull();
}
}
/*******************************************************************************
** Exercise the registered source through QQQ's search action.
*******************************************************************************/
private List<QPossibleValue<?>> settingsValues(String source, Map<String, Serializable> otherValues) throws Exception
{
asAdmin();
return (new SearchPossibleValueSourceAction().execute(new SearchPossibleValueSourceInput()
.withPossibleValueSourceName(source).withOtherValues(otherValues)).getResults());
}
}
@@ -226,9 +226,13 @@ public class AssistantTestFixture
update("page", new QRecord().withValue("id", 12).withValue("metaDescription", "Who I am and what I build."));
update("page", new QRecord().withValue("id", 12).withValue("content", "Body of page 12, rewritten with a longer story about the home lab."));
insert("media", List.of(
new QRecord().withValue("fileName", "cover.heic").withValue("mimeType", "image/heic"),
new QRecord().withValue("fileName", "logo.png").withValue("mimeType", "image/png").withValue("width", 512)));
List<QRecord> media = new ArrayList<>();
for(Integer i = 0; i < 101; i++)
{
media.add(new QRecord().withValue("fileName", "image-" + i + (i < 81 ? ".jpg" : ".png"))
.withValue("mimeType", i < 81 ? "image/jpeg" : "image/png").withValue("width", 512));
}
insert("media", media);
List<QRecord> stats = new ArrayList<>();
for(int d = 1; d <= 5; d++)
@@ -180,6 +180,7 @@ public class GoldenTasks
},
o -> live(o, "search_records", "/app/blog_post/1")));
tasks.add(PresentationGoldenTask.task());
return (tasks);
}
@@ -0,0 +1,171 @@
/*
* 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.testing;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.core.type.TypeReference;
import com.kingsrook.qbits.assistant.engine.TurnEvent;
import com.kingsrook.qbits.assistant.provider.ModelPart;
import com.kingsrook.qbits.assistant.provider.ModelResponse;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import static com.kingsrook.qbits.assistant.testing.GoldenTasks.map;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Owner's image breakdown, shared by deterministic and live provider evals.
*******************************************************************************/
public class PresentationGoldenTask
{
/*******************************************************************************
** Counts come from UI API tool results, including the omitted zero types.
*******************************************************************************/
public static GoldenTasks.Task task()
{
return (new GoldenTasks.Task("images-by-type-presentation", AssistantTestFixture.EDITOR, "How many images do I have by type?", provider ->
{
List<Object[]> calls = new ArrayList<>();
for(String mime : List.of("image/jpeg", "image/png", "image/webp", "image/gif", "image/svg+xml"))
{
calls.add(new Object[] {"count_records", map("table", "media", "filter", GoldenTasks.filter("AND", GoldenTasks.criterion("mimeType", "EQUALS", mime)))});
}
provider.thenToolUses(null, calls).thenRespond(request ->
{
List<ModelPart> results = EvalSupport.lastToolResults(request);
Integer jpeg = ((Number) EvalSupport.unwrap(results.get(0).getText()).get("count")).intValue();
Integer png = ((Number) EvalSupport.unwrap(results.get(1).getText()).get("count")).intValue();
for(Integer i = 2; i < results.size(); i++)
{
assertThat(((Number) EvalSupport.unwrap(results.get(i).getText()).get("count")).intValue()).isZero();
}
return (new ModelResponse().withParts(List.of(ModelPart.toolUse("present", "present_results", input(jpeg, png)))).withStopReason(ModelResponse.STOP_TOOL_USE));
}).thenText("");
}, PresentationGoldenTask::check, PresentationGoldenTask::check));
}
/*******************************************************************************
** The presentation input, deliberately using null percentage placeholders.
*******************************************************************************/
public static Map<String, Object> input(Integer jpeg, Integer png)
{
return (map("summary", (jpeg + png) + " media files are images: " + jpeg + " JPEG and " + png + " PNG.",
"metrics", List.of(map("label", "Images", "value", jpeg + png)),
"table", map("title", "Media by type", "columns", List.of(map("label", "Type"), map("label", "Count"), map("label", "Share", "percentOfColumn", 1)),
"rows", List.of(java.util.Arrays.asList("JPEG", jpeg, null), java.util.Arrays.asList("PNG", png, null)),
"rowFilter", map("tableName", "media", "fieldName", "mimeType"), "rowKeys", List.of("image/jpeg", "image/png")),
"chart", map("chartType", "pie", "title", "Share by type", "labelColumn", 0, "valueColumn", 1),
"filterLinks", List.of(filterLink("JPEG", "image/jpeg"), filterLink("PNG", "image/png"))));
}
/*******************************************************************************
** A raw MIME criterion for the presentation tool.
*******************************************************************************/
private static Map<String, Object> filterLink(String label, String mime)
{
return (map("label", "Open " + label + " media", "tableName", "media", "criteria", List.of(map("fieldName", "mimeType", "operator", "EQUALS", "values", List.of(mime)))));
}
/*******************************************************************************
** Check actual block semantics, allowing a live model to choose labels and
** column order. Values, shares and filter criteria must remain exact.
*******************************************************************************/
public static void check(TurnRunner.Outcome outcome)
{
assertThat(outcome.end()).containsEntry("status", "COMPLETE");
assertThat(outcome.toolCalls()).anyMatch(call -> "count_records".equals(call.getToolName()) && "OK".equals(call.getOutcome()));
assertThat(outcome.events(TurnEvent.PRESENTATION)).hasSize(1);
Map<String, Object> part = object(outcome.events(TurnEvent.PRESENTATION).get(0).get("part"));
assertPart(part);
}
/*******************************************************************************
** Also used against the persisted part, not only the streamed event.
*******************************************************************************/
public static void assertPart(Map<String, Object> part)
{
assertThat(part.get("type")).isEqualTo("presentation");
assertThat((String) part.get("summary")).isNotBlank().doesNotContain("\n").hasSizeLessThanOrEqualTo(300);
assertThat((String) part.get("markdown")).contains("\n|");
Map<String, Object> table = block(part, "table");
List<?> rows = (List<?>) table.get("rows");
assertThat(rows).hasSize(2);
assertThat(rows).anySatisfy(row -> assertThat(object(object(row).get("display")).values()).contains("81", "80.2%"));
assertThat(rows).anySatisfy(row -> assertThat(object(object(row).get("display")).values()).contains("20", "19.8%"));
Map<String, Object> chart = block(part, "chart");
assertThat(chart.get("chartType")).isEqualTo("pie");
List<?> points = (List<?>) chart.get("points");
assertThat(points).hasSize(2);
assertThat(points.stream().map(p -> ((Number) object(p).get("value")).intValue()).toList()).containsExactlyInAnyOrder(81, 20);
List<?> links = (List<?>) block(part, "filterLinks").get("items");
assertThat(links).hasSize(2);
List<Object> mimes = new ArrayList<>();
for(Object entry : links)
{
String href = (String) object(entry).get("href");
assertThat(href).startsWith("/app/media?filter=");
try
{
Map<String, Object> filter = JsonUtils.toObject(URLDecoder.decode(href.substring(href.indexOf('=') + 1), StandardCharsets.UTF_8), new TypeReference<Map<String, Object>>() {});
assertThat(filter).containsEntry("booleanOperator", "AND");
List<?> criteria = (List<?>) filter.get("criteria");
assertThat(criteria).hasSize(1);
Map<String, Object> criterion = object(criteria.get(0));
assertThat(criterion).containsEntry("fieldName", "mimeType").containsEntry("operator", "EQUALS");
mimes.addAll((List<?>) criterion.get("values"));
}
catch(Exception e)
{
throw (new AssertionError("Invalid filtered list href", e));
}
}
assertThat(mimes).containsExactlyInAnyOrder("image/jpeg", "image/png");
}
/*******************************************************************************
** Find one block of a given kind.
*******************************************************************************/
public static Map<String, Object> block(Map<String, Object> part, String kind)
{
return (((List<?>) part.get("blocks")).stream().map(PresentationGoldenTask::object).filter(block -> kind.equals(block.get("kind"))).findFirst().orElseThrow());
}
/*******************************************************************************
** Typed test view of a JSON object.
*******************************************************************************/
@SuppressWarnings("unchecked")
public static Map<String, Object> object(Object value)
{
return ((Map<String, Object>) value);
}
}
@@ -59,7 +59,8 @@ public class TurnRunner
*******************************************************************************/
public String text()
{
return (events.stream().filter(e -> TurnEvent.TEXT_DELTA.equals(e.getType())).map(e -> String.valueOf(e.getData().get("text"))).collect(Collectors.joining()));
return (events.stream().filter(e -> TurnEvent.TEXT_DELTA.equals(e.getType()) || TurnEvent.PRESENTATION.equals(e.getType()))
.map(e -> TurnEvent.PRESENTATION.equals(e.getType()) ? String.valueOf(((Map<?, ?>) e.getData().get("part")).get("markdown")) : String.valueOf(e.getData().get("text"))).collect(Collectors.joining()));
}
@@ -0,0 +1,275 @@
/*
* 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.List;
import java.util.Map;
import com.kingsrook.qbits.assistant.engine.MessageParts;
import com.kingsrook.qbits.assistant.engine.PolicyService;
import com.kingsrook.qbits.assistant.engine.TurnEvent;
import com.kingsrook.qbits.assistant.model.AssistantMessage;
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qbits.assistant.testing.PresentationGoldenTask;
import com.kingsrook.qbits.assistant.testing.TurnRunner;
import com.kingsrook.qbits.assistant.uiapi.UiApiCall;
import com.kingsrook.qbits.assistant.uiapi.UiApiGateway;
import com.kingsrook.qbits.assistant.uiapi.UiApiResult;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolOutcome;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolResult;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import org.junit.jupiter.api.Test;
import static com.kingsrook.qbits.assistant.testing.PresentationGoldenTask.block;
import static com.kingsrook.qbits.assistant.testing.PresentationGoldenTask.object;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Presentation persistence, permission boundaries, formatting and input limits.
*******************************************************************************/
class PresentResultsToolTest extends BaseAssistantTest
{
/*******************************************************************************
** Streamed, replayed and stored presentations agree; retention removes data.
*******************************************************************************/
@Test
void testPersistenceAndReplay() throws Exception
{
PresentationGoldenTask.task().script().accept(provider);
TurnRunner.Outcome outcome = runner.run(AssistantTestFixture.EDITOR, "How many images do I have by type?");
PresentationGoldenTask.check(outcome);
QContext.init(qInstance, AssistantTestFixture.ownerSession(AssistantTestFixture.EDITOR, config));
Map<String, Object> event = outcome.events(TurnEvent.PRESENTATION).get(0);
AssistantMessage message = runner.runtime().getStore().getMessage(AssistantTestFixture.EDITOR, (Integer) event.get("messageId"));
MessageParts parts = MessageParts.fromJson(message.getPartsJson());
Map<String, Object> part = parts.list().stream().filter(p -> "presentation".equals(p.get("type"))).findFirst().orElseThrow();
PresentationGoldenTask.assertPart(part);
assertThat(part).isEqualTo(com.kingsrook.qqq.backend.core.utils.JsonUtils.toObject(com.kingsrook.qqq.backend.core.utils.JsonUtils.toJson(event.get("part")), Map.class));
List<?> rows = (List<?>) block(part, "table").get("rows");
List<?> points = (List<?>) block(part, "chart").get("points");
List<?> links = (List<?>) block(part, "filterLinks").get("items");
for(Integer i = 0; i < 2; i++)
{
assertThat(object(rows.get(i)).get("href")).isEqualTo(object(links.get(i)).get("href"));
assertThat(object(points.get(i)).get("href")).isEqualTo(object(rows.get(i)).get("href"));
}
assertThat(runner.runtime().getStore().listEvents(AssistantTestFixture.EDITOR, outcome.turnId(), 0, 1000))
.anyMatch(e -> TurnEvent.PRESENTATION.equals(e.getEventType()));
assertThat(parts.text()).contains("81", "20", "\n|");
assertThat(parts.stripRecordData()).isTrue();
assertThat(parts.list()).noneMatch(p -> "presentation".equals(p.get("type")));
assertThat(outcome.toolCalls()).anySatisfy(call ->
{
assertThat(call.getToolName()).isEqualTo(PresentResultsTool.NAME);
assertThat(call.getInputJson()).contains("presentation");
assertThat(call.getResultJson()).contains("Do not repeat");
});
}
/*******************************************************************************
** The real UI metadata hides media from an analyst, as does presentation.
*******************************************************************************/
@Test
void testUiDenial() throws Exception
{
String user = AssistantTestFixture.ANALYST;
UiApiResult metadata = runner.runtime().getUiApiGateway().invoke(new UiApiCall("GET", "/metaData").withSessionUUID(user));
assertThat(object(metadata.json().get("tables"))).doesNotContainKey("media");
provider.thenToolUse(PresentResultsTool.NAME, input("summary", "The list is unavailable.", "recordLinks", List.of(input("label", "Image", "tableName", "media", "recordId", "1")))).thenText("");
TurnRunner.Outcome outcome = runner.run(user, "Open an image");
assertThat(outcome.toolCalls().get(0).getResultJson()).contains("table not visible through UI API: media");
Map<String, Object> part = object(outcome.events(TurnEvent.PRESENTATION).get(0).get("part"));
assertThat((List<?>) part.get("blocks")).isEmpty();
assertThat(outcome.toolCalls().get(0).getInputJson()).doesNotContain("recordId");
}
/*******************************************************************************
** Cache metadata once per turn and send the original session UUID.
*******************************************************************************/
@Test
void testMetadataCache() throws Exception
{
List<UiApiCall> calls = new ArrayList<>();
UiApiGateway gateway = new UiApiGateway()
{
/*******************************************************************************
** Fixture metadata gateway.
*******************************************************************************/
@Override
public boolean isInvocable(String method, String path)
{
return (true);
}
/*******************************************************************************
** Fixture metadata gateway.
*******************************************************************************/
@Override
public UiApiResult invoke(UiApiCall call)
{
calls.add(call);
return (new UiApiResult(200, "{\"tables\":{\"media\":{\"readPermission\":true}}}"));
}
};
UiApiToolbox.Context context = new UiApiToolbox.Context("editor-session", "UTC", gateway, PolicyService.ToolPolicy.defaults(), null);
PresentResultsTool tool = new PresentResultsTool(qInstance);
assertThat(tool.execute(context, "1", PresentationGoldenTask.input(81, 20)).getOutcome()).isEqualTo(AiToolOutcome.OK);
tool.execute(context, "2", PresentationGoldenTask.input(81, 20));
assertThat(calls).hasSize(1).allMatch(call -> "editor-session".equals(call.getSessionUUID()) && "/metaData".equals(call.getPathTemplate()));
new PresentResultsTool(qInstance).execute(context, "3", PresentationGoldenTask.input(81, 20));
assertThat(calls).hasSize(2);
}
/*******************************************************************************
** Unknown metadata is dropped independently without persisting its values.
*******************************************************************************/
@Test
void testUnknownMetadata()
{
AiToolResult result = execute(input("summary", "Available results.", "metrics", List.of(
input("label", "Unknown table", "value", 9876, "tableName", "missing"),
input("label", "Unknown field", "value", 5432, "tableName", "media", "fieldName", "missing"),
input("label", "Known", "value", 101))));
assertThat(result.getModelContent()).contains("Dropped metrics[0]", "unknown table", "Dropped metrics[1]", "unknown field");
assertThat(result.getRender().toString()).doesNotContain("9876", "5432");
assertThat((List<?>) block(result.getRender(), "metrics").get("items")).hasSize(1);
}
/*******************************************************************************
** Markdown punctuation, external URLs and pipes remain literal text.
*******************************************************************************/
@Test
void testUntrustedLabelsAndLinks()
{
AiToolResult result = execute(input("summary", "Available results.", "metrics", List.of(input("label", "[evil](https://evil.example) | <img>", "value", 1)),
"recordLinks", List.of(input("label", "Open", "tableName", "media", "recordId", "a/b?x#y")),
"actions", List.of(input("label", "Ask", "prompt", "Show images"), input("label", "Open media", "navigateTo", "media"), input("label", "Bad", "prompt", "Ask", "navigateTo", "media"))));
String markdown = (String) result.getRender().get("markdown");
assertThat(markdown).doesNotContain("https://", "<img>", "[evil]").contains("&#124;", "[Open](/app/media/a%2Fb%3Fx%23y)", "- Ask: Show images", "[Open media](/app/media)");
assertThat(result.getModelContent()).contains("exactly one");
}
/*******************************************************************************
** Formatting follows metadata while raw values remain numeric.
*******************************************************************************/
@Test
void testFormatting()
{
qInstance.getTable("media").addField(new QFieldMetaData("price", QFieldType.DECIMAL).withDisplayFormat("$%,.2f"));
AiToolResult result = execute(input("summary", "Amounts.", "metrics", List.of(input("label", "Amount", "value", 1234.5, "tableName", "media", "fieldName", "price"), input("label", "Count", "value", 12000)),
"table", input("columns", List.of(input("label", "Amount", "tableName", "media", "fieldName", "price")), "rows", List.of(List.of(1234.5)))));
assertThat(block(result.getRender(), "metrics").toString()).contains("$1,234.50", "12,000", "1234.5");
assertThat(block(result.getRender(), "table").toString()).contains("$1,234.50");
}
/*******************************************************************************
** Percentages cannot refer to labels, derived percentages or bad indexes.
*******************************************************************************/
@Test
void testInvalidPercentColumn()
{
for(Object source : List.of(0, 3, 1.5))
{
AiToolResult result = execute(input("summary", "Invalid share.", "table", input("columns", List.of(input("label", "Name"), input("label", "Share", "percentOfColumn", source)), "rows", List.of(List.of("JPEG", 0)))));
assertThat(result.getModelContent()).contains("Dropped table");
assertThat((List<?>) result.getRender().get("blocks")).isEmpty();
}
}
/*******************************************************************************
** Zero totals are defined, and charts never exceed twelve points.
*******************************************************************************/
@Test
void testChartLimitAndZeroTotal()
{
List<List<Object>> rows = new ArrayList<>();
for(Integer i = 0; i < 13; i++)
{
rows.add(List.of("Item " + i, 0, 0));
}
AiToolResult result = execute(input("summary", "Thirteen items.", "table", input("columns", List.of(input("label", "Item"), input("label", "Count"), input("label", "Share", "percentOfColumn", 1)), "rows", rows),
"chart", input("chartType", "bar", "labelColumn", 0, "valueColumn", 1)));
assertThat((List<?>) block(result.getRender(), "chart").get("points")).hasSize(12);
assertThat((List<?>) block(result.getRender(), "table").get("rows")).hasSize(13);
assertThat(result.getModelContent()).contains("truncated to 12");
assertThat(block(result.getRender(), "table").toString()).contains("0.0%");
}
/*******************************************************************************
** Operator validation does not coerce raw numeric/boolean filter values.
*******************************************************************************/
@Test
void testCriteriaValidation()
{
AiToolResult result = execute(input("summary", "Links.", "filterLinks", List.of(
input("label", "Invalid", "tableName", "media", "criteria", List.of(input("fieldName", "width", "operator", "INVENTED", "values", List.of(10)))),
input("label", "Valid", "tableName", "media", "criteria", List.of(input("fieldName", "width", "operator", "EQUALS", "values", List.of(512)))))));
assertThat(result.getModelContent()).contains("invalid criteria operator");
List<?> links = (List<?>) block(result.getRender(), "filterLinks").get("items");
String decoded = java.net.URLDecoder.decode((String) object(links.get(0)).get("href"), java.nio.charset.StandardCharsets.UTF_8);
assertThat(decoded).contains("\"values\":[512]");
}
/*******************************************************************************
** Invalid summaries produce an error tool result; no presentation is emitted.
*******************************************************************************/
@Test
void testInvalidSummaryAndAlwaysOffered() throws Exception
{
assertThat(runner.runtime().getContextBuilder().tools(new PolicyService.ToolPolicy(true, java.util.Set.of("navigate"), 20, true)))
.anyMatch(tool -> PresentResultsTool.NAME.equals(tool.getName()) && !Boolean.TRUE.equals(tool.getStrict()));
assertThat(execute(input("summary", "x".repeat(301))).getOutcome()).isEqualTo(AiToolOutcome.INVALID_INPUT);
provider.thenToolUse(PresentResultsTool.NAME, Map.of()).thenText("Unable to present.");
TurnRunner.Outcome outcome = runner.run(AssistantTestFixture.EDITOR, "Present");
assertThat(outcome.events(TurnEvent.PRESENTATION)).isEmpty();
assertThat(outcome.toolCalls().get(0).getOutcome()).isEqualTo("INVALID_INPUT");
}
/*******************************************************************************
** Use the fixture's real UI gateway for unit-level presentation checks.
*******************************************************************************/
private AiToolResult execute(Map<String, Object> input)
{
return (new PresentResultsTool(qInstance).execute(new UiApiToolbox.Context(AssistantTestFixture.EDITOR, "UTC", runner.runtime().getUiApiGateway(), PolicyService.ToolPolicy.defaults(), null), "present", input));
}
}
@@ -0,0 +1,350 @@
/*
* 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.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
import com.kingsrook.qbits.assistant.access.DataAccessMode;
import com.kingsrook.qbits.assistant.api.AssistantApi;
import com.kingsrook.qbits.assistant.engine.PolicyService;
import com.kingsrook.qbits.assistant.model.AssistantDataAccess;
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
import com.kingsrook.qbits.assistant.provider.ModelPart;
import com.kingsrook.qbits.assistant.provider.ModelResponse;
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qbits.assistant.testing.TurnRunner;
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
import com.kingsrook.qbits.assistant.uiapi.UiApiCall;
import com.kingsrook.qqq.backend.core.actions.ai.AiToolOutcome;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.savedbulkloadprofiles.SavedBulkLoadProfileMetaDataProvider;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import org.json.JSONObject;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static com.kingsrook.qbits.assistant.testing.EvalSupport.firstResult;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** The bulk-load tools: definitions, the UI API calls they make (as the
** user, with the route header from the provider, never the model), pending
** actions for writes, the confirmed save and run, and log hygiene. Plus the
** golden eval: a messy CSV -> profile -> dry run with an error -> fixed ->
** saved -> loaded.
*******************************************************************************/
class BulkLoadToolsTest extends BaseAssistantTest
{
private final FakeBulkLoadServer server = new FakeBulkLoadServer(true);
private final List<BulkLoadRunner.Request> runs = new ArrayList<>();
/*******************************************************************************
** The fake server as the gateway, and a recording runner.
*******************************************************************************/
@Override
protected void customizeConfig(AssistantQBitConfig config)
{
config.withUiApiGateway(server).withBulkLoadRunner(request ->
{
runs.add(request);
return (new BulkLoadRunner.RunHandle("AWAITING_CONFIRMATION", 42L, null, "Job 42 is waiting for your confirmation on its job record.", "POST /processes/{processName}/jobs", 201));
});
}
/*******************************************************************************
** The saved-profile tables (so the tools are offered), and write access.
*******************************************************************************/
@BeforeEach
void setUp() throws QException
{
new SavedBulkLoadProfileMetaDataProvider().defineAll(qInstance, AssistantTestFixture.BACKEND, null);
setAccess(BulkLoadTools.PROFILE_TABLE, DataAccessMode.READ_WRITE);
setAccess("blog_post", DataAccessMode.READ_WRITE);
}
/*******************************************************************************
**
*******************************************************************************/
@AfterEach
void clear()
{
QContext.clear();
}
/*******************************************************************************
**
*******************************************************************************/
private void setAccess(String tableName, DataAccessMode mode) throws QException
{
QContext.init(qInstance, AssistantTestFixture.sessionFor(AssistantTestFixture.ADMIN));
try
{
config.getRuntime().getStore().insert(new AssistantDataAccess().withObjectType(AssistantDataAccess.TYPE_TABLE).withObjectName(tableName).withMode(mode.name()));
config.getRuntime().getDataAccessService().invalidate();
}
finally
{
QContext.clear();
}
}
/*******************************************************************************
** One tool call as the turn engine makes it.
*******************************************************************************/
private UiApiToolbox.Outcome tool(String toolName, Map<String, Object> in, boolean hipaa)
{
QContext.init(qInstance, AssistantTestFixture.ownerSession(AssistantTestFixture.ADMIN, config));
try
{
return (config.getRuntime().getBulkLoadTools().execute(new UiApiToolbox.Context("session-1", null, server, PolicyService.ToolPolicy.defaults(),
write -> config.getRuntime().getStore().insert(new AssistantPendingAction().withUserId(AssistantTestFixture.ADMIN).withToolName(write.toolName())
.withTableName(write.tableName()).withUiApiOperation(write.operation()).withParamsJson(JsonUtils.toJson(write.values()))
.withSummary(write.summary()).withStatus(AssistantPendingAction.STATUS_PENDING).withExpiresAt(java.time.Instant.now().plusSeconds(600))).getId()),
"call-1", toolName, in, hipaa));
}
finally
{
QContext.clear();
}
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testDefinitionsAreOffered()
{
List<String> names = config.getRuntime().getContextBuilder().tools().stream().map(d -> d.getName()).toList();
assertThat(names).contains(BulkLoadTools.LIST, BulkLoadTools.DESCRIBE, BulkLoadTools.ATTACH, BulkLoadTools.SUGGEST, BulkLoadTools.DRY_RUN);
assertThat(config.getRuntime().getBulkLoadTools().definitions(PolicyService.ToolPolicy.defaults(), true))
.extracting(d -> d.getName()).containsExactlyInAnyOrderElementsOf(BulkLoadTools.NAMES);
assertThat(config.getRuntime().getBulkLoadTools().definitions(PolicyService.ToolPolicy.defaults(), false))
.extracting(d -> d.getName()).doesNotContain(BulkLoadTools.SAVE, BulkLoadTools.RUN);
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testReadToolsCallTheUiApiWithTheProvidersRoute()
{
UiApiToolbox.Outcome list = tool(BulkLoadTools.LIST, Map.of("tableName", "blog_post"), false);
assertThat(list.getResult().getOutcome()).isEqualTo(AiToolOutcome.OK);
assertThat(list.getResult().getModelContent()).contains("Posts from the CMS export");
assertThat(list.getOperation()).isEqualTo("POST /table/{tableName}/query");
UiApiToolbox.Outcome describe = tool(BulkLoadTools.DESCRIBE, Map.of("id", 3), false);
assertThat(describe.getResult().getModelContent()).contains("fieldList");
////////////////////////////////////////////////////////////////////////
// the route header comes from the provider flag, whatever the input //
////////////////////////////////////////////////////////////////////////
tool(BulkLoadTools.ATTACH, Map.of("tableName", "blog_post", "uploadRef", "up_1", "X-QQQ-AI-Route", "hipaa", "route", "hipaa"), false);
tool(BulkLoadTools.ATTACH, Map.of("tableName", "blog_post", "uploadRef", "up_1"), true);
List<UiApiCall> attaches = server.calls("GET /bulkLoad/uploads/{uploadRef}/profile");
assertThat(attaches.get(0).getHeaders()).containsEntry(BulkLoadTools.ROUTE_HEADER, "standard");
assertThat(attaches.get(1).getHeaders()).containsEntry(BulkLoadTools.ROUTE_HEADER, "hipaa");
assertThat(attaches.get(0).getSessionUUID()).isEqualTo("session-1");
UiApiToolbox.Outcome missing = tool(BulkLoadTools.ATTACH, Map.of("tableName", "blog_post", "uploadRef", "nope"), false);
assertThat(missing.getResult().getOutcome()).isEqualTo(AiToolOutcome.NOT_FOUND);
UiApiToolbox.Outcome invalid = tool(BulkLoadTools.DRY_RUN, Map.of("tableName", "blog_post", "uploadRef", "up_1"), false);
assertThat(invalid.getResult().getOutcome()).isEqualTo(AiToolOutcome.INVALID_INPUT);
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testDataAccessAndAnOlderServer() throws QException
{
setAccess("page", DataAccessMode.NONE);
UiApiToolbox.Outcome denied = tool(BulkLoadTools.SUGGEST, Map.of("tableName", "page", "uploadRef", "up_1"), false);
assertThat(denied.getResult().getOutcome()).isEqualTo(AiToolOutcome.DENIED);
assertThat(server.getCalls()).isEmpty();
setAccess("media", DataAccessMode.READ);
UiApiToolbox.Outcome readOnly = tool(BulkLoadTools.RUN, Map.of("tableName", "media", "uploadRef", "up_1", "profileId", 7), false);
assertThat(readOnly.getResult().getOutcome()).isEqualTo(AiToolOutcome.DENIED);
BulkLoadTools older = new BulkLoadTools(qInstance, config.getRuntime().getDataAccessService(), null);
FakeBulkLoadServer oldApi = new FakeBulkLoadServer(false);
QContext.init(qInstance, AssistantTestFixture.ownerSession(AssistantTestFixture.ADMIN, config));
UiApiToolbox.Outcome outcome = older.execute(new UiApiToolbox.Context("s", null, oldApi, null, null), "c", BulkLoadTools.SUGGEST,
Map.of("tableName", "blog_post", "uploadRef", "up_1"), false);
assertThat(outcome.getResult().getOutcome()).isEqualTo(AiToolOutcome.ERROR);
assertThat(outcome.getResult().getModelContent()).contains("does not offer");
assertThat(oldApi.getCalls()).isEmpty();
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testJobsLaunchRunnerSaysWhenTheLaunchIsMissing() throws QException
{
BulkLoadRunner.RunHandle handle = new JobsLaunchBulkLoadRunner().start(new BulkLoadRunner.Request("s", null, server, "blog_post", "up_1", 7));
assertThat(handle.status()).isEqualTo("NOT_AVAILABLE");
assertThat(handle.summary()).contains("durable jobs launch");
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testForLogDropsSamplesAndValues()
{
String logged = BulkLoadTools.forLog("<qqq_data source=\"x\">{\"columns\":[{\"header\":\"A\",\"sampleValues\":[\"" + FakeBulkLoadServer.SAMPLE_SENTINEL
+ "\"]}],\"summaryLines\":[{\"bulletsOfText\":[\"Row 3 [x]\"]}],\"profile\":{\"fieldList\":[{\"valueMappings\":{\"secret\":1}}]}}</qqq_data>");
assertThat(logged).doesNotContain(FakeBulkLoadServer.SAMPLE_SENTINEL).doesNotContain("Row 3 [x]").doesNotContain("secret").contains("header");
assertThat(BulkLoadTools.forLog(null)).isNull();
assertThat(BulkLoadTools.forLog("plain")).isEqualTo("plain");
}
/*******************************************************************************
** Golden eval (owner request 2026-10-13): upload a CSV with messy headers;
** the assistant proposes a profile, validates it, fixes one error, saves
** it (shared with a group, after the user confirms) and runs the load
** (after the user confirms). The scripted model stands in for the LLM;
** its fix reads the dry run's error from the tool result.
*******************************************************************************/
@Test
@SuppressWarnings("unchecked")
void testGoldenMessyCsvToSavedProfileAndLoad() throws Exception
{
Map<String, Object> attachments = Map.of("attachments", List.of(Map.of("uploadRef", "up_1", "fileName", "posts.csv")));
AtomicReference<String> suggested = new AtomicReference<>();
provider.thenToolUse(BulkLoadTools.ATTACH, Map.of("tableName", "blog_post", "uploadRef", "up_1"))
.thenToolUse(BulkLoadTools.SUGGEST, Map.of("tableName", "blog_post", "uploadRef", "up_1"))
.thenRespond(request ->
{
Map<String, Object> suggestion = firstResult(request);
suggested.set(JsonUtils.toJson(suggestion.get("profile")));
return (toolUse("dry1", BulkLoadTools.DRY_RUN, Map.of("tableName", "blog_post", "uploadRef", "up_1", "profile", suggestion.get("profile"))));
})
.thenRespond(request ->
{
////////////////////////////////////////////////////////////////
// read the error, fix it: default the missing required field //
////////////////////////////////////////////////////////////////
Map<String, Object> dryRun = firstResult(request);
assertThat(dryRun.get("errorCount")).isEqualTo(2);
String missingField = String.valueOf(((Map<?, ?>) ((List<?>) dryRun.get("errors")).get(0)).get("fieldName"));
Map<String, Object> profile = new JSONObject(suggested.get()).toMap();
List<Map<String, Object>> fields = new ArrayList<>((List<Map<String, Object>>) profile.get("fieldList"));
fields.add(new LinkedHashMap<>(Map.of("fieldName", missingField, "defaultValue", "DRAFT")));
profile.put("fieldList", fields);
return (toolUse("dry2", BulkLoadTools.DRY_RUN, Map.of("tableName", "blog_post", "uploadRef", "up_1", "profile", profile, "mode", "FULL")));
})
.thenRespond(request ->
{
Map<String, Object> profile = new JSONObject(suggested.get()).toMap();
((List<Map<String, Object>>) profile.get("fieldList")).add(new LinkedHashMap<>(Map.of("fieldName", "status", "defaultValue", "DRAFT")));
return (toolUse("save", BulkLoadTools.SAVE, Map.of("tableName", "blog_post", "label", "CMS post export", "profile", profile,
"shares", List.of(Map.of("audienceType", "group", "audienceId", "kof22-admins")))));
})
.thenText("The file maps cleanly once Status defaults to DRAFT (2 rows, no errors). Confirm to save the profile \"CMS post export\".");
TurnRunner.Outcome first = runner.run(AssistantTestFixture.ADMIN, null, "Load the attached posts.csv into blog posts and save the mapping for next time.", attachments);
List<String> tools = first.toolCalls().stream().map(AssistantToolCall::getToolName).toList();
assertThat(tools).containsExactly(BulkLoadTools.ATTACH, BulkLoadTools.SUGGEST, BulkLoadTools.DRY_RUN, BulkLoadTools.DRY_RUN, BulkLoadTools.SAVE);
List<UiApiCall> validates = server.calls("POST /bulkLoad/{tableName}/validate");
assertThat(validates).hasSize(2);
assertThat(validates).allSatisfy(c -> assertThat(c.getHeaders()).containsEntry(BulkLoadTools.ROUTE_HEADER, "standard"));
assertThat(first.toolCalls().get(2).getResultSummary()).contains("2 with errors");
assertThat(first.toolCalls().get(3).getResultSummary()).contains("0 with errors");
assertThat(first.toolCalls()).allSatisfy(c -> assertThat(String.valueOf(c.getResultJson()) + c.getInputJson()).doesNotContain(FakeBulkLoadServer.SAMPLE_SENTINEL));
assertThat(server.calls("POST /processes/{processName}/init")).as("nothing is saved before the user confirms").isEmpty();
Integer saveActionId = first.toolCalls().get(4).getPendingActionId();
assertThat(saveActionId).isNotNull();
QContext.init(qInstance, AssistantTestFixture.sessionFor(AssistantTestFixture.ADMIN));
Map<String, Object> saved = new AssistantApi(config.getRuntime(), QContext.getQSession()).confirmAction(saveActionId);
QContext.clear();
assertThat(saved.get("status")).isEqualTo(AssistantPendingAction.STATUS_EXECUTED);
List<UiApiCall> processCalls = server.calls("POST /processes/{processName}/init");
assertThat(processCalls).extracting(c -> c.getPathParams().get("processName")).containsExactly("storeSavedBulkLoadProfile", "insertSharedRecord");
Map<String, Object> stored = FakeBulkLoadServer.formValues(processCalls.get(0));
assertThat(stored).containsEntry("label", "CMS post export").containsEntry("tableName", "blog_post");
assertThat(String.valueOf(stored.get("mappingJson"))).contains("DRAFT");
assertThat(FakeBulkLoadServer.formValues(processCalls.get(1))).containsEntry("audienceType", "group").containsEntry("audienceId", "kof22-admins")
.containsEntry("recordId", 7).containsEntry("scopeId", "READ_ONLY");
////////////////////////////////////////////////////////////////
// second turn: run the load with the saved profile; confirm //
////////////////////////////////////////////////////////////////
provider.thenToolUse(BulkLoadTools.RUN, Map.of("tableName", "blog_post", "uploadRef", "up_1", "profileId", 7))
.thenText("The load is queued as job 42; start it from its job record when you are ready.");
TurnRunner.Outcome second = runner.run(AssistantTestFixture.ADMIN, first.conversationId(), "Now load it.", attachments);
AssistantToolCall runCall = second.toolCalls().get(0);
assertThat(runCall.getToolName()).isEqualTo(BulkLoadTools.RUN);
assertThat(runs).as("nothing runs before the user confirms").isEmpty();
QContext.init(qInstance, AssistantTestFixture.sessionFor(AssistantTestFixture.ADMIN));
Map<String, Object> ran = new AssistantApi(config.getRuntime(), QContext.getQSession()).confirmAction(runCall.getPendingActionId());
QContext.clear();
assertThat(ran.get("outcome")).isEqualTo("OK");
assertThat(runs).hasSize(1);
assertThat(runs.get(0).savedBulkLoadProfileId()).isEqualTo(7);
assertThat(runs.get(0).uploadRef()).isEqualTo("up_1");
assertThat(runs.get(0).tableName()).isEqualTo("blog_post");
}
/*******************************************************************************
**
*******************************************************************************/
private static ModelResponse toolUse(String id, String name, Map<String, Object> input)
{
return (new ModelResponse().withParts(List.of(ModelPart.toolUse("toolu_" + id, name, input))).withStopReason(ModelResponse.STOP_TOOL_USE));
}
}
@@ -0,0 +1,176 @@
/*
* 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.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
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.utils.JsonUtils;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** A stand-in for the server's bulk-load UI API operations (core's
** /bulkLoad/... and the profile processes), recording every call. Its
** validate answers like core's dry run: the blog_post status field is
** required, so a profile that neither maps nor defaults it has errors.
*******************************************************************************/
public class FakeBulkLoadServer implements UiApiGateway
{
public static final String SAMPLE_SENTINEL = "SAMPLE_SENTINEL_5b1e";
private static final Set<String> OPERATIONS = Set.of(
"GET /bulkLoad/uploads/{uploadRef}/profile", "POST /bulkLoad/{tableName}/suggestMapping", "POST /bulkLoad/{tableName}/validate",
"POST /table/{tableName}/query", "GET /table/{tableName}/{primaryKey}", "POST /processes/{processName}/init");
private final List<UiApiCall> calls = new ArrayList<>();
private final boolean offersBulkLoad;
/*******************************************************************************
**
*******************************************************************************/
public FakeBulkLoadServer(boolean offersBulkLoad)
{
this.offersBulkLoad = offersBulkLoad;
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public boolean isInvocable(String method, String pathTemplate)
{
String operation = method + " " + pathTemplate;
return (OPERATIONS.contains(operation) && (offersBulkLoad || !pathTemplate.startsWith("/bulkLoad")));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public synchronized UiApiResult invoke(UiApiCall call)
{
calls.add(call);
String operation = call.operation();
return (switch(operation)
{
case "GET /bulkLoad/uploads/{uploadRef}/profile" -> "up_1".equals(call.getPathParams().get("uploadRef"))
? ok(new JSONObject().put("uploadRef", "up_1").put("fileName", "posts.csv").put("hasHeaderRow", true).put("rowsProfiled", 2)
.put("columns", new JSONArray()
.put(new JSONObject().put("columnIndex", 0).put("header", "Post Title").put("mappedFieldName", "title").put("samplesAllowed", true)
.put("sampleValues", new JSONArray().put(SAMPLE_SENTINEL)))
.put(new JSONObject().put("columnIndex", 1).put("header", "Teaser").put("samplesAllowed", false))))
: new UiApiResult(404, "{\"error\":\"Upload not found.\"}");
case "POST /bulkLoad/{tableName}/suggestMapping" -> ok(new JSONObject()
.put("profile", new JSONObject().put("version", "v1").put("layout", "FLAT").put("hasHeaderRow", true)
.put("fieldList", new JSONArray().put(new JSONObject().put("fieldName", "title").put("columnIndex", 0).put("headerName", "Post Title"))))
.put("unmappedColumns", new JSONArray().put(new JSONObject().put("columnIndex", 1).put("header", "Teaser")))
.put("unmappedRequiredFields", new JSONArray().put(new JSONObject().put("fieldName", "status").put("label", "Status"))));
case "POST /bulkLoad/{tableName}/validate" -> validate(call);
case "POST /table/{tableName}/query" -> ok(new JSONObject().put("records", new JSONArray()
.put(new JSONObject().put("values", new JSONObject().put("id", 3).put("label", "Posts from the CMS export").put("userId", "admin-1")
.put("mappingJson", "{\"version\":\"v1\",\"fieldList\":[{\"fieldName\":\"title\",\"columnIndex\":0}]}")))));
case "GET /table/{tableName}/{primaryKey}" -> ok(new JSONObject().put("record", new JSONObject().put("values", new JSONObject().put("id", 3)
.put("label", "Posts from the CMS export").put("tableName", "blog_post").put("userId", "admin-1")
.put("mappingJson", "{\"version\":\"v1\",\"fieldList\":[{\"fieldName\":\"title\",\"columnIndex\":0}]}"))));
case "POST /processes/{processName}/init" -> "storeSavedBulkLoadProfile".equals(call.getPathParams().get("processName"))
? ok(new JSONObject().put("type", "COMPLETE").put("values", new JSONObject().put("savedBulkLoadProfileList",
new JSONArray().put(new JSONObject().put("values", new JSONObject().put("id", 7).put("label", "x"))))))
: ok(new JSONObject().put("type", "COMPLETE").put("values", new JSONObject()));
default -> new UiApiResult(404, "{\"error\":\"no such operation\"}");
});
}
/*******************************************************************************
** Errors unless status is mapped or defaulted.
*******************************************************************************/
private UiApiResult validate(UiApiCall call)
{
JSONObject body = new JSONObject(JsonUtils.toJson(call.getJsonBody()));
JSONArray fieldList = body.getJSONObject("profile").getJSONArray("fieldList");
boolean hasStatus = false;
for(int i = 0; i < fieldList.length(); i++)
{
JSONObject field = fieldList.getJSONObject(i);
if("status".equals(field.optString("fieldName")) && (field.has("columnIndex") || !field.isNull("defaultValue") && field.has("defaultValue")))
{
hasStatus = true;
}
}
JSONObject result = new JSONObject().put("mode", body.optString("mode", "SAMPLE")).put("processName", "blog_post.bulkInsert").put("recordCount", 2);
if(hasStatus)
{
return (ok(result.put("okCount", 2).put("errorCount", 0).put("errors", new JSONArray())));
}
return (ok(result.put("okCount", 0).put("errorCount", 2).put("errors", new JSONArray()
.put(new JSONObject().put("rowNumbers", "Rows 2-3").put("fieldName", "status").put("errorCode", "REQUIRED").put("message", "Missing value in required field: Status")))));
}
/*******************************************************************************
**
*******************************************************************************/
private static UiApiResult ok(JSONObject json)
{
return (new UiApiResult(200, json.toString()));
}
/*******************************************************************************
** The calls, in order.
*******************************************************************************/
public synchronized List<UiApiCall> getCalls()
{
return (new ArrayList<>(calls));
}
/*******************************************************************************
** Calls of one operation.
*******************************************************************************/
public synchronized List<UiApiCall> calls(String operation)
{
return (calls.stream().filter(c -> c.operation().equals(operation)).toList());
}
/*******************************************************************************
** A form value of a process call, as JSON.
*******************************************************************************/
public static Map<String, Object> formValues(UiApiCall call)
{
return (new JSONObject(call.getFormParams().get("values")).toMap());
}
}