Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
467c2199dc
|
||
|
|
01309ddfe9 | ||
|
|
de04965cd7
|
||
|
|
61e6f809f1
|
||
|
|
8e8a5c59cd | ||
|
|
87835961f4
|
||
|
|
c3a828c1c3
|
||
|
|
de215f8ff9
|
||
|
|
9e1bda8dae
|
||
|
|
907d943f8d
|
||
|
|
5e1c213fd6 | ||
|
|
1a65aa2257 | ||
|
|
17c376c5b1
|
||
|
|
2e90fbdc5d
|
||
|
|
6a26a7d4fa
|
||
|
|
1d66154bd2 | ||
|
|
57673131ff
|
||
|
|
c27994c432
|
||
|
|
1947688c34
|
||
|
|
c487c87051
|
||
|
|
14bd4e377c | ||
|
|
cb7d5d4d5a
|
||
|
|
51e0692f46 | ||
|
|
6f80dcaff4
|
||
|
|
55804d9059
|
||
|
|
73159e536e | ||
|
|
c05c606745
|
||
|
|
51fcfa6a33
|
No files matched your search
@@ -29,6 +29,8 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
|
||||
|
||||
## Modules
|
||||
|
||||
- `qbit-assistant-provider-api`: standalone provider SPI/value types and `engine.ModelPricing` (original package names); only Jackson annotations in production. Provider validation takes unresolved `ProviderSettings`, not an entity.
|
||||
|
||||
- `qbit-assistant` (`com.kingsrook.qbits.assistant`):
|
||||
- `AssistantQBitProducer`, `AssistantQBitConfig` (with `AssistantModelRouting` and `AssistantBudgets`), `AssistantRuntime`, `AssistantSecurity`.
|
||||
- `metadata/`: tables, owner key type, the Assistant app (home + main tables in the nav), the retention process, the settings customizer (key-reference checks, masked key status), and the `assistant.admin` custom permission checker.
|
||||
@@ -37,7 +39,7 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
|
||||
- `persistence/AssistantStore`: the **only** code that calls QQQ data actions, and only on the QBit's own tables, under the user's session.
|
||||
- `engine/`: `TurnEngine` (the loop), `ContextBuilder` (cache-prefix order), `SystemPrompt`, `PolicyService` (policy row, budgets), `RateLimiter`, the `TurnEvent` / `TurnEventLog` stream, and `MessageParts`.
|
||||
- `api/`: `AssistantApi` (transport-free) and `AssistantRouteProvider` (Javalin, SSE).
|
||||
- `provider/`: the provider-neutral SPI, plus `fake/ScriptedModelProvider` for tests, evals and demos.
|
||||
- `provider/`: runtime-backed `RegistryModelProvider`, plus `fake/ScriptedModelProvider` for tests, evals and demos. The SPI lives in `qbit-assistant-provider-api`.
|
||||
- `developer/`: the record Developer-screen panel, on the core developer-panel contract.
|
||||
- `processes/`: `AssistantRetentionStep` and `TestConnectionProcess` (record action on `assistant_provider`).
|
||||
- `engine/ProviderService` (active provider from `assistant_provider` rows, key references, prices), `UsageRecorder` (one `assistant_usage` row per model call), `ConnectionTest`.
|
||||
@@ -46,6 +48,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`: JDBC vector storage; depends on the runtime RAG interfaces (`VectorStore`, `ChunkVector`).
|
||||
|
||||
## Invariants (do not break)
|
||||
|
||||
- **Sessions:**
|
||||
@@ -93,4 +97,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 seven modules and the parent POM publish. The flatten plugin resolves `${revision}`.
|
||||
+63
-4
@@ -1,6 +1,65 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased — P-c
|
||||
## Unreleased - Provider API extraction (#30)
|
||||
|
||||
- Extract provider SPI/value types and `engine.ModelPricing` into `qbit-assistant-provider-api`, preserving package names. Its only production dependency is Jackson annotations.
|
||||
- Move Anthropic, OpenAI, Bedrock and Vertex main dependencies to the API; retain runtime fixtures in test scope. Pgvector still needs runtime RAG interfaces.
|
||||
- Provider-specific validation now takes unresolved `ProviderSettings` instead of `AssistantProvider`; add an ArchUnit boundary and standalone coverage gates.
|
||||
## Unreleased - P-g
|
||||
|
||||
- Add admin Scheduled tasks, owner-locked run history, Quartz cron/timezones,
|
||||
and a 60-second QQQ scheduled process using the host's scheduler context.
|
||||
- Log in through the loopback UI API with a separately allow-listed credential
|
||||
environment reference; verify the designated user before starting a normal turn.
|
||||
- Add Review/Result Approve write mode, Revoke write mode and Run now actions.
|
||||
Approval binds the task definition; scheduled writes always await confirmation.
|
||||
- Attribute scheduled UI API requests and tool logs, apply per-run STOP caps,
|
||||
and send optional Alerts notifications to the designated user. HIPAA summaries
|
||||
contain only `see conversation`.
|
||||
- Add unique schedule-slot claims, fixed-clock fixture integration tests and
|
||||
additive Postgres DDL. No API routes or provider modules change.
|
||||
|
||||
## Unreleased - P-f
|
||||
|
||||
- Add OpenAI-compatible embeddings (including Azure), explicit CHAT/EMBEDDING
|
||||
model kinds, editable discovery hints, and budgeted EMBED usage.
|
||||
- Add admin Knowledge sources with streamed Review/Result Index now and Clear
|
||||
index actions. Indexing uses the clicking admin's UI API session, chunks text,
|
||||
skips unchanged hashes and reconciles disappeared records.
|
||||
- Add content-free in-memory and optional JDBC pgvector stores, per-instance
|
||||
schemas, HNSW cosine search and transactional record replacement.
|
||||
- Add permission-filtered `search_knowledge`, fresh UI API reads, existing
|
||||
masking/redaction, PHI embedding guards, classification propagation and citations.
|
||||
- Add embedding HTTP contracts, real UI API RAG security/golden tests and
|
||||
always-on JDBC contracts; the pgvector container test skips without Docker.
|
||||
## Unreleased - Answer presentation
|
||||
|
||||
- Add `present_results`: server-formatted metrics, tables, computed percentages,
|
||||
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.
|
||||
|
||||
|
||||
## Unreleased — P-c
|
||||
|
||||
- Add configurable table/field classification, persistent turn/conversation maxima,
|
||||
and PHI routing checks before every model call, with a safe blocked-turn reason.
|
||||
@@ -30,8 +89,8 @@ Phase 0/1 of the QQQ Assistant: read-only Q&A and navigation. Requires QQQ 4.1 w
|
||||
- Tools: `list_tables`, `describe_table`, `query_records`, `count_records`, `get_record`, `search_records`, `render_widget`, `get_record_history`, and new **`create_record`, `update_record`, `delete_record`**.
|
||||
|
||||
### Added (0.1.0-RC.6)
|
||||
- **Profiles** (`assistant_profile`, nav "Profiles"): display name, icon, personality (≤ 2,000 chars, appended to the per-user system block), greeting, starter prompts; one default, optional app / permission scope. `/assistant/config` returns `profile`.
|
||||
- **Skills** (`assistant_skill`, nav "Skills", linked through `assistant_profile_skill`, a child list on profiles): admin-written Markdown instructions read only when relevant (keywords / apps), ≤ 6,000 chars each and 12,000 per question. Instructions only: they never grant tools, endpoints or access (ArchUnit-enforced).
|
||||
- **Profiles** (`assistant_profile`, nav "Profiles"): display name, icon, personality (≤ 2,000 chars, appended to the per-user system block), greeting, starter prompts; one default, optional app / permission scope. `/assistant/config` returns `profile`.
|
||||
- **Skills** (`assistant_skill`, nav "Skills", linked through `assistant_profile_skill`, a child list on profiles): admin-written Markdown instructions read only when relevant (keywords / apps), ≤ 6,000 chars each and 12,000 per question. Instructions only: they never grant tools, endpoints or access (ArchUnit-enforced).
|
||||
- **Explain access**: `GET /qqq/v1/assistant/access?table=&prompt=` and the `assistantAccess` home widget: per table and tool, allowed or the reason.
|
||||
- **Recent denials** card on the Assistant home.
|
||||
- `AssistantStore` refuses any table that is not `assistant_*`; ArchUnit forbids the tool / UI API / access packages from using QQQ actions, the AI kernel or the profile and skill classes.
|
||||
@@ -54,7 +113,7 @@ Phase 0/1 of the QQQ Assistant: read-only Q&A and navigation. Requires QQQ 4.1 w
|
||||
|
||||
### Added (0.1.0-RC.4)
|
||||
- **Provider settings** (`assistant_provider`): type (registered `AssistantProviderType`s; `AnthropicProviderType` in the Anthropic module), model, enabled, default, base URL override, monthly cost cap, max output tokens per request, max concurrent requests, and the last connection test. The default enabled row is the active provider; with no rows the provider registered in code is used, so existing hosts keep working.
|
||||
- **Key handling:** the key is never stored. A row names the environment variable that holds it (`apiKeyEnvName`, allow-listed); `apiKeyStatus` is computed on read (`Set • …abcd`) and dropped on save. A pasted key, an unrelated variable or an unlisted base URL is refused. New config: `withProviderType`, `allowedApiKeyEnvNames`, `allowedBaseUrls`, `withSecretResolver`, `providerTestTimeoutSeconds`.
|
||||
- **Key handling:** the key is never stored. A row names the environment variable that holds it (`apiKeyEnvName`, allow-listed); `apiKeyStatus` is computed on read (`Set • …abcd`) and dropped on save. A pasted key, an unrelated variable or an unlisted base URL is refused. New config: `withProviderType`, `allowedApiKeyEnvNames`, `allowedBaseUrls`, `withSecretResolver`, `providerTestTimeoutSeconds`.
|
||||
- **Test connection**, a record action on providers (standard streamed ETL Review and Result lines): one short live call, result saved on the row and recorded as a `TEST` usage row.
|
||||
- **Per-call usage** (`assistant_usage`): one row per model call (turn steps and tests) with provider, model, status, error kind, HTTP status, latency, tokens and cost. Child list on conversations and providers.
|
||||
- **Model prices** (`assistant_model_price`), editable, used for every call's cost; the reference DDL seeds the Anthropic list prices of 2026-10. The built-in `pricing` defaults now cover every current Claude model.
|
||||
|
||||
@@ -29,6 +29,8 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
|
||||
|
||||
## Modules
|
||||
|
||||
- `qbit-assistant-provider-api`: standalone provider SPI/value types and `engine.ModelPricing` (original package names); only Jackson annotations in production. Provider validation takes unresolved `ProviderSettings`, not an entity.
|
||||
|
||||
- `qbit-assistant` (`com.kingsrook.qbits.assistant`):
|
||||
- `AssistantQBitProducer`, `AssistantQBitConfig` (with `AssistantModelRouting` and `AssistantBudgets`), `AssistantRuntime`, `AssistantSecurity`.
|
||||
- `metadata/`: tables, owner key type, the Assistant app (home + main tables in the nav), the retention process, the settings customizer (key-reference checks, masked key status), and the `assistant.admin` custom permission checker.
|
||||
@@ -37,7 +39,7 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
|
||||
- `persistence/AssistantStore`: the **only** code that calls QQQ data actions, and only on the QBit's own tables, under the user's session.
|
||||
- `engine/`: `TurnEngine` (the loop), `ContextBuilder` (cache-prefix order), `SystemPrompt`, `PolicyService` (policy row, budgets), `RateLimiter`, the `TurnEvent` / `TurnEventLog` stream, and `MessageParts`.
|
||||
- `api/`: `AssistantApi` (transport-free) and `AssistantRouteProvider` (Javalin, SSE).
|
||||
- `provider/`: the provider-neutral SPI, plus `fake/ScriptedModelProvider` for tests, evals and demos.
|
||||
- `provider/`: runtime-backed `RegistryModelProvider`, plus `fake/ScriptedModelProvider` for tests, evals and demos. The SPI lives in `qbit-assistant-provider-api`.
|
||||
- `developer/`: the record Developer-screen panel, on the core developer-panel contract.
|
||||
- `processes/`: `AssistantRetentionStep` and `TestConnectionProcess` (record action on `assistant_provider`).
|
||||
- `engine/ProviderService` (active provider from `assistant_provider` rows, key references, prices), `UsageRecorder` (one `assistant_usage` row per model call), `ConnectionTest`.
|
||||
@@ -46,13 +48,17 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
|
||||
- `qbit-assistant-provider-bedrock`: Claude through the Anthropic Bedrock backend, AWS default credentials and SigV4; IRSA/web-identity hosts supply STS.
|
||||
- `qbit-assistant-provider-vertex`: Claude through the Anthropic Vertex backend and Gemini through OpenAI-compatible Chat Completions, with refreshed Google ADC tokens.
|
||||
|
||||
- `qbit-assistant-rag-pgvector`: optional host-supplied JDBC vector store, isolated by instance schema; depends on runtime RAG interfaces (`VectorStore`, `ChunkVector`), with no QQQ RDBMS internals.
|
||||
|
||||
## Invariants (do not break)
|
||||
|
||||
- **Sessions:**
|
||||
- Scheduled runs log in as the designated user through `POST /qqq/v1/manageSession`; resolve only its returned UUID, verify the user id, and never construct or elevate a session. The QQQ-supplied scheduler context touches only assistant bookkeeping.
|
||||
- Never use `QSystemUserSession` or construct a `QSession` in main code (ArchUnit).
|
||||
- Every route uses `ExecutorSessionUtils.setupSession` (the user's own session).
|
||||
- `AssistantSecurity` only ever adds the user's OWN id key, or the all-access key for `assistant.admin` holders.
|
||||
- **Data access:**
|
||||
- The vector index stores no record content: only source/table/key/field/chunk, content hash and embedding. Retrieval re-reads every hit through the UI API as the asking user, then applies the existing get_record exposure/masking path.
|
||||
- Only `persistence/` calls QueryAction, InsertAction and the other data actions (ArchUnit).
|
||||
- Data tools run only through `UiApiToolbox` → `UiApiGateway` (spec'd `/qqq/v1` operations with the user's `sessionUUID`); never core data actions for user data. Only `navigate` and `search_help` use `AiToolKernel.execute`.
|
||||
- Data access (`DataAccessService`) and the policy only ever narrow what the UI API allows; a table whose AI exposure is NONE stays NONE.
|
||||
@@ -64,11 +70,15 @@ Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed def
|
||||
- Errors (DENIED, NOT_FOUND, INVALID_INPUT, ERROR) are `is_error` results.
|
||||
- Unknown tools and over-limit calls also get error results.
|
||||
- **Keys:** never stored in a table. `assistant_provider.apiKeyEnvName` holds only an allowed environment variable NAME; `apiKeyStatus` is computed on read (last 4 at most) and stripped on save.
|
||||
- Scheduled task `credentialEnvName` is a separately allow-listed environment variable NAME. Tokens never enter rows, model context or logs. `credentialStatus` is computed and stripped on save.
|
||||
- Scheduled write approval is invalidated by changes to prompt, run-as user, credential reference, profile or write request. Scheduled writes always require the designated user's panel confirmation.
|
||||
- **Logging:** never log message content, prompts, tool results or keys. Structured end-of-turn log only.
|
||||
- **Thinking:** thinking is never streamed or stored. The Anthropic adapter keeps the raw assistant message (with signatures) only in memory, as `providerState`, for the tool loop.
|
||||
- **Links:** only `/app` or `/app/...` hrefs are emitted (`TurnEngine.isAppHref`).
|
||||
- **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.
|
||||
|
||||
## Tests
|
||||
@@ -97,4 +107,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 seven modules and the parent POM publish. The flatten plugin resolves `${revision}`.
|
||||
@@ -4,12 +4,92 @@ An in-app AI assistant for any QQQ application. The user asks a question in a ch
|
||||
|
||||
Design spec: `QRun-IO/qqq` `docs/specs/2026-10-11-qqq-assistant-qbit-design.md`.
|
||||
|
||||
## Modules
|
||||
|
||||
| Artifact | What it is |
|
||||
|---|---|
|
||||
| `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-api` | Provider SPI, connection settings, messages, usage and pricing types; only Jackson annotations at runtime. |
|
||||
| `com.kingsrook.qbits:qbit-assistant` | The QBit: conversation tables, turn engine, `/qqq/v1/assistant` routes (JSON + Server-Sent Events), budgets, kill switch, the provider registry, 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. |
|
||||
| `com.kingsrook.qbits:qbit-assistant-provider-openai` | OpenAI-compatible chat and embedding providers. |
|
||||
| `com.kingsrook.qbits:qbit-assistant-provider-bedrock` | Claude through AWS Bedrock. |
|
||||
| `com.kingsrook.qbits:qbit-assistant-provider-vertex` | Claude and Gemini through Google Vertex AI. |
|
||||
| `com.kingsrook.qbits:qbit-assistant-rag-pgvector` | JDBC vector store; retains the runtime dependency for `VectorStore` and `ChunkVector`. |
|
||||
|
||||
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.
|
||||
### Implementing a provider outside qbit-assistant
|
||||
|
||||
Depend on `com.kingsrook.qbits:qbit-assistant-provider-api` at the same version as
|
||||
QBit Assistant, and implement `AssistantModelProvider` and optionally
|
||||
`AssistantProviderType`. No assistant runtime dependency is needed. Existing
|
||||
`com.kingsrook.qbits.assistant.provider.*` imports and
|
||||
`com.kingsrook.qbits.assistant.engine.ModelPricing` stay the same.
|
||||
`AssistantProviderType.validate(ProviderSettings)` receives unresolved settings
|
||||
(including `apiKeyEnvName`), without a database entity or resolved credentials.
|
||||
Hosts register providers through `AssistantQBitConfig` in `qbit-assistant`.
|
||||
|
||||
Data tools use the QQQ UI API (`/qqq/v1`) with the signed-in user's session. The UI API enforces permissions and record locks; assistant data-access policy and field exposure can only narrow access. Only record-free navigation and help use the core AI tool kernel. Every tool result reaches the model wrapped as untrusted data.
|
||||
|
||||
## 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 +283,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 +466,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;
|
||||
@@ -439,3 +520,103 @@ Both modes audit each PII/PHI model call and every routing block in
|
||||
PHI conversations use `hipaaRetentionDays` even with HIPAA mode off. A null value
|
||||
uses normal retention; tool-result/replay cleanup remains independently configured.
|
||||
See [UPGRADE.md](UPGRADE.md) for schema changes, plaintext migration, and key handling.
|
||||
|
||||
## Data-driven budgets
|
||||
|
||||
Admins configure **Assistant > Budgets** for UTC DAY or MONTH allowances in USD or
|
||||
TOKENS, scoped to INSTANCE, USER, ROLE (a permission), PROVIDER or MODEL. Provider
|
||||
and model scope values are registry record ids. Warnings default to 80%; at 100%
|
||||
the default action is DOWNGRADE, and the hard ceiling defaults to 120% (always
|
||||
STOP). Multiple budgets apply together and STOP wins.
|
||||
|
||||
An explicit downgrade model is optional. Otherwise the cheapest enabled usable
|
||||
model is selected, preferring LOCAL providers. PHI never goes to a provider that
|
||||
is not HIPAA-eligible. No eligible target means STOP. Administrators can grant
|
||||
extra allowance or temporary unlimited use from the budget's Overrides child list,
|
||||
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, 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.
|
||||
|
||||
## Scheduled tasks
|
||||
|
||||
Administrators configure recurring prompts in **Assistant → Scheduled tasks**.
|
||||
Tasks use Quartz cron syntax (for example `0 0 8 * * ?`) and an IANA timezone
|
||||
(for example `America/Chicago`; default `UTC`). Tasks start disabled.
|
||||
|
||||
```java
|
||||
config.withUiApiBaseUrl("http://127.0.0.1:8000")
|
||||
.withScheduledTasksSchedulerName("hostScheduler")
|
||||
.withAllowedScheduledCredentialEnvNames(Set.of("ASSISTANT_SCHEDULED_TOKEN"));
|
||||
```
|
||||
|
||||
Provide a long-lived access token accepted by the host's authentication provider
|
||||
in that environment variable. Store only its **name** on the task and set
|
||||
`runAsUserId` to the corresponding QQQ user id. The runner posts the token to
|
||||
`/qqq/v1/manageSession`, reads its session cookie and verifies the returned
|
||||
session's user before starting a new conversation. No system session reads user
|
||||
data. Configure the loopback URL at startup; a scheduled job cannot rely on a
|
||||
browser having visited first.
|
||||
|
||||
For kof22, provision a designated Authentik account with the intended application
|
||||
permissions, obtain a token that kof22's QQQ authentication provider accepts as an
|
||||
`accessToken`, and inject it through the existing secret-manager/ExternalSecret
|
||||
pipeline. An Authentik management API token is suitable only if the host's auth
|
||||
adapter explicitly accepts it. Rotate the token in the secret manager, never in
|
||||
the task table. This feature does not provision an account or token.
|
||||
|
||||
**Run now**, **Approve write mode** and **Revoke write mode** are Review/Result
|
||||
record actions. Write approval names the designated user and displays the exact
|
||||
prompt SHA-256. Changing prompt, user, credential reference, profile or write-mode
|
||||
request clears approval. Scheduled writes always wait for that user's panel
|
||||
confirmation, even if normal policy disables confirmation.
|
||||
|
||||
Each run keeps an owner-locked audit row and a normal conversation. RECORD adds
|
||||
no notification; NOTIFICATION and BOTH also notify the designated user through
|
||||
the optional Alerts QBit SPI. Without Alerts, notification delivery is log-only.
|
||||
HIPAA summaries are always `see conversation`. Cost caps stop at dispatch
|
||||
boundaries, so the last completed call can cross the cap; no further call starts.
|
||||
Apply the [P-g migration](UPGRADE.md#p-g-scheduled-tasks) before enabling jobs.
|
||||
### Answer presentation
|
||||
|
||||
The always-available `present_results` tool turns retrieved results into metrics,
|
||||
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.
|
||||
+140
@@ -1,5 +1,145 @@
|
||||
# Upgrading qbit-assistant
|
||||
|
||||
## Provider API extraction (#30)
|
||||
|
||||
Hosts that depend on `qbit-assistant` keep working: it now brings in
|
||||
`qbit-assistant-provider-api` transitively. Package names are unchanged, including
|
||||
`com.kingsrook.qbits.assistant.engine.ModelPricing`. No schema migration is needed.
|
||||
|
||||
External providers and applications using only provider/pricing types should
|
||||
replace their `qbit-assistant` dependency with
|
||||
`com.kingsrook.qbits:qbit-assistant-provider-api` at the same version. This removes
|
||||
the assistant runtime and QQQ from that dependency path; only Jackson annotations
|
||||
are required by the API. Anthropic, OpenAI, Bedrock and Vertex adapters use this
|
||||
split; pgvector retains `qbit-assistant` for its runtime RAG interfaces.
|
||||
|
||||
Recompile custom provider types that override `validate(AssistantProvider)` and
|
||||
change the signature to `validate(ProviderSettings)`. The settings carry the
|
||||
configured kind, URL, API version, region, project, CA bundle name, timeouts and
|
||||
`apiKeyEnvName`; the key and bearer-token supplier are not resolved for validation.
|
||||
Providers using the default validator need no source change. Keep the runtime and
|
||||
provider artifacts on the same version; old validator overrides must be rebuilt.
|
||||
## P-g: scheduled tasks
|
||||
|
||||
Apply the **P-g** DDL in [docs/schema-postgres.sql](docs/schema-postgres.sql)
|
||||
before deploying. The unique `(task_id, scheduled_for)` constraint is required
|
||||
for multi-replica safety; retain run rows as schedule-slot claim history.
|
||||
Existing conversations default to channel `panel`. Tasks start disabled.
|
||||
|
||||
Set `withScheduledTasksSchedulerName(...)` to an existing QQQ scheduler and
|
||||
configure `withUiApiBaseUrl(...)` with this replica's own listener URL. The runner
|
||||
polls every 60 seconds. QQQ must supply a scheduler session able to manage the
|
||||
assistant tables; this QBit never creates that session. The configured backend
|
||||
must enforce unique keys atomically (Postgres does).
|
||||
|
||||
Allow only designated credential references with
|
||||
`withAllowedScheduledCredentialEnvNames(Set.of("ASSISTANT_SCHEDULED_TOKEN"))`.
|
||||
Provide that variable through the host's secret manager. It must hold a token
|
||||
accepted by the host's `manageSession` authentication provider for `runAsUserId`.
|
||||
The runner fails closed on missing/disallowed credentials or an identity mismatch.
|
||||
See [scheduled-task setup](README.md#scheduled-tasks) for kof22 provisioning.
|
||||
|
||||
QQQ RC.5 has no conditional UpdateInput. The runner uses a database-unique
|
||||
schedule-slot insertion as its optimistic claim, then advances `nextRunAt`
|
||||
before starting work. Crashes can skip a slot; they never retry it. No ArchUnit
|
||||
exception or fabricated session is introduced. Core client-channel enforcement
|
||||
arrives with the client-channel RC; the header and tool audit are already sent.
|
||||
|
||||
Notifications use the optional Alerts QBit notification SPI with a single-user
|
||||
audience. Without it, delivery logs run id/status only. All output modes retain
|
||||
the owner-locked audit row and conversation. HIPAA run summaries never copy the
|
||||
answer; they contain only `see conversation`.
|
||||
|
||||
## P-f: knowledge retrieval
|
||||
|
||||
Apply the **P-f** assistant-table DDL in
|
||||
[docs/schema-postgres.sql](docs/schema-postgres.sql) before deployment. It adds
|
||||
`assistant_model.model_kind` (existing rows default to CHAT),
|
||||
`embedding_dimensions`, and the admin-only `assistant_rag_source` table.
|
||||
No sources are seeded. Existing chat selections remain unchanged; explicitly
|
||||
EMBEDDING models are excluded from chat, classifier, fallback and downgrade
|
||||
selection. Correct discovery's kind/dimensions if a model name is ambiguous.
|
||||
|
||||
For persistent vectors, add `qbit-assistant-rag-pgvector` at the same version as
|
||||
the QBit, supply the host's JDBC DataSource/driver, and install pgvector in the
|
||||
database. A DBA runs `CREATE EXTENSION IF NOT EXISTS vector`; the application
|
||||
role needs schema creation rights for initial setup (or a DBA-created schema),
|
||||
then normal DML rights. `ensureSchema()` checks for an existing extension first
|
||||
and does not require extension installation privileges when it is already present.
|
||||
The optional pgvector DDL is a **commented reference block**, separate from the
|
||||
assistant-table migration. See [RAG setup](README.md#rag-setup).
|
||||
|
||||
Use a distinct validated schema for each QQQ instance. `vector(N)` fixes dimensions
|
||||
for that store; changing dimensions requires a new schema and re-indexing.
|
||||
Sources can use different models with that dimension; ranking is restricted to
|
||||
sources using the query's model. If the weights behind an unchanged model id
|
||||
change, clear and rebuild its sources. Editing source chunk/filter/model settings
|
||||
or registry model identity/dimensions automatically clears affected vectors.
|
||||
|
||||
Every indexing batch produces EMBED usage and updates existing budget rollups.
|
||||
PHI source content and PHI conversation queries require a HIPAA-eligible embedding
|
||||
provider. No background runner or designated credentials are installed in P-f.
|
||||
The default in-memory store is lost on restart and must be indexed again.
|
||||
|
||||
## P-e: profile routing and provider fallback
|
||||
|
||||
Apply the additive **P-e** ALTER TABLE statements at the end of
|
||||
[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.
|
||||
The event unique key is required: it deduplicates each state per budget, scope and
|
||||
UTC period across replicas. Do not remove it or truncate events during a period.
|
||||
|
||||
Budgets appear in Assistant navigation. Overrides and immutable events are child
|
||||
lists. All four tables require `assistant.admin` and have AI exposure NONE.
|
||||
Overrides record the actual session grantor and require a reason and expiry.
|
||||
The standard host QQQ audit configuration applies to budget and override edits.
|
||||
`assistant_budget_usage` is a private accounting projection populated from persisted
|
||||
usage writes. It copies only counters, time, user, provider and model, with no owner
|
||||
lock, so shared INSTANCE/PROVIDER/MODEL sums can run under the caller's own session.
|
||||
The source usage table retains its owner locks. The projection is read-only, has no
|
||||
nav entry and no AI exposure. **Pause usage writes during migration and run the DDL's
|
||||
backfill before restarting**; otherwise shared totals would omit pre-upgrade usage.
|
||||
Keep accounting rows through their budget periods even if detail rows are removed.
|
||||
ROLE budgets give each permission holder a separate allowance, checked with their
|
||||
current session. Override scopeValue, when set, narrows the allowance to one user id.
|
||||
|
||||
Existing policy/config budgets and provider monthly caps remain additional limits;
|
||||
an override of a budget row does not waive those existing caps.
|
||||
|
||||
Provider and MODEL scope values are numeric provider/model registry row ids;
|
||||
USER is a user id and ROLE is a permission name. INSTANCE has no scope value.
|
||||
Periods reset at midnight UTC or the first of the month UTC. Token usage includes
|
||||
input, output, cache-read and cache-write tokens; USD uses the recorded micro-dollar
|
||||
cost, not today's model price. No default budget rows are seeded.
|
||||
|
||||
## P-c: classification, PHI routing and encrypted storage
|
||||
|
||||
Apply these additive schema changes before deploying P-c (adjust column mappings
|
||||
|
||||
+212
@@ -376,6 +376,185 @@ AI exposure NONE is not enough, because the assistant acts as the user and an ad
|
||||
to 2190 days in either mode; shorter tool-result/replay cleanup is unchanged.
|
||||
The app home shows HIPAA mode and PHI retention.
|
||||
|
||||
### P-d as built
|
||||
|
||||
- ROLE allowances are per permission holder. Shared scopes sum a private immutable
|
||||
accounting projection of usage counters; owner-locked source rows stay locked.
|
||||
Migration backfills existing usage before writes resume. No sessions are elevated.
|
||||
|
||||
- Standard admin budget rows add daily/monthly cost or token allowances above the
|
||||
existing config/policy budgets and provider monthly caps. Overrides carry a
|
||||
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.
|
||||
- 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.
|
||||
|
||||
### P-g as built
|
||||
|
||||
- `assistant_scheduled_task` is admin-only, AI exposure NONE, and appears as
|
||||
Scheduled tasks. The owner-locked, read-only `assistant_scheduled_run` child
|
||||
list retains run status, accounting and a bounded summary. Conversations gain
|
||||
read-only `scheduledTaskId` and `channel` (`panel` or `assistant/scheduled`).
|
||||
Task and run timestamps use the runtime clock; cron uses Quartz and a validated
|
||||
ZoneId, including DST. Enablement and schedule edits recompute `nextRunAt`.
|
||||
- Credentials are references, never stored tokens. `credentialEnvName` must
|
||||
match `[A-Z_][A-Z0-9_]*` and the dedicated
|
||||
`allowedScheduledCredentialEnvNames` allowlist. The existing secret resolver
|
||||
supplies its value. Read-only `credentialStatus` reports presence without
|
||||
exposing token characters and is stripped on every save.
|
||||
- A run sends JSON `{accessToken: ...}` to the same loopback base URL's
|
||||
`POST /qqq/v1/manageSession`. RC.5 with HTTP-only cookies returns the UUID in
|
||||
`Set-Cookie: sessionUUID`; legacy JSON `uuid` is also accepted. Only then does
|
||||
the runner resolve that UUID through the host authentication module, exactly
|
||||
the UUID lookup used by the UI request pipeline. No direct token authentication,
|
||||
automated-user session or constructed QSession exists in assistant code.
|
||||
The resolved user id must equal `runAsUserId` before any model call. MOCK in
|
||||
the integration fixture maps test tokens to the existing user-prefix sessions;
|
||||
that token adapter is test code only.
|
||||
- kof22 provisions an Authentik designated account with normal QQQ permissions
|
||||
and injects an accepted long-lived token through its secret manager and
|
||||
ExternalSecret into an allow-listed environment variable. The task holds the
|
||||
variable name and QQQ user id. Host authentication must accept that token type
|
||||
as `accessToken`; an Authentik management API token is not automatically an
|
||||
application access token. Neither account nor credential is provisioned here.
|
||||
- `assistantScheduledTasksRunner` registers a 60-second QScheduleMetaData when
|
||||
`scheduledTasksSchedulerName` names a host scheduler. QQQ supplies its normal
|
||||
scheduler context for assistant-table bookkeeping; no ArchUnit exception is
|
||||
necessary. Configure `uiApiBaseUrl` at startup. No browser visit is required.
|
||||
- **Claim compatibility:** RC.5 UpdateInput cannot express a conditional
|
||||
`WHERE next_run_at = expected`. AssistantStore therefore claims the expected
|
||||
schedule slot using the database-enforced unique `(taskId, scheduledFor)` run
|
||||
key and advances `nextRunAt` before login/dispatch. This is a unique-insert
|
||||
optimistic claim rather than a conditional update. The memory fixture also
|
||||
serializes claim operations on its instance. The database constraint, not
|
||||
that monitor, prevents cross-replica duplicate execution. Do not remove claim
|
||||
history or deploy to a backend without atomic unique keys. A crash can lose a
|
||||
run; it never retries a claimed slot. A later tick advances an already-claimed
|
||||
slot left behind by a crash between claim and schedule update.
|
||||
- The unchanged AssistantApi starts a new normal turn under the logged-in
|
||||
designated user, preserving start gates, rate limiting, P-c classification,
|
||||
P-d budgets and P-e routing/fallback. An internal context supplies the task's
|
||||
selected profile and additional cost cap. Cost includes chat, classifier,
|
||||
failed calls and knowledge-query embeddings. STOP is checked at dispatch
|
||||
boundaries; an in-flight call can exceed the cap, and no next call is sent.
|
||||
- Every scheduled UI API request, including metadata and knowledge retrieval,
|
||||
carries `X-QQQ-Client: assistant/scheduled/<taskId>`. The only uiapi change is
|
||||
additive `LoopbackUiApiGateway.invokeWithClient`; custom gateways currently
|
||||
fail closed for scheduled requests because their channel contract is unknown.
|
||||
Tool-call rows also record `clientChannel`. Core channel deny enforcement
|
||||
remains pending the client-channel RC; existing AI exposure/data-access
|
||||
restrictions continue to apply on RC.5.
|
||||
- Approve write mode, Revoke write mode and Run now are admin record actions
|
||||
using standard streamed ETL Review/Result. Review shows the exact prompt's
|
||||
SHA-256 and run-as user. Result rereads and compares the complete reviewed
|
||||
definition before recording the actual administrator's user id and time.
|
||||
A persisted approval hash additionally binds prompt, run-as user, credential
|
||||
reference, profile and requested mode. Editing any of these clears approval;
|
||||
ordinary saves cannot set approval or computed bookkeeping fields.
|
||||
- This base has create/update/delete tools, but no W1 conversation mode/policy
|
||||
switch. Scheduled runs explicitly narrow the existing tool policy: write tools
|
||||
require both request and matching approval, a READ_WRITE table and the policy's
|
||||
allowed-tools list. All scheduled writes require panel confirmation, including
|
||||
when normal policy allows immediate writes. A pending proposal adds a waiting
|
||||
notice to the run summary; the scheduler never confirms it.
|
||||
- RECORD keeps the run and conversation; NOTIFICATION and BOTH also submit an
|
||||
Alerts notification with `NotificationAudience.user(runAsUserId)` and an
|
||||
`/app/...` conversation link. Reflection targets the installed Alerts QBit's
|
||||
RC.5 notification SPI/sink, following WebhooksAlerts' optional-dependency
|
||||
pattern. Without it, a log contains run id and status only. Notification text
|
||||
contains no answer data. In HIPAA mode run summaries contain exactly
|
||||
`see conversation`; conversation payloads retain the existing encryption.
|
||||
The existing record URL is an admin-screen link. Non-admin owners can read
|
||||
their scheduled conversations in the panel's conversation list; opening a
|
||||
particular existing conversation from a notification still needs a frontend
|
||||
deep-link contract (the current panel only supports open/new-prompt links).
|
||||
This phase does not broaden existing conversation record-screen permissions.
|
||||
- Failures close the run as FAILED or BLOCKED with sanitized errors and wait
|
||||
until the next schedule. All output modes retain their audit record. Tests
|
||||
exercise real manageSession and UI API requests with a fixed clock, designated
|
||||
permissions and owner locks, headers, competing runners, approval invalidation,
|
||||
notification modes, PHI refusal, encrypted HIPAA storage and cost STOP.
|
||||
|
||||
### Phases (each a green PR)
|
||||
| | Scope | Depends on |
|
||||
|---|---|---|
|
||||
@@ -400,3 +579,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.
|
||||
@@ -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.
|
||||
@@ -388,3 +388,189 @@ CREATE TABLE assistant_data_audit
|
||||
CREATE INDEX assistant_data_audit_user_id ON assistant_data_audit (user_id);
|
||||
CREATE INDEX assistant_data_audit_conversation_id ON assistant_data_audit (conversation_id);
|
||||
CREATE INDEX assistant_data_audit_turn_id ON assistant_data_audit (turn_id);
|
||||
|
||||
-- P-d: data-driven budgets (UTC periods, USD or whole-token allowances).
|
||||
CREATE TABLE assistant_budget
|
||||
(
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
enabled BOOLEAN DEFAULT TRUE,
|
||||
scope_type VARCHAR(16) NOT NULL,
|
||||
scope_value VARCHAR(200),
|
||||
period VARCHAR(16) NOT NULL,
|
||||
metric VARCHAR(16) NOT NULL,
|
||||
limit_value NUMERIC NOT NULL CHECK (limit_value > 0),
|
||||
warn_percent INTEGER DEFAULT 80 CHECK (warn_percent >= 0 AND warn_percent < 100),
|
||||
at_limit_action VARCHAR(16) DEFAULT 'DOWNGRADE',
|
||||
downgrade_model_id INTEGER REFERENCES assistant_model(id),
|
||||
hard_ceiling_percent INTEGER DEFAULT 120 CHECK (hard_ceiling_percent >= 100),
|
||||
notes VARCHAR(2000),
|
||||
create_date TIMESTAMP WITH TIME ZONE,
|
||||
modify_date TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
CREATE TABLE assistant_budget_override
|
||||
(
|
||||
id SERIAL PRIMARY KEY,
|
||||
budget_id INTEGER NOT NULL REFERENCES assistant_budget(id),
|
||||
scope_value VARCHAR(200),
|
||||
extra_value NUMERIC,
|
||||
unlimited_until TIMESTAMP WITH TIME ZONE,
|
||||
reason VARCHAR(1000) NOT NULL,
|
||||
granted_by VARCHAR(200),
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
create_date TIMESTAMP WITH TIME ZONE,
|
||||
modify_date TIMESTAMP WITH TIME ZONE,
|
||||
CHECK ((extra_value > 0 AND unlimited_until IS NULL) OR (extra_value IS NULL AND unlimited_until IS NOT NULL))
|
||||
);
|
||||
CREATE INDEX assistant_budget_override_budget_id ON assistant_budget_override(budget_id);
|
||||
CREATE TABLE assistant_budget_event
|
||||
(
|
||||
id SERIAL PRIMARY KEY,
|
||||
budget_id INTEGER NOT NULL REFERENCES assistant_budget(id),
|
||||
scope_key VARCHAR(400) NOT NULL,
|
||||
period_start TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
state VARCHAR(16) NOT NULL,
|
||||
used_value NUMERIC,
|
||||
limit_value NUMERIC,
|
||||
user_id VARCHAR(200),
|
||||
create_date TIMESTAMP WITH TIME ZONE,
|
||||
UNIQUE (budget_id, scope_key, period_start, state)
|
||||
);
|
||||
CREATE INDEX assistant_usage_budget_period ON assistant_usage(create_date, user_id, provider_id, model);
|
||||
|
||||
-- Private projection: standard admin permissions, AI NONE, no owner lock.
|
||||
-- No conversation identifiers, text, request ids or credentials are copied.
|
||||
CREATE TABLE assistant_budget_usage
|
||||
(
|
||||
id SERIAL PRIMARY KEY,
|
||||
usage_id INTEGER NOT NULL UNIQUE,
|
||||
user_id VARCHAR(255),
|
||||
provider_id INTEGER,
|
||||
model VARCHAR(200),
|
||||
occurred_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
cost_micros BIGINT,
|
||||
input_tokens BIGINT,
|
||||
output_tokens BIGINT,
|
||||
cache_read_tokens BIGINT,
|
||||
cache_write_tokens BIGINT
|
||||
);
|
||||
CREATE INDEX assistant_budget_usage_period ON assistant_budget_usage(occurred_at, provider_id, model);
|
||||
-- Run once before enabling P-d, with writes paused, to include existing usage.
|
||||
INSERT INTO assistant_budget_usage
|
||||
(usage_id, user_id, provider_id, model, occurred_at, cost_micros, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens)
|
||||
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);
|
||||
|
||||
-- P-g: designated-user scheduled assistant tasks. Install before enabling jobs.
|
||||
CREATE TABLE assistant_scheduled_task (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
prompt TEXT NOT NULL CHECK (char_length(prompt) BETWEEN 1 AND 4000),
|
||||
run_as_user_id VARCHAR(200) NOT NULL,
|
||||
credential_env_name VARCHAR(200) NOT NULL,
|
||||
credential_status VARCHAR(200), -- computed; always NULL in stored rows
|
||||
profile_id INTEGER REFERENCES assistant_profile(id),
|
||||
cron_expression VARCHAR(200) NOT NULL,
|
||||
timezone VARCHAR(100) NOT NULL DEFAULT 'UTC',
|
||||
next_run_at TIMESTAMPTZ,
|
||||
output_mode VARCHAR(20) NOT NULL DEFAULT 'RECORD' CHECK (output_mode IN ('RECORD', 'NOTIFICATION', 'BOTH')),
|
||||
write_mode_requested BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
write_mode_approved_by VARCHAR(200),
|
||||
write_mode_approved_at TIMESTAMPTZ,
|
||||
write_mode_approval_hash VARCHAR(64),
|
||||
max_cost_usd_per_run NUMERIC(18,6) CHECK (max_cost_usd_per_run >= 0),
|
||||
last_run_at TIMESTAMPTZ,
|
||||
last_run_status VARCHAR(20),
|
||||
last_run_error VARCHAR(500),
|
||||
create_date TIMESTAMPTZ,
|
||||
modify_date TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX assistant_scheduled_task_due ON assistant_scheduled_task(next_run_at) WHERE enabled;
|
||||
|
||||
ALTER TABLE assistant_conversation ADD COLUMN scheduled_task_id INTEGER REFERENCES assistant_scheduled_task(id);
|
||||
ALTER TABLE assistant_conversation ADD COLUMN channel VARCHAR(64) NOT NULL DEFAULT 'panel';
|
||||
ALTER TABLE assistant_tool_call ADD COLUMN client_channel VARCHAR(64);
|
||||
|
||||
CREATE TABLE assistant_scheduled_run (
|
||||
id SERIAL PRIMARY KEY,
|
||||
task_id INTEGER NOT NULL REFERENCES assistant_scheduled_task(id),
|
||||
user_id VARCHAR(200) NOT NULL,
|
||||
scheduled_for TIMESTAMPTZ NOT NULL,
|
||||
started_at TIMESTAMPTZ,
|
||||
ended_at TIMESTAMPTZ,
|
||||
status VARCHAR(20) NOT NULL CHECK (status IN ('RUNNING', 'OK', 'FAILED', 'SKIPPED', 'BLOCKED')),
|
||||
conversation_id INTEGER REFERENCES assistant_conversation(id) ON DELETE SET NULL,
|
||||
turn_id INTEGER REFERENCES assistant_turn(id) ON DELETE SET NULL,
|
||||
summary VARCHAR(2000),
|
||||
cost_micros BIGINT,
|
||||
error VARCHAR(500),
|
||||
create_date TIMESTAMPTZ,
|
||||
modify_date TIMESTAMPTZ,
|
||||
UNIQUE (task_id, scheduled_for)
|
||||
);
|
||||
CREATE INDEX assistant_scheduled_run_owner ON assistant_scheduled_run(user_id, started_at);
|
||||
@@ -35,3 +35,8 @@ Light and dark, 390 and 1440 px:
|
||||
- `p-provider-local-*`: Local GPU (Ollama), keyless LOCAL settings, discovery/test results and discovered models.
|
||||
- `p-model-*`: a discovered model's capabilities, compute prices and connection-test result.
|
||||
- `p-home-providers-*`: the home's provider summary and usage from the executed demo calls, including disabled OpenRouter with its key Not set.
|
||||
|
||||
Answer presentation (owner priority, 2026-10-13), taken against `OpenAiDemoServer` with the Next UI build of QRun-IO/qqq-frontend-next#111 (`next dev` proxied to the demo). The demo's OpenAI-compatible mock answers "How many images do I have by type?" the way a model would through the real adapter: five `count_records` calls through the UI API, then `present_results`.
|
||||
|
||||
Light and dark, 390 and 1440 px:
|
||||
- `panel-presentation-*`: the summary line, an Images metric, the "Media by type" table (counts and the server-computed 80.2% and 19.8%, each row a filtered-list link), the pie chart, two filter links (Open JPEG media, Open PNG media) and a follow-up action.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 124 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
@@ -20,7 +20,7 @@
|
||||
<!-- Multi-module build. The qqq-orb sets the revision here; the modules -->
|
||||
<!-- inherit it, and the flatten plugin resolves it in the published POMs. -->
|
||||
<properties>
|
||||
<revision>0.1.0-RC.7</revision>
|
||||
<revision>0.1.0-SNAPSHOT</revision>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
@@ -50,11 +50,13 @@
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<modules>
|
||||
<module>qbit-assistant-provider-api</module>
|
||||
<module>qbit-assistant</module>
|
||||
<module>qbit-assistant-provider-anthropic</module>
|
||||
<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>
|
||||
@@ -84,6 +86,11 @@
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-assistant-provider-api</artifactId>
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-assistant-provider-anthropic</artifactId>
|
||||
@@ -193,7 +200,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 +212,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>
|
||||
|
||||
@@ -32,9 +32,14 @@
|
||||
<url>https://github.com/QRun-IO/qbit-assistant</url>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-assistant-provider-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-assistant</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
|
||||
+20
@@ -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);
|
||||
|
||||
|
||||
+33
@@ -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;
|
||||
|
||||
+18
@@ -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}}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?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-provider-api</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>QBit Assistant - Provider API</name>
|
||||
<description>Provider SPI and pricing value types, independent of the assistant runtime</description>
|
||||
<url>https://github.com/QRun-IO/qbit-assistant</url>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-annotations</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
<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>
|
||||
<dependency>
|
||||
<groupId>com.tngtech.archunit</groupId>
|
||||
<artifactId>archunit-junit5</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<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>
|
||||
+3
-4
@@ -20,7 +20,6 @@ package com.kingsrook.qbits.assistant.engine;
|
||||
import java.io.Serializable;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.assistant.AssistantModelRouting;
|
||||
import com.kingsrook.qbits.assistant.provider.ModelUsage;
|
||||
|
||||
|
||||
@@ -63,9 +62,9 @@ public class ModelPricing implements Serializable
|
||||
public static Map<String, ModelPricing> defaults()
|
||||
{
|
||||
Map<String, ModelPricing> map = new LinkedHashMap<>();
|
||||
map.put(AssistantModelRouting.SONNET_5_5, of(2.0, 10.0, 0.1));
|
||||
map.put(AssistantModelRouting.OPUS_5_5, of(4.0, 20.0, 0.05));
|
||||
map.put(AssistantModelRouting.HAIKU_5_5, of(0.10, 0.50, 0.1));
|
||||
map.put("claude-sonnet-5-5", of(2.0, 10.0, 0.1));
|
||||
map.put("claude-opus-5-5", of(4.0, 20.0, 0.05));
|
||||
map.put("claude-haiku-5-5", of(0.10, 0.50, 0.1));
|
||||
map.put("claude-fable-5-1", of(10.0, 50.0, 0.025));
|
||||
map.put("claude-fable-5", of(10.0, 50.0, 0.1));
|
||||
map.put("claude-opus-5", of(5.0, 25.0, 0.1));
|
||||
+10
@@ -58,4 +58,14 @@ public interface AssistantModelProvider
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional embedding capability. Unsupported adapters fail before transport.
|
||||
*******************************************************************************/
|
||||
default EmbeddingResponse embed(EmbeddingRequest request) throws ModelProviderException
|
||||
{
|
||||
throw (new ModelProviderException(ModelProviderException.Kind.INVALID_REQUEST, null, "Embeddings are unsupported by this provider."));
|
||||
}
|
||||
|
||||
}
|
||||
+3
-2
@@ -18,7 +18,6 @@ package com.kingsrook.qbits.assistant.provider;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProvider;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -140,8 +139,10 @@ public interface AssistantProviderType
|
||||
|
||||
/*******************************************************************************
|
||||
** Additional settings errors, without resolving credentials or doing I/O.
|
||||
** Settings contain configured values and apiKeyEnvName; apiKey, the bearer
|
||||
** token supplier and SSLContext are not resolved for validation.
|
||||
*******************************************************************************/
|
||||
default List<String> validate(AssistantProvider row)
|
||||
default List<String> validate(ProviderSettings settings)
|
||||
{
|
||||
return (List.of());
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.provider;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One embedding batch. Providers accept at most 64 inputs per request.
|
||||
*******************************************************************************/
|
||||
public record EmbeddingRequest(String model, List<String> input)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Snapshot the inputs and reject empty or oversized batches before transport.
|
||||
*******************************************************************************/
|
||||
public EmbeddingRequest
|
||||
{
|
||||
input = List.copyOf(input);
|
||||
if(model == null || model.isBlank() || input.isEmpty() || input.size() > 64)
|
||||
{
|
||||
throw (new IllegalArgumentException("An embedding request needs a model and 1 to 64 inputs."));
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.provider;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Ordered vectors and billable usage. No input text is retained in the response.
|
||||
*******************************************************************************/
|
||||
public record EmbeddingResponse(List<float[]> vectors, ModelUsage usage, String requestId)
|
||||
{
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+23
@@ -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);
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+32
@@ -36,6 +36,7 @@ public class ProviderSettings
|
||||
private String tlsCaBundle;
|
||||
@JsonIgnore
|
||||
private transient SSLContext sslContext;
|
||||
private String apiKeyEnvName;
|
||||
private String kind;
|
||||
private String apiKey;
|
||||
private String baseUrl;
|
||||
@@ -397,4 +398,35 @@ public class ProviderSettings
|
||||
this.bearerTokenSupplier = bearerTokenSupplier;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The configured environment variable name, never its secret value.
|
||||
*******************************************************************************/
|
||||
public String getApiKeyEnvName()
|
||||
{
|
||||
return (this.apiKeyEnvName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for apiKeyEnvName
|
||||
*******************************************************************************/
|
||||
public void setApiKeyEnvName(String apiKeyEnvName)
|
||||
{
|
||||
this.apiKeyEnvName = apiKeyEnvName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for apiKeyEnvName
|
||||
*******************************************************************************/
|
||||
public ProviderSettings withApiKeyEnvName(String apiKeyEnvName)
|
||||
{
|
||||
this.apiKeyEnvName = apiKeyEnvName;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+75
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.provider;
|
||||
|
||||
|
||||
import java.util.Set;
|
||||
import com.tngtech.archunit.base.DescribedPredicate;
|
||||
import com.tngtech.archunit.core.domain.JavaClass;
|
||||
import com.tngtech.archunit.core.domain.JavaClasses;
|
||||
import com.tngtech.archunit.core.importer.ClassFileImporter;
|
||||
import com.tngtech.archunit.core.importer.ImportOption;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
|
||||
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Keep external provider consumers independent of the assistant runtime and QQQ.
|
||||
*******************************************************************************/
|
||||
class ProviderApiArchitectureTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Allow only the extracted types, including their nested enums, under the
|
||||
** assistant namespace. A future dependency on any runtime package must fail.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void dependsOnlyOnApiTypes()
|
||||
{
|
||||
Set<String> apiTypes = Set.of(
|
||||
"com.kingsrook.qbits.assistant.provider.AssistantModelProvider",
|
||||
"com.kingsrook.qbits.assistant.provider.AssistantProviderType",
|
||||
"com.kingsrook.qbits.assistant.provider.CacheHint",
|
||||
"com.kingsrook.qbits.assistant.provider.CompletableStreamHandle",
|
||||
"com.kingsrook.qbits.assistant.provider.DiscoveredModel",
|
||||
"com.kingsrook.qbits.assistant.provider.EmbeddingRequest",
|
||||
"com.kingsrook.qbits.assistant.provider.EmbeddingResponse",
|
||||
"com.kingsrook.qbits.assistant.provider.HealthResult",
|
||||
"com.kingsrook.qbits.assistant.provider.ModelCapabilities",
|
||||
"com.kingsrook.qbits.assistant.provider.ModelEventListener",
|
||||
"com.kingsrook.qbits.assistant.provider.ModelMessage",
|
||||
"com.kingsrook.qbits.assistant.provider.ModelPart",
|
||||
"com.kingsrook.qbits.assistant.provider.ModelProviderException",
|
||||
"com.kingsrook.qbits.assistant.provider.ModelRequest",
|
||||
"com.kingsrook.qbits.assistant.provider.ModelResponse",
|
||||
"com.kingsrook.qbits.assistant.provider.ModelToolDefinition",
|
||||
"com.kingsrook.qbits.assistant.provider.ModelUsage",
|
||||
"com.kingsrook.qbits.assistant.provider.ProviderSettings",
|
||||
"com.kingsrook.qbits.assistant.provider.StreamHandle",
|
||||
"com.kingsrook.qbits.assistant.provider.SystemBlock",
|
||||
"com.kingsrook.qbits.assistant.provider.TokenCount",
|
||||
"com.kingsrook.qbits.assistant.provider.TrustedCaBundle",
|
||||
"com.kingsrook.qbits.assistant.engine.ModelPricing");
|
||||
JavaClasses imported = new ClassFileImporter()
|
||||
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
|
||||
.importPackages("com.kingsrook.qbits.assistant");
|
||||
DescribedPredicate<JavaClass> outsideRuntime = DescribedPredicate.describe("outside assistant or an extracted API type", type ->
|
||||
!type.getName().startsWith("com.kingsrook.qbits.assistant.") || apiTypes.contains(type.getName().split("\\$", 2)[0]));
|
||||
classes().should().onlyDependOnClassesThat(outsideRuntime).check(imported);
|
||||
noClasses().should().dependOnClassesThat().resideInAnyPackage("com.kingsrook.qqq..").check(imported);
|
||||
}
|
||||
}
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
* 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.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import javax.net.ssl.SSLContext;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qbits.assistant.engine.ModelPricing;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Provider contracts work on a classpath with no assistant runtime or QQQ.
|
||||
*******************************************************************************/
|
||||
class ProviderApiTest
|
||||
{
|
||||
private final ObjectMapper mapper = new ObjectMapper().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve provider-neutral requests and tool order through JSON; signed
|
||||
** provider state remains in memory only.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void requestRoundTripAndCopy() throws Exception
|
||||
{
|
||||
ModelCapabilities capabilities = new ModelCapabilities().withTools(true).withParallelToolCalls(true)
|
||||
.withPromptCaching(true).withThinking(false).withMaxContextTokens(32000)
|
||||
.withVision(false).withStreaming(true).withReasoningEffort(true);
|
||||
ModelToolDefinition tool = new ModelToolDefinition().withName("lookup").withDescription("Look up a record")
|
||||
.withInputSchema(Map.of("type", "object")).withStrict(true);
|
||||
ModelMessage user = ModelMessage.user("Find it").withCacheHint(CacheHint.SHORT);
|
||||
ModelMessage assistant = ModelMessage.assistant("Looking").withPart(ModelPart.toolUse("call-1", "lookup", null))
|
||||
.withProviderState("private-signature");
|
||||
ModelMessage result = new ModelMessage().withRole(ModelMessage.Role.USER)
|
||||
.withParts(List.of(ModelPart.toolResult("call-1", "denied", true)));
|
||||
ModelRequest request = new ModelRequest().withModel("example").withEffort("low").withMaxTokens(100)
|
||||
.withParallelToolCalls(true).withStreaming(false).withCapabilities(capabilities)
|
||||
.withSystem(List.of(new SystemBlock("rules", CacheHint.LONG), new SystemBlock().withText("user context").withCacheHint(CacheHint.SHORT)))
|
||||
.withTools(List.of(tool)).withMessages(new ArrayList<>(List.of(user, assistant, result)));
|
||||
String json = mapper.writeValueAsString(request);
|
||||
assertThat(json).doesNotContain("private-signature", "providerState");
|
||||
ModelRequest restored = mapper.readValue(json, ModelRequest.class);
|
||||
assertThat(mapper.<JsonNode>valueToTree(restored)).isEqualTo(mapper.valueToTree(request));
|
||||
assertThat(restored.getMessages().get(1).getProviderState()).isNull();
|
||||
assertThat(restored.getMessages().get(2).getParts().get(0).getIsError()).isTrue();
|
||||
ModelRequest copy = request.copy();
|
||||
assertThat(mapper.<JsonNode>valueToTree(copy)).isEqualTo(mapper.valueToTree(request));
|
||||
copy.getMessages().clear();
|
||||
assertThat(request.getMessages()).hasSize(3);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Response helpers retain tool ordering, text, usage and safe metadata.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void responseAndPricingRoundTrip() throws Exception
|
||||
{
|
||||
ModelUsage usage = new ModelUsage().withInputTokens(2L).withOutputTokens(3L)
|
||||
.withCacheReadTokens(4L).withCacheWriteTokens(5L).withCacheWrite1hTokens(1L);
|
||||
ModelPart first = ModelPart.toolUse("first", "lookup", Map.of("id", 1));
|
||||
ModelPart second = ModelPart.toolUse("second", "lookup", Map.of("id", 2));
|
||||
ModelResponse response = new ModelResponse().withParts(List.of(ModelPart.text("a"), first, ModelPart.text("b"), second))
|
||||
.withModel("example").withStopReason(ModelResponse.STOP_TOOL_USE).withRequestId("request-1")
|
||||
.withUsage(usage).withProviderState("signature");
|
||||
assertThat(response.getText()).isEqualTo("ab");
|
||||
assertThat(response.getToolUses()).containsExactly(first, second);
|
||||
String json = mapper.writeValueAsString(response);
|
||||
assertThat(json).doesNotContain("signature", "providerState");
|
||||
ModelResponse restored = mapper.readValue(json, ModelResponse.class);
|
||||
assertThat(mapper.<JsonNode>valueToTree(restored)).isEqualTo(mapper.valueToTree(response));
|
||||
assertThat(restored.getProviderState()).isNull();
|
||||
assertThat(response.getProviderState()).isEqualTo("signature");
|
||||
ModelPricing pricing = ModelPricing.of(0.1, 0.5, 0.1);
|
||||
ModelPricing restoredPricing = mapper.readValue(mapper.writeValueAsString(pricing), ModelPricing.class);
|
||||
assertThat(restoredPricing.costMicros(usage)).isEqualTo(3);
|
||||
assertThat(new ModelUsage().withInputTokens(null).add(usage).add(null).billableTokens()).isEqualTo(10);
|
||||
TokenCount tokens = mapper.readValue(mapper.writeValueAsString(new TokenCount().withInputTokens(12L)), TokenCount.class);
|
||||
assertThat(tokens.getInputTokens()).isEqualTo(12L);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Plain validation settings require no credentials or entity classes, and
|
||||
** live token suppliers/TLS contexts cannot be serialized into diagnostics.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void settingsRoundTrip() throws Exception
|
||||
{
|
||||
ProviderSettings settings = new ProviderSettings().withKind("EXAMPLE").withApiKeyEnvName("EXAMPLE_KEY")
|
||||
.withApiKey("test-only-key").withBaseUrl("https://example.invalid").withApiVersion("v1")
|
||||
.withRegion("region").withCloudProject("project").withTlsCaBundle("corporate")
|
||||
.withConnectTimeoutSeconds(5).withRequestTimeoutSeconds(30)
|
||||
.withBearerTokenSupplier(() -> "ephemeral-token").withSslContext(SSLContext.getDefault());
|
||||
String json = mapper.writeValueAsString(settings);
|
||||
assertThat(json).doesNotContain("ephemeral-token", "bearerTokenSupplier", "sslContext");
|
||||
ProviderSettings restored = mapper.readValue(json, ProviderSettings.class);
|
||||
assertThat(mapper.<JsonNode>valueToTree(restored)).isEqualTo(mapper.valueToTree(settings));
|
||||
assertThat(settings.getBearerTokenSupplier().get()).isEqualTo("ephemeral-token");
|
||||
assertThat(settings.getSslContext()).isNotNull();
|
||||
assertThat(restored.getBearerTokenSupplier()).isNull();
|
||||
assertThat(restored.getSslContext()).isNull();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Embedding batches snapshot input and enforce the transport size contract.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void embeddingContracts()
|
||||
{
|
||||
List<String> input = new ArrayList<>(List.of("first", "second"));
|
||||
EmbeddingRequest request = new EmbeddingRequest("embed", input);
|
||||
input.clear();
|
||||
assertThat(request.input()).containsExactly("first", "second");
|
||||
assertThatThrownBy(() -> request.input().add("third")).isInstanceOf(UnsupportedOperationException.class);
|
||||
assertThatThrownBy(() -> new EmbeddingRequest(" ", List.of("text"))).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> new EmbeddingRequest(null, List.of("text"))).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> new EmbeddingRequest("embed", List.of())).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> new EmbeddingRequest("embed", Collections.nCopies(65, "text"))).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThat(new EmbeddingRequest("embed", Collections.nCopies(64, "text")).input()).hasSize(64);
|
||||
float[] vector = new float[] { 0.25F, 0.75F };
|
||||
EmbeddingResponse response = new EmbeddingResponse(List.of(vector), new ModelUsage().withInputTokens(2L), "request");
|
||||
assertThat(response.vectors().getFirst()).containsExactly(0.25F, 0.75F);
|
||||
assertThat(response.usage().getInputTokens()).isEqualTo(2L);
|
||||
assertThat(response.requestId()).isEqualTo("request");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cancellation is idempotent, preserves accrued usage, and wins over late
|
||||
** completion. Timeout also cancels the underlying transport.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void cancellationAndTimeout() throws Exception
|
||||
{
|
||||
AtomicInteger cancellations = new AtomicInteger();
|
||||
ModelUsage usage = new ModelUsage().withInputTokens(10L);
|
||||
CompletableStreamHandle handle = new CompletableStreamHandle();
|
||||
handle.setPartialUsage(() -> usage);
|
||||
handle.setOnCancel(cancellations::incrementAndGet);
|
||||
handle.cancel();
|
||||
handle.cancel();
|
||||
handle.complete(new ModelResponse().withStopReason(ModelResponse.STOP_END_TURN));
|
||||
handle.fail(new ModelProviderException(ModelProviderException.Kind.SERVER, 500, "late failure"));
|
||||
assertThat(handle.isCancelled()).isTrue();
|
||||
assertThat(cancellations.get()).isEqualTo(1);
|
||||
assertThat(handle.await(Duration.ofSeconds(1)).getUsage()).isSameAs(usage);
|
||||
CompletableStreamHandle timeout = new CompletableStreamHandle();
|
||||
timeout.setPartialUsage(() -> usage);
|
||||
assertThatThrownBy(() -> timeout.await(Duration.ofMillis(1))).isInstanceOfSatisfying(ModelProviderException.class, error ->
|
||||
{
|
||||
assertThat(error.getKind()).isEqualTo(ModelProviderException.Kind.TIMEOUT);
|
||||
assertThat(error.getPartialUsage()).isSameAs(usage);
|
||||
});
|
||||
assertThat(timeout.isCancelled()).isTrue();
|
||||
handle.setPartialUsage(() ->
|
||||
{
|
||||
throw (new IllegalStateException("closed"));
|
||||
});
|
||||
assertThat(handle.partialUsage()).isNull();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Error metadata is bounded and redacted independently of error classification.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void errorDetails()
|
||||
{
|
||||
ModelProviderException error = new ModelProviderException(ModelProviderException.Kind.INVALID_REQUEST, 400, "Invalid schema")
|
||||
.withStrictSchemaRejected(true).withProviderDetails("invalid_request", "bad key sk-abcdefghijk\ntry again", "req-123");
|
||||
assertThat(error.isStrictSchemaRejected()).isTrue();
|
||||
assertThat(error.getProviderErrorType()).isEqualTo("invalid_request");
|
||||
assertThat(error.getProviderMessage()).isEqualTo("bad key [redacted] try again");
|
||||
assertThat(error.getRequestId()).isEqualTo("req-123");
|
||||
assertThat(error.isRetryable()).isFalse();
|
||||
assertThat(ModelProviderException.sanitizeProviderMessage(" ", 10)).isNull();
|
||||
assertThat(ModelProviderException.sanitizeProviderMessage(null, 10)).isNull();
|
||||
assertThat(ModelProviderException.sanitizeProviderMessage("word ".repeat(100), 20)).hasSize(20);
|
||||
assertThat(ModelProviderException.sanitizeProviderMessage("a".repeat(50), 100)).isEqualTo("[redacted]");
|
||||
}
|
||||
}
|
||||
+80
@@ -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.provider;
|
||||
|
||||
|
||||
import java.time.Duration;
|
||||
import com.kingsrook.qbits.assistant.engine.ModelPricing;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Standalone contracts moved from the runtime's component tests.
|
||||
*******************************************************************************/
|
||||
class ProviderComponentsTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testPricing()
|
||||
{
|
||||
ModelPricing sonnet = ModelPricing.defaults().get("claude-sonnet-5-5");
|
||||
ModelUsage usage = new ModelUsage().withInputTokens(6000L).withOutputTokens(800L).withCacheReadTokens(12_000L).withCacheWriteTokens(1000L).withCacheWrite1hTokens(400L);
|
||||
//////////////////////////////////////////////////////////////////////////////////
|
||||
// 6000*2 + 800*10 + 12000*0.2 + 600*2.5 + 400*4 = 12000+8000+2400+1500+1600 //
|
||||
//////////////////////////////////////////////////////////////////////////////////
|
||||
assertThat(sonnet.costMicros(usage)).isEqualTo(25_500);
|
||||
assertThat(sonnet.costMicros(null)).isZero();
|
||||
assertThat(new ModelPricing().costMicros(usage)).isZero();
|
||||
assertThat(ModelPricing.defaults().get("claude-opus-5-5").getCacheReadUsdPerMTok()).isEqualTo(0.2);
|
||||
assertThat(usage.billableTokens()).isEqualTo(7800);
|
||||
assertThat(new ModelUsage().add(usage).add(null).getCacheReadTokens()).isEqualTo(12_000);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void testStreamHandle() throws Exception
|
||||
{
|
||||
CompletableStreamHandle handle = new CompletableStreamHandle();
|
||||
handle.complete(new ModelResponse().withStopReason("end_turn"));
|
||||
assertThat(handle.await(Duration.ofSeconds(1)).getStopReason()).isEqualTo("end_turn");
|
||||
|
||||
CompletableStreamHandle failing = new CompletableStreamHandle();
|
||||
failing.fail(new ModelProviderException(ModelProviderException.Kind.RATE_LIMITED, 429, "slow down"));
|
||||
assertThatThrownBy(() -> failing.await(Duration.ofSeconds(1))).isInstanceOfSatisfying(ModelProviderException.class, e ->
|
||||
{
|
||||
assertThat(e.isRetryable()).isTrue();
|
||||
assertThat(e.getHttpStatus()).isEqualTo(429);
|
||||
});
|
||||
|
||||
CompletableStreamHandle cancelled = new CompletableStreamHandle();
|
||||
cancelled.setOnCancel(() ->
|
||||
{
|
||||
throw (new IllegalStateException("closing"));
|
||||
});
|
||||
cancelled.cancel();
|
||||
cancelled.cancel();
|
||||
assertThat(cancelled.await(Duration.ofSeconds(1)).getStopReason()).isEqualTo(ModelResponse.STOP_CANCELLED);
|
||||
assertThat(new ModelProviderException(ModelProviderException.Kind.INVALID_REQUEST, 400, "x").isRetryable()).isFalse();
|
||||
}
|
||||
}
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
* 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;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A minimal external provider compiles and uses SPI defaults without the runtime.
|
||||
*******************************************************************************/
|
||||
class ProviderDefaultsTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Legacy two-argument factories and static model lists remain supported.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void minimalProviderDefaults() throws Exception
|
||||
{
|
||||
ExampleProvider provider = new ExampleProvider();
|
||||
ProviderSettings settings = new ProviderSettings().withApiKey("test-key").withBaseUrl("https://example.invalid");
|
||||
assertThat(provider.create(settings)).isSameAs(provider);
|
||||
assertThat(provider.settings.getApiKey()).isEqualTo("test-key");
|
||||
assertThat(provider.settings.getBaseUrl()).isEqualTo("https://example.invalid");
|
||||
assertThat(provider.getKinds()).containsExactly("EXAMPLE_PROVIDER");
|
||||
assertThat(provider.getDefaultBaseUrl("EXAMPLE_PROVIDER")).isNull();
|
||||
assertThat(provider.getDefaultApiKeyEnvName("EXAMPLE_PROVIDER")).isEqualTo("EXAMPLE_KEY");
|
||||
assertThat(provider.getDefaultLocation("EXAMPLE_PROVIDER")).isEqualTo("CLOUD");
|
||||
assertThat(provider.requiresBaseUrl("EXAMPLE_PROVIDER")).isTrue();
|
||||
assertThat(provider.validate(settings)).isEmpty();
|
||||
assertThat(provider.discoverModels(settings)).containsExactly(new DiscoveredModel("example", "example", null, null));
|
||||
assertThat(provider.checkHealth(settings).healthy()).isNull();
|
||||
assertThat(provider.checkHealth(settings).message()).isEqualTo("Health check not supported");
|
||||
assertThat(new HealthResult(false, 20L, "bad sk-abcdefghijk").message()).isEqualTo("bad [redacted]");
|
||||
assertThat(provider.countTokens(new ModelRequest())).isNull();
|
||||
assertThatThrownBy(() -> provider.embed(new EmbeddingRequest("example", List.of("text"))))
|
||||
.isInstanceOfSatisfying(ModelProviderException.class, error -> assertThat(error.getKind()).isEqualTo(ModelProviderException.Kind.INVALID_REQUEST));
|
||||
StringBuilder deltas = new StringBuilder();
|
||||
ModelEventListener listener = deltas::append;
|
||||
listener.onToolUseStart("call", "lookup");
|
||||
assertThat(deltas).isEmpty();
|
||||
listener.onTextDelta("visible answer");
|
||||
assertThat(deltas).hasToString("visible answer");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Only the original required SPI methods are implemented.
|
||||
*******************************************************************************/
|
||||
private static class ExampleProvider implements AssistantProviderType, AssistantModelProvider
|
||||
{
|
||||
private ProviderSettings settings;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Minimal provider contract.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getType()
|
||||
{
|
||||
return ("example-provider");
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Minimal provider contract.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getLabel()
|
||||
{
|
||||
return ("Example");
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Minimal provider contract.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<String> getModels()
|
||||
{
|
||||
return (List.of("example"));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Minimal provider contract.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getDefaultApiKeyEnvName()
|
||||
{
|
||||
return ("EXAMPLE_KEY");
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Minimal provider contract.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getDefaultBaseUrl()
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Minimal provider contract.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public AssistantModelProvider create(String apiKey, String baseUrl)
|
||||
{
|
||||
settings = new ProviderSettings().withApiKey(apiKey).withBaseUrl(baseUrl);
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Minimal provider contract.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getName()
|
||||
{
|
||||
return ("example");
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Minimal provider contract.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ModelCapabilities getCapabilities(String modelId)
|
||||
{
|
||||
return (new ModelCapabilities());
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Minimal provider contract.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public StreamHandle stream(ModelRequest request, ModelEventListener listener)
|
||||
{
|
||||
throw (new UnsupportedOperationException());
|
||||
}
|
||||
}
|
||||
}
|
||||
File renamed without changes.
@@ -41,9 +41,14 @@
|
||||
<artifactId>qbit-assistant-provider-anthropic</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-assistant-provider-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-assistant</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
|
||||
+3
-4
@@ -24,7 +24,6 @@ import com.anthropic.backends.Backend;
|
||||
import com.anthropic.bedrock.backends.BedrockBackend;
|
||||
import com.anthropic.client.okhttp.AnthropicOkHttpClient;
|
||||
import com.anthropic.core.Timeout;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProvider;
|
||||
import com.kingsrook.qbits.assistant.provider.AssistantModelProvider;
|
||||
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
|
||||
import com.kingsrook.qbits.assistant.provider.DiscoveredModel;
|
||||
@@ -117,14 +116,14 @@ public class BedrockProviderType implements AssistantProviderType
|
||||
** Validate cloud settings without loading credentials.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<String> validate(AssistantProvider row)
|
||||
public List<String> validate(ProviderSettings settings)
|
||||
{
|
||||
List<String> errors = new ArrayList<>();
|
||||
if(row.getRegion() == null || row.getRegion().isBlank())
|
||||
if(settings.getRegion() == null || settings.getRegion().isBlank())
|
||||
{
|
||||
errors.add("Region is required for BEDROCK.");
|
||||
}
|
||||
if(row.getApiKeyEnvName() != null && !row.getApiKeyEnvName().isBlank())
|
||||
if(settings.getApiKeyEnvName() != null && !settings.getApiKeyEnvName().isBlank())
|
||||
{
|
||||
errors.add("Leave API key variable empty: AWS Bedrock (Claude) uses the AWS default credential chain (environment, profile or server role).");
|
||||
}
|
||||
|
||||
@@ -32,9 +32,14 @@
|
||||
<url>https://github.com/QRun-IO/qbit-assistant</url>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-assistant-provider-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-assistant</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
|
||||
+81
-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);
|
||||
}
|
||||
|
||||
+12
-2
@@ -84,13 +84,23 @@ final class OpenAiHttp
|
||||
** Azure uses deployment paths and a version query parameter.
|
||||
*******************************************************************************/
|
||||
HttpRequest.Builder request(String model, Integer seconds) throws ModelProviderException
|
||||
{
|
||||
return (request(model, seconds, "chat/completions"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Chat and embeddings share authentication, TLS, deadlines and Azure paths.
|
||||
*******************************************************************************/
|
||||
HttpRequest.Builder request(String model, Integer seconds, String operation) throws ModelProviderException
|
||||
{
|
||||
try
|
||||
{
|
||||
String path = model == null ? "/models" : "/chat/completions";
|
||||
String path = model == null ? "/models" : "/" + operation;
|
||||
if("AZURE_OPENAI".equals(settings.getKind()))
|
||||
{
|
||||
path = model == null ? "/openai/models" : "/openai/deployments/" + encode(model) + "/chat/completions";
|
||||
path = model == null ? "/openai/models" : "/openai/deployments/" + encode(model) + "/" + operation;
|
||||
path += "?api-version=" + encode(settings.getApiVersion() == null || settings.getApiVersion().isBlank() ? "2024-10-21" : settings.getApiVersion());
|
||||
}
|
||||
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(base + path)).timeout(Duration.ofSeconds(seconds)).header("Content-Type", "application/json");
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
+21
@@ -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";
|
||||
|
||||
|
||||
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.provider.openai;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.assistant.provider.EmbeddingRequest;
|
||||
import com.kingsrook.qbits.assistant.provider.ModelProviderException;
|
||||
import com.kingsrook.qbits.assistant.provider.ProviderSettings;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Embedding wire contracts through the same local HTTP harness as chat.
|
||||
*******************************************************************************/
|
||||
class OpenAiEmbeddingTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Input arrays, response indexes, usage and authentication are preserved.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void shapeOrderAndAzure() throws Exception
|
||||
{
|
||||
for(String kind : List.of("OPENAI", "AZURE_OPENAI", "OLLAMA"))
|
||||
{
|
||||
try(MockOpenAiServer server = new MockOpenAiServer())
|
||||
{
|
||||
server.enqueue(200, "{\"data\":[{\"index\":1,\"embedding\":[0,1]},{\"index\":0,\"embedding\":[1,0]}],\"usage\":{\"prompt_tokens\":7}}");
|
||||
var provider = new OpenAiCompatibleModelProvider(new ProviderSettings().withKind(kind).withBaseUrl(server.url()).withApiKey("synthetic-key").withApiVersion("2024-10-21"));
|
||||
var response = provider.embed(new EmbeddingRequest("bge-m3", List.of("first", "second")));
|
||||
assertThat(response.vectors()).hasSize(2);
|
||||
assertThat(response.vectors().getFirst()).containsExactly(1, 0);
|
||||
assertThat(response.usage().getInputTokens()).isEqualTo(7L);
|
||||
var captured = server.requests.getFirst();
|
||||
assertThat(captured.path()).isEqualTo(kind.equals("AZURE_OPENAI") ? "/v1/openai/deployments/bge-m3/embeddings?api-version=2024-10-21" : "/v1/embeddings");
|
||||
var body = OpenAiHttp.JSON.readTree(captured.body());
|
||||
assertThat(body.path("model").asText()).isEqualTo("bge-m3");
|
||||
assertThat(body.path("input").size()).isEqualTo(2);
|
||||
assertThat(body.has("messages")).isFalse();
|
||||
assertThat(body.has("stream")).isFalse();
|
||||
assertThat(kind.equals("AZURE_OPENAI") ? captured.apiKey() : captured.authorization()).contains("synthetic-key");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Reuse chat's safe error categories, without echoing credentials.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void errorsAndMalformedVectors() throws Exception
|
||||
{
|
||||
try(MockOpenAiServer server = new MockOpenAiServer())
|
||||
{
|
||||
var provider = new OpenAiCompatibleModelProvider(new ProviderSettings().withKind("OPENAI").withBaseUrl(server.url()).withApiKey("synthetic-key"));
|
||||
server.enqueue(429, "{\"error\":{\"message\":\"synthetic-key rejected\"}}");
|
||||
assertThatThrownBy(() -> provider.embed(new EmbeddingRequest("embed", List.of("text"))))
|
||||
.isInstanceOfSatisfying(ModelProviderException.class, e ->
|
||||
{
|
||||
assertThat(e.getKind()).isEqualTo(ModelProviderException.Kind.RATE_LIMITED);
|
||||
assertThat(e.getMessage()).doesNotContain("synthetic-key");
|
||||
});
|
||||
for(String body : List.of("{}", "{\"data\":[{\"index\":0,\"embedding\":[\"not numeric\"]}]}",
|
||||
"{\"data\":[{\"index\":2,\"embedding\":[1]}]}", "{\"data\":[{\"index\":0,\"embedding\":[1]},{\"index\":0,\"embedding\":[1]}]}"))
|
||||
{
|
||||
server.enqueue(200, body);
|
||||
assertThatThrownBy(() -> provider.embed(new EmbeddingRequest("embed", List.of("text")))).isInstanceOf(ModelProviderException.class);
|
||||
}
|
||||
assertThatThrownBy(() -> new EmbeddingRequest("embed", java.util.Collections.nCopies(65, "text"))).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> new EmbeddingRequest("embed", List.of())).isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Deadline covers a stalled response body, not only HTTP headers.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void bodyTimeout() throws Exception
|
||||
{
|
||||
try(MockOpenAiServer server = new MockOpenAiServer())
|
||||
{
|
||||
server.stall(true);
|
||||
var provider = new OpenAiCompatibleModelProvider(new ProviderSettings().withKind("OLLAMA").withBaseUrl(server.url()).withRequestTimeoutSeconds(1));
|
||||
assertThatThrownBy(() -> provider.embed(new EmbeddingRequest("embed", List.of("text"))))
|
||||
.isInstanceOfSatisfying(ModelProviderException.class, e -> assertThat(e.getKind()).isEqualTo(ModelProviderException.Kind.TIMEOUT));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The transport accepts a complete 64-input batch and rejects overflow
|
||||
** before sending. EmbeddingService owns splitting larger indexing jobs.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void maximumBatch() throws Exception
|
||||
{
|
||||
try(MockOpenAiServer server = new MockOpenAiServer())
|
||||
{
|
||||
var body = OpenAiHttp.JSON.createObjectNode();
|
||||
var data = body.putArray("data");
|
||||
for(Integer i = 0; i < 64; i++)
|
||||
{
|
||||
data.addObject().put("index", i).putArray("embedding").add(1).add(0);
|
||||
}
|
||||
server.enqueue(200, body.toString());
|
||||
var provider = new OpenAiCompatibleModelProvider(new ProviderSettings().withKind("OLLAMA").withBaseUrl(server.url()));
|
||||
assertThat(provider.embed(new EmbeddingRequest("embed", java.util.Collections.nCopies(64, "text"))).vectors()).hasSize(64);
|
||||
assertThat(OpenAiHttp.JSON.readTree(server.requests.getFirst().body()).path("input").size()).isEqualTo(64);
|
||||
assertThatThrownBy(() -> provider.embed(new EmbeddingRequest("embed", java.util.Collections.nCopies(65, "text")))).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThat(server.requests).hasSize(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
+95
-11
@@ -21,8 +21,13 @@ import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantModel;
|
||||
@@ -35,6 +40,9 @@ import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
|
||||
import com.kingsrook.qbits.assistant.provider.fake.ScriptedModelProvider;
|
||||
import com.kingsrook.qbits.assistant.provider.openai.OpenAiCompatibleProviderType;
|
||||
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
|
||||
import com.kingsrook.qbits.assistant.testing.EvalSupport;
|
||||
import com.kingsrook.qbits.assistant.testing.GoldenTasks;
|
||||
import com.kingsrook.qbits.assistant.testing.PresentationGoldenTask;
|
||||
import com.kingsrook.qbits.assistant.testing.TurnRunner;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.QProcessCallbackFactory;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
@@ -61,6 +69,8 @@ public class OpenAiDemoServer extends AbstractQQQApplication
|
||||
{
|
||||
private static final String ADMIN = "admin-demo";
|
||||
private static final String MODEL = "qwen3:30b-a3b";
|
||||
private static final String IMAGE_QUESTION = "How many images do I have by type?";
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
private static QInstance instance;
|
||||
|
||||
|
||||
@@ -160,6 +170,8 @@ public class OpenAiDemoServer extends AbstractQQQApplication
|
||||
{
|
||||
throw (new QException("Demo assistant turn failed: " + outcome.errorCode()));
|
||||
}
|
||||
TurnRunner.Outcome presentation = new TurnRunner(instance, config).run(ADMIN, IMAGE_QUESTION);
|
||||
PresentationGoldenTask.check(presentation);
|
||||
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK)
|
||||
.withCustomizer(new QCodeReference(DemoSessionCustomizer.class)));
|
||||
instance.setHomeAppName(AssistantMetaDataBuilder.APP_NAME);
|
||||
@@ -167,6 +179,7 @@ public class OpenAiDemoServer extends AbstractQQQApplication
|
||||
System.out.println("Assistant home: http://localhost:" + port + "/app/" + AssistantMetaDataBuilder.APP_NAME);
|
||||
System.out.println("Local provider: http://localhost:" + port + "/app/assistant_provider/" + providerId);
|
||||
System.out.println("Local model: http://localhost:" + port + "/app/assistant_model/" + modelId);
|
||||
System.out.println("Image presentation conversation id: " + presentation.conversationId());
|
||||
}
|
||||
|
||||
|
||||
@@ -211,7 +224,7 @@ public class OpenAiDemoServer extends AbstractQQQApplication
|
||||
{
|
||||
try(exchange)
|
||||
{
|
||||
exchange.getRequestBody().readAllBytes();
|
||||
byte[] request = exchange.getRequestBody().readAllBytes();
|
||||
String path = exchange.getRequestURI().getPath();
|
||||
String body;
|
||||
if("GET".equals(exchange.getRequestMethod()) && "/v1/models".equals(path))
|
||||
@@ -227,16 +240,7 @@ public class OpenAiDemoServer extends AbstractQQQApplication
|
||||
else if("POST".equals(exchange.getRequestMethod()) && "/v1/chat/completions".equals(path))
|
||||
{
|
||||
exchange.getResponseHeaders().set("Content-Type", "text/event-stream");
|
||||
body = """
|
||||
data: {"id":"demo-completion","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello from the local demo provider."}}]}
|
||||
|
||||
data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
|
||||
|
||||
data: {"choices":[],"usage":{"prompt_tokens":24,"completion_tokens":8,"total_tokens":32}}
|
||||
|
||||
data: [DONE]
|
||||
|
||||
""";
|
||||
body = completion(JSON.readTree(request));
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -248,4 +252,84 @@ public class OpenAiDemoServer extends AbstractQQQApplication
|
||||
exchange.getResponseBody().flush();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Follow the latest user turn, so connection tests and repeated chats are
|
||||
** independent. Counts are read from the adapter's actual tool messages.
|
||||
*******************************************************************************/
|
||||
private static String completion(JsonNode request) throws IOException
|
||||
{
|
||||
String question = "";
|
||||
Map<String, Integer> counts = new LinkedHashMap<>();
|
||||
boolean presented = false;
|
||||
for(JsonNode message : request.path("messages"))
|
||||
{
|
||||
if("user".equals(message.path("role").asText()))
|
||||
{
|
||||
question = message.path("content").asText();
|
||||
counts.clear();
|
||||
presented = false;
|
||||
}
|
||||
else if("tool".equals(message.path("role").asText()) && IMAGE_QUESTION.equals(question))
|
||||
{
|
||||
String id = message.path("tool_call_id").asText();
|
||||
if(id.startsWith("count-image/"))
|
||||
{
|
||||
counts.put(id.substring("count-".length()), ((Number) EvalSupport.unwrap(message.path("content").asText()).get("count")).intValue());
|
||||
}
|
||||
else if("present-images".equals(id))
|
||||
{
|
||||
presented = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> delta = Map.of("role", "assistant", "content", "Hello from the local demo provider.");
|
||||
String finish = "stop";
|
||||
if(IMAGE_QUESTION.equals(question) && !presented)
|
||||
{
|
||||
List<Map<String, Object>> calls = new ArrayList<>();
|
||||
if(counts.isEmpty())
|
||||
{
|
||||
for(String mime : List.of("image/jpeg", "image/png", "image/webp", "image/gif", "image/svg+xml"))
|
||||
{
|
||||
calls.add(toolCall(calls.size(), "count-" + mime, "count_records", Map.of("table", "media", "filter",
|
||||
GoldenTasks.filter("AND", GoldenTasks.criterion("mimeType", "EQUALS", mime)))));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if(counts.size() != 5 || counts.get("image/webp") != 0 || counts.get("image/gif") != 0 || counts.get("image/svg+xml") != 0)
|
||||
{
|
||||
throw (new IOException("Unexpected demo image counts."));
|
||||
}
|
||||
Map<String, Object> input = PresentationGoldenTask.input(counts.get("image/jpeg"), counts.get("image/png"));
|
||||
input.put("actions", List.of(Map.of("label", "Show the PNG files", "prompt", "Show the PNG files")));
|
||||
calls.add(toolCall(0, "present-images", "present_results", input));
|
||||
}
|
||||
delta = Map.of("role", "assistant", "tool_calls", calls);
|
||||
finish = "tool_calls";
|
||||
}
|
||||
else if(presented)
|
||||
{
|
||||
delta = Map.of("role", "assistant", "content", "You can open either image type above.");
|
||||
}
|
||||
return ("data: " + JSON.writeValueAsString(Map.of("id", "demo-completion", "object", "chat.completion.chunk",
|
||||
"choices", List.of(Map.of("index", 0, "delta", delta)))) + "\n\n"
|
||||
+ "data: " + JSON.writeValueAsString(Map.of("choices", List.of(Map.of("index", 0, "delta", Map.of(), "finish_reason", finish)))) + "\n\n"
|
||||
+ "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":24,\"completion_tokens\":8,\"total_tokens\":32}}\n\n"
|
||||
+ "data: [DONE]\n\n");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Encode tool arguments as a JSON string, as OpenAI streaming requires.
|
||||
*******************************************************************************/
|
||||
private static Map<String, Object> toolCall(Integer index, String id, String name, Map<String, Object> arguments) throws IOException
|
||||
{
|
||||
return (Map.of("index", index, "id", id, "type", "function", "function", Map.of("name", name, "arguments", JSON.writeValueAsString(arguments))));
|
||||
}
|
||||
}
|
||||
+13
-2
@@ -29,6 +29,11 @@ Startup prints these URLs (with the selected port):
|
||||
- Local provider: `http://localhost:8099/app/assistant_provider/1`
|
||||
- Priced and tested model: `http://localhost:8099/app/assistant_model/1`
|
||||
|
||||
Startup also prints `Image presentation conversation id: <id>`. Open that
|
||||
conversation in the assistant panel history as `admin-demo` to capture the
|
||||
answer to "How many images do I have by type?". The id is assigned on each run;
|
||||
the conversation and its presentation are memory-backed and reset on restart.
|
||||
|
||||
Every browser session is `admin-demo`. Startup inserts an enabled, default,
|
||||
keyless Local GPU (Ollama) provider, marked LOCAL and HIPAA eligible for the
|
||||
synthetic demo, and a disabled CLOUD OpenRouter provider whose key is Not set.
|
||||
@@ -40,8 +45,14 @@ mock: `qwen3:30b-a3b`, `gpt-oss:20b`, `qwen3-coder:30b`, and `bge-m3`.
|
||||
A normal update sets qwen3's input/output compute prices to $0.05/$0.10 per
|
||||
million tokens. The provider and model Test connection processes then each
|
||||
make a real HTTP completion call, recording two TEST usage rows and the
|
||||
last-test fields. One assistant turn records a third usage row with purpose
|
||||
TURN. Discovery timestamps/status also come from the actual process.
|
||||
last-test fields. The greeting turn records a third usage row with purpose
|
||||
TURN. An image breakdown turn then makes three more model calls through the
|
||||
real OpenAI adapter and engine: five MIME-filtered `count_records` calls,
|
||||
`present_results` using the returned counts, and a short closing message.
|
||||
The fixture seeds 81 JPEG and 20 PNG rows (no WebP, GIF or SVG). The presentation
|
||||
includes a table with computed shares, filtered row links, a pie chart, JPEG
|
||||
and PNG filter links, and a "Show the PNG files" prompt action.
|
||||
Discovery timestamps/status also come from the actual process.
|
||||
|
||||
Replies and token counts are synthetic mock API responses (24 input / 8 output
|
||||
tokens per completion), not live GPU measurements or billed cloud usage.
|
||||
|
||||
@@ -45,9 +45,14 @@
|
||||
<artifactId>qbit-assistant-provider-openai</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-assistant-provider-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-assistant</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
|
||||
+4
-5
@@ -19,7 +19,6 @@ package com.kingsrook.qbits.assistant.provider.vertex;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProvider;
|
||||
import com.kingsrook.qbits.assistant.provider.AssistantModelProvider;
|
||||
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
|
||||
import com.kingsrook.qbits.assistant.provider.DiscoveredModel;
|
||||
@@ -104,18 +103,18 @@ public class VertexProviderType implements AssistantProviderType
|
||||
** Validate cloud settings without loading credentials.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<String> validate(AssistantProvider row)
|
||||
public List<String> validate(ProviderSettings settings)
|
||||
{
|
||||
List<String> errors = new ArrayList<>();
|
||||
if(row.getRegion() == null || row.getRegion().isBlank())
|
||||
if(settings.getRegion() == null || settings.getRegion().isBlank())
|
||||
{
|
||||
errors.add("Region is required for VERTEX.");
|
||||
}
|
||||
if(row.getApiKeyEnvName() != null && !row.getApiKeyEnvName().isBlank())
|
||||
if(settings.getApiKeyEnvName() != null && !settings.getApiKeyEnvName().isBlank())
|
||||
{
|
||||
errors.add("Leave API key variable empty: Google Vertex AI (Claude, Gemini) uses Google Application Default Credentials (GOOGLE_APPLICATION_CREDENTIALS or workload identity).");
|
||||
}
|
||||
if(row.getCloudProject() == null || row.getCloudProject().isBlank())
|
||||
if(settings.getCloudProject() == null || settings.getCloudProject().isBlank())
|
||||
{
|
||||
errors.add("GCP project id (cloudProject) is required for VERTEX.");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
~ Copyright 2026 Kingsrook, LLC
|
||||
~
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~ You may obtain a copy of the License at
|
||||
~
|
||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||
~
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
-->
|
||||
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-assistant-parent</artifactId>
|
||||
<version>${revision}</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>qbit-assistant-rag-pgvector</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>QBit Assistant - pgvector</name>
|
||||
<description>Content-free per-instance vector storage through JDBC</description>
|
||||
<url>https://github.com/QRun-IO/qbit-assistant</url>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-assistant</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>1.21.4</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<version>1.21.4</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<version>42.7.14</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.assertj</groupId>
|
||||
<artifactId>assertj-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<!-- live evals call the real API: opt-in only -->
|
||||
<excludedGroups>${test.excludedGroups}</excludedGroups>
|
||||
<redirectTestOutputToFile>true</redirectTestOutputToFile>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>prepare-agent</id>
|
||||
<goals>
|
||||
<goal>prepare-agent</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>report</id>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>report</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>check</id>
|
||||
<goals>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<rules>
|
||||
<rule>
|
||||
<element>BUNDLE</element>
|
||||
<limits>
|
||||
<limit>
|
||||
<counter>INSTRUCTION</counter>
|
||||
<value>COVEREDRATIO</value>
|
||||
<minimum>0.70</minimum>
|
||||
</limit>
|
||||
<limit>
|
||||
<counter>CLASS</counter>
|
||||
<value>COVEREDRATIO</value>
|
||||
<minimum>0.90</minimum>
|
||||
</limit>
|
||||
</limits>
|
||||
</rule>
|
||||
</rules>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
+452
@@ -0,0 +1,452 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.rag.pgvector;
|
||||
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.StringJoiner;
|
||||
import javax.sql.DataSource;
|
||||
import com.kingsrook.qbits.assistant.rag.ChunkVector;
|
||||
import com.kingsrook.qbits.assistant.rag.VectorStore;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Host-supplied JDBC only, without QQQ RDBMS internals. No text is ever stored.
|
||||
*******************************************************************************/
|
||||
public class PgVectorStore implements VectorStore
|
||||
{
|
||||
private final ConnectionSupplier connections;
|
||||
private final Integer dimensions;
|
||||
private String schema = "assistant_rag";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The host owns credentials and connection pooling; each call closes its lease.
|
||||
*******************************************************************************/
|
||||
@FunctionalInterface
|
||||
public interface ConnectionSupplier
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Return a connection owned by this caller until it closes it.
|
||||
*******************************************************************************/
|
||||
Connection open() throws SQLException;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Use any host DataSource and a fixed vector dimension for this instance.
|
||||
*******************************************************************************/
|
||||
public PgVectorStore(DataSource dataSource, Integer dimensions)
|
||||
{
|
||||
this(dataSource::getConnection, dimensions);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** HNSW vector cosine indexes support up to 2000 dimensions.
|
||||
*******************************************************************************/
|
||||
public PgVectorStore(ConnectionSupplier connections, Integer dimensions)
|
||||
{
|
||||
if(connections == null || dimensions == null || dimensions < 1 || dimensions > 2000)
|
||||
{
|
||||
throw (new IllegalArgumentException("A connection supplier and 1 to 2000 dimensions are required."));
|
||||
}
|
||||
this.connections = connections;
|
||||
this.dimensions = dimensions;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure before use. Identifiers cannot be parameters, so validate strictly.
|
||||
*******************************************************************************/
|
||||
public PgVectorStore withSchema(String schema)
|
||||
{
|
||||
if(schema == null || !schema.matches("[a-z_][a-z0-9_]{0,62}"))
|
||||
{
|
||||
throw (new IllegalArgumentException("The vector schema must be a lower-case SQL identifier of at most 63 characters."));
|
||||
}
|
||||
this.schema = schema;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The DBA normally installs vector. An existing extension needs no elevated
|
||||
** privileges; if absent, attempt installation and report an actionable error.
|
||||
*******************************************************************************/
|
||||
public void ensureSchema() throws QException
|
||||
{
|
||||
try(Connection connection = connections.open())
|
||||
{
|
||||
try(PreparedStatement check = connection.prepareStatement("SELECT 1 FROM pg_extension WHERE extname = ?"))
|
||||
{
|
||||
check.setString(1, "vector");
|
||||
try(ResultSet rows = check.executeQuery())
|
||||
{
|
||||
if(!rows.next())
|
||||
{
|
||||
try(PreparedStatement extension = connection.prepareStatement("CREATE EXTENSION IF NOT EXISTS vector"))
|
||||
{
|
||||
extension.execute();
|
||||
}
|
||||
catch(SQLException e)
|
||||
{
|
||||
throw (new QException("Ask the DBA to run CREATE EXTENSION IF NOT EXISTS vector in this database, then retry.", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for(String sql : List.of("CREATE SCHEMA IF NOT EXISTS " + schema,
|
||||
"CREATE TABLE IF NOT EXISTS " + table() + " (id BIGSERIAL PRIMARY KEY, source_id INT NOT NULL, table_name TEXT NOT NULL, record_pk TEXT NOT NULL, field_name TEXT NOT NULL, chunk_index INT NOT NULL, content_hash CHAR(64) NOT NULL, embedding vector(" + dimensions + ") NOT NULL, indexed_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(source_id, table_name, record_pk, field_name, chunk_index))",
|
||||
"CREATE INDEX IF NOT EXISTS assistant_rag_chunk_cosine ON " + table() + " USING hnsw (embedding vector_cosine_ops)"))
|
||||
{
|
||||
try(PreparedStatement statement = connection.prepareStatement(sql))
|
||||
{
|
||||
statement.execute();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(SQLException e)
|
||||
{
|
||||
throw (failure(e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate the entire batch before sending anything to JDBC.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void upsert(List<ChunkVector> chunks) throws QException
|
||||
{
|
||||
chunks.forEach(chunk -> vector(chunk.embedding()));
|
||||
try(Connection connection = connections.open())
|
||||
{
|
||||
upsert(connection, chunks);
|
||||
}
|
||||
catch(SQLException e)
|
||||
{
|
||||
throw (failure(e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Values, including vectors and arbitrary record keys, are bound parameters.
|
||||
*******************************************************************************/
|
||||
private void upsert(Connection connection, List<ChunkVector> chunks) throws SQLException
|
||||
{
|
||||
String sql = "INSERT INTO " + table() + " (source_id, table_name, record_pk, field_name, chunk_index, content_hash, embedding) VALUES (?, ?, ?, ?, ?, ?, ?::vector) ON CONFLICT (source_id, table_name, record_pk, field_name, chunk_index) DO UPDATE SET content_hash = EXCLUDED.content_hash, embedding = EXCLUDED.embedding, indexed_at = CURRENT_TIMESTAMP";
|
||||
try(PreparedStatement statement = connection.prepareStatement(sql))
|
||||
{
|
||||
for(ChunkVector chunk : chunks)
|
||||
{
|
||||
statement.setInt(1, chunk.source());
|
||||
statement.setString(2, chunk.table());
|
||||
statement.setString(3, chunk.pk());
|
||||
statement.setString(4, chunk.field());
|
||||
statement.setInt(5, chunk.chunkIndex());
|
||||
statement.setString(6, chunk.contentHash());
|
||||
statement.setString(7, vector(chunk.embedding()));
|
||||
statement.addBatch();
|
||||
}
|
||||
statement.executeBatch();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A record replacement is transactional, including empty replacements.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void replaceRecord(Integer source, String table, String pk, List<ChunkVector> chunks) throws QException
|
||||
{
|
||||
for(ChunkVector chunk : chunks)
|
||||
{
|
||||
vector(chunk.embedding());
|
||||
if(!source.equals(chunk.source()) || !table.equals(chunk.table()) || !pk.equals(chunk.pk()))
|
||||
{
|
||||
throw (new IllegalArgumentException("Replacement chunks must belong to the specified record."));
|
||||
}
|
||||
}
|
||||
try(Connection connection = connections.open())
|
||||
{
|
||||
Boolean autoCommit = connection.getAutoCommit();
|
||||
connection.setAutoCommit(false);
|
||||
try
|
||||
{
|
||||
deleteRecord(connection, source, table, pk);
|
||||
upsert(connection, chunks);
|
||||
connection.commit();
|
||||
}
|
||||
catch(SQLException e)
|
||||
{
|
||||
connection.rollback();
|
||||
throw (e);
|
||||
}
|
||||
finally
|
||||
{
|
||||
connection.setAutoCommit(autoCommit);
|
||||
}
|
||||
}
|
||||
catch(SQLException e)
|
||||
{
|
||||
throw (failure(e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Remove one record, always scoped to its source.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void deleteRecord(Integer source, String table, String pk) throws QException
|
||||
{
|
||||
try(Connection connection = connections.open())
|
||||
{
|
||||
deleteRecord(connection, source, table, pk);
|
||||
}
|
||||
catch(SQLException e)
|
||||
{
|
||||
throw (failure(e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared transactional delete statement.
|
||||
*******************************************************************************/
|
||||
private void deleteRecord(Connection connection, Integer source, String table, String pk) throws SQLException
|
||||
{
|
||||
try(PreparedStatement statement = connection.prepareStatement("DELETE FROM " + table() + " WHERE source_id = ? AND table_name = ? AND record_pk = ?"))
|
||||
{
|
||||
statement.setInt(1, source);
|
||||
statement.setString(2, table);
|
||||
statement.setString(3, pk);
|
||||
statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Clear a source, preserving every other source in the instance.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void deleteSource(Integer source) throws QException
|
||||
{
|
||||
try(Connection connection = connections.open(); PreparedStatement statement = connection.prepareStatement("DELETE FROM " + table() + " WHERE source_id = ?"))
|
||||
{
|
||||
statement.setInt(1, source);
|
||||
statement.executeUpdate();
|
||||
}
|
||||
catch(SQLException e)
|
||||
{
|
||||
throw (failure(e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** HNSW cosine ordering; tables are values, never interpolated SQL identifiers.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Hit> search(float[] query, Integer k, Set<String> tables) throws QException
|
||||
{
|
||||
return (search(query, k, tables, Set.of()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply enabled compatible source restrictions before the nearest-hit limit.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Hit> search(float[] query, Integer k, Set<String> tables, Set<Integer> sources) throws QException
|
||||
{
|
||||
if(tables.isEmpty() || k <= 0)
|
||||
{
|
||||
return (List.of());
|
||||
}
|
||||
String vector = vector(query);
|
||||
String sql = "SELECT source_id, table_name, record_pk, field_name, chunk_index, 1 - (embedding <=> ?::vector) AS score FROM " + table()
|
||||
+ " WHERE table_name IN (" + String.join(", ", Collections.nCopies(tables.size(), "?")) + ")"
|
||||
+ (sources.isEmpty() ? "" : " AND source_id IN (" + String.join(", ", Collections.nCopies(sources.size(), "?")) + ")")
|
||||
+ " ORDER BY embedding <=> ?::vector LIMIT ?";
|
||||
try(Connection connection = connections.open(); PreparedStatement statement = connection.prepareStatement(sql))
|
||||
{
|
||||
Integer parameter = 1;
|
||||
statement.setString(parameter++, vector);
|
||||
for(String table : tables)
|
||||
{
|
||||
statement.setString(parameter++, table);
|
||||
}
|
||||
for(Integer source : sources)
|
||||
{
|
||||
statement.setInt(parameter++, source);
|
||||
}
|
||||
statement.setString(parameter++, vector);
|
||||
statement.setInt(parameter, k);
|
||||
List<Hit> hits = new ArrayList<>();
|
||||
try(ResultSet rows = statement.executeQuery())
|
||||
{
|
||||
while(rows.next())
|
||||
{
|
||||
Double score = rows.getDouble("score");
|
||||
if(Double.isFinite(score))
|
||||
{
|
||||
hits.add(new Hit(rows.getInt("source_id"), rows.getString("table_name"), rows.getString("record_pk"), rows.getString("field_name"), rows.getInt("chunk_index"), score));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (hits);
|
||||
}
|
||||
catch(SQLException e)
|
||||
{
|
||||
throw (failure(e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Count chunks for the source's status screen.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Long count(Integer source) throws QException
|
||||
{
|
||||
try(Connection connection = connections.open(); PreparedStatement statement = connection.prepareStatement("SELECT COUNT(*) FROM " + table() + " WHERE source_id = ?"))
|
||||
{
|
||||
statement.setInt(1, source);
|
||||
try(ResultSet rows = statement.executeQuery())
|
||||
{
|
||||
rows.next();
|
||||
return (rows.getLong(1));
|
||||
}
|
||||
}
|
||||
catch(SQLException e)
|
||||
{
|
||||
throw (failure(e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Content-free reconciliation data. Embedding text is pgvector's numeric form.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<ChunkVector> chunks(Integer source) throws QException
|
||||
{
|
||||
try(Connection connection = connections.open(); PreparedStatement statement = connection.prepareStatement("SELECT table_name, record_pk, field_name, chunk_index, content_hash, embedding::text FROM " + table() + " WHERE source_id = ?"))
|
||||
{
|
||||
statement.setInt(1, source);
|
||||
List<ChunkVector> chunks = new ArrayList<>();
|
||||
try(ResultSet rows = statement.executeQuery())
|
||||
{
|
||||
while(rows.next())
|
||||
{
|
||||
chunks.add(new ChunkVector(source, rows.getString("table_name"), rows.getString("record_pk"), rows.getString("field_name"), rows.getInt("chunk_index"), rows.getString("content_hash"), parse(rows.getString("embedding"))));
|
||||
}
|
||||
}
|
||||
return (chunks);
|
||||
}
|
||||
catch(SQLException e)
|
||||
{
|
||||
throw (failure(e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Qualified name uses only the validated instance schema.
|
||||
*******************************************************************************/
|
||||
private String table()
|
||||
{
|
||||
return (schema + ".assistant_rag_chunk");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Serialize only finite coordinates with the configured dimension.
|
||||
*******************************************************************************/
|
||||
private String vector(float[] vector)
|
||||
{
|
||||
if(vector == null || vector.length != dimensions)
|
||||
{
|
||||
throw (new IllegalArgumentException("Vector dimensions differ from the configured pgvector schema."));
|
||||
}
|
||||
StringJoiner values = new StringJoiner(",", "[", "]");
|
||||
for(float value : vector)
|
||||
{
|
||||
if(!Float.isFinite(value))
|
||||
{
|
||||
throw (new IllegalArgumentException("Vector coordinates must be finite."));
|
||||
}
|
||||
values.add(Float.toString(value));
|
||||
}
|
||||
return (values.toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse the database's numeric representation without any source content.
|
||||
*******************************************************************************/
|
||||
private float[] parse(String text)
|
||||
{
|
||||
String[] parts = text.substring(1, text.length() - 1).split(",");
|
||||
float[] vector = new float[parts.length];
|
||||
for(Integer i = 0; i < parts.length; i++)
|
||||
{
|
||||
vector[i] = Float.parseFloat(parts[i]);
|
||||
}
|
||||
vector(vector);
|
||||
return (vector);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Do not expose database URLs, SQL values or credentials in tool responses.
|
||||
*******************************************************************************/
|
||||
private static QException failure(SQLException cause)
|
||||
{
|
||||
return (new QException("The knowledge vector store is unavailable. Check its schema, dimensions and database permissions.", cause));
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.rag.pgvector;
|
||||
|
||||
|
||||
import java.sql.DriverManager;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.assistant.rag.ChunkVector;
|
||||
import com.kingsrook.qbits.assistant.rag.TextChunks;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
import org.testcontainers.utility.DockerImageName;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Real pgvector contract; skipped automatically on hosts without Docker.
|
||||
*******************************************************************************/
|
||||
@Testcontainers(disabledWithoutDocker = true)
|
||||
class PgVectorContainerTest
|
||||
{
|
||||
@Container
|
||||
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>(DockerImageName.parse("pgvector/pgvector:pg16").asCompatibleSubstituteFor("postgres"));
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** DDL idempotence, cosine ranking, upsert, deletes and instance isolation.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void realVectorLifecycle() throws Exception
|
||||
{
|
||||
PgVectorStore.ConnectionSupplier connections = () -> DriverManager.getConnection(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword());
|
||||
PgVectorStore store = new PgVectorStore(connections, 3).withSchema("instance_one");
|
||||
PgVectorStore other = new PgVectorStore(connections, 3).withSchema("instance_two");
|
||||
store.ensureSchema();
|
||||
store.ensureSchema();
|
||||
other.ensureSchema();
|
||||
ChunkVector first = new ChunkVector(1, "article", "1", "body", 0, TextChunks.hash("one"), new float[] { 1, 0, 0 });
|
||||
ChunkVector second = new ChunkVector(1, "article", "2", "body", 0, TextChunks.hash("two"), new float[] { 0, 1, 0 });
|
||||
store.upsert(List.of(first, second));
|
||||
store.upsert(List.of(first));
|
||||
assertThat(store.count(1)).isEqualTo(2);
|
||||
assertThat(other.count(1)).isZero();
|
||||
assertThat(store.search(new float[] { 1, 0, 0 }, 1, Set.of("article"))).singleElement().satisfies(hit ->
|
||||
{
|
||||
assertThat(hit.pk()).isEqualTo("1");
|
||||
assertThat(hit.score()).isEqualTo(1.0);
|
||||
});
|
||||
assertThat(store.chunks(1)).hasSize(2);
|
||||
store.replaceRecord(1, "article", "1", List.of());
|
||||
assertThat(store.count(1)).isEqualTo(1);
|
||||
store.deleteRecord(1, "article", "2");
|
||||
assertThat(store.count(1)).isZero();
|
||||
store.upsert(List.of(first));
|
||||
store.deleteSource(1);
|
||||
assertThat(store.count(1)).isZero();
|
||||
}
|
||||
}
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.rag.pgvector;
|
||||
|
||||
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import javax.sql.DataSource;
|
||||
import com.kingsrook.qbits.assistant.rag.ChunkVector;
|
||||
import com.kingsrook.qbits.assistant.rag.TextChunks;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Always-on JDBC SQL-shape and transaction tests, with no Docker dependency.
|
||||
*******************************************************************************/
|
||||
class PgVectorStoreTest
|
||||
{
|
||||
private final List<String> sql = new ArrayList<>();
|
||||
private final List<Map<Integer, Object>> parameters = new ArrayList<>();
|
||||
private final List<String> operations = new ArrayList<>();
|
||||
private Boolean extensionExists = true;
|
||||
private Boolean failBatch = false;
|
||||
private Boolean failExtension = false;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** JDBC recording connection; values never appear in SQL text.
|
||||
*******************************************************************************/
|
||||
private Connection connection()
|
||||
{
|
||||
return ((Connection) Proxy.newProxyInstance(getClass().getClassLoader(), new Class<?>[] { Connection.class }, (proxy, method, args) ->
|
||||
{
|
||||
operations.add(method.getName());
|
||||
if(method.getName().equals("prepareStatement"))
|
||||
{
|
||||
return (statement((String) args[0]));
|
||||
}
|
||||
return (method.getName().equals("getAutoCommit") ? true : null);
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Capture each binding and emulate rows or SQL failures.
|
||||
*******************************************************************************/
|
||||
private PreparedStatement statement(String query)
|
||||
{
|
||||
sql.add(query);
|
||||
Map<Integer, Object> values = new HashMap<>();
|
||||
parameters.add(values);
|
||||
return ((PreparedStatement) Proxy.newProxyInstance(getClass().getClassLoader(), new Class<?>[] { PreparedStatement.class }, (proxy, method, args) ->
|
||||
{
|
||||
if(method.getName().startsWith("set"))
|
||||
{
|
||||
values.put((Integer) args[0], args[1]);
|
||||
}
|
||||
if(method.getName().equals("executeQuery"))
|
||||
{
|
||||
return (rows(query));
|
||||
}
|
||||
if(method.getName().equals("executeBatch"))
|
||||
{
|
||||
if(failBatch)
|
||||
{
|
||||
throw (new SQLException("synthetic batch failure"));
|
||||
}
|
||||
return (new int[] { 1 });
|
||||
}
|
||||
if(method.getName().equals("executeUpdate"))
|
||||
{
|
||||
return (1);
|
||||
}
|
||||
if(method.getName().equals("execute"))
|
||||
{
|
||||
if(failExtension && query.startsWith("CREATE EXTENSION"))
|
||||
{
|
||||
throw (new SQLException("extension privilege required"));
|
||||
}
|
||||
return (true);
|
||||
}
|
||||
return (null);
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One content-free result; the extension probe can return no rows.
|
||||
*******************************************************************************/
|
||||
private ResultSet rows(String query)
|
||||
{
|
||||
Boolean[] first = { true };
|
||||
return ((ResultSet) Proxy.newProxyInstance(getClass().getClassLoader(), new Class<?>[] { ResultSet.class }, (proxy, method, args) ->
|
||||
{
|
||||
if(method.getName().equals("next"))
|
||||
{
|
||||
Boolean next = first[0] && (!query.contains("pg_extension") || extensionExists);
|
||||
first[0] = false;
|
||||
return (next);
|
||||
}
|
||||
return (switch(method.getName())
|
||||
{
|
||||
case "getString" -> switch(String.valueOf(args[0]))
|
||||
{
|
||||
case "content_hash" -> TextChunks.hash("synthetic");
|
||||
case "embedding" -> "[1,0,0]";
|
||||
case "record_pk" -> "pk'; --";
|
||||
case "field_name" -> "body";
|
||||
default -> "article";
|
||||
};
|
||||
case "getInt" -> "chunk_index".equals(args[0]) ? 0 : 1;
|
||||
case "getLong" -> 1L;
|
||||
case "getDouble" -> 1.0;
|
||||
default -> null;
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Exercise every statement with values that would be unsafe to interpolate.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void statementsAreBoundAndContentFree() throws Exception
|
||||
{
|
||||
PgVectorStore store = new PgVectorStore(this::connection, 3).withSchema("instance_one");
|
||||
store.ensureSchema();
|
||||
assertThat(sql).anyMatch(s -> s.contains("embedding vector(3)"));
|
||||
assertThat(sql).anyMatch(s -> s.contains("USING hnsw (embedding vector_cosine_ops)"));
|
||||
assertThat(sql).noneMatch(s -> s.contains("CREATE EXTENSION"));
|
||||
ChunkVector chunk = new ChunkVector(1, "article", "pk'; --", "body", 0, TextChunks.hash("synthetic"), new float[] { 1, 0, 0 });
|
||||
store.upsert(List.of(chunk));
|
||||
store.replaceRecord(1, "article", "pk'; --", List.of(chunk));
|
||||
assertThat(store.search(new float[] { 1, 0, 0 }, 8, Set.of("article", "injection'; --"))).hasSize(1);
|
||||
assertThat(store.search(new float[] { 1, 0, 0 }, 8, Set.of("article"), Set.of(1))).hasSize(1);
|
||||
assertThat(store.chunks(1)).singleElement().satisfies(v -> assertThat(v.embedding()).containsExactly(1, 0, 0));
|
||||
assertThat(store.count(1)).isEqualTo(1);
|
||||
store.deleteRecord(1, "article", "pk'; --");
|
||||
store.deleteSource(1);
|
||||
assertThat(sql).anyMatch(s -> s.contains("?::vector") && s.contains("ON CONFLICT"));
|
||||
assertThat(sql).anyMatch(s -> s.contains("ORDER BY embedding <=> ?::vector LIMIT ?"));
|
||||
assertThat(sql).noneMatch(s -> s.contains("pk'; --") || s.contains("injection'; --") || s.contains("synthetic"));
|
||||
assertThat(parameters).anyMatch(p -> p.containsValue("pk'; --"));
|
||||
assertThat(parameters).anyMatch(p -> p.containsValue("injection'; --"));
|
||||
assertThat(operations).contains("commit", "close", "setAutoCommit");
|
||||
assertThat(store.search(new float[] { 1, 0, 0 }, 8, Set.of())).isEmpty();
|
||||
assertThat(store.search(new float[] { 1, 0, 0 }, 0, Set.of("article"))).isEmpty();
|
||||
DataSource dataSource = (DataSource) Proxy.newProxyInstance(getClass().getClassLoader(), new Class<?>[] { DataSource.class }, (p, method, args) -> connection());
|
||||
assertThat(new PgVectorStore(dataSource, 3).count(1)).isEqualTo(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** DBA failure is actionable; failed replacement rolls back before closing.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void extensionAndRollback() throws Exception
|
||||
{
|
||||
extensionExists = false;
|
||||
PgVectorStore store = new PgVectorStore(this::connection, 3);
|
||||
store.ensureSchema();
|
||||
assertThat(sql).contains("CREATE EXTENSION IF NOT EXISTS vector");
|
||||
failExtension = true;
|
||||
assertThatThrownBy(store::ensureSchema).hasMessageContaining("DBA");
|
||||
failBatch = true;
|
||||
assertThatThrownBy(() -> store.replaceRecord(1, "article", "1", List.of())).hasMessageContaining("vector store");
|
||||
assertThat(operations).contains("rollback");
|
||||
PgVectorStore down = new PgVectorStore(() ->
|
||||
{
|
||||
throw (new SQLException("offline"));
|
||||
}, 3);
|
||||
assertThatThrownBy(down::ensureSchema).hasMessageContaining("vector store");
|
||||
assertThatThrownBy(() -> down.count(1)).hasMessageContaining("vector store");
|
||||
assertThatThrownBy(() -> down.chunks(1)).hasMessageContaining("vector store");
|
||||
assertThatThrownBy(() -> down.upsert(List.of())).hasMessageContaining("vector store");
|
||||
assertThatThrownBy(() -> down.deleteRecord(1, "a", "1")).hasMessageContaining("vector store");
|
||||
assertThatThrownBy(() -> down.deleteSource(1)).hasMessageContaining("vector store");
|
||||
assertThatThrownBy(() -> down.search(new float[] { 1, 0, 0 }, 8, Set.of("a"))).hasMessageContaining("vector store");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Identifier and coordinate validation happens before opening a connection.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void invalidInputsNeverReachJdbc()
|
||||
{
|
||||
PgVectorStore store = new PgVectorStore(this::connection, 3);
|
||||
for(String name : List.of("a.b", "bad; DROP SCHEMA public", "UPPER", "a".repeat(64)))
|
||||
{
|
||||
assertThatThrownBy(() -> store.withSchema(name)).isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
assertThatThrownBy(() -> new PgVectorStore(this::connection, 2001)).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> store.search(new float[] { 1 }, 1, Set.of("a"))).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> store.search(new float[] { Float.NaN, 0, 0 }, 1, Set.of("a"))).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThat(operations).isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -28,10 +28,14 @@
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>QBit Assistant</name>
|
||||
<description>In-app AI assistant QBit for QQQ: conversations, turn engine, SSE routes, budgets, provider SPI</description>
|
||||
<description>In-app AI assistant QBit for QQQ: conversations, turn engine, SSE routes, budgets and provider registry</description>
|
||||
<url>https://github.com/QRun-IO/qbit-assistant</url>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-assistant-provider-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-core</artifactId>
|
||||
|
||||
+3
-3
@@ -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
|
||||
{
|
||||
|
||||
@@ -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";
|
||||
@@ -107,6 +110,9 @@ public class AssistantQBitConfig implements QBitConfig
|
||||
private Integer policyCacheSeconds = 30;
|
||||
private String ownerSecurityKeyType = DEFAULT_OWNER_KEY_TYPE;
|
||||
private String shortcut = DEFAULT_SHORTCUT;
|
||||
private Set<String> allowedScheduledCredentialEnvNames = new LinkedHashSet<>();
|
||||
private String scheduledTasksSchedulerName;
|
||||
|
||||
private String retentionSchedulerName;
|
||||
private Boolean registerRoutes = true;
|
||||
private Map<String, ModelPricing> pricing = ModelPricing.defaults();
|
||||
@@ -130,6 +136,7 @@ public class AssistantQBitConfig implements QBitConfig
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
private String uiApiBaseUrl;
|
||||
private UiApiGateway uiApiGateway;
|
||||
private com.kingsrook.qbits.assistant.tools.bulkload.BulkLoadRunner bulkLoadRunner;
|
||||
private Integer pendingActionTtlSeconds = 900;
|
||||
private List<String> customRedactionPatterns = new ArrayList<>();
|
||||
private Integer killSwitchPollMillis = 2000;
|
||||
@@ -210,6 +217,13 @@ public class AssistantQBitConfig implements QBitConfig
|
||||
requirePositive(errors, "killSwitchPollMillis", killSwitchPollMillis);
|
||||
requirePositive(errors, "providerTestTimeoutSeconds", providerTestTimeoutSeconds);
|
||||
requirePositive(errors, "homeMaxRows", homeMaxRows);
|
||||
for(String name : allowedScheduledCredentialEnvNames == null ? Set.<String>of() : allowedScheduledCredentialEnvNames)
|
||||
{
|
||||
if(name == null || !ENV_NAME.matcher(name).matches())
|
||||
{
|
||||
errors.add("allowedScheduledCredentialEnvNames contains an invalid environment variable name.");
|
||||
}
|
||||
}
|
||||
for(String name : allowedApiKeyEnvNames == null ? Set.<String>of() : allowedApiKeyEnvNames)
|
||||
{
|
||||
if(name == null || !ENV_NAME.matcher(name).matches())
|
||||
@@ -1199,6 +1213,38 @@ public class AssistantQBitConfig implements QBitConfig
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for bulkLoadRunner (null: the durable jobs launch,
|
||||
** JobsLaunchBulkLoadRunner) - how bulk_load_run starts a load.
|
||||
*******************************************************************************/
|
||||
public com.kingsrook.qbits.assistant.tools.bulkload.BulkLoadRunner getBulkLoadRunner()
|
||||
{
|
||||
return (this.bulkLoadRunner);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for bulkLoadRunner
|
||||
*******************************************************************************/
|
||||
public void setBulkLoadRunner(com.kingsrook.qbits.assistant.tools.bulkload.BulkLoadRunner bulkLoadRunner)
|
||||
{
|
||||
this.bulkLoadRunner = bulkLoadRunner;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for bulkLoadRunner
|
||||
*******************************************************************************/
|
||||
public AssistantQBitConfig withBulkLoadRunner(com.kingsrook.qbits.assistant.tools.bulkload.BulkLoadRunner bulkLoadRunner)
|
||||
{
|
||||
this.bulkLoadRunner = bulkLoadRunner;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for uiApiGateway (null: the loopback stand-in)
|
||||
*******************************************************************************/
|
||||
@@ -1788,4 +1834,97 @@ public class AssistantQBitConfig implements QBitConfig
|
||||
configuredClassifications.withClassification(tableName, fieldName, classification);
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for vectorStore
|
||||
*******************************************************************************/
|
||||
public VectorStore getVectorStore()
|
||||
{
|
||||
return (vectorStore);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for vectorStore
|
||||
*******************************************************************************/
|
||||
public void setVectorStore(VectorStore vectorStore)
|
||||
{
|
||||
this.vectorStore = vectorStore;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for vectorStore
|
||||
*******************************************************************************/
|
||||
public AssistantQBitConfig withVectorStore(VectorStore vectorStore)
|
||||
{
|
||||
this.vectorStore = vectorStore;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for allowedScheduledCredentialEnvNames
|
||||
*******************************************************************************/
|
||||
public Set<String> getAllowedScheduledCredentialEnvNames()
|
||||
{
|
||||
return (allowedScheduledCredentialEnvNames);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for allowedScheduledCredentialEnvNames
|
||||
*******************************************************************************/
|
||||
public void setAllowedScheduledCredentialEnvNames(Set<String> allowedScheduledCredentialEnvNames)
|
||||
{
|
||||
this.allowedScheduledCredentialEnvNames = allowedScheduledCredentialEnvNames;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for allowedScheduledCredentialEnvNames
|
||||
*******************************************************************************/
|
||||
public AssistantQBitConfig withAllowedScheduledCredentialEnvNames(Set<String> allowedScheduledCredentialEnvNames)
|
||||
{
|
||||
this.allowedScheduledCredentialEnvNames = allowedScheduledCredentialEnvNames;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for scheduledTasksSchedulerName
|
||||
*******************************************************************************/
|
||||
public String getScheduledTasksSchedulerName()
|
||||
{
|
||||
return (scheduledTasksSchedulerName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for scheduledTasksSchedulerName
|
||||
*******************************************************************************/
|
||||
public void setScheduledTasksSchedulerName(String scheduledTasksSchedulerName)
|
||||
{
|
||||
this.scheduledTasksSchedulerName = scheduledTasksSchedulerName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for scheduledTasksSchedulerName
|
||||
*******************************************************************************/
|
||||
public AssistantQBitConfig withScheduledTasksSchedulerName(String scheduledTasksSchedulerName)
|
||||
{
|
||||
this.scheduledTasksSchedulerName = scheduledTasksSchedulerName;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
+39
-5
@@ -21,13 +21,21 @@ import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
import com.kingsrook.qbits.assistant.api.AssistantRoutes;
|
||||
import com.kingsrook.qbits.assistant.developer.AssistantDeveloperPanels;
|
||||
import com.kingsrook.qbits.assistant.home.AssistantAccessWidget;
|
||||
import com.kingsrook.qbits.assistant.home.AssistantHome;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantInstanceMetaData;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantRecordScreens;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantBudget;
|
||||
import com.kingsrook.qbits.assistant.processes.DiscoverModelsProcess;
|
||||
import com.kingsrook.qbits.assistant.processes.KnowledgeIndexProcess;
|
||||
import com.kingsrook.qbits.assistant.processes.ScheduledTaskProcess;
|
||||
import com.kingsrook.qbits.assistant.processes.TestConnectionProcess;
|
||||
import com.kingsrook.qbits.assistant.processes.TestModelConnectionProcess;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
|
||||
@@ -155,6 +163,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);
|
||||
@@ -167,12 +178,23 @@ public class AssistantQBitProducer implements QBitMetaDataProducer<AssistantQBit
|
||||
QProcessMetaData testConnection = TestConnectionProcess.process();
|
||||
testConnection.setSourceQBitName(qBitMetaData.getName());
|
||||
qInstance.addProcess(testConnection);
|
||||
for(QProcessMetaData process : java.util.List.of(DiscoverModelsProcess.process(), TestModelConnectionProcess.process()))
|
||||
for(QProcessMetaData process : List.of(DiscoverModelsProcess.process(), TestModelConnectionProcess.process()))
|
||||
{
|
||||
process.setSourceQBitName(qBitMetaData.getName());
|
||||
qInstance.addProcess(process);
|
||||
}
|
||||
|
||||
for(QProcessMetaData process : KnowledgeIndexProcess.processes())
|
||||
{
|
||||
process.setSourceQBitName(qBitMetaData.getName());
|
||||
qInstance.addProcess(process);
|
||||
}
|
||||
|
||||
for(QProcessMetaData process : ScheduledTaskProcess.processes(qBitConfig))
|
||||
{
|
||||
process.setSourceQBitName(qBitMetaData.getName());
|
||||
qInstance.addProcess(process);
|
||||
}
|
||||
qInstance.addWidget(AssistantHome.widget());
|
||||
qInstance.addWidget(AssistantAccessWidget.widget());
|
||||
|
||||
@@ -185,6 +207,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.
|
||||
*******************************************************************************/
|
||||
@@ -201,19 +235,19 @@ public class AssistantQBitProducer implements QBitMetaDataProducer<AssistantQBit
|
||||
boolean routes = false;
|
||||
if(Boolean.TRUE.equals(qBitConfig.getRegisterRoutes()) && isJavalinAvailable())
|
||||
{
|
||||
com.kingsrook.qbits.assistant.api.AssistantRoutes.register(qInstance);
|
||||
AssistantRoutes.register(qInstance);
|
||||
routes = true;
|
||||
}
|
||||
|
||||
if(qBitConfig.isEffectivelyEnabled())
|
||||
{
|
||||
com.kingsrook.qbits.assistant.metadata.AssistantInstanceMetaData.register(qInstance);
|
||||
AssistantInstanceMetaData.register(qInstance);
|
||||
}
|
||||
|
||||
boolean developerPanel = false;
|
||||
if(com.kingsrook.qbits.assistant.developer.AssistantDeveloperPanels.isContractAvailable())
|
||||
if(AssistantDeveloperPanels.isContractAvailable())
|
||||
{
|
||||
com.kingsrook.qbits.assistant.developer.AssistantDeveloperPanels.register(qInstance);
|
||||
AssistantDeveloperPanels.register(qInstance);
|
||||
developerPanel = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ 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;
|
||||
import com.kingsrook.qbits.assistant.engine.ContextBuilder;
|
||||
import com.kingsrook.qbits.assistant.engine.PolicyService;
|
||||
import com.kingsrook.qbits.assistant.engine.ProfileService;
|
||||
@@ -32,13 +33,22 @@ import com.kingsrook.qbits.assistant.engine.ProviderService;
|
||||
import com.kingsrook.qbits.assistant.engine.RateLimiter;
|
||||
import com.kingsrook.qbits.assistant.engine.RunningTurn;
|
||||
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
|
||||
import com.kingsrook.qbits.assistant.rag.InMemoryVectorStore;
|
||||
import com.kingsrook.qbits.assistant.rag.RagService;
|
||||
import com.kingsrook.qbits.assistant.rag.VectorStore;
|
||||
import com.kingsrook.qbits.assistant.scheduled.ScheduledTurn;
|
||||
import com.kingsrook.qbits.assistant.tools.KnowledgeTool;
|
||||
import com.kingsrook.qbits.assistant.tools.bulkload.BulkLoadTools;
|
||||
import com.kingsrook.qbits.assistant.tools.UiApiToolbox;
|
||||
import com.kingsrook.qbits.assistant.uiapi.LoopbackUiApiGateway;
|
||||
import com.kingsrook.qbits.assistant.uiapi.UiApiCall;
|
||||
import com.kingsrook.qbits.assistant.uiapi.UiApiGateway;
|
||||
import com.kingsrook.qbits.assistant.uiapi.UiApiResult;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiToolKernel;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiToolKernelSettings;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.exposure.AiExposurePolicy;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.output.AiRedactor;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
@@ -51,12 +61,19 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
*******************************************************************************/
|
||||
public class AssistantRuntime
|
||||
{
|
||||
private final ScheduledTurn scheduledTurn = new ScheduledTurn();
|
||||
|
||||
private final RagService ragService;
|
||||
private final KnowledgeTool knowledgeTool;
|
||||
private final BulkLoadTools bulkLoadTools;
|
||||
|
||||
private final QInstance qInstance;
|
||||
private final AssistantQBitConfig config;
|
||||
private final Clock clock;
|
||||
private final AssistantStore store;
|
||||
private final PolicyService policyService;
|
||||
private final ProviderService providerService;
|
||||
private final BudgetService budgetService;
|
||||
private final RateLimiter rateLimiter;
|
||||
private final AiToolKernel kernel;
|
||||
private final ContextBuilder contextBuilder;
|
||||
@@ -92,6 +109,8 @@ public class AssistantRuntime
|
||||
this.store = new AssistantStore(config);
|
||||
this.policyService = new PolicyService(config, store, clock);
|
||||
this.providerService = new ProviderService(config, store, clock);
|
||||
this.budgetService = new BudgetService(store, providerService, clock);
|
||||
this.policyService.withBudgetService(budgetService, providerService);
|
||||
this.rateLimiter = new RateLimiter(clock);
|
||||
AiToolKernelSettings settings = kernelSettings(config);
|
||||
this.kernel = new AiToolKernel(qInstance, settings);
|
||||
@@ -101,8 +120,41 @@ public class AssistantRuntime
|
||||
this.accessExplainer = new AccessExplainer(qInstance, dataAccessService);
|
||||
this.toolbox = new UiApiToolbox(qInstance, dataAccessService, exposurePolicy, new AiRedactor(settings.getCustomRedactionPatterns()),
|
||||
config.getMaxCellChars() == null ? AiToolKernelSettings.DEFAULT_MAX_CELL_CHARS : config.getMaxCellChars());
|
||||
this.uiApiGateway = config.getUiApiGateway() != null ? config.getUiApiGateway() : new LoopbackUiApiGateway(this::uiApiBaseUrl, qInstance);
|
||||
this.contextBuilder = new ContextBuilder(config, kernel, toolbox);
|
||||
UiApiGateway delegate = config.getUiApiGateway() != null ? config.getUiApiGateway() : new LoopbackUiApiGateway(this::uiApiBaseUrl, qInstance);
|
||||
this.uiApiGateway = new UiApiGateway()
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Delegate using this instance's configured collaborator.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean isInvocable(String method, String pathTemplate)
|
||||
{
|
||||
return (delegate.isInvocable(method, pathTemplate));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Delegate using this instance's configured collaborator.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public UiApiResult invoke(UiApiCall call) throws QException
|
||||
{
|
||||
String channel = scheduledTurn.channel(call.getSessionUUID());
|
||||
if(channel != null)
|
||||
{
|
||||
if(delegate instanceof LoopbackUiApiGateway loopback)
|
||||
{
|
||||
return (loopback.invokeWithClient(call, channel));
|
||||
}
|
||||
throw (new QException("Scheduled tasks require the loopback gateway for client attribution."));
|
||||
}
|
||||
return (delegate.invoke(call));
|
||||
}
|
||||
};
|
||||
VectorStore vectors = config.getVectorStore() == null ? new InMemoryVectorStore() : config.getVectorStore();
|
||||
this.ragService = new RagService(this, vectors);
|
||||
this.knowledgeTool = new KnowledgeTool(this, ragService, vectors);
|
||||
this.bulkLoadTools = new BulkLoadTools(qInstance, dataAccessService, config.getBulkLoadRunner());
|
||||
this.contextBuilder = new ContextBuilder(config, kernel, toolbox).withKnowledgeTool(knowledgeTool).withBulkLoadTools(bulkLoadTools);
|
||||
}
|
||||
|
||||
|
||||
@@ -405,4 +457,49 @@ public class AssistantRuntime
|
||||
return (providerService);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Data-driven budget enforcement.
|
||||
*******************************************************************************/
|
||||
public BudgetService getBudgetService()
|
||||
{
|
||||
return (budgetService);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Manual indexing service, always using the current caller's session.
|
||||
*******************************************************************************/
|
||||
public RagService getRagService()
|
||||
{
|
||||
return (ragService);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Permission-filtered retrieval tool.
|
||||
*******************************************************************************/
|
||||
public KnowledgeTool getKnowledgeTool()
|
||||
{
|
||||
return (knowledgeTool);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Server-owned scheduled execution context.
|
||||
*******************************************************************************/
|
||||
public ScheduledTurn getScheduledTurn()
|
||||
{
|
||||
return (scheduledTurn);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The bulk-load tools (saved profiles, attached files, dry runs, loads).
|
||||
*******************************************************************************/
|
||||
public BulkLoadTools getBulkLoadTools()
|
||||
{
|
||||
return (bulkLoadTools);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* 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.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Budget signals through core's non-throwing Alerts API and default logging sink.
|
||||
*******************************************************************************/
|
||||
public final class BudgetAlerts
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private BudgetAlerts()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Raise one deduplicated budget transition through the configured core sink.
|
||||
*******************************************************************************/
|
||||
public static void raise(String type, String dedupKey, AlertSeverity severity, String title, String summary)
|
||||
{
|
||||
QAlerts.raise(new AlertSignal(type).withDedupKey(dedupKey).withSeverity(severity).withTitle(title).withSummary(summary));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
/*
|
||||
* 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.AssistantBudget;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantBudgetEvent;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantBudgetOverride;
|
||||
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;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Data-driven budgets layered on the existing policy and provider caps.
|
||||
** Evaluation occurs at dispatch boundaries, never during streaming tokens.
|
||||
*******************************************************************************/
|
||||
public class BudgetService
|
||||
{
|
||||
private final AssistantStore store;
|
||||
private final ProviderService providers;
|
||||
private final Clock clock;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read-only budget snapshot, also used by the app home.
|
||||
*******************************************************************************/
|
||||
public record Snapshot(AssistantBudget budget, String scopeKey, Instant periodStart, BigDecimal used, BigDecimal limit, BigDecimal percent, String state, List<AssistantBudgetOverride> overrides)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Strictest decision and newly raised notices.
|
||||
*******************************************************************************/
|
||||
public record Decision(boolean stopped, String reason, ProviderService.Active active, List<String> notices)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A safe terminal budget error, distinct from provider failures.
|
||||
*******************************************************************************/
|
||||
public static class Blocked extends QException
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Name the budget that refused this dispatch.
|
||||
*******************************************************************************/
|
||||
public Blocked(String reason)
|
||||
{
|
||||
super(reason);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Production constructor.
|
||||
*******************************************************************************/
|
||||
public BudgetService(AssistantStore store, ProviderService providers, Clock clock)
|
||||
{
|
||||
this.store = store;
|
||||
this.providers = providers;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Evaluate all applicable budgets. STOP wins; a downgrade is checked against
|
||||
** the original and target scopes so switching cannot evade another budget.
|
||||
*******************************************************************************/
|
||||
public Decision evaluate(String userId, ProviderService.Active active, boolean requiresHipaa, boolean preflight) throws QException
|
||||
{
|
||||
List<String> notices = new ArrayList<>();
|
||||
ProviderService.Active selected = active;
|
||||
List<AssistantBudget> budgets = store.budgets();
|
||||
List<Integer> applied = new ArrayList<>();
|
||||
List<Snapshot> pending = new ArrayList<>();
|
||||
List<String> messages = new ArrayList<>();
|
||||
for(int pass = 0; pass <= budgets.size(); pass++)
|
||||
{
|
||||
boolean changed = false;
|
||||
for(AssistantBudget budget : budgets)
|
||||
{
|
||||
if(applied.contains(budget.getId()) || (!applies(budget, userId, active) && !applies(budget, userId, selected)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
applied.add(budget.getId());
|
||||
Snapshot snapshot = snapshot(budget, userId);
|
||||
String state = snapshot.state();
|
||||
String message = null;
|
||||
ProviderService.Active target = null;
|
||||
if("DOWNGRADED".equals(state))
|
||||
{
|
||||
target = providers.budgetDowngrade(budget.getDowngradeModelId(), requiresHipaa, active);
|
||||
if(target == null)
|
||||
{
|
||||
state = "STOPPED";
|
||||
}
|
||||
else
|
||||
{
|
||||
message = "Budget reached: switched to " + target.model() + " for the rest of the period.";
|
||||
}
|
||||
}
|
||||
if("STOPPED".equals(state))
|
||||
{
|
||||
message = "Assistant budget '" + budget.getName() + "' has been reached. Ask an administrator for an override.";
|
||||
}
|
||||
if("WARNED".equals(state))
|
||||
{
|
||||
message = "You have used " + snapshot.percent().setScale(0, RoundingMode.DOWN) + "% of the "
|
||||
+ ("DAY".equals(budget.getPeriod()) ? "daily" : "monthly") + " assistant budget '" + budget.getName() + "'.";
|
||||
}
|
||||
if("STOPPED".equals(state))
|
||||
{
|
||||
if(transition(snapshot, state, userId, message))
|
||||
{
|
||||
notices.add(message);
|
||||
}
|
||||
return (new Decision(true, message, selected, notices));
|
||||
}
|
||||
if(message != null && (!preflight || !"DOWNGRADED".equals(state)))
|
||||
{
|
||||
pending.add(snapshot);
|
||||
messages.add(message);
|
||||
}
|
||||
if(target != null)
|
||||
{
|
||||
selected = target;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if(!changed)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
for(int i = 0; i < pending.size(); i++)
|
||||
{
|
||||
Snapshot snapshot = pending.get(i);
|
||||
String message = "DOWNGRADED".equals(snapshot.state()) ? "Budget reached: switched to " + selected.model() + " for the rest of the period." : messages.get(i);
|
||||
if(transition(snapshot, snapshot.state(), userId, message))
|
||||
{
|
||||
notices.add(message);
|
||||
}
|
||||
}
|
||||
return (new Decision(false, null, selected, notices));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the caller and selected provider/model are within this scope.
|
||||
*******************************************************************************/
|
||||
private boolean applies(AssistantBudget budget, String userId, ProviderService.Active active) throws QException
|
||||
{
|
||||
return (switch(budget.getScopeType())
|
||||
{
|
||||
case "INSTANCE" -> true;
|
||||
case "USER" -> Objects.equals(budget.getScopeValue(), userId);
|
||||
case "ROLE" -> QContext.getQSession().hasPermission(budget.getScopeValue());
|
||||
case "PROVIDER" -> active != null && Objects.equals(budget.getScopeValue(), String.valueOf(active.providerId()));
|
||||
case "MODEL" -> modelMatches(budget, active);
|
||||
default -> false;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Model scope is a registry row id, never an ambiguous cross-provider name.
|
||||
*******************************************************************************/
|
||||
private boolean modelMatches(AssistantBudget budget, ProviderService.Active active) throws QException
|
||||
{
|
||||
AssistantModel model = scopedModel(budget);
|
||||
return (model != null && active != null && Objects.equals(model.getProviderId(), active.providerId()) && Objects.equals(model.getModelId(), active.model()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve the scoped registry model.
|
||||
*******************************************************************************/
|
||||
private AssistantModel scopedModel(AssistantBudget budget) throws QException
|
||||
{
|
||||
return (store.listModels().stream().filter(m -> String.valueOf(m.getId()).equals(budget.getScopeValue())).findFirst().orElse(null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** UTC period, usage, active overrides and effective state, without events.
|
||||
** ROLE allowances are per permission holder, evaluated with their session.
|
||||
*******************************************************************************/
|
||||
public Snapshot snapshot(AssistantBudget budget, String userId) throws QException
|
||||
{
|
||||
userId = "USER".equals(budget.getScopeType()) ? budget.getScopeValue() : userId;
|
||||
Instant now = clock.instant();
|
||||
LocalDate day = LocalDate.ofInstant(now, ZoneOffset.UTC);
|
||||
LocalDate start = "MONTH".equals(budget.getPeriod()) ? day.withDayOfMonth(1) : day;
|
||||
Instant from = start.atStartOfDay().toInstant(ZoneOffset.UTC);
|
||||
Instant until = ("MONTH".equals(budget.getPeriod()) ? start.plusMonths(1) : start.plusDays(1)).atStartOfDay().toInstant(ZoneOffset.UTC);
|
||||
String scopeKey = budget.getScopeType() + ":" + ("ROLE".equals(budget.getScopeType()) ? budget.getScopeValue() + ":" + userId : Objects.toString(budget.getScopeValue(), ""));
|
||||
boolean shared = List.of("INSTANCE", "PROVIDER", "MODEL").contains(budget.getScopeType());
|
||||
String dateField = shared ? "occurredAt" : "createDate";
|
||||
QQueryFilter filter = new QQueryFilter().withCriteria(dateField, QCriteriaOperator.GREATER_THAN_OR_EQUALS, from)
|
||||
.withCriteria(dateField, QCriteriaOperator.LESS_THAN, until);
|
||||
switch(budget.getScopeType())
|
||||
{
|
||||
case "USER" -> filter.withCriteria("userId", QCriteriaOperator.EQUALS, budget.getScopeValue());
|
||||
case "ROLE" -> filter.withCriteria("userId", QCriteriaOperator.EQUALS, userId);
|
||||
case "PROVIDER" -> filter.withCriteria("providerId", QCriteriaOperator.EQUALS, Integer.valueOf(budget.getScopeValue()));
|
||||
case "MODEL" ->
|
||||
{
|
||||
AssistantModel model = scopedModel(budget);
|
||||
filter.withCriteria("providerId", QCriteriaOperator.EQUALS, model == null ? -1 : model.getProviderId());
|
||||
filter.withCriteria("model", QCriteriaOperator.EQUALS, model == null ? "" : model.getModelId());
|
||||
}
|
||||
default ->
|
||||
{
|
||||
// Shared accounting contains no conversation or request data.
|
||||
}
|
||||
}
|
||||
BigDecimal used = BigDecimal.ZERO;
|
||||
for(AssistantUsage usage : store.query(shared ? AssistantBudgetUsage.TABLE_NAME : AssistantUsage.TABLE_NAME, filter, AssistantUsage.class))
|
||||
{
|
||||
used = used.add("COST_USD".equals(budget.getMetric()) ? BigDecimal.valueOf(nz(usage.getCostMicros()), 6)
|
||||
: BigDecimal.valueOf(nz(usage.getInputTokens())).add(BigDecimal.valueOf(nz(usage.getOutputTokens())))
|
||||
.add(BigDecimal.valueOf(nz(usage.getCacheReadTokens()))).add(BigDecimal.valueOf(nz(usage.getCacheWriteTokens()))));
|
||||
}
|
||||
BigDecimal limit = budget.getLimitValue();
|
||||
boolean unlimited = false;
|
||||
List<AssistantBudgetOverride> overrides = new ArrayList<>();
|
||||
for(AssistantBudgetOverride override : store.budgetOverrides(budget.getId()))
|
||||
{
|
||||
if(override.getExpiresAt() == null || !override.getExpiresAt().isAfter(now)
|
||||
|| (override.getScopeValue() != null && !override.getScopeValue().isBlank() && !override.getScopeValue().equals(userId)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
overrides.add(override);
|
||||
if(override.getUnlimitedUntil() != null && override.getUnlimitedUntil().isAfter(now))
|
||||
{
|
||||
unlimited = true;
|
||||
}
|
||||
if(override.getExtraValue() != null)
|
||||
{
|
||||
limit = limit.add(override.getExtraValue());
|
||||
}
|
||||
}
|
||||
BigDecimal percent = unlimited ? BigDecimal.ZERO : used.multiply(BigDecimal.valueOf(100)).divide(limit, 2, RoundingMode.DOWN);
|
||||
String state = unlimited ? "UNLIMITED" : percent.compareTo(BigDecimal.valueOf(budget.getHardCeilingPercent())) >= 0 ? "STOPPED"
|
||||
: percent.compareTo(BigDecimal.valueOf(100)) >= 0 ? ("STOP".equals(budget.getAtLimitAction()) ? "STOPPED" : "DOWNGRADED")
|
||||
: percent.compareTo(BigDecimal.valueOf(budget.getWarnPercent())) >= 0 ? "WARNED" : "OK";
|
||||
return (new Snapshot(budget, scopeKey, from, used, unlimited ? null : limit, percent, state, overrides));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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
|
||||
{
|
||||
if(!store.budgetEvent(new AssistantBudgetEvent().withBudgetId(snapshot.budget().getId()).withScopeKey(snapshot.scopeKey())
|
||||
.withPeriodStart(snapshot.periodStart()).withState(state).withUsedValue(snapshot.used()).withLimitValue(snapshot.limit())
|
||||
.withUserId(userId).withCreateDate(clock.instant())))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
String type = "assistant.budget." + switch(state)
|
||||
{
|
||||
case "WARNED" -> "warning";
|
||||
case "DOWNGRADED" -> "downgraded";
|
||||
default -> "stopped";
|
||||
};
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Null usage counters are zero.
|
||||
*******************************************************************************/
|
||||
private static long nz(Long value)
|
||||
{
|
||||
return (value == null ? 0 : value);
|
||||
}
|
||||
}
|
||||
+34
-4
@@ -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);
|
||||
}
|
||||
}
|
||||
+19
-4
@@ -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,284 @@
|
||||
/*
|
||||
* 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.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
import com.kingsrook.qbits.assistant.AssistantRuntime;
|
||||
import com.kingsrook.qbits.assistant.classification.DataClassification;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantConversation;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProfile;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProvider;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantTurn;
|
||||
import com.kingsrook.qbits.assistant.provider.CacheHint;
|
||||
import com.kingsrook.qbits.assistant.provider.ModelMessage;
|
||||
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.qbits.assistant.scheduled.ScheduledTurn;
|
||||
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
|
||||
{
|
||||
var scheduled = runtime.getScheduledTurn().task(conversation.getId());
|
||||
ScheduledTurn.checkCost(scheduled, 0L);
|
||||
AssistantProfile profile = runtime.getProfileService().pick(session, runtime.appNameFor(pageContext), scheduled == null ? null : scheduled.getProfileId());
|
||||
String mode = profile == null || profile.getRoutingMode() == null ? "OFF" : profile.getRoutingMode();
|
||||
Boolean escalation = profile == null && Boolean.TRUE.equals(runtime.getConfig().getModels().getEscalationEnabled());
|
||||
String decision = "OFF";
|
||||
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())));
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
-6
@@ -17,12 +17,6 @@
|
||||
package com.kingsrook.qbits.assistant.engine;
|
||||
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.assistant.AssistantBudgets;
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantPolicy;
|
||||
@@ -31,6 +25,12 @@ import com.kingsrook.qbits.assistant.persistence.AssistantStore;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -47,6 +47,9 @@ public class PolicyService
|
||||
private final AssistantStore store;
|
||||
private final Clock clock;
|
||||
|
||||
private BudgetService budgetService;
|
||||
private ProviderService providerService;
|
||||
|
||||
private volatile AssistantPolicy cached;
|
||||
private volatile Instant cachedAt;
|
||||
|
||||
@@ -210,6 +213,26 @@ public class PolicyService
|
||||
** `pendingCostMicros` of a running turn not yet recorded.
|
||||
*******************************************************************************/
|
||||
public BudgetStatus checkBudget(EffectivePolicy policy, String userId, long pendingTokens, long pendingCostMicros) throws QException
|
||||
{
|
||||
if(budgetService != null && pendingTokens == 0 && pendingCostMicros == 0)
|
||||
{
|
||||
BudgetService.Decision decision = budgetService.evaluate(userId, providerService.resolve(policy.mainModel()), false, true);
|
||||
if(decision.stopped() || !decision.notices().isEmpty())
|
||||
{
|
||||
BudgetStatus configured = checkConfiguredBudget(policy, userId, pendingTokens, pendingCostMicros);
|
||||
return (configured.hardExceeded() ? configured : new BudgetStatus(decision.stopped(), !decision.notices().isEmpty(),
|
||||
decision.stopped() ? decision.reason() : String.join(" ", decision.notices())));
|
||||
}
|
||||
}
|
||||
return (checkConfiguredBudget(policy, userId, pendingTokens, pendingCostMicros));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Existing config budgets remain additive and unchanged.
|
||||
*******************************************************************************/
|
||||
public BudgetStatus checkConfiguredBudget(EffectivePolicy policy, String userId, long pendingTokens, long pendingCostMicros) throws QException
|
||||
{
|
||||
AssistantBudgets b = policy.budgets();
|
||||
LocalDate today = LocalDate.now(clock.withZone(ZoneOffset.UTC));
|
||||
@@ -320,4 +343,14 @@ public class PolicyService
|
||||
return (override != null ? override : base);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Wire the data-driven start-turn check into the existing 429 BUDGET path.
|
||||
*******************************************************************************/
|
||||
public PolicyService withBudgetService(BudgetService budgets, ProviderService providers)
|
||||
{
|
||||
this.budgetService = budgets;
|
||||
this.providerService = providers;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
+28
-2
@@ -107,7 +107,17 @@ public class ProfileService
|
||||
*******************************************************************************/
|
||||
public Persona persona(QSession session, String appName)
|
||||
{
|
||||
AssistantProfile profile = pick(session, appName);
|
||||
return (persona(session, appName, null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An administrator-selected scheduled profile still respects role scope.
|
||||
*******************************************************************************/
|
||||
public Persona persona(QSession session, String appName, Integer profileId)
|
||||
{
|
||||
AssistantProfile profile = pick(session, appName, profileId);
|
||||
if(profile == null)
|
||||
{
|
||||
return (new Persona(null, DEFAULT_DISPLAY_NAME, DEFAULT_ICON, null, null, List.of()));
|
||||
@@ -215,8 +225,24 @@ public class ProfileService
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private AssistantProfile pick(QSession session, String appName)
|
||||
public AssistantProfile pick(QSession session, String appName)
|
||||
{
|
||||
return (pick(session, appName, null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit profiles supply instructions and routing, never permissions.
|
||||
*******************************************************************************/
|
||||
public AssistantProfile pick(QSession session, String appName, Integer profileId)
|
||||
{
|
||||
if(profileId != null)
|
||||
{
|
||||
return (load().profiles().stream().filter(p -> profileId.equals(p.getId()) && !Boolean.FALSE.equals(p.getEnabled())
|
||||
&& (!StringUtils.hasContent(p.getPermissionName()) || session.hasPermission(p.getPermissionName()))).findFirst().orElse(null));
|
||||
}
|
||||
|
||||
List<AssistantProfile> enabled = load().profiles().stream().filter(p -> !Boolean.FALSE.equals(p.getEnabled())).toList();
|
||||
return (enabled.stream()
|
||||
.filter(p -> appliesTo(p, session, appName))
|
||||
|
||||
+144
@@ -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();
|
||||
}
|
||||
}
|
||||
+138
-16
@@ -17,6 +17,18 @@
|
||||
package com.kingsrook.qbits.assistant.engine;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantModel;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantModelPrice;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProvider;
|
||||
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
|
||||
import com.kingsrook.qbits.assistant.provider.AssistantModelProvider;
|
||||
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
|
||||
import com.kingsrook.qbits.assistant.provider.ProviderSettings;
|
||||
import com.kingsrook.qbits.assistant.provider.RegistryModelProvider;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@@ -31,18 +43,6 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantModel;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantModelPrice;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProvider;
|
||||
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
|
||||
import com.kingsrook.qbits.assistant.provider.AssistantModelProvider;
|
||||
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
|
||||
import com.kingsrook.qbits.assistant.provider.ProviderSettings;
|
||||
import com.kingsrook.qbits.assistant.provider.RegistryModelProvider;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
@@ -186,6 +186,16 @@ public class ProviderService
|
||||
** with a message for the admin when the row cannot be used.
|
||||
*******************************************************************************/
|
||||
public Active forRow(AssistantProvider row, String fallbackModel) throws ProviderSetupException
|
||||
{
|
||||
return (forRow(row, fallbackModel, false));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve only the requested kind; embedding calls never select chat models.
|
||||
*******************************************************************************/
|
||||
private Active forRow(AssistantProvider row, String fallbackModel, Boolean embedding) throws ProviderSetupException
|
||||
{
|
||||
if(Boolean.TRUE.equals(config.getHipaaMode()) && !Boolean.TRUE.equals(row.getHipaaEligible()))
|
||||
{
|
||||
@@ -205,7 +215,7 @@ public class ProviderService
|
||||
String model = StringUtils.hasContent(row.getModel()) ? row.getModel() : null;
|
||||
if(model == null && !models.isEmpty())
|
||||
{
|
||||
model = models.stream().filter(m -> Boolean.TRUE.equals(m.getEnabled())).map(AssistantModel::getModelId).findFirst().orElse(null);
|
||||
model = models.stream().filter(m -> Boolean.TRUE.equals(m.getEnabled()) && !m.isChat().equals(embedding)).map(AssistantModel::getModelId).findFirst().orElse(null);
|
||||
if(model == null)
|
||||
{
|
||||
throw (new ProviderSetupException("All models of this provider are disabled. Enable a model first."));
|
||||
@@ -217,7 +227,7 @@ public class ProviderService
|
||||
}
|
||||
String selected = model;
|
||||
AssistantModel registry = models.stream().filter(m -> java.util.Objects.equals(selected, m.getModelId())).findFirst().orElse(null);
|
||||
if(registry != null && !Boolean.TRUE.equals(registry.getEnabled()))
|
||||
if(registry != null && (!Boolean.TRUE.equals(registry.getEnabled()) || registry.isChat().equals(embedding)))
|
||||
{
|
||||
throw (new ProviderSetupException("The selected model is disabled in this provider's model registry."));
|
||||
}
|
||||
@@ -248,7 +258,11 @@ public class ProviderService
|
||||
return (errors);
|
||||
}
|
||||
|
||||
errors.addAll(type.validate(row));
|
||||
errors.addAll(type.validate(new ProviderSettings().withKind(kind(row))
|
||||
.withApiKeyEnvName(row.getApiKeyEnvName()).withBaseUrl(row.getBaseUrl())
|
||||
.withApiVersion(row.getApiVersion()).withRegion(row.getRegion()).withCloudProject(row.getCloudProject())
|
||||
.withTlsCaBundle(row.getTlsCaBundle()).withConnectTimeoutSeconds(row.getConnectTimeoutSeconds())
|
||||
.withRequestTimeoutSeconds(row.getRequestTimeoutSeconds())));
|
||||
String envName = keyEnvName(row);
|
||||
if(envName != null && !AssistantQBitConfig.ENV_NAME.matcher(envName).matches())
|
||||
{
|
||||
@@ -668,7 +682,7 @@ public class ProviderService
|
||||
{
|
||||
throw (new ProviderSetupException("No API key: the environment variable " + keyEnvName(row) + " is not set on this server."));
|
||||
}
|
||||
return (new ProviderSettings().withKind(kind(row)).withApiKey(key)
|
||||
return (new ProviderSettings().withKind(kind(row)).withApiKeyEnvName(row.getApiKeyEnvName()).withApiKey(key)
|
||||
.withBaseUrl(StringUtils.hasContent(row.getBaseUrl()) ? row.getBaseUrl().trim() : null)
|
||||
.withApiVersion(row.getApiVersion()).withRegion(row.getRegion()).withCloudProject(row.getCloudProject())
|
||||
.withTlsCaBundle(row.getTlsCaBundle()).withSslContext(StringUtils.hasContent(row.getTlsCaBundle()) ? config.getTrustedCaBundles().get(row.getTlsCaBundle()) : null)
|
||||
@@ -732,4 +746,112 @@ public class ProviderService
|
||||
{
|
||||
return (java.util.Optional.empty());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve a specific enabled registry model and provider without changing
|
||||
** 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 || !model.isChat())
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
AssistantProvider row = store.getProvider(model.getProviderId());
|
||||
if(row == null || !Boolean.TRUE.equals(row.getEnabled()) || ((requiresHipaa || Boolean.TRUE.equals(config.getHipaaMode())) && !Boolean.TRUE.equals(row.getHipaaEligible())))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
AssistantProvider selected = new AssistantProvider(row.toQRecord()).withModel(model.getModelId());
|
||||
try
|
||||
{
|
||||
Active active = forRow(selected, model.getModelId());
|
||||
return (requireTools && active.provider().getCapabilities(active.model()) != null && Boolean.FALSE.equals(active.provider().getCapabilities(active.model()).getTools()) ? null : active);
|
||||
}
|
||||
catch(ProviderSetupException e)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cheapest usable enabled registry model, with local providers first.
|
||||
*******************************************************************************/
|
||||
public Active budgetDowngrade(Integer explicitModelId, boolean requiresHipaa, Active original) throws QException
|
||||
{
|
||||
if(explicitModelId != null)
|
||||
{
|
||||
Active explicit = resolveModel(explicitModelId, requiresHipaa);
|
||||
return (sameModel(explicit, original) ? null : explicit);
|
||||
}
|
||||
Active best = null;
|
||||
double bestPrice = Double.MAX_VALUE;
|
||||
for(AssistantModel model : store.listModels())
|
||||
{
|
||||
Active candidate = resolveModel(model.getId(), requiresHipaa);
|
||||
if(candidate == null || sameModel(candidate, original))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
ModelPricing price = pricing(candidate, candidate.model());
|
||||
double total = (price.getInputUsdPerMTok() == null ? 0 : price.getInputUsdPerMTok()) + (price.getOutputUsdPerMTok() == null ? 0 : price.getOutputUsdPerMTok());
|
||||
boolean local = "LOCAL".equals(location(candidate.row()));
|
||||
boolean bestLocal = best != null && "LOCAL".equals(location(best.row()));
|
||||
if(best == null || (local && !bestLocal) || (local == bestLocal && total < bestPrice))
|
||||
{
|
||||
best = candidate;
|
||||
bestPrice = total;
|
||||
}
|
||||
}
|
||||
return (best);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A downgrade must choose another provider/model pair.
|
||||
*******************************************************************************/
|
||||
private static boolean sameModel(Active left, Active right)
|
||||
{
|
||||
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" })
|
||||
);
|
||||
|
||||
|
||||
|
||||
+8
-3
@@ -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();
|
||||
|
||||
+163
-47
@@ -27,16 +27,20 @@ import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
import com.kingsrook.qbits.assistant.AssistantRuntime;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantConversation;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantMessage;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantPendingAction;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantScheduledTask;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantTurn;
|
||||
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
|
||||
@@ -51,6 +55,10 @@ import com.kingsrook.qbits.assistant.provider.ModelRequest;
|
||||
import com.kingsrook.qbits.assistant.provider.ModelResponse;
|
||||
import com.kingsrook.qbits.assistant.provider.ModelUsage;
|
||||
import com.kingsrook.qbits.assistant.provider.StreamHandle;
|
||||
import com.kingsrook.qbits.assistant.scheduled.ScheduledTurn;
|
||||
import com.kingsrook.qbits.assistant.tools.KnowledgeTool;
|
||||
import com.kingsrook.qbits.assistant.tools.PresentResultsTool;
|
||||
import com.kingsrook.qbits.assistant.tools.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;
|
||||
@@ -101,6 +109,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;
|
||||
@@ -123,6 +132,9 @@ public class TurnEngine implements Runnable
|
||||
private final StringBuilder textBuffer = new StringBuilder();
|
||||
private long lastTextFlush = System.currentTimeMillis();
|
||||
|
||||
private AssistantScheduledTask scheduledTask;
|
||||
private PolicyService.ToolPolicy toolPolicy;
|
||||
|
||||
private Instant startedAt;
|
||||
private Instant deadline;
|
||||
private int toolCallCount = 0;
|
||||
@@ -130,10 +142,10 @@ public class TurnEngine implements Runnable
|
||||
private long costMicros = 0;
|
||||
|
||||
private TurnClassification classification;
|
||||
private FallbackChain fallback;
|
||||
private ProviderService.Active active;
|
||||
private String model;
|
||||
private Long capMicros;
|
||||
private long monthCostAtStart = 0;
|
||||
private ProviderService.Active budgetOrigin;
|
||||
|
||||
private volatile boolean finished = false;
|
||||
|
||||
@@ -146,6 +158,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;
|
||||
@@ -180,6 +193,13 @@ public class TurnEngine implements Runnable
|
||||
try
|
||||
{
|
||||
QContext.init(runtime.getQInstance(), session);
|
||||
scheduledTask = runtime.getScheduledTurn().task(conversation.getId());
|
||||
if(conversation.getScheduledTaskId() != null && scheduledTask == null)
|
||||
{
|
||||
throw (new QException("Scheduled execution context is no longer available; start a new conversation."));
|
||||
}
|
||||
toolPolicy = scheduledTask == null ? policy.tools() : ScheduledTurn.tools(runtime, scheduledTask, policy.tools());
|
||||
ScheduledTurn.checkCost(runtime, conversation.getId(), turn.getId());
|
||||
classification = new TurnClassification(config.getClassificationSource(), conversation, pageContext);
|
||||
store.saveClassification(userId, conversation.getId(), turn.getId(), classification);
|
||||
startKillSwitchWatcher();
|
||||
@@ -192,18 +212,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();
|
||||
capMicros = ProviderService.capMicros(active.row());
|
||||
if(capMicros != null)
|
||||
{
|
||||
monthCostAtStart = runtime.getProviderService().monthToDateCostMicros(active.providerId());
|
||||
}
|
||||
budgetOrigin = active;
|
||||
AssistantModelProvider provider = active.provider();
|
||||
ModelCapabilities caps = provider.getCapabilities(model);
|
||||
|
||||
@@ -217,9 +245,9 @@ public class TurnEngine implements Runnable
|
||||
}
|
||||
|
||||
String appName = runtime.appNameFor(pageContext);
|
||||
ProfileService.Persona persona = runtime.getProfileService().persona(session, appName);
|
||||
ProfileService.Persona persona = runtime.getProfileService().persona(session, appName, scheduledTask == null ? null : scheduledTask.getProfileId());
|
||||
String profileText = ProfileService.instructions(persona, runtime.getProfileService().relevantSkills(persona.profileId(), userText, appName));
|
||||
ModelRequest request = runtime.getContextBuilder().build(session, model, policy.mainEffort(), history, userText, pageContext, policy.tools(), profileText);
|
||||
ModelRequest request = runtime.getContextBuilder().build(session, model, route.effort(), history, userText, pageContext, toolPolicy, profileText);
|
||||
Integer maxPerRequest = active.row() == null ? null : active.row().getMaxOutputTokensPerRequest();
|
||||
if(maxPerRequest != null && (request.getMaxTokens() == null || request.getMaxTokens() > maxPerRequest))
|
||||
{
|
||||
@@ -229,7 +257,7 @@ public class TurnEngine implements Runnable
|
||||
{
|
||||
request.setParallelToolCalls(false);
|
||||
}
|
||||
Set<String> allowedTools = runtime.getContextBuilder().toolNames(policy.tools());
|
||||
Set<String> allowedTools = runtime.getContextBuilder().toolNames(toolPolicy);
|
||||
|
||||
boolean toolLimitReached = false;
|
||||
while(true)
|
||||
@@ -244,16 +272,9 @@ 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();
|
||||
ScheduledTurn.checkCost(runtime, conversation.getId(), turn.getId());
|
||||
|
||||
if(ModelResponse.STOP_CANCELLED.equals(stopReason))
|
||||
{
|
||||
@@ -264,7 +285,7 @@ public class TurnEngine implements Runnable
|
||||
break;
|
||||
}
|
||||
|
||||
PolicyService.BudgetStatus budget = runtime.getPolicyService().checkBudget(policy, userId, usage.billableTokens(), costMicros);
|
||||
PolicyService.BudgetStatus budget = runtime.getPolicyService().checkConfiguredBudget(policy, userId, usage.billableTokens(), costMicros);
|
||||
if(budget.hardExceeded())
|
||||
{
|
||||
status = "ERROR";
|
||||
@@ -335,6 +356,12 @@ public class TurnEngine implements Runnable
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(BudgetService.Blocked e)
|
||||
{
|
||||
status = "ERROR";
|
||||
errorCode = "BUDGET";
|
||||
errorText = e.getMessage();
|
||||
}
|
||||
catch(TurnDataRouting.Blocked e)
|
||||
{
|
||||
status = "ERROR";
|
||||
@@ -343,11 +370,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
|
||||
@@ -361,7 +383,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)
|
||||
{
|
||||
@@ -506,23 +528,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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -531,8 +553,50 @@ 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));
|
||||
}
|
||||
ScheduledTurn.checkCost(runtime, conversation.getId(), turn.getId());
|
||||
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 = fallback.budget(budgetOrigin, candidate);
|
||||
budgetDecision.notices().forEach(text -> notice("warning", text));
|
||||
if(budgetDecision.stopped())
|
||||
{
|
||||
throw (new BudgetService.Blocked(budgetDecision.reason()));
|
||||
}
|
||||
ProviderService.Active selected = budgetDecision.active();
|
||||
if(!Objects.equals(active.providerId(), selected.providerId()) || !Objects.equals(active.model(), selected.model()))
|
||||
{
|
||||
active = selected;
|
||||
model = active.model();
|
||||
request.setModel(model);
|
||||
FallbackChain.neutralHistory(request);
|
||||
request.getTools().forEach(tool -> tool.setStrict(false));
|
||||
ModelCapabilities capabilities = active.provider().getCapabilities(model);
|
||||
if(capabilities != null && Boolean.FALSE.equals(capabilities.getParallelToolCalls()))
|
||||
{
|
||||
request.setParallelToolCalls(false);
|
||||
}
|
||||
Integer maxTokens = active.row() == null ? null : active.row().getMaxOutputTokensPerRequest();
|
||||
if(maxTokens != null && (request.getMaxTokens() == null || request.getMaxTokens() > maxTokens))
|
||||
{
|
||||
request.setMaxTokens(maxTokens);
|
||||
}
|
||||
}
|
||||
AssistantModelProvider provider = active.provider();
|
||||
Long selectedCap = ProviderService.capMicros(active.row());
|
||||
if(selectedCap != null && runtime.getProviderService().monthToDateCostMicros(active.providerId()) >= selectedCap)
|
||||
{
|
||||
throw (new BudgetService.Blocked("The assistant has reached this provider's monthly cost cap. Ask an administrator."));
|
||||
}
|
||||
ModelEventListener listener = new ModelEventListener()
|
||||
{
|
||||
@Override
|
||||
@@ -557,8 +621,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
|
||||
{
|
||||
@@ -574,6 +639,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
|
||||
@@ -641,7 +711,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 //
|
||||
@@ -656,8 +730,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
|
||||
{
|
||||
@@ -692,11 +778,11 @@ public class TurnEngine implements Runnable
|
||||
{
|
||||
future.get(waitMillis, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
catch(java.util.concurrent.TimeoutException e)
|
||||
catch(TimeoutException e)
|
||||
{
|
||||
future.cancel(true);
|
||||
}
|
||||
catch(java.util.concurrent.ExecutionException e)
|
||||
catch(ExecutionException e)
|
||||
{
|
||||
LOG.debug("tool future failed", hipaaSafe(e));
|
||||
}
|
||||
@@ -750,7 +836,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());
|
||||
}
|
||||
@@ -760,7 +847,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();
|
||||
@@ -778,7 +870,8 @@ public class TurnEngine implements Runnable
|
||||
.withUserId(userId)
|
||||
.withToolCallId(use.getToolUseId())
|
||||
.withToolName(use.getToolName())
|
||||
.withInputJson(truncate(redactInput(use.getInput()), MAX_INPUT_JSON_CHARS))
|
||||
.withClientChannel(scheduledTask == null ? null : "assistant/scheduled/" + scheduledTask.getId())
|
||||
.withInputJson(truncate(redactInput(PresentResultsTool.NAME.equals(use.getToolName()) ? (presentation ? result.getRender() : Map.of()) : use.getInput()), MAX_INPUT_JSON_CHARS))
|
||||
.withOutcome(outcome)
|
||||
.withPermissionCheck(result.getPermissionCheck())
|
||||
.withTableName(result.getTableName())
|
||||
@@ -787,9 +880,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()));
|
||||
@@ -807,9 +900,24 @@ public class TurnEngine implements Runnable
|
||||
** API), the gateway, the policy's tool settings, and the pending-action
|
||||
** store for writes that need confirmation.
|
||||
*******************************************************************************/
|
||||
private boolean hipaaRoute()
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// the X-QQQ-AI-Route the bulk-load tools send: the turn's provider's //
|
||||
// HIPAA eligibility, never anything the model says //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
return (active != null && active.row() != null && Boolean.TRUE.equals(active.row().getHipaaEligible()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The UI API tool context: the user's session, the gateway, the policy's
|
||||
** tools and the pending-action hook.
|
||||
*******************************************************************************/
|
||||
private UiApiToolbox.Context toolContext()
|
||||
{
|
||||
return (new UiApiToolbox.Context(session.getUuid(), userTimezone(), runtime.getUiApiGateway(), policy.tools(), this::proposeWrite));
|
||||
return (new UiApiToolbox.Context(session.getUuid(), userTimezone(), runtime.getUiApiGateway(), toolPolicy, this::proposeWrite));
|
||||
}
|
||||
|
||||
|
||||
@@ -862,7 +970,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("\\"));
|
||||
}
|
||||
@@ -940,6 +1048,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)
|
||||
@@ -979,6 +1091,10 @@ public class TurnEngine implements Runnable
|
||||
finally
|
||||
{
|
||||
running.getLog().close();
|
||||
if(scheduledTask != null)
|
||||
{
|
||||
runtime.getScheduledTurn().detach(conversation.getId(), session.getUuid());
|
||||
}
|
||||
runtime.unregister(turn.getId());
|
||||
runtime.getRateLimiter().release(userId);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
+77
-16
@@ -17,6 +17,26 @@
|
||||
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;
|
||||
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.AssistantBudget;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantBudgetOverride;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantConversation;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProvider;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantUsage;
|
||||
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
@@ -26,20 +46,6 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.assistant.AssistantRuntime;
|
||||
import com.kingsrook.qbits.assistant.engine.PolicyService;
|
||||
import com.kingsrook.qbits.assistant.engine.ProviderService;
|
||||
import com.kingsrook.qbits.assistant.engine.UsageRecorder;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantConversation;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProvider;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantUsage;
|
||||
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -65,6 +71,10 @@ public class AssistantHomeData
|
||||
private final List<QRecord> failedTools = new ArrayList<>();
|
||||
private final List<QRecord> deniedTools = new ArrayList<>();
|
||||
|
||||
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;
|
||||
@@ -137,6 +147,14 @@ public class AssistantHomeData
|
||||
AssistantHomeData data = new AssistantHomeData();
|
||||
data.hipaaMode = runtime.getConfig().getHipaaMode();
|
||||
data.hipaaRetentionDays = runtime.getConfig().getHipaaRetentionDays();
|
||||
for(AssistantBudget budget : runtime.getStore().budgets())
|
||||
{
|
||||
BudgetService.Snapshot snapshot = runtime.getBudgetService().snapshot(budget, AssistantSecurity.userId(QContext.getQSession()));
|
||||
List<AssistantBudgetOverride> overrides = runtime.getStore().budgetOverrides(budget.getId()).stream()
|
||||
.filter(o -> o.getExpiresAt() != null && o.getExpiresAt().isAfter(runtime.getClock().instant()))
|
||||
.filter(o -> o.getUnlimitedUntil() == null || o.getUnlimitedUntil().isAfter(runtime.getClock().instant())).toList();
|
||||
data.budgets.add(new BudgetService.Snapshot(budget, snapshot.scopeKey(), snapshot.periodStart(), snapshot.used(), snapshot.limit(), snapshot.percent(), snapshot.state(), overrides));
|
||||
}
|
||||
data.today = LocalDate.now(runtime.getClock().withZone(ZoneOffset.UTC));
|
||||
LocalDate firstDay = data.today.minusDays(DAYS - 1);
|
||||
LocalDate monthStart = data.today.withDayOfMonth(1);
|
||||
@@ -163,6 +181,7 @@ public class AssistantHomeData
|
||||
.withOrderBy(new QFilterOrderBy("id", false)), max);
|
||||
data.truncated |= usage.size() >= max;
|
||||
|
||||
Map<String, Totals> providerTotals = new LinkedHashMap<>();
|
||||
Map<String, Totals> users = new LinkedHashMap<>();
|
||||
Map<String, Totals> models = new LinkedHashMap<>();
|
||||
Map<Integer, Long> providerMonth = new LinkedHashMap<>();
|
||||
@@ -187,6 +206,9 @@ public class AssistantHomeData
|
||||
day.tokensIn += in;
|
||||
day.tokensOut += out;
|
||||
day.costMicros += cost;
|
||||
add(users, row.getValueString("userId") == null ? "(none)" : row.getValueString("userId"), in, out, cost);
|
||||
add(models, row.getValueString("model") == null ? "(unknown)" : row.getValueString("model"), in, out, cost);
|
||||
add(providerTotals, row.getValueString("providerId") == null ? "Code-registered provider" : "Provider #" + row.getValueString("providerId"), in, out, cost);
|
||||
}
|
||||
|
||||
if(!at.isBefore(weekAt) && turn)
|
||||
@@ -204,8 +226,6 @@ public class AssistantHomeData
|
||||
{
|
||||
data.monthCostMicros += cost;
|
||||
data.monthRequests++;
|
||||
add(users, row.getValueString("userId") == null ? "(none)" : row.getValueString("userId"), in, out, cost);
|
||||
add(models, row.getValueString("model") == null ? "(unknown)" : row.getValueString("model"), in, out, cost);
|
||||
Integer providerId = row.getValueInteger("providerId");
|
||||
if(providerId != null)
|
||||
{
|
||||
@@ -213,6 +233,7 @@ public class AssistantHomeData
|
||||
}
|
||||
}
|
||||
}
|
||||
data.byProvider.addAll(top(providerTotals));
|
||||
data.byUser.addAll(top(users));
|
||||
data.byModel.addAll(top(models));
|
||||
|
||||
@@ -220,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;
|
||||
@@ -517,4 +548,34 @@ public class AssistantHomeData
|
||||
{
|
||||
return (hipaaRetentionDays);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Current enabled budgets and active overrides.
|
||||
*******************************************************************************/
|
||||
public List<BudgetService.Snapshot> getBudgets()
|
||||
{
|
||||
return (budgets);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Provider usage over the same 30-day window as users and models.
|
||||
*******************************************************************************/
|
||||
public List<Totals> getByProvider()
|
||||
{
|
||||
return (byProvider);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Provider switches in the displayed period, including recovered failures.
|
||||
*******************************************************************************/
|
||||
public Long getFallbackCount()
|
||||
{
|
||||
return (fallbackCount);
|
||||
}
|
||||
}
|
||||
+90
-19
@@ -17,6 +17,16 @@
|
||||
package com.kingsrook.qbits.assistant.home;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.assistant.engine.BudgetService;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantRecordScreens;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantBudget;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantBudgetOverride;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantConversation;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantModelPrice;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProvider;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantUsage;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.text.NumberFormat;
|
||||
@@ -28,13 +38,6 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantRecordScreens;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantConversation;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantModelPrice;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProvider;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantUsage;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -90,7 +93,9 @@ public final class AssistantHomeView
|
||||
status(html, data, prefix);
|
||||
providers(html, data, prefix);
|
||||
html.append("<div style=\"").append(GRID).append("\">");
|
||||
budgets(html, data, prefix);
|
||||
usageByDay(html, data, prefix);
|
||||
byProvider(html, data);
|
||||
costs(html, data, prefix);
|
||||
byUser(html, data);
|
||||
tools(html, data, prefix);
|
||||
@@ -188,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()))
|
||||
@@ -232,14 +242,13 @@ public final class AssistantHomeView
|
||||
*******************************************************************************/
|
||||
private static void usageByDay(StringBuilder html, AssistantHomeData data, String prefix)
|
||||
{
|
||||
open(html, "Usage, last " + AssistantHomeData.DAYS + " days");
|
||||
open(html, "Usage over time, last " + AssistantHomeData.DAYS + " days");
|
||||
Map<LocalDate, AssistantHomeData.DayUsage> days = data.getDays();
|
||||
long conversations = 0;
|
||||
long requests = 0;
|
||||
long tokensIn = 0;
|
||||
long tokensOut = 0;
|
||||
long cost = 0;
|
||||
long max = 1;
|
||||
for(AssistantHomeData.DayUsage day : days.values())
|
||||
{
|
||||
conversations += day.conversations;
|
||||
@@ -247,7 +256,6 @@ public final class AssistantHomeView
|
||||
tokensIn += day.tokensIn;
|
||||
tokensOut += day.tokensOut;
|
||||
cost += day.costMicros;
|
||||
max = Math.max(max, day.requests);
|
||||
}
|
||||
|
||||
if(requests == 0 && conversations == 0)
|
||||
@@ -256,16 +264,16 @@ public final class AssistantHomeView
|
||||
}
|
||||
else
|
||||
{
|
||||
html.append("<div role=\"img\" aria-label=\"Model requests per day, last ").append(days.size()).append(" days\" style=\"display:flex;align-items:flex-end;gap:2px;height:3rem;margin:0 0 .25rem;\">");
|
||||
long maxCost = days.values().stream().mapToLong(d -> d.costMicros).max().orElse(1);
|
||||
long maxTokens = days.values().stream().mapToLong(d -> d.tokensIn + d.tokensOut).max().orElse(1);
|
||||
for(Map.Entry<LocalDate, AssistantHomeData.DayUsage> entry : days.entrySet())
|
||||
{
|
||||
AssistantHomeData.DayUsage day = entry.getValue();
|
||||
int pct = (int) Math.max(day.requests == 0 ? 0 : 4, Math.round(day.requests * 100.0 / max));
|
||||
html.append("<span title=\"").append(esc(DAY.format(entry.getKey()) + ": " + day.requests + " requests")).append("\" style=\"flex:1 1 0;min-width:0;height:").append(pct)
|
||||
.append("%;background:").append(day.errors > 0 ? AMBER : BAR).append(";border-radius:2px 2px 0 0;\"></span>");
|
||||
html.append("<div style=\"").append(LINE).append("\"><span>").append(esc(DAY.format(entry.getKey()))).append("</span><span>")
|
||||
.append(esc(money(day.costMicros) + ", " + number(day.tokensIn + day.tokensOut) + " tokens")).append("</span></div>");
|
||||
bar(html, maxCost == 0 ? 0 : 100.0 * day.costMicros / maxCost, BAR, "Daily cost");
|
||||
bar(html, maxTokens == 0 ? 0 : 100.0 * (day.tokensIn + day.tokensOut) / maxTokens, GREY, "Daily tokens");
|
||||
}
|
||||
html.append("</div><div style=\"display:flex;justify-content:space-between;font-size:.75rem;margin:0 0 .75rem;").append(MUTED).append("\"><span>")
|
||||
.append(esc(DAY.format(days.keySet().iterator().next()))).append("</span><span>").append(esc(DAY.format(data.getToday()))).append("</span></div>");
|
||||
|
||||
html.append("<dl style=\"").append(LIST).append("\">");
|
||||
row(html, "Conversations", number(conversations));
|
||||
@@ -316,9 +324,10 @@ public final class AssistantHomeView
|
||||
}
|
||||
}
|
||||
html.append("</dl>");
|
||||
html.append("<h4>By model, last 30 days</h4>");
|
||||
if(data.getByModel().isEmpty())
|
||||
{
|
||||
empty(html, "No model calls this month.");
|
||||
empty(html, "No model calls in the last 30 days.");
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -342,10 +351,10 @@ public final class AssistantHomeView
|
||||
*******************************************************************************/
|
||||
private static void byUser(StringBuilder html, AssistantHomeData data)
|
||||
{
|
||||
open(html, "By user, this month");
|
||||
open(html, "By user, last 30 days");
|
||||
if(data.getByUser().isEmpty())
|
||||
{
|
||||
empty(html, "No one has used the assistant this month.");
|
||||
empty(html, "No one has used the assistant in the last 30 days.");
|
||||
}
|
||||
for(AssistantHomeData.Totals t : data.getByUser())
|
||||
{
|
||||
@@ -419,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.");
|
||||
@@ -668,4 +678,65 @@ public final class AssistantHomeView
|
||||
return (out.toString());
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Enabled budgets with their effective allowance and active overrides.
|
||||
*******************************************************************************/
|
||||
private static void budgets(StringBuilder html, AssistantHomeData data, String prefix)
|
||||
{
|
||||
open(html, "Budgets");
|
||||
if(data.getBudgets().isEmpty())
|
||||
{
|
||||
empty(html, "No enabled data-driven budgets. Configured policy and provider limits still apply.");
|
||||
}
|
||||
for(BudgetService.Snapshot snapshot : data.getBudgets())
|
||||
{
|
||||
AssistantBudget budget = snapshot.budget();
|
||||
html.append("<div style=\"").append(ROW).append("\"><strong>")
|
||||
.append(recordLink(prefix, AssistantBudget.TABLE_NAME, budget.getId(), budget.getName())).append("</strong>");
|
||||
html.append("<p>").append(esc(snapshot.scopeKey() + " · " + budget.getPeriod() + " (UTC) · " + budget.getMetric())).append("</p>");
|
||||
html.append("<p>").append(esc(snapshot.used().stripTrailingZeros().toPlainString() + " / "
|
||||
+ (snapshot.limit() == null ? "Unlimited" : snapshot.limit().stripTrailingZeros().toPlainString()) + " · " + snapshot.state())).append("</p>");
|
||||
bar(html, snapshot.percent().doubleValue(), snapshot.percent().intValue() >= 100 ? RED
|
||||
: snapshot.percent().intValue() >= budget.getWarnPercent() ? AMBER : "currentColor", "Budget used " + snapshot.percent() + "%");
|
||||
for(AssistantBudgetOverride override : snapshot.overrides())
|
||||
{
|
||||
html.append("<p>").append(esc("Override: " + (override.getExtraValue() == null ? "Unlimited until " + override.getUnlimitedUntil() : "+" + override.getExtraValue())
|
||||
+ " · " + nvl(override.getScopeValue(), "Whole scope") + " · " + override.getReason() + " · expires " + override.getExpiresAt())).append("</p>");
|
||||
}
|
||||
html.append("</div>");
|
||||
}
|
||||
close(html);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A fluid semantic bar; values are clamped and text remains readable.
|
||||
*******************************************************************************/
|
||||
private static void bar(StringBuilder html, double percent, String color, String label)
|
||||
{
|
||||
html.append("<div aria-label=\"").append(esc(label)).append("\" style=\"background:rgba(127,127,127,.15);margin:.2rem 0;height:.35rem;\"><div style=\"height:100%;width:")
|
||||
.append(Math.max(0, Math.min(100, percent))).append("%;background:").append(color).append(";\"></div></div>");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Provider breakdown over the same window as users, models and tools.
|
||||
*******************************************************************************/
|
||||
private static void byProvider(StringBuilder html, AssistantHomeData data)
|
||||
{
|
||||
open(html, "By provider, last 30 days");
|
||||
if(data.getByProvider().isEmpty())
|
||||
{
|
||||
empty(html, "No provider usage in the last 30 days.");
|
||||
}
|
||||
for(AssistantHomeData.Totals total : data.getByProvider())
|
||||
{
|
||||
html.append("<div style=\"").append(LINE).append("\"><span>").append(esc(total.key)).append("</span><span>")
|
||||
.append(esc(money(total.costMicros) + ", " + number(total.tokensIn + total.tokensOut) + " tokens")).append("</span></div>");
|
||||
}
|
||||
close(html);
|
||||
}
|
||||
}
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.metadata;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
import com.kingsrook.qbits.assistant.AssistantRuntime;
|
||||
import com.kingsrook.qbits.assistant.AssistantSecurity;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantBudget;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantBudgetOverride;
|
||||
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.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage;
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validates budgets and attributes admin overrides to the current session.
|
||||
*******************************************************************************/
|
||||
public class AssistantBudgetCustomizer implements TableCustomizerInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Validate the merged row so partial updates cannot bypass invariants.
|
||||
*******************************************************************************/
|
||||
@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);
|
||||
for(QRecord record : records)
|
||||
{
|
||||
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);
|
||||
if(merged.getValue("budgetId") != null)
|
||||
{
|
||||
checkOverride(record, merged, input instanceof InsertInput);
|
||||
}
|
||||
else
|
||||
{
|
||||
checkBudget(record, merged);
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Defaults and scope-specific validation.
|
||||
*******************************************************************************/
|
||||
private static void checkBudget(QRecord record, QRecord merged) throws QException
|
||||
{
|
||||
defaults(record, merged, "enabled", true);
|
||||
defaults(record, merged, "warnPercent", 80);
|
||||
defaults(record, merged, "hardCeilingPercent", 120);
|
||||
defaults(record, merged, "atLimitAction", "DOWNGRADE");
|
||||
String scope = merged.getValueString("scopeType");
|
||||
String value = merged.getValueString("scopeValue");
|
||||
choice(record, merged, "scopeType", List.of("INSTANCE", "USER", "ROLE", "PROVIDER", "MODEL"));
|
||||
choice(record, merged, "period", List.of("DAY", "MONTH"));
|
||||
choice(record, merged, "metric", List.of("COST_USD", "TOKENS"));
|
||||
choice(record, merged, "atLimitAction", List.of("DOWNGRADE", "STOP"));
|
||||
if("INSTANCE".equals(scope) ? value != null && !value.isBlank() : value == null || value.isBlank())
|
||||
{
|
||||
error(record, "Scope value must be empty for INSTANCE and present for every other scope.");
|
||||
}
|
||||
AssistantRuntime runtime = AssistantRuntime.of(QContext.getQInstance());
|
||||
if("PROVIDER".equals(scope) || "MODEL".equals(scope))
|
||||
{
|
||||
try
|
||||
{
|
||||
Integer id = Integer.valueOf(value);
|
||||
boolean exists = "PROVIDER".equals(scope) ? runtime.getStore().getProvider(id) != null : runtime.getStore().listModels().stream().anyMatch(m -> id.equals(m.getId()));
|
||||
if(!exists)
|
||||
{
|
||||
error(record, "Scope value must reference an existing provider or model registry row.");
|
||||
}
|
||||
}
|
||||
catch(NumberFormatException e)
|
||||
{
|
||||
error(record, "Scope value must be a numeric record id.");
|
||||
}
|
||||
}
|
||||
Integer modelId = merged.getValueInteger("downgradeModelId");
|
||||
if(modelId != null && runtime.getStore().listModels().stream().noneMatch(m -> modelId.equals(m.getId())))
|
||||
{
|
||||
error(record, "Downgrade model must reference an existing model registry row.");
|
||||
}
|
||||
BigDecimal limit = merged.getValueBigDecimal("limitValue");
|
||||
if(limit == null || limit.signum() <= 0 || ("TOKENS".equals(merged.getValueString("metric")) && limit.stripTrailingZeros().scale() > 0))
|
||||
{
|
||||
error(record, "Limit must be positive; token limits must be whole numbers.");
|
||||
}
|
||||
int warn = merged.getValueInteger("warnPercent");
|
||||
if(warn < 0 || warn >= 100 || merged.getValueInteger("hardCeilingPercent") < 100)
|
||||
{
|
||||
error(record, "Warning percent must be 0 through 99 and hard ceiling must be at least 100.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Only admins grant overrides; never accept a supplied grantor.
|
||||
*******************************************************************************/
|
||||
private static void checkOverride(QRecord record, QRecord merged, boolean insert) throws QException
|
||||
{
|
||||
if(!QContext.getQSession().hasPermission(AssistantQBitConfig.ADMIN_PERMISSION))
|
||||
{
|
||||
error(record, "Only assistant.admin may grant budget overrides.");
|
||||
}
|
||||
if(insert)
|
||||
{
|
||||
record.setValue("grantedBy", AssistantSecurity.userId(QContext.getQSession()));
|
||||
}
|
||||
else
|
||||
{
|
||||
record.getValues().remove("grantedBy");
|
||||
}
|
||||
AssistantBudgetOverride override = new AssistantBudgetOverride(merged);
|
||||
AssistantRuntime runtime = AssistantRuntime.of(QContext.getQInstance());
|
||||
AssistantBudget budget = runtime.getStore().budget(override.getBudgetId());
|
||||
if(budget == null)
|
||||
{
|
||||
error(record, "Budget must reference an existing budget.");
|
||||
}
|
||||
if(override.getReason() == null || override.getReason().isBlank())
|
||||
{
|
||||
error(record, "A reason is required.");
|
||||
}
|
||||
if(override.getExpiresAt() == null)
|
||||
{
|
||||
error(record, "An expiry is required.");
|
||||
}
|
||||
if((override.getExtraValue() == null) == (override.getUnlimitedUntil() == null))
|
||||
{
|
||||
error(record, "Set exactly one of extra value or unlimited until.");
|
||||
}
|
||||
if(override.getExtraValue() != null && (override.getExtraValue().signum() <= 0 || (budget != null && "TOKENS".equals(budget.getMetric()) && override.getExtraValue().stripTrailingZeros().scale() > 0)))
|
||||
{
|
||||
error(record, "Extra value must be positive; token values must be whole numbers.");
|
||||
}
|
||||
if(override.getUnlimitedUntil() != null && override.getExpiresAt() != null && override.getUnlimitedUntil().isAfter(override.getExpiresAt()))
|
||||
{
|
||||
error(record, "Unlimited until cannot be after expiry.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fill an omitted setting.
|
||||
*******************************************************************************/
|
||||
private static void defaults(QRecord record, QRecord merged, String field, Serializable value)
|
||||
{
|
||||
if(merged.getValue(field) == null)
|
||||
{
|
||||
merged.setValue(field, value);
|
||||
record.setValue(field, value);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate a possible value even for non-UI callers.
|
||||
*******************************************************************************/
|
||||
private static void choice(QRecord record, QRecord merged, String field, List<String> choices)
|
||||
{
|
||||
if(merged.getValueString(field) == null || !choices.contains(merged.getValueString(field)))
|
||||
{
|
||||
error(record, "Invalid " + field + ".");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Safe validation message.
|
||||
*******************************************************************************/
|
||||
private static void error(QRecord record, String message)
|
||||
{
|
||||
record.addError(new BadInputStatusMessage(message));
|
||||
}
|
||||
}
|
||||
+39
-1
@@ -22,6 +22,10 @@ import java.util.List;
|
||||
import com.kingsrook.qbits.assistant.AssistantQBitConfig;
|
||||
import com.kingsrook.qbits.assistant.home.AssistantAccessWidget;
|
||||
import com.kingsrook.qbits.assistant.home.AssistantHome;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantBudget;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantBudgetEvent;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantBudgetOverride;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantBudgetUsage;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantConversation;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantDataAccess;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantDataAudit;
|
||||
@@ -34,6 +38,9 @@ import com.kingsrook.qbits.assistant.model.AssistantPolicy;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProfile;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProfileSkill;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProvider;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantRagSource;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantScheduledRun;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantScheduledTask;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantSkill;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantTurn;
|
||||
@@ -106,7 +113,7 @@ public class AssistantMetaDataBuilder
|
||||
** calls on a conversation; model calls on a provider) or reached from the
|
||||
** home. Processes are table and record actions, never nav entries.
|
||||
*******************************************************************************/
|
||||
public static final List<String> NAV_TABLES = List.of(AssistantProvider.TABLE_NAME, AssistantProfile.TABLE_NAME, AssistantSkill.TABLE_NAME, AssistantDataAccess.TABLE_NAME, AssistantConversation.TABLE_NAME, AssistantModelPrice.TABLE_NAME, AssistantPolicy.TABLE_NAME);
|
||||
public static final List<String> NAV_TABLES = List.of(AssistantScheduledTask.TABLE_NAME, AssistantRagSource.TABLE_NAME, AssistantBudget.TABLE_NAME, AssistantProvider.TABLE_NAME, AssistantProfile.TABLE_NAME, AssistantSkill.TABLE_NAME, AssistantDataAccess.TABLE_NAME, AssistantConversation.TABLE_NAME, AssistantModelPrice.TABLE_NAME, AssistantPolicy.TABLE_NAME);
|
||||
|
||||
private final AssistantQBitConfig config;
|
||||
|
||||
@@ -182,6 +189,24 @@ public class AssistantMetaDataBuilder
|
||||
.withUniqueKey(new UniqueKey("name"))
|
||||
.withRecordLabelFormat("%s").withRecordLabelFields("name"));
|
||||
|
||||
QCodeReference scheduledCustomizer = new QCodeReference(AssistantScheduledTaskCustomizer.class);
|
||||
tables.add(text(base(AssistantScheduledTask.TABLE_NAME, "Scheduled tasks", "schedule", AssistantScheduledTask.class), "prompt")
|
||||
.withRecordLabelFormat("%s").withRecordLabelFields("name")
|
||||
.withCustomizer(TableCustomizers.PRE_INSERT_RECORD, scheduledCustomizer).withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, scheduledCustomizer)
|
||||
.withCustomizer(TableCustomizers.POST_QUERY_RECORD, scheduledCustomizer));
|
||||
tables.add(readOnly(owned(base(AssistantScheduledRun.TABLE_NAME, "Scheduled runs", "history", AssistantScheduledRun.class), false))
|
||||
.withoutCapabilities(Capability.TABLE_DELETE)
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED))
|
||||
.withUniqueKey(new UniqueKey("taskId", "scheduledFor"))
|
||||
.withRecordLabelFormat("Run #%s").withRecordLabelFields("id"));
|
||||
|
||||
QCodeReference ragCustomizer = new QCodeReference(AssistantRagSourceCustomizer.class);
|
||||
tables.add(text(base(AssistantRagSource.TABLE_NAME, "Knowledge sources", "manage_search", AssistantRagSource.class), "filterJson")
|
||||
.withRecordLabelFormat("%s").withRecordLabelFields("name")
|
||||
.withCustomizer(TableCustomizers.PRE_INSERT_RECORD, ragCustomizer).withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, ragCustomizer)
|
||||
.withCustomizer(TableCustomizers.POST_INSERT_RECORD, ragCustomizer).withCustomizer(TableCustomizers.POST_UPDATE_RECORD, ragCustomizer)
|
||||
.withCustomizer(TableCustomizers.POST_DELETE_RECORD, ragCustomizer));
|
||||
|
||||
tables.add(settings(base(AssistantModel.TABLE_NAME, "Assistant Model", "memory", AssistantModel.class))
|
||||
.withUniqueKey(new UniqueKey("providerId", "modelId"))
|
||||
.withRecordLabelFormat("%s").withRecordLabelFields("modelId"));
|
||||
@@ -241,6 +266,19 @@ public class AssistantMetaDataBuilder
|
||||
table.getField("resultSummary").withBehavior(ValueTooLongBehavior.PASS_THROUGH);
|
||||
}
|
||||
}
|
||||
tables.add(readOnly(base(AssistantBudgetUsage.TABLE_NAME, "Budget Accounting", "receipt_long", AssistantBudgetUsage.class))
|
||||
.withoutCapabilities(Capability.TABLE_DELETE).withUniqueKey(new UniqueKey("usageId")));
|
||||
QCodeReference budgetCustomizer = new QCodeReference(AssistantBudgetCustomizer.class);
|
||||
tables.add(base(AssistantBudget.TABLE_NAME, "Budgets", "savings", AssistantBudget.class)
|
||||
.withRecordLabelFormat("%s").withRecordLabelFields("name")
|
||||
.withCustomizer(TableCustomizers.PRE_INSERT_RECORD, budgetCustomizer)
|
||||
.withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, budgetCustomizer));
|
||||
tables.add(base(AssistantBudgetOverride.TABLE_NAME, "Budget Override", "approval", AssistantBudgetOverride.class)
|
||||
.withCustomizer(TableCustomizers.PRE_INSERT_RECORD, budgetCustomizer)
|
||||
.withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, budgetCustomizer));
|
||||
tables.add(readOnly(base(AssistantBudgetEvent.TABLE_NAME, "Budget Event", "notifications", AssistantBudgetEvent.class))
|
||||
.withoutCapabilities(Capability.TABLE_DELETE)
|
||||
.withUniqueKey(new UniqueKey("budgetId", "scopeKey", "periodStart", "state")));
|
||||
tables.forEach(AssistantRecordScreens::apply);
|
||||
return (tables);
|
||||
}
|
||||
|
||||
+9
@@ -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)
|
||||
{
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.metadata;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qbits.assistant.AssistantRuntime;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantRagSource;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate source configuration; invalidate vectors whenever their meaning changes.
|
||||
*******************************************************************************/
|
||||
public class AssistantRagSourceCustomizer implements TableCustomizerInterface
|
||||
{
|
||||
private static final List<String> SEMANTIC_FIELDS = List.of("tableName", "fieldNames", "filterJson", "embeddingModelId", "chunkChars", "chunkOverlapChars");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate merged edits, not partial update payloads. Status writes remain
|
||||
** possible even after a host removes a table or disables a model.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preInsertOrUpdate(AbstractActionInput input, List<QRecord> records, boolean isPreview, Optional<List<QRecord>> oldRecordList) throws QException
|
||||
{
|
||||
Optional<Map<Serializable, QRecord>> old = oldRecordListToMap("id", oldRecordList);
|
||||
AssistantRuntime runtime = AssistantRuntime.of(QContext.getQInstance());
|
||||
for(QRecord record : records)
|
||||
{
|
||||
if(record.getValues().keySet().stream().allMatch(name -> name.equals("id") || name.startsWith("lastIndex") || name.startsWith("indexed") || name.endsWith("Date")))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
QRecord merged = new QRecord();
|
||||
old.map(m -> m.get(record.getValue("id"))).ifPresent(o -> merged.setValues(new LinkedHashMap<>(o.getValues())));
|
||||
record.getValues().forEach(merged::setValue);
|
||||
for(var entry : Map.<String, Serializable>of("enabled", true, "chunkChars", 1500, "chunkOverlapChars", 200).entrySet())
|
||||
{
|
||||
if(merged.getValue(entry.getKey()) == null)
|
||||
{
|
||||
record.setValue(entry.getKey(), entry.getValue());
|
||||
merged.setValue(entry.getKey(), entry.getValue());
|
||||
}
|
||||
}
|
||||
if(runtime != null)
|
||||
{
|
||||
for(String error : runtime.getRagService().validate(new AssistantRagSource(merged)))
|
||||
{
|
||||
record.addError(new BadInputStatusMessage(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Old vectors cannot survive a model, filter or chunk-layout change.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postInsertOrUpdate(AbstractActionInput input, List<QRecord> records, Optional<List<QRecord>> oldRecordList) throws QException
|
||||
{
|
||||
Optional<Map<Serializable, QRecord>> old = oldRecordListToMap("id", oldRecordList);
|
||||
AssistantRuntime runtime = AssistantRuntime.of(QContext.getQInstance());
|
||||
if(runtime != null && old.isPresent())
|
||||
{
|
||||
for(QRecord record : records)
|
||||
{
|
||||
QRecord previous = old.get().get(record.getValue("id"));
|
||||
if(previous != null && SEMANTIC_FIELDS.stream().anyMatch(name -> record.getValues().containsKey(name) && !Objects.equals(previous.getValue(name), record.getValue(name))))
|
||||
{
|
||||
runtime.getRagService().clear(new AssistantRagSource(previous));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Deleted sources no longer have searchable vectors.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postDelete(DeleteInput input, List<QRecord> records) throws QException
|
||||
{
|
||||
AssistantRuntime runtime = AssistantRuntime.of(QContext.getQInstance());
|
||||
if(runtime != null)
|
||||
{
|
||||
for(QRecord record : records)
|
||||
{
|
||||
runtime.getRagService().remove(new AssistantRagSource(record));
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
}
|
||||
+155
-6
@@ -20,12 +20,16 @@ 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;
|
||||
import com.kingsrook.qbits.assistant.engine.ProfileService;
|
||||
import com.kingsrook.qbits.assistant.engine.ProviderService;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantBudget;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantBudgetEvent;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantBudgetOverride;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantConversation;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantDataAccess;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantDataAudit;
|
||||
@@ -38,6 +42,9 @@ import com.kingsrook.qbits.assistant.model.AssistantPolicy;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProfile;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProfileSkill;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProvider;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantRagSource;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantScheduledRun;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantScheduledTask;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantSkill;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantToolCall;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantTurn;
|
||||
@@ -47,7 +54,10 @@ import com.kingsrook.qbits.assistant.model.AssistantUsageDaily;
|
||||
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReferenceWithProperties;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.AdornmentType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.DisplayFormat;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldAdornment;
|
||||
@@ -124,6 +134,15 @@ public final class AssistantRecordScreens
|
||||
}
|
||||
|
||||
return (List.of(
|
||||
QPossibleValueSource.newForTable(AssistantScheduledTask.TABLE_NAME),
|
||||
enumSource("assistant_scheduled_output", List.of(new QPossibleValue<>("RECORD", "Record"), new QPossibleValue<>("NOTIFICATION", "Notification"), new QPossibleValue<>("BOTH", "Both"))),
|
||||
QPossibleValueSource.newForTable(AssistantBudget.TABLE_NAME),
|
||||
QPossibleValueSource.newForTable(AssistantModel.TABLE_NAME).withName("assistant_registry_model"),
|
||||
QPossibleValueSource.newForTable(AssistantModel.TABLE_NAME).withName("assistant_budget_model"),
|
||||
budgetEnum("scopeType", "INSTANCE", "USER", "ROLE", "PROVIDER", "MODEL"),
|
||||
budgetEnum("period", "DAY", "MONTH"),
|
||||
budgetEnum("metric", "COST_USD", "TOKENS"),
|
||||
budgetEnum("atLimitAction", "DOWNGRADE", "STOP"),
|
||||
new QPossibleValueSource().withName(EFFORT_PVS_NAME).withType(QPossibleValueSourceType.ENUM)
|
||||
.withValueFormatAndFields(PVSValueFormatAndFields.LABEL_ONLY).withEnumValues(efforts),
|
||||
QPossibleValueSource.newForTable(AssistantTurn.TABLE_NAME),
|
||||
@@ -158,6 +177,38 @@ public final class AssistantRecordScreens
|
||||
{
|
||||
switch(table.getName())
|
||||
{
|
||||
case AssistantScheduledTask.TABLE_NAME ->
|
||||
{
|
||||
link(table, "profileId", AssistantProfile.TABLE_NAME, "Profile");
|
||||
pvs(table, "outputMode", "assistant_scheduled_output", "Output mode");
|
||||
help(table, "credentialEnvName", "Name of an allowed environment variable holding this user's access token. Never enter the token here.");
|
||||
help(table, "cronExpression", "Quartz cron expression, including seconds; for example 0 0 8 * * ? for 08:00 daily.");
|
||||
help(table, "timezone", "IANA time zone, for example America/Chicago. Defaults to UTC; daylight saving time follows this zone.");
|
||||
help(table, "writeModeRequested", "Requires administrator approval of this exact prompt and identity. Each proposed write still requires the user's confirmation.");
|
||||
help(table, "maxCostUsdPerRun", "Optional USD STOP cap. Checked before each model or embedding call; a completed call may cross the cap.");
|
||||
sections(table,
|
||||
section("task", "Task", "schedule", Tier.T1, "id", "name", "enabled", "prompt", "profileId"),
|
||||
section("identity", "Run as", "person", Tier.T2, "runAsUserId", "credentialEnvName", "credentialStatus"),
|
||||
section("schedule", "Schedule and output", "event", Tier.T2, "cronExpression", "timezone", "nextRunAt", "outputMode", "maxCostUsdPerRun"),
|
||||
section("approval", "Write approval", "verified_user", Tier.T2, "writeModeRequested", "writeModeApprovedBy", "writeModeApprovedAt", "writeModeApprovalHash"),
|
||||
section("lastRun", "Last run", "history", Tier.T2, "lastRunAt", "lastRunStatus", "lastRunError"));
|
||||
}
|
||||
case AssistantScheduledRun.TABLE_NAME ->
|
||||
{
|
||||
link(table, "taskId", AssistantScheduledTask.TABLE_NAME, "Task");
|
||||
link(table, "conversationId", AssistantConversation.TABLE_NAME, "Conversation");
|
||||
link(table, "turnId", AssistantTurn.TABLE_NAME, "Turn");
|
||||
help(table, "costMicros", MICROS_HELP);
|
||||
sections(table,
|
||||
section("run", "Run", "history", Tier.T1, "id", "taskId", "userId", "status", "scheduledFor", "startedAt", "endedAt"),
|
||||
section("result", "Result", "chat", Tier.T2, "conversationId", "turnId", "summary", "costMicros", "error"));
|
||||
}
|
||||
case AssistantBudget.TABLE_NAME -> budget(table);
|
||||
case AssistantBudgetOverride.TABLE_NAME, AssistantBudgetEvent.TABLE_NAME ->
|
||||
{
|
||||
link(table, "budgetId", AssistantBudget.TABLE_NAME, "Budget");
|
||||
sections(table);
|
||||
}
|
||||
case AssistantConversation.TABLE_NAME -> conversation(table);
|
||||
case AssistantMessage.TABLE_NAME -> message(table);
|
||||
case AssistantTurn.TABLE_NAME -> turn(table);
|
||||
@@ -168,6 +219,7 @@ public final class AssistantRecordScreens
|
||||
case AssistantUsageDaily.TABLE_NAME -> usageDaily(table);
|
||||
case AssistantProvider.TABLE_NAME -> provider(table);
|
||||
case AssistantModel.TABLE_NAME -> model(table);
|
||||
case AssistantRagSource.TABLE_NAME -> knowledge(table);
|
||||
case AssistantModelPrice.TABLE_NAME -> price(table);
|
||||
case AssistantUsage.TABLE_NAME -> usage(table);
|
||||
case AssistantDataAccess.TABLE_NAME -> dataAccess(table);
|
||||
@@ -191,6 +243,11 @@ public final class AssistantRecordScreens
|
||||
*******************************************************************************/
|
||||
public static void addConversationChildLists(QInstance qInstance)
|
||||
{
|
||||
QTableMetaData scheduledTask = qInstance.getTable(AssistantScheduledTask.TABLE_NAME);
|
||||
if(scheduledTask != null)
|
||||
{
|
||||
childList(qInstance, scheduledTask, AssistantScheduledRun.TABLE_NAME, "Runs", "history", List.of("startedAt", "status", "conversationId", "costMicros", "summary"), "taskId", "scheduled");
|
||||
}
|
||||
QTableMetaData conversation = qInstance.getTable(AssistantConversation.TABLE_NAME);
|
||||
if(conversation == null)
|
||||
{
|
||||
@@ -219,6 +276,12 @@ public final class AssistantRecordScreens
|
||||
}
|
||||
}
|
||||
|
||||
QTableMetaData budget = qInstance.getTable(AssistantBudget.TABLE_NAME);
|
||||
if(budget != null)
|
||||
{
|
||||
childList(qInstance, budget, AssistantBudgetOverride.TABLE_NAME, "Overrides", "approval", List.of("scopeValue", "extraValue", "unlimitedUntil", "reason", "grantedBy", "expiresAt"), "budgetId", "budget");
|
||||
childList(qInstance, budget, AssistantBudgetEvent.TABLE_NAME, "Events", "notifications", List.of("scopeKey", "periodStart", "state", "usedValue", "limitValue", "userId", "createDate"), "budgetId", "budget");
|
||||
}
|
||||
QTableMetaData provider = qInstance.getTable(AssistantProvider.TABLE_NAME);
|
||||
if(provider != null)
|
||||
{
|
||||
@@ -264,16 +327,30 @@ public final class AssistantRecordScreens
|
||||
return (List.of(
|
||||
new QPossibleValueSource().withName(PROVIDER_TYPE_PVS_NAME).withType(QPossibleValueSourceType.ENUM).withIdType(QFieldType.STRING)
|
||||
.withValueFormatAndFields(PVSValueFormatAndFields.LABEL_ONLY).withEnumValues(types),
|
||||
new QPossibleValueSource().withName(MODEL_PVS_NAME).withType(QPossibleValueSourceType.ENUM).withIdType(QFieldType.STRING)
|
||||
.withValueFormatAndFields(PVSValueFormatAndFields.LABEL_ONLY).withEnumValues(modelValues),
|
||||
enumSource("assistant_provider_kind", kinds),
|
||||
settingsSource(MODEL_PVS_NAME, modelValues, true, true),
|
||||
settingsSource("assistant_price_model", modelValues, true, false),
|
||||
settingsSource("assistant_provider_kind", kinds, false, false),
|
||||
enumSource("assistant_provider_location", List.of(new QPossibleValue<>("LOCAL", "Local"), new QPossibleValue<>("CLOUD", "Cloud"))),
|
||||
enumSource("assistant_routing_mode", List.of(new QPossibleValue<>("OFF", "Off"), new QPossibleValue<>("HEURISTIC", "Heuristic"), new QPossibleValue<>("CLASSIFIER", "Classifier"))),
|
||||
enumSource("assistant_model_kind", List.of(new QPossibleValue<>("CHAT", "Chat"), new QPossibleValue<>("EMBEDDING", "Embedding"))),
|
||||
enumSource("assistant_model_source", List.of(new QPossibleValue<>("DISCOVERED", "Discovered"), new QPossibleValue<>("MANUAL", "Manual"))),
|
||||
QPossibleValueSource.newForTable(AssistantProvider.TABLE_NAME)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Contingent searches use RC.5's otherValues input.
|
||||
*******************************************************************************/
|
||||
private static QPossibleValueSource settingsSource(String name, List<QPossibleValue<?>> union, Boolean models, Boolean providerRow)
|
||||
{
|
||||
return (new QPossibleValueSource().withName(name).withType(QPossibleValueSourceType.CUSTOM).withIdType(QFieldType.STRING)
|
||||
.withValueFormatAndFields(PVSValueFormatAndFields.LABEL_ONLY)
|
||||
.withCustomCodeReference(new QCodeReferenceWithProperties(AssistantSettingsPossibleValueProvider.class, Map.of("union", new ArrayList<>(union), "models", models, "providerRow", providerRow))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Display label shared by provider settings and home cards. Custom kinds
|
||||
** retain their registered name.
|
||||
@@ -309,6 +386,11 @@ public final class AssistantRecordScreens
|
||||
private static void provider(QTableMetaData table)
|
||||
{
|
||||
pvs(table, "providerType", PROVIDER_TYPE_PVS_NAME, "Type");
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// Add possibleValueSourceDependsOn("providerType") to model and kind //
|
||||
// once the next QQQ RC includes core feature/contingent-possible-values. //
|
||||
// See QQQ docs/CONTINGENT-POSSIBLE-VALUES.md. //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
pvs(table, "model", MODEL_PVS_NAME, "Model");
|
||||
pvs(table, "kind", "assistant_provider_kind", "Kind");
|
||||
pvs(table, "location", "assistant_provider_location", "Location");
|
||||
@@ -344,6 +426,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"));
|
||||
}
|
||||
@@ -356,6 +439,11 @@ public final class AssistantRecordScreens
|
||||
private static void price(QTableMetaData table)
|
||||
{
|
||||
pvs(table, "providerType", PROVIDER_TYPE_PVS_NAME, "Provider Type");
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// Add possibleValueSourceDependsOn("providerType") when the next QQQ //
|
||||
// RC includes it (QQQ docs/CONTINGENT-POSSIBLE-VALUES.md). //
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
pvs(table, "model", "assistant_price_model", "Model");
|
||||
for(String field : List.of("inputUsdPerMTok", "outputUsdPerMTok", "cacheWrite5mUsdPerMTok", "cacheWrite1hUsdPerMTok"))
|
||||
{
|
||||
help(table, field, "USD per million tokens.");
|
||||
@@ -385,7 +473,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"),
|
||||
@@ -446,11 +534,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"),
|
||||
@@ -499,6 +589,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 + ").");
|
||||
@@ -507,6 +601,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"));
|
||||
}
|
||||
@@ -840,7 +935,7 @@ public final class AssistantRecordScreens
|
||||
qInstance.addWidget(ChildRecordListRenderer.widgetMetaDataBuilder(join)
|
||||
.withLabel(label)
|
||||
.withMaxRows(50)
|
||||
.withCanAddChildRecord(AssistantModel.TABLE_NAME.equals(childTableName))
|
||||
.withCanAddChildRecord(AssistantModel.TABLE_NAME.equals(childTableName) || AssistantBudgetOverride.TABLE_NAME.equals(childTableName))
|
||||
.withOnlyIncludeFieldNames(shown)
|
||||
.getWidgetMetaData());
|
||||
|
||||
@@ -857,6 +952,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");
|
||||
@@ -864,10 +961,62 @@ 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"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Budget choices use standard possible-value sources.
|
||||
*******************************************************************************/
|
||||
private static QPossibleValueSource budgetEnum(String field, String... values)
|
||||
{
|
||||
List<QPossibleValue<?>> choices = new ArrayList<>();
|
||||
for(String value : values)
|
||||
{
|
||||
choices.add(new QPossibleValue<>(value, value));
|
||||
}
|
||||
return (enumSource("assistant_budget_" + field, choices));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Budget record screen.
|
||||
*******************************************************************************/
|
||||
private static void budget(QTableMetaData table)
|
||||
{
|
||||
for(String field : List.of("scopeType", "period", "metric", "atLimitAction"))
|
||||
{
|
||||
table.getField(field).withPossibleValueSourceName("assistant_budget_" + field);
|
||||
}
|
||||
table.getField("downgradeModelId").withPossibleValueSourceName("assistant_budget_model");
|
||||
help(table, "scopeValue", "Empty for INSTANCE; user id, permission name, provider row id or model registry row id for the selected scope.");
|
||||
help(table, "limitValue", "USD for COST_USD, or whole tokens for TOKENS. Periods use UTC.");
|
||||
help(table, "downgradeModelId", "Empty chooses the cheapest enabled eligible model, preferring LOCAL providers.");
|
||||
sections(table,
|
||||
section("budget", "Budget", "savings", Tier.T1, "id", "name", "enabled", "scopeType", "scopeValue", "period", "metric", "limitValue"),
|
||||
section("thresholds", "Thresholds", "notifications", Tier.T2, "warnPercent", "atLimitAction", "downgradeModelId", "hardCeilingPercent", "notes"));
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Standard source editor and status, with explicit embedding model selection.
|
||||
*******************************************************************************/
|
||||
private static void knowledge(QTableMetaData table)
|
||||
{
|
||||
pvs(table, "embeddingModelId", "assistant_registry_model", "Embedding model");
|
||||
table.getField("embeddingModelId").withPossibleValueSourceFilter(new QQueryFilter()
|
||||
.withCriteria("modelKind", QCriteriaOperator.EQUALS, "EMBEDDING").withCriteria("enabled", QCriteriaOperator.EQUALS, true));
|
||||
help(table, "embeddingModelId", "Select an enabled EMBEDDING model. Chat models are refused on save.");
|
||||
help(table, "filterJson", "Optional QQQ query filter JSON. Index now reads it through the UI API as the clicking administrator.");
|
||||
sections(table,
|
||||
section("source", "Knowledge source", "manage_search", Tier.T1, "id", "name", "enabled", "tableName", "fieldNames", "titleFieldName", "filterJson"),
|
||||
section("embedding", "Embedding", "memory", Tier.T2, "embeddingModelId", "chunkChars", "chunkOverlapChars"),
|
||||
section("status", "Last index", "info", Tier.T2, "lastIndexedAt", "lastIndexStatus", "lastIndexError", "indexedRecordCount", "indexedChunkCount"));
|
||||
}
|
||||
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.metadata;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qbits.assistant.AssistantRuntime;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantScheduledTask;
|
||||
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
|
||||
import com.kingsrook.qbits.assistant.scheduled.ScheduledTasks;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.QueryOrGetInputInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate task edits and invalidate approvals before changed text is saved.
|
||||
*******************************************************************************/
|
||||
public class AssistantScheduledTaskCustomizer implements TableCustomizerInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Internal status writes bypass this customizer only inside AssistantStore.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preInsertOrUpdate(AbstractActionInput input, List<QRecord> records, boolean isPreview, Optional<List<QRecord>> oldRecordList) throws QException
|
||||
{
|
||||
Optional<Map<Serializable, QRecord>> old = oldRecordListToMap("id", oldRecordList);
|
||||
AssistantRuntime runtime = AssistantRuntime.of(QContext.getQInstance());
|
||||
for(QRecord record : records)
|
||||
{
|
||||
record.getValues().remove("credentialStatus");
|
||||
if(input instanceof UpdateInput update
|
||||
&& update.hasFlag(AssistantStore.ScheduledWrite.INTERNAL))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
QRecord previous = old.map(rows -> rows.get(record.getValue("id"))).orElse(null);
|
||||
for(String field : List.of("writeModeApprovedBy", "writeModeApprovedAt", "writeModeApprovalHash", "nextRunAt", "lastRunAt", "lastRunStatus", "lastRunError"))
|
||||
{
|
||||
record.getValues().remove(field);
|
||||
}
|
||||
QRecord merged = new QRecord();
|
||||
merged.setValues(previous == null ? new LinkedHashMap<>() : new LinkedHashMap<>(previous.getValues()));
|
||||
record.getValues().forEach(merged::setValue);
|
||||
for(var entry : Map.<String, Serializable>of("enabled", false, "timezone", "UTC", "outputMode", "RECORD", "writeModeRequested", false).entrySet())
|
||||
{
|
||||
if(merged.getValue(entry.getKey()) == null)
|
||||
{
|
||||
merged.setValue(entry.getKey(), entry.getValue());
|
||||
record.setValue(entry.getKey(), entry.getValue());
|
||||
}
|
||||
}
|
||||
if(previous == null || ScheduledTasks.APPROVAL_FIELDS.stream().anyMatch(field -> !Objects.equals(previous.getValue(field), merged.getValue(field))))
|
||||
{
|
||||
record.setValue("writeModeApprovedBy", null);
|
||||
record.setValue("writeModeApprovedAt", null);
|
||||
record.setValue("writeModeApprovalHash", null);
|
||||
}
|
||||
try
|
||||
{
|
||||
AssistantScheduledTask task = new AssistantScheduledTask(merged);
|
||||
ScheduledTasks.checkReference(runtime.getConfig(), task.getCredentialEnvName());
|
||||
if(task.getPrompt() == null || task.getPrompt().isBlank() || task.getPrompt().length() > 4000)
|
||||
{
|
||||
throw (new QException("The prompt must contain 1 to 4000 characters."));
|
||||
}
|
||||
if(!List.of("RECORD", "NOTIFICATION", "BOTH").contains(task.getOutputMode()))
|
||||
{
|
||||
throw (new QException("Output mode must be RECORD, NOTIFICATION or BOTH."));
|
||||
}
|
||||
if(task.getMaxCostUsdPerRun() != null && task.getMaxCostUsdPerRun().signum() < 0)
|
||||
{
|
||||
throw (new QException("The per-run cost cap cannot be negative."));
|
||||
}
|
||||
var next = ScheduledTasks.next(task, runtime.getClock().instant());
|
||||
if(previous == null || List.of("enabled", "cronExpression", "timezone").stream().anyMatch(field -> !Objects.equals(previous.getValue(field), merged.getValue(field))))
|
||||
{
|
||||
record.setValue("nextRunAt", Boolean.TRUE.equals(task.getEnabled()) ? next : null);
|
||||
}
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
record.addError(new BadInputStatusMessage(e.getMessage()));
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Computed status is never persisted.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postQuery(QueryOrGetInputInterface input, List<QRecord> records) throws QException
|
||||
{
|
||||
AssistantRuntime runtime = AssistantRuntime.of(QContext.getQInstance());
|
||||
for(QRecord record : records)
|
||||
{
|
||||
record.setValue("credentialStatus", ScheduledTasks.credentialStatus(runtime.getConfig(), new AssistantScheduledTask(record)));
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
}
|
||||
+86
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.metadata;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.assistant.AssistantRuntime;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantModel;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProvider;
|
||||
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
|
||||
import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
|
||||
import com.kingsrook.qqq.backend.core.actions.values.QCustomPossibleValueProvider;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.values.SearchPossibleValueSourceInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.InitializableViaCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReferenceWithProperties;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Provider settings choices, narrowed by the selected provider type.
|
||||
*******************************************************************************/
|
||||
public class AssistantSettingsPossibleValueProvider implements QCustomPossibleValueProvider<String>, InitializableViaCodeReference
|
||||
{
|
||||
private final Map<String, QPossibleValue<String>> union = new LinkedHashMap<>();
|
||||
private Boolean models;
|
||||
private Boolean providerRow;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Price rows share model choices but their id is never a provider id.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void initialize(QCodeReference codeReference)
|
||||
{
|
||||
Map<String, Serializable> properties = ((QCodeReferenceWithProperties) codeReference).getProperties();
|
||||
models = (Boolean) properties.get("models");
|
||||
providerRow = (Boolean) properties.get("providerRow");
|
||||
for(Object object : (List<?>) properties.get("union"))
|
||||
{
|
||||
QPossibleValue<?> value = (QPossibleValue<?>) object;
|
||||
union.put(String.valueOf(value.getId()), new QPossibleValue<>(String.valueOf(value.getId()), value.getLabel()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Saved values resolve across types, including disabled registry models.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QPossibleValue<String> getPossibleValue(Serializable idValue) throws QException
|
||||
{
|
||||
String id = String.valueOf(idValue);
|
||||
if(union.containsKey(id))
|
||||
{
|
||||
return (union.get(id));
|
||||
}
|
||||
if(models && idValue != null)
|
||||
{
|
||||
for(AssistantModel model : new AssistantStore().listModels())
|
||||
{
|
||||
if(id.equals(model.getModelId()))
|
||||
{
|
||||
return (new QPossibleValue<>(id, id));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Keep union fallback for callers that do not supply a known provider type.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QPossibleValue<String>> search(SearchPossibleValueSourceInput input) throws QException
|
||||
{
|
||||
Map<String, Serializable> otherValues = input.getOtherValues();
|
||||
AssistantProviderType type = otherValues == null ? null : AssistantRuntime.of(QContext.getQInstance()).getConfig().getProviderTypes().get(otherValues.get("providerType"));
|
||||
Map<String, QPossibleValue<String>> values = new LinkedHashMap<>();
|
||||
if(type == null)
|
||||
{
|
||||
values.putAll(union);
|
||||
}
|
||||
else
|
||||
{
|
||||
for(String id : models ? type.getModels() : type.getKinds())
|
||||
{
|
||||
values.put(id, new QPossibleValue<>(id, models ? id : AssistantRecordScreens.providerKindLabel(id)));
|
||||
}
|
||||
if(models && providerRow && otherValues.get("id") != null)
|
||||
{
|
||||
AssistantStore store = new AssistantStore();
|
||||
String providerId = String.valueOf(otherValues.get("id"));
|
||||
AssistantProvider provider = store.listProviders().stream()
|
||||
.filter(row -> providerId.equals(String.valueOf(row.getId())) && type.getType().equals(row.getProviderType())).findFirst().orElse(null);
|
||||
if(provider != null)
|
||||
{
|
||||
for(AssistantModel model : store.listModels())
|
||||
{
|
||||
if(provider.getId().equals(model.getProviderId()) && Boolean.TRUE.equals(model.getEnabled()) && "CHAT".equals(model.getModelKind()))
|
||||
{
|
||||
values.put(model.getModelId(), new QPossibleValue<>(model.getModelId(), model.getModelId()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return (completeCustomPVSSearch(input, new ArrayList<>(values.values())));
|
||||
}
|
||||
}
|
||||
+563
@@ -0,0 +1,563 @@
|
||||
/*
|
||||
* 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 com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QField;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Persisted assistant budget budget.
|
||||
*******************************************************************************/
|
||||
public class AssistantBudget extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "assistant_budget";
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@QField(isRequired = true, maxLength = 200)
|
||||
private String name;
|
||||
|
||||
@QField()
|
||||
private Boolean enabled;
|
||||
|
||||
@QField(isRequired = true, maxLength = 16)
|
||||
private String scopeType;
|
||||
|
||||
@QField(maxLength = 200)
|
||||
private String scopeValue;
|
||||
|
||||
@QField(isRequired = true, maxLength = 16)
|
||||
private String period;
|
||||
|
||||
@QField(isRequired = true, maxLength = 16)
|
||||
private String metric;
|
||||
|
||||
@QField(isRequired = true)
|
||||
private BigDecimal limitValue;
|
||||
|
||||
@QField()
|
||||
private Integer warnPercent;
|
||||
|
||||
@QField(maxLength = 16)
|
||||
private String atLimitAction;
|
||||
|
||||
@QField()
|
||||
private Integer downgradeModelId;
|
||||
|
||||
@QField()
|
||||
private Integer hardCeilingPercent;
|
||||
|
||||
@QField(maxLength = 2000)
|
||||
private String notes;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant modifyDate;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Default constructor.
|
||||
*******************************************************************************/
|
||||
public AssistantBudget()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Populate from a record.
|
||||
*******************************************************************************/
|
||||
public AssistantBudget(QRecord record) throws QException
|
||||
{
|
||||
populateFromQRecord(record);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for id
|
||||
*******************************************************************************/
|
||||
public Integer getId()
|
||||
{
|
||||
return (this.id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for id
|
||||
*******************************************************************************/
|
||||
public void setId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for id
|
||||
*******************************************************************************/
|
||||
public AssistantBudget withId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for name
|
||||
*******************************************************************************/
|
||||
public String getName()
|
||||
{
|
||||
return (this.name);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for name
|
||||
*******************************************************************************/
|
||||
public void setName(String name)
|
||||
{
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for name
|
||||
*******************************************************************************/
|
||||
public AssistantBudget withName(String name)
|
||||
{
|
||||
this.name = name;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for enabled
|
||||
*******************************************************************************/
|
||||
public Boolean getEnabled()
|
||||
{
|
||||
return (this.enabled);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for enabled
|
||||
*******************************************************************************/
|
||||
public void setEnabled(Boolean enabled)
|
||||
{
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for enabled
|
||||
*******************************************************************************/
|
||||
public AssistantBudget withEnabled(Boolean enabled)
|
||||
{
|
||||
this.enabled = enabled;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for scopeType
|
||||
*******************************************************************************/
|
||||
public String getScopeType()
|
||||
{
|
||||
return (this.scopeType);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for scopeType
|
||||
*******************************************************************************/
|
||||
public void setScopeType(String scopeType)
|
||||
{
|
||||
this.scopeType = scopeType;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for scopeType
|
||||
*******************************************************************************/
|
||||
public AssistantBudget withScopeType(String scopeType)
|
||||
{
|
||||
this.scopeType = scopeType;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for scopeValue
|
||||
*******************************************************************************/
|
||||
public String getScopeValue()
|
||||
{
|
||||
return (this.scopeValue);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for scopeValue
|
||||
*******************************************************************************/
|
||||
public void setScopeValue(String scopeValue)
|
||||
{
|
||||
this.scopeValue = scopeValue;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for scopeValue
|
||||
*******************************************************************************/
|
||||
public AssistantBudget withScopeValue(String scopeValue)
|
||||
{
|
||||
this.scopeValue = scopeValue;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for period
|
||||
*******************************************************************************/
|
||||
public String getPeriod()
|
||||
{
|
||||
return (this.period);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for period
|
||||
*******************************************************************************/
|
||||
public void setPeriod(String period)
|
||||
{
|
||||
this.period = period;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for period
|
||||
*******************************************************************************/
|
||||
public AssistantBudget withPeriod(String period)
|
||||
{
|
||||
this.period = period;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for metric
|
||||
*******************************************************************************/
|
||||
public String getMetric()
|
||||
{
|
||||
return (this.metric);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for metric
|
||||
*******************************************************************************/
|
||||
public void setMetric(String metric)
|
||||
{
|
||||
this.metric = metric;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for metric
|
||||
*******************************************************************************/
|
||||
public AssistantBudget withMetric(String metric)
|
||||
{
|
||||
this.metric = metric;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for limitValue
|
||||
*******************************************************************************/
|
||||
public BigDecimal getLimitValue()
|
||||
{
|
||||
return (this.limitValue);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for limitValue
|
||||
*******************************************************************************/
|
||||
public void setLimitValue(BigDecimal limitValue)
|
||||
{
|
||||
this.limitValue = limitValue;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for limitValue
|
||||
*******************************************************************************/
|
||||
public AssistantBudget withLimitValue(BigDecimal limitValue)
|
||||
{
|
||||
this.limitValue = limitValue;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for warnPercent
|
||||
*******************************************************************************/
|
||||
public Integer getWarnPercent()
|
||||
{
|
||||
return (this.warnPercent);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for warnPercent
|
||||
*******************************************************************************/
|
||||
public void setWarnPercent(Integer warnPercent)
|
||||
{
|
||||
this.warnPercent = warnPercent;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for warnPercent
|
||||
*******************************************************************************/
|
||||
public AssistantBudget withWarnPercent(Integer warnPercent)
|
||||
{
|
||||
this.warnPercent = warnPercent;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for atLimitAction
|
||||
*******************************************************************************/
|
||||
public String getAtLimitAction()
|
||||
{
|
||||
return (this.atLimitAction);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for atLimitAction
|
||||
*******************************************************************************/
|
||||
public void setAtLimitAction(String atLimitAction)
|
||||
{
|
||||
this.atLimitAction = atLimitAction;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for atLimitAction
|
||||
*******************************************************************************/
|
||||
public AssistantBudget withAtLimitAction(String atLimitAction)
|
||||
{
|
||||
this.atLimitAction = atLimitAction;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for downgradeModelId
|
||||
*******************************************************************************/
|
||||
public Integer getDowngradeModelId()
|
||||
{
|
||||
return (this.downgradeModelId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for downgradeModelId
|
||||
*******************************************************************************/
|
||||
public void setDowngradeModelId(Integer downgradeModelId)
|
||||
{
|
||||
this.downgradeModelId = downgradeModelId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for downgradeModelId
|
||||
*******************************************************************************/
|
||||
public AssistantBudget withDowngradeModelId(Integer downgradeModelId)
|
||||
{
|
||||
this.downgradeModelId = downgradeModelId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for hardCeilingPercent
|
||||
*******************************************************************************/
|
||||
public Integer getHardCeilingPercent()
|
||||
{
|
||||
return (this.hardCeilingPercent);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for hardCeilingPercent
|
||||
*******************************************************************************/
|
||||
public void setHardCeilingPercent(Integer hardCeilingPercent)
|
||||
{
|
||||
this.hardCeilingPercent = hardCeilingPercent;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for hardCeilingPercent
|
||||
*******************************************************************************/
|
||||
public AssistantBudget withHardCeilingPercent(Integer hardCeilingPercent)
|
||||
{
|
||||
this.hardCeilingPercent = hardCeilingPercent;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for notes
|
||||
*******************************************************************************/
|
||||
public String getNotes()
|
||||
{
|
||||
return (this.notes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for notes
|
||||
*******************************************************************************/
|
||||
public void setNotes(String notes)
|
||||
{
|
||||
this.notes = notes;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for notes
|
||||
*******************************************************************************/
|
||||
public AssistantBudget withNotes(String notes)
|
||||
{
|
||||
this.notes = notes;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for createDate
|
||||
*******************************************************************************/
|
||||
public Instant getCreateDate()
|
||||
{
|
||||
return (this.createDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for createDate
|
||||
*******************************************************************************/
|
||||
public void setCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for createDate
|
||||
*******************************************************************************/
|
||||
public AssistantBudget withCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for modifyDate
|
||||
*******************************************************************************/
|
||||
public Instant getModifyDate()
|
||||
{
|
||||
return (this.modifyDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for modifyDate
|
||||
*******************************************************************************/
|
||||
public void setModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for modifyDate
|
||||
*******************************************************************************/
|
||||
public AssistantBudget withModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
+359
@@ -0,0 +1,359 @@
|
||||
/*
|
||||
* 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 com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QField;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Persisted assistant budget budget event.
|
||||
*******************************************************************************/
|
||||
public class AssistantBudgetEvent extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "assistant_budget_event";
|
||||
|
||||
@QField(isEditable = false, isPrimaryKey = true)
|
||||
private Integer id;
|
||||
|
||||
@QField(isRequired = true)
|
||||
private Integer budgetId;
|
||||
|
||||
@QField(isRequired = true, maxLength = 400)
|
||||
private String scopeKey;
|
||||
|
||||
@QField(isRequired = true)
|
||||
private Instant periodStart;
|
||||
|
||||
@QField(isRequired = true, maxLength = 16)
|
||||
private String state;
|
||||
|
||||
@QField()
|
||||
private BigDecimal usedValue;
|
||||
|
||||
@QField()
|
||||
private BigDecimal limitValue;
|
||||
|
||||
@QField(maxLength = 200)
|
||||
private String userId;
|
||||
|
||||
@QField(isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Default constructor.
|
||||
*******************************************************************************/
|
||||
public AssistantBudgetEvent()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Populate from a record.
|
||||
*******************************************************************************/
|
||||
public AssistantBudgetEvent(QRecord record) throws QException
|
||||
{
|
||||
populateFromQRecord(record);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for id
|
||||
*******************************************************************************/
|
||||
public Integer getId()
|
||||
{
|
||||
return (this.id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for id
|
||||
*******************************************************************************/
|
||||
public void setId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for id
|
||||
*******************************************************************************/
|
||||
public AssistantBudgetEvent withId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for budgetId
|
||||
*******************************************************************************/
|
||||
public Integer getBudgetId()
|
||||
{
|
||||
return (this.budgetId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for budgetId
|
||||
*******************************************************************************/
|
||||
public void setBudgetId(Integer budgetId)
|
||||
{
|
||||
this.budgetId = budgetId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for budgetId
|
||||
*******************************************************************************/
|
||||
public AssistantBudgetEvent withBudgetId(Integer budgetId)
|
||||
{
|
||||
this.budgetId = budgetId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for scopeKey
|
||||
*******************************************************************************/
|
||||
public String getScopeKey()
|
||||
{
|
||||
return (this.scopeKey);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for scopeKey
|
||||
*******************************************************************************/
|
||||
public void setScopeKey(String scopeKey)
|
||||
{
|
||||
this.scopeKey = scopeKey;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for scopeKey
|
||||
*******************************************************************************/
|
||||
public AssistantBudgetEvent withScopeKey(String scopeKey)
|
||||
{
|
||||
this.scopeKey = scopeKey;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for periodStart
|
||||
*******************************************************************************/
|
||||
public Instant getPeriodStart()
|
||||
{
|
||||
return (this.periodStart);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for periodStart
|
||||
*******************************************************************************/
|
||||
public void setPeriodStart(Instant periodStart)
|
||||
{
|
||||
this.periodStart = periodStart;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for periodStart
|
||||
*******************************************************************************/
|
||||
public AssistantBudgetEvent withPeriodStart(Instant periodStart)
|
||||
{
|
||||
this.periodStart = periodStart;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for state
|
||||
*******************************************************************************/
|
||||
public String getState()
|
||||
{
|
||||
return (this.state);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for state
|
||||
*******************************************************************************/
|
||||
public void setState(String state)
|
||||
{
|
||||
this.state = state;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for state
|
||||
*******************************************************************************/
|
||||
public AssistantBudgetEvent withState(String state)
|
||||
{
|
||||
this.state = state;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for usedValue
|
||||
*******************************************************************************/
|
||||
public BigDecimal getUsedValue()
|
||||
{
|
||||
return (this.usedValue);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for usedValue
|
||||
*******************************************************************************/
|
||||
public void setUsedValue(BigDecimal usedValue)
|
||||
{
|
||||
this.usedValue = usedValue;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for usedValue
|
||||
*******************************************************************************/
|
||||
public AssistantBudgetEvent withUsedValue(BigDecimal usedValue)
|
||||
{
|
||||
this.usedValue = usedValue;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for limitValue
|
||||
*******************************************************************************/
|
||||
public BigDecimal getLimitValue()
|
||||
{
|
||||
return (this.limitValue);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for limitValue
|
||||
*******************************************************************************/
|
||||
public void setLimitValue(BigDecimal limitValue)
|
||||
{
|
||||
this.limitValue = limitValue;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for limitValue
|
||||
*******************************************************************************/
|
||||
public AssistantBudgetEvent withLimitValue(BigDecimal limitValue)
|
||||
{
|
||||
this.limitValue = limitValue;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for userId
|
||||
*******************************************************************************/
|
||||
public String getUserId()
|
||||
{
|
||||
return (this.userId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for userId
|
||||
*******************************************************************************/
|
||||
public void setUserId(String userId)
|
||||
{
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for userId
|
||||
*******************************************************************************/
|
||||
public AssistantBudgetEvent withUserId(String userId)
|
||||
{
|
||||
this.userId = userId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for createDate
|
||||
*******************************************************************************/
|
||||
public Instant getCreateDate()
|
||||
{
|
||||
return (this.createDate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for createDate
|
||||
*******************************************************************************/
|
||||
public void setCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for createDate
|
||||
*******************************************************************************/
|
||||
public AssistantBudgetEvent withCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
}
|
||||
Loaded 100 of 151 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user