feat: starter defaults, setup card and table help (no empty config tables) #48
No files matched your search
@@ -1,5 +1,13 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased - Starter defaults
|
||||
|
||||
- Ship `db/liquibase/qbit-assistant/assistant-defaults.changelog.sql`: insert-only starter rows for the policy, a default profile, four starter skills offered through it, a monthly instance budget (warn, downgrade, stop), current Anthropic prices and a disabled example scheduled task.
|
||||
- Add the Setup card to the Assistant home: every configuration table with its state (set up, needs setup, using defaults, optional) and the next step.
|
||||
- Add the `assistant_data_access` table action "Add rows for every table", which writes the default access out as editable rows.
|
||||
- Add `help/qbit-assistant.yaml` (QQQ help-bundle format) for the app and every assistant table, saying what each table is for and how its rows get created; applied by `AssistantHelp` until core help bundles ship in a release.
|
||||
- Guardrail tests: every table is used at runtime, seeded or explained, and has empty-state help; the changelog seeds a fresh schema and the seeded rows change the assistant's behavior.
|
||||
|
||||
## Unreleased - X1/Y1
|
||||
|
||||
- Add default-off `withRecordHistory(true)` host opt-in: remove only `audit*` on RC.6, or invoke core #1193 audit-read/write-deny support when available. Preserve user permissions and all other channel denials; kof22 does not opt in.
|
||||
|
||||
@@ -173,6 +173,15 @@ The key never goes in config, tables or logs. It lives in the application's secr
|
||||
|
||||
The provider's `toString` never contains the key. Its errors carry only the HTTP status and the API error type.
|
||||
|
||||
### Starter defaults
|
||||
|
||||
Include `db/liquibase/qbit-assistant/assistant-defaults.changelog.sql` (shipped in
|
||||
the jar) after your assistant DDL. A new installation then has a policy row, a
|
||||
default profile with four starter skills, a $50/month instance budget, current
|
||||
Anthropic prices and a disabled example scheduled task. Every row is insert-only,
|
||||
so administrators can edit them freely. The home's **Setup** card shows what is
|
||||
set up and the next step for the rest (usually: add a provider).
|
||||
|
||||
## Provider settings (the Providers table)
|
||||
|
||||
Admins set up providers on the **Providers** screen (`assistant_provider`), a standard QQQ table:
|
||||
|
||||
+22
@@ -1,5 +1,27 @@
|
||||
# Upgrading qbit-assistant
|
||||
|
||||
## Starter defaults, setup card and table help
|
||||
|
||||
The jar now ships `db/liquibase/qbit-assistant/assistant-defaults.changelog.sql`.
|
||||
Include it from the host's master changelog **after** the changesets that create
|
||||
the `assistant_*` tables:
|
||||
|
||||
```yaml
|
||||
- include: { file: db/liquibase/qbit-assistant/assistant-defaults.changelog.sql }
|
||||
```
|
||||
|
||||
It is insert-only (every row is skipped when its name or key exists, so edits
|
||||
survive) and guarded by preconditions. It adds: one `default` policy row (only
|
||||
when the host has none), a `default` profile, four starter skills linked to it,
|
||||
a **$50/month instance budget** (warn 80%, downgrade 100%, stop 120%), the
|
||||
current Anthropic list prices and a disabled example scheduled task. The budget
|
||||
is enforced from the next question: raise its limit, or turn it off, before
|
||||
deploying if $50 a month is too low for the host.
|
||||
|
||||
No DDL changes. New: the Assistant home's Setup card, the `assistant_data_access`
|
||||
table action **Add rows for every table** (`assistantDataAccessAddDefaults`,
|
||||
admin only) and bundle help on every assistant table (`help/qbit-assistant.yaml`).
|
||||
|
||||
## X1/Y1: channel wall and audit attribution
|
||||
|
||||
Requires QQQ 4.1.0-RC.6. Every assistant table, process, report and widget is
|
||||
|
||||
@@ -73,6 +73,13 @@
|
||||
<artifactId>archunit-junit5</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- AssistantDefaultsChangelogTest runs the shipped seed changelog against H2 (PostgreSQL mode). -->
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<version>2.3.232</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
+4
-1
@@ -26,10 +26,12 @@ 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.AssistantChannelWall;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantHelp;
|
||||
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.DataAccessDefaultsProcess;
|
||||
import com.kingsrook.qbits.assistant.processes.DiscoverModelsProcess;
|
||||
import com.kingsrook.qbits.assistant.processes.KnowledgeIndexProcess;
|
||||
import com.kingsrook.qbits.assistant.processes.ScheduledTaskProcess;
|
||||
@@ -180,7 +182,7 @@ public class AssistantQBitProducer implements QBitMetaDataProducer<AssistantQBit
|
||||
QProcessMetaData testConnection = TestConnectionProcess.process();
|
||||
testConnection.setSourceQBitName(qBitMetaData.getName());
|
||||
qInstance.addProcess(testConnection);
|
||||
for(QProcessMetaData process : List.of(DiscoverModelsProcess.process(), TestModelConnectionProcess.process()))
|
||||
for(QProcessMetaData process : List.of(DiscoverModelsProcess.process(), TestModelConnectionProcess.process(), DataAccessDefaultsProcess.process()))
|
||||
{
|
||||
process.setSourceQBitName(qBitMetaData.getName());
|
||||
qInstance.addProcess(process);
|
||||
@@ -202,6 +204,7 @@ public class AssistantQBitProducer implements QBitMetaDataProducer<AssistantQBit
|
||||
|
||||
QAppMetaData app = builder.buildAdminApp(tables);
|
||||
qInstance.addApp(app);
|
||||
AssistantHelp.apply(qInstance);
|
||||
|
||||
AssistantChannelWall.register(qInstance);
|
||||
if(Boolean.TRUE.equals(qBitConfig.getRecordHistory()))
|
||||
|
||||
+13
@@ -75,6 +75,7 @@ public class AssistantHomeData
|
||||
private final List<Totals> byProvider = new ArrayList<>();
|
||||
|
||||
private Long fallbackCount = 0L;
|
||||
private List<AssistantSetupStatus.Item> setup = List.of();
|
||||
private Boolean hipaaMode;
|
||||
private Integer hipaaRetentionDays;
|
||||
private LocalDate today;
|
||||
@@ -313,6 +314,7 @@ public class AssistantHomeData
|
||||
data.providers.add(new ProviderHealth(row, type == null ? row.getProviderType() : type.getLabel(), status, (status.startsWith("Set") || status.startsWith("Not needed")), service.validate(row), active != null && active.getId().equals(row.getId()),
|
||||
providerMonth.getOrDefault(row.getId(), 0L), ProviderService.capMicros(row), service.kind(row), service.location(row), service.models(row.getId()).stream().filter(m -> Boolean.TRUE.equals(m.getEnabled())).count()));
|
||||
}
|
||||
data.setup = AssistantSetupStatus.load(runtime, data.providers);
|
||||
return (data);
|
||||
}
|
||||
|
||||
@@ -578,4 +580,15 @@ public class AssistantHomeData
|
||||
{
|
||||
return (fallbackCount);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Setup card: each configuration table, its state and the next step.
|
||||
*******************************************************************************/
|
||||
public List<AssistantSetupStatus.Item> getSetup()
|
||||
{
|
||||
return (setup);
|
||||
}
|
||||
|
||||
}
|
||||
+47
@@ -90,6 +90,7 @@ public final class AssistantHomeView
|
||||
public static String render(AssistantHomeData data, String prefix)
|
||||
{
|
||||
StringBuilder html = new StringBuilder("<div class=\"assistant-home\" style=\"" + ROOT + "\">");
|
||||
setup(html, data, prefix);
|
||||
status(html, data, prefix);
|
||||
providers(html, data, prefix);
|
||||
html.append("<div style=\"").append(GRID).append("\">");
|
||||
@@ -111,6 +112,52 @@ public final class AssistantHomeView
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Setup card: each part of the assistant, set up or not, and the next
|
||||
** step, linked to its table.
|
||||
*******************************************************************************/
|
||||
private static void setup(StringBuilder html, AssistantHomeData data, String prefix)
|
||||
{
|
||||
if(data.getSetup().isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
open(html, "Setup");
|
||||
long todo = data.getSetup().stream().filter(i -> i.state() == AssistantSetupStatus.State.TODO).count();
|
||||
html.append("<p style=\"margin:0 0 .6rem;font-size:.9rem;\">")
|
||||
.append(todo == 0 ? "Everything the assistant needs is set up." : esc(todo + (todo == 1 ? " item needs" : " items need") + " attention."))
|
||||
.append("</p><ul style=\"list-style:none;margin:0;padding:0;\">");
|
||||
for(AssistantSetupStatus.Item item : data.getSetup())
|
||||
{
|
||||
String color = switch(item.state())
|
||||
{
|
||||
case OK -> GREEN;
|
||||
case TODO -> RED;
|
||||
case DEFAULT -> AMBER;
|
||||
case OPTIONAL -> GREY;
|
||||
};
|
||||
String label = switch(item.state())
|
||||
{
|
||||
case OK -> "Set up";
|
||||
case TODO -> "Needs setup";
|
||||
case DEFAULT -> "Using defaults";
|
||||
case OPTIONAL -> "Optional";
|
||||
};
|
||||
html.append("<li style=\"").append(ROW).append("\"><div style=\"display:flex;flex-wrap:wrap;align-items:center;gap:.5rem .75rem;\">")
|
||||
.append("<strong><a style=\"").append(LINK).append("\" href=\"").append(esc(prefix + "/" + item.tableName())).append("\">").append(esc(item.label())).append("</a></strong>")
|
||||
.append(chip(color, label)).append("</div><div style=\"font-size:.9rem;margin-top:.35rem;\">").append(esc(item.detail())).append("</div>");
|
||||
if(item.nextStep() != null)
|
||||
{
|
||||
html.append("<div style=\"").append(MUTED).append("font-size:.9rem;margin-top:.2rem;\">Next: ").append(esc(item.nextStep())).append("</div>");
|
||||
}
|
||||
html.append("</li>");
|
||||
}
|
||||
html.append("</ul>");
|
||||
close(html);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The status row: is the assistant answering, with what, at what cost.
|
||||
*******************************************************************************/
|
||||
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
/*
|
||||
* 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.home;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.assistant.AssistantRuntime;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantBudget;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantDataAccess;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantModelPrice;
|
||||
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.AssistantScheduledTask;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantSkill;
|
||||
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Setup card on the Assistant home: each configuration table, whether it
|
||||
** is set up, and the next step. Only reads the QBit's own configuration
|
||||
** tables (small), as the signed-in administrator.
|
||||
*******************************************************************************/
|
||||
public final class AssistantSetupStatus
|
||||
{
|
||||
private static final int LIMIT = 1000;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** OK: working. TODO: the assistant needs it. DEFAULT: working on built-in
|
||||
** defaults (nothing to do). OPTIONAL: a feature that is off until set up.
|
||||
*******************************************************************************/
|
||||
public enum State
|
||||
{
|
||||
OK, TODO, DEFAULT, OPTIONAL
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One line of the card.
|
||||
*******************************************************************************/
|
||||
public record Item(String label, String tableName, State state, String detail, String nextStep)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private AssistantSetupStatus()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build the items. `providers` is what the home already loaded.
|
||||
*******************************************************************************/
|
||||
public static List<Item> load(AssistantRuntime runtime, List<AssistantHomeData.ProviderHealth> providers) throws QException
|
||||
{
|
||||
AssistantStore store = runtime.getStore();
|
||||
List<Item> items = new ArrayList<>();
|
||||
|
||||
///////////////
|
||||
// providers //
|
||||
///////////////
|
||||
AssistantHomeData.ProviderHealth active = providers.stream().filter(AssistantHomeData.ProviderHealth::active).findFirst().orElse(null);
|
||||
if(active != null && active.keyPresent() && active.problems().isEmpty())
|
||||
{
|
||||
items.add(new Item("Provider", AssistantProvider.TABLE_NAME, State.OK, active.row().getName() + " answers questions.", null));
|
||||
}
|
||||
else if(providers.isEmpty())
|
||||
{
|
||||
items.add(new Item("Provider", AssistantProvider.TABLE_NAME, State.TODO, "No provider yet.", "Add one with New: pick the type and model, and name the environment variable that holds the API key."));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.add(new Item("Provider", AssistantProvider.TABLE_NAME, State.TODO, "The default provider cannot be used.", "Turn one on, set it as default, make sure its key variable is set, then run Test connection."));
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////
|
||||
// prices: the active cloud model needs one, or it costs $0 //
|
||||
///////////////////////////////////////////////////////////////
|
||||
List<AssistantModelPrice> prices = store.query(AssistantModelPrice.TABLE_NAME, new QQueryFilter().withLimit(LIMIT), AssistantModelPrice.class);
|
||||
boolean activePriced = active == null || "LOCAL".equals(active.location()) || prices.stream().anyMatch(p ->
|
||||
p.getProviderType() != null && p.getProviderType().equals(active.row().getProviderType()) && p.getModel() != null && p.getModel().equals(active.row().getModel()));
|
||||
items.add(prices.isEmpty()
|
||||
? new Item("Model prices", AssistantModelPrice.TABLE_NAME, State.TODO, "No prices: every call costs $0, so cost budgets never trigger.", "Add the list price of each cloud model you use.")
|
||||
: activePriced
|
||||
? new Item("Model prices", AssistantModelPrice.TABLE_NAME, State.OK, prices.size() + " model price" + (prices.size() == 1 ? "" : "s") + ".", null)
|
||||
: new Item("Model prices", AssistantModelPrice.TABLE_NAME, State.TODO, "The default model (" + active.row().getModel() + ") has no price, so its calls cost $0.", "Add a price row for it."));
|
||||
|
||||
////////////
|
||||
// policy //
|
||||
////////////
|
||||
List<AssistantPolicy> policies = store.query(AssistantPolicy.TABLE_NAME, new QQueryFilter().withLimit(1), AssistantPolicy.class);
|
||||
if(policies.isEmpty())
|
||||
{
|
||||
items.add(new Item("Policy", AssistantPolicy.TABLE_NAME, State.DEFAULT, "No policy row: the code configuration applies.", "Add one to control the kill switch, confirmations and tool limits here."));
|
||||
}
|
||||
else if(Boolean.TRUE.equals(policies.get(0).getKillSwitch()))
|
||||
{
|
||||
items.add(new Item("Policy", AssistantPolicy.TABLE_NAME, State.TODO, "The kill switch is on: the assistant answers nothing.", "Turn it off on the policy row when ready."));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.add(new Item("Policy", AssistantPolicy.TABLE_NAME, State.OK, "Writes " + (Boolean.FALSE.equals(policies.get(0).getWritesRequireConfirmation()) ? "do not wait" : "wait") + " for confirmation.", null));
|
||||
}
|
||||
|
||||
////////////////////////
|
||||
// profiles and skills //
|
||||
////////////////////////
|
||||
List<AssistantProfile> profiles = enabled(store.query(AssistantProfile.TABLE_NAME, new QQueryFilter().withLimit(LIMIT), AssistantProfile.class));
|
||||
boolean hasDefault = profiles.stream().anyMatch(p -> Boolean.TRUE.equals(p.getIsDefault()));
|
||||
items.add(profiles.isEmpty()
|
||||
? new Item("Profiles", AssistantProfile.TABLE_NAME, State.DEFAULT, "No profile: the built-in assistant answers, with no skills.", "Add a default profile to name the assistant, set its tone and offer skills.")
|
||||
: new Item("Profiles", AssistantProfile.TABLE_NAME, hasDefault ? State.OK : State.DEFAULT,
|
||||
profiles.size() + " profile" + (profiles.size() == 1 ? "" : "s") + (hasDefault ? ", with a default." : ", none of them the default."),
|
||||
hasDefault ? null : "Mark one as the default so it applies everywhere else."));
|
||||
|
||||
Set<Integer> linkedSkillIds = new HashSet<>();
|
||||
Set<Integer> profileIds = new HashSet<>(profiles.stream().map(AssistantProfile::getId).toList());
|
||||
for(AssistantProfileSkill link : store.query(AssistantProfileSkill.TABLE_NAME, new QQueryFilter().withLimit(LIMIT * 5), AssistantProfileSkill.class))
|
||||
{
|
||||
if(profileIds.contains(link.getProfileId()))
|
||||
{
|
||||
linkedSkillIds.add(link.getSkillId());
|
||||
}
|
||||
}
|
||||
List<AssistantSkill> skills = enabled(store.query(AssistantSkill.TABLE_NAME, new QQueryFilter().withLimit(LIMIT), AssistantSkill.class));
|
||||
long offered = skills.stream().filter(s -> linkedSkillIds.contains(s.getId())).count();
|
||||
items.add(skills.isEmpty()
|
||||
? new Item("Skills", AssistantSkill.TABLE_NAME, State.OPTIONAL, "No skills.", "Write know-how for your business terms, then link it to a profile.")
|
||||
: offered == 0
|
||||
? new Item("Skills", AssistantSkill.TABLE_NAME, State.TODO, skills.size() + " skill" + (skills.size() == 1 ? "" : "s") + ", but none is linked to a turned-on profile, so the model never sees them.", "Link them to a profile (Profile skills).")
|
||||
: new Item("Skills", AssistantSkill.TABLE_NAME, State.OK, offered + " of " + skills.size() + " skill" + (skills.size() == 1 ? " is" : "s are") + " offered through a profile.", null));
|
||||
|
||||
/////////////
|
||||
// budgets //
|
||||
/////////////
|
||||
List<AssistantBudget> budgets = store.budgets();
|
||||
items.add(budgets.isEmpty()
|
||||
? new Item("Budgets", AssistantBudget.TABLE_NAME, State.TODO, "No budget: spending is limited only by the policy's configured caps.", "Add a monthly instance budget (warn, downgrade, stop).")
|
||||
: new Item("Budgets", AssistantBudget.TABLE_NAME, State.OK, budgets.size() + " budget" + (budgets.size() == 1 ? "" : "s") + " checked before every question.", null));
|
||||
|
||||
/////////////////
|
||||
// data access //
|
||||
/////////////////
|
||||
int accessRows = store.query(AssistantDataAccess.TABLE_NAME, new QQueryFilter().withLimit(LIMIT), AssistantDataAccess.class).size();
|
||||
items.add(accessRows == 0
|
||||
? new Item("Data access", AssistantDataAccess.TABLE_NAME, State.DEFAULT, "Defaults: the assistant may read what each user can read; it proposes no changes.",
|
||||
"Run Add rows for every table to review them, then raise tables to READ_WRITE where it may propose changes.")
|
||||
: new Item("Data access", AssistantDataAccess.TABLE_NAME, State.OK, accessRows + " row" + (accessRows == 1 ? "" : "s") + "; tables without a row use the default.", null));
|
||||
|
||||
///////////////////////////////////////
|
||||
// optional: knowledge and schedules //
|
||||
///////////////////////////////////////
|
||||
long sources = enabled(store.query(AssistantRagSource.TABLE_NAME, new QQueryFilter().withLimit(LIMIT), AssistantRagSource.class)).size();
|
||||
items.add(sources == 0
|
||||
? new Item("Knowledge sources", AssistantRagSource.TABLE_NAME, State.OPTIONAL, "Off: search by meaning is not offered.", "Add a source (a readable table, its text fields and an embedding model), then index it.")
|
||||
: new Item("Knowledge sources", AssistantRagSource.TABLE_NAME, State.OK, sources + " source" + (sources == 1 ? "" : "s") + " turned on.", null));
|
||||
|
||||
List<AssistantScheduledTask> tasks = store.query(AssistantScheduledTask.TABLE_NAME, new QQueryFilter().withLimit(LIMIT), AssistantScheduledTask.class);
|
||||
long onTasks = tasks.stream().filter(t -> Boolean.TRUE.equals(t.getEnabled())).count();
|
||||
items.add(onTasks == 0
|
||||
? new Item("Scheduled tasks", AssistantScheduledTask.TABLE_NAME, State.OPTIONAL, tasks.isEmpty() ? "None." : tasks.size() + " task" + (tasks.size() == 1 ? "" : "s") + ", all turned off.",
|
||||
"Set a run-as user and an allowed credential variable on a task, then turn it on.")
|
||||
: new Item("Scheduled tasks", AssistantScheduledTask.TABLE_NAME, State.OK, onTasks + " task" + (onTasks == 1 ? "" : "s") + " turned on.", null));
|
||||
return (items);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Rows not explicitly turned off.
|
||||
*******************************************************************************/
|
||||
private static <T extends QRecordEntity> List<T> enabled(List<T> rows)
|
||||
{
|
||||
return (rows.stream().filter(row -> !Boolean.FALSE.equals(row.toQRecord().getValueBoolean("enabled"))).toList());
|
||||
}
|
||||
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.metadata;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
||||
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.help.QHelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Applies help/qbit-assistant.yaml (the QQQ help-bundle format) to the
|
||||
** Assistant app and its assistant_* tables: what each table is for and how
|
||||
** its rows get created, shown on the table screen and when it is empty.
|
||||
** STAND-IN(core=#1198): replace with HelpBundles.register once the pinned
|
||||
** QQQ release includes it; the bundle file stays the same.
|
||||
*******************************************************************************/
|
||||
public final class AssistantHelp
|
||||
{
|
||||
public static final String RESOURCE = "/help/qbit-assistant.yaml";
|
||||
public static final String SLOT = "help";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private AssistantHelp()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The bundle's entries by key, validated (fails startup if malformed).
|
||||
*******************************************************************************/
|
||||
public static Map<String, JsonNode> entries() throws QException
|
||||
{
|
||||
try(InputStream stream = AssistantHelp.class.getResourceAsStream(RESOURCE))
|
||||
{
|
||||
if(stream == null)
|
||||
{
|
||||
throw (new IOException("missing resource"));
|
||||
}
|
||||
JsonNode bundle = new ObjectMapper(new YAMLFactory()).readTree(stream);
|
||||
if(!"qbit-assistant".equals(bundle.path("bundle").asText()) || !"QBIT".equals(bundle.path("layer").asText()))
|
||||
{
|
||||
throw (new IOException("invalid bundle header"));
|
||||
}
|
||||
Map<String, JsonNode> entries = new LinkedHashMap<>();
|
||||
for(JsonNode entry : bundle.path("entries"))
|
||||
{
|
||||
String key = entry.path("key").asText();
|
||||
String summary = entry.path("summary").asText();
|
||||
if(key.isBlank() || summary.isBlank() || summary.length() > 120 || entry.path("content").asText().isBlank() || entries.put(key, entry) != null)
|
||||
{
|
||||
throw (new IOException("invalid or duplicate entry: " + key));
|
||||
}
|
||||
}
|
||||
return (entries);
|
||||
}
|
||||
catch(IOException | RuntimeException e)
|
||||
{
|
||||
throw (new QException("Could not load " + RESOURCE, e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply the help to the app and every assistant_* table in the instance.
|
||||
** A host's own help on a table is kept (the bundle only fills the slot).
|
||||
*******************************************************************************/
|
||||
public static void apply(QInstance qInstance) throws QException
|
||||
{
|
||||
Map<String, JsonNode> entries = entries();
|
||||
for(QTableMetaData table : qInstance.getTables().values())
|
||||
{
|
||||
JsonNode entry = entries.get("table:" + table.getName() + ";slot:" + SLOT);
|
||||
if(entry != null && (table.getHelpContent() == null || !table.getHelpContent().containsKey(SLOT)))
|
||||
{
|
||||
table.withHelpContent(SLOT, content(entry));
|
||||
}
|
||||
}
|
||||
QAppMetaData app = qInstance.getApp(AssistantMetaDataBuilder.APP_NAME);
|
||||
JsonNode appEntry = entries.get("app:" + AssistantMetaDataBuilder.APP_NAME + ";slot:" + SLOT);
|
||||
if(app != null && appEntry != null && (app.getHelpContent() == null || !app.getHelpContent().containsKey(SLOT)))
|
||||
{
|
||||
app.withHelpContent(SLOT, content(appEntry));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Summary as the first paragraph, then the content, as markdown.
|
||||
*******************************************************************************/
|
||||
static QHelpContent content(JsonNode entry)
|
||||
{
|
||||
return (new QHelpContent().withContentAsMarkdown(entry.path("summary").asText().strip() + "\n\n" + entry.path("content").asText().strip()));
|
||||
}
|
||||
|
||||
}
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.assistant.processes;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.assistant.AssistantRuntime;
|
||||
import com.kingsrook.qbits.assistant.access.DataAccessMode;
|
||||
import com.kingsrook.qbits.assistant.access.DataAccessService;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantDataAccess;
|
||||
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractExtractStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** "Add rows for every table": a table action on assistant_data_access that
|
||||
** writes one row per application table that has none yet, with the access
|
||||
** the assistant already gets by default (READ, or NONE where the table's AI
|
||||
** exposure is NONE). It changes no behavior on its own; it makes the defaults
|
||||
** visible and editable. Existing rows are never touched. Standard streamed
|
||||
** ETL: Review lists what will be added, Result what was added.
|
||||
*******************************************************************************/
|
||||
public final class DataAccessDefaultsProcess
|
||||
{
|
||||
public static final String NAME = "assistantDataAccessAddDefaults";
|
||||
|
||||
public static final String NOTE = "Added by 'Add rows for every table': the assistant's default for this table. The user's own permissions still apply.";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private DataAccessDefaultsProcess()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The process: a table-level action (no records need to be selected).
|
||||
*******************************************************************************/
|
||||
public static QProcessMetaData process()
|
||||
{
|
||||
Map<String, Serializable> values = new HashMap<>();
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, AssistantDataAccess.TABLE_NAME);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, AssistantDataAccess.TABLE_NAME);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, false);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE,
|
||||
"These rows will be added: one per table that has none, with the access the assistant already has by default. Existing rows are not changed.");
|
||||
QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(Extract.class, Transform.class, Load.class, values);
|
||||
process.setName(NAME);
|
||||
process.setLabel("Add rows for every table");
|
||||
process.setIcon(new QIcon("playlist_add"));
|
||||
process.setTableName(AssistantDataAccess.TABLE_NAME);
|
||||
process.setMinInputRecords(0);
|
||||
process.setPermissionRules(AssistantMetaDataBuilder.adminRules());
|
||||
return (process);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The rows that are missing: one per application table without a row.
|
||||
*******************************************************************************/
|
||||
public static List<AssistantDataAccess> missingRows(QInstance qInstance, DataAccessService access, AssistantStore store) throws QException
|
||||
{
|
||||
Set<String> existing = new HashSet<>();
|
||||
for(AssistantDataAccess row : store.query(AssistantDataAccess.TABLE_NAME, new QQueryFilter().withLimit(10_000), AssistantDataAccess.class))
|
||||
{
|
||||
if(row.getObjectType() == null || AssistantDataAccess.TYPE_TABLE.equals(row.getObjectType()))
|
||||
{
|
||||
existing.add(row.getObjectName());
|
||||
}
|
||||
}
|
||||
|
||||
List<AssistantDataAccess> rs = new ArrayList<>();
|
||||
for(QTableMetaData table : qInstance.getTables().values().stream().sorted((a, b) -> a.getName().compareTo(b.getName())).toList())
|
||||
{
|
||||
if(table.getName().startsWith("assistant_") || existing.contains(table.getName()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
DataAccessMode mode = access.forTable(table.getName()).mode();
|
||||
rs.add(new AssistantDataAccess().withObjectType(AssistantDataAccess.TYPE_TABLE).withObjectName(table.getName())
|
||||
.withMode(mode.name()).withNotes(NOTE));
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The running assistant (or a clear error).
|
||||
*******************************************************************************/
|
||||
private static AssistantRuntime runtime() throws QException
|
||||
{
|
||||
AssistantRuntime runtime = AssistantRuntime.of(QContext.getQInstance());
|
||||
if(runtime == null)
|
||||
{
|
||||
throw (new QException("The assistant is not running on this server."));
|
||||
}
|
||||
return (runtime);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Extract: the missing rows, computed (no query of the selected records).
|
||||
*******************************************************************************/
|
||||
public static class Extract extends AbstractExtractStep
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Integer doCount(RunBackendStepInput input) throws QException
|
||||
{
|
||||
AssistantRuntime runtime = runtime();
|
||||
return (missingRows(QContext.getQInstance(), runtime.getDataAccessService(), runtime.getStore()).size());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
AssistantRuntime runtime = runtime();
|
||||
for(AssistantDataAccess row : missingRows(QContext.getQInstance(), runtime.getDataAccessService(), runtime.getStore()))
|
||||
{
|
||||
getRecordPipe().addRecord(row.toQRecord());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Transform: counts by mode for the review screen.
|
||||
*******************************************************************************/
|
||||
public static class Transform extends AbstractTransformStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final Map<String, ProcessSummaryLine> byMode = new HashMap<>();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
for(QRecord record : input.getRecords())
|
||||
{
|
||||
String mode = record.getValueString("mode");
|
||||
byMode.computeIfAbsent(mode, m -> DiscoverModelsProcess.line("NONE".equals(m) ? Status.INFO : Status.OK,
|
||||
"table will get a row with access " + m, "tables will get a row with access " + m,
|
||||
"table got a row with access " + m, "tables got a row with access " + m)).incrementCountAndAddPrimaryKey(record.getValue("objectName"));
|
||||
output.getRecords().add(record);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
byMode.values().forEach(line -> line.addSelfToListIfAnyCount(lines));
|
||||
if(lines.isEmpty())
|
||||
{
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, null, "Every table already has a row."));
|
||||
}
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Load: insert through the store (the user's own session; admin only).
|
||||
*******************************************************************************/
|
||||
public static class Load extends AbstractLoadStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ProcessSummaryLine added = DiscoverModelsProcess.line(Status.OK, "row was added", "rows were added", "row was added", "rows were added");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
AssistantRuntime runtime = runtime();
|
||||
for(QRecord record : input.getRecords())
|
||||
{
|
||||
AssistantDataAccess row = runtime.getStore().insert(new AssistantDataAccess(record));
|
||||
added.incrementCountAndAddPrimaryKey(row.getId());
|
||||
output.getRecords().add(row.toQRecord());
|
||||
}
|
||||
runtime.getDataAccessService().invalidate();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
added.addSelfToListIfAnyCount(lines);
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
-- liquibase formatted sql
|
||||
|
||||
-- qbit-assistant starter defaults: a working assistant out of the box.
|
||||
-- Include it from the host's master changelog AFTER the changesets that create
|
||||
-- the assistant_* tables (docs/schema-postgres.sql):
|
||||
--
|
||||
-- - include: { file: db/liquibase/qbit-assistant/assistant-defaults.changelog.sql }
|
||||
--
|
||||
-- Rules for this file:
|
||||
-- * insert-only: every row is inserted only when its natural key is absent,
|
||||
-- so an administrator's edits always survive and nothing is ever updated;
|
||||
-- * no secrets: environment variable NAMES only, never values;
|
||||
-- * portable SQL (PostgreSQL and H2): INSERT ... SELECT ... WHERE NOT EXISTS;
|
||||
-- * every changeset has a precondition on its table, so a host that has not
|
||||
-- created a table yet simply skips it (MARK_RAN).
|
||||
-- AssistantDefaultsChangelogTest runs this file twice against a fresh schema.
|
||||
|
||||
-- changeset qbit-assistant:defaults-1
|
||||
-- preconditions onFail:MARK_RAN onError:MARK_RAN
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM assistant_policy
|
||||
-- comment: The single policy row, only when the host has none. Nulls inherit the code configuration (models, config budgets). Writes wait for the user's confirmation, PII is redacted, 50 rows per call.
|
||||
INSERT INTO assistant_policy (name, kill_switch, writes_require_confirmation, redact_pii, max_rows_per_call, create_date, modify_date)
|
||||
SELECT 'default', FALSE, TRUE, TRUE, 50, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
WHERE NOT EXISTS (SELECT 1 FROM assistant_policy);
|
||||
-- rollback DELETE FROM assistant_policy WHERE name = 'default';
|
||||
|
||||
-- changeset qbit-assistant:defaults-2
|
||||
-- preconditions onFail:MARK_RAN onError:MARK_RAN
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM assistant_profile WHERE name = 'default'
|
||||
-- comment: The default profile (persona): used for every app and user that no more specific profile matches.
|
||||
INSERT INTO assistant_profile (name, display_name, icon, personality, greeting, starter_prompts, is_default, enabled, create_date, modify_date)
|
||||
SELECT 'default', 'Assistant', 'auto_awesome',
|
||||
'Be concise, friendly and plain-spoken. Lead with the answer, then the detail that supports it. When something is ambiguous, ask one short question instead of guessing. Offer a link to the list or record you used so the user can check it.',
|
||||
'Hi! I can find and count records, explain a record and its history, and open the right screen for you. What would you like to know?',
|
||||
'What can you help me with?
|
||||
Which tables can I look at?
|
||||
What changed in the last 7 days?',
|
||||
TRUE, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
WHERE NOT EXISTS (SELECT 1 FROM assistant_profile WHERE name = 'default');
|
||||
-- rollback DELETE FROM assistant_profile WHERE name = 'default';
|
||||
|
||||
-- changeset qbit-assistant:defaults-3
|
||||
-- preconditions onFail:MARK_RAN onError:MARK_RAN
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM assistant_skill WHERE name = 'find-and-count'
|
||||
-- comment: Starter skill. Skills are know-how text only: they never grant tools, data or permissions.
|
||||
INSERT INTO assistant_skill (name, description, instructions, enabled, trigger_keywords, create_date, modify_date)
|
||||
SELECT 'find-and-count', 'Finding, filtering and counting records.',
|
||||
'When the user asks to find, list or count records:
|
||||
1. If you do not already know the table and field names, call list_tables and describe_table first. Never guess names.
|
||||
2. For "how many" questions use count_records; for lists use query_records with a filter, not search_records.
|
||||
3. Say in one short phrase which filter you used (for example "open orders created this month").
|
||||
4. Always include a filterLinks item for the list you counted, so the user can open exactly those records.
|
||||
5. If the result was truncated, say so and offer the link instead of listing more.',
|
||||
TRUE, 'find,show,list,which,how many,count,filter,search,look up', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
WHERE NOT EXISTS (SELECT 1 FROM assistant_skill WHERE name = 'find-and-count');
|
||||
-- rollback DELETE FROM assistant_skill WHERE name = 'find-and-count';
|
||||
|
||||
-- changeset qbit-assistant:defaults-4
|
||||
-- preconditions onFail:MARK_RAN onError:MARK_RAN
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM assistant_skill WHERE name = 'explain-a-record'
|
||||
-- comment: Starter skill: summarizing one record and its change history.
|
||||
INSERT INTO assistant_skill (name, description, instructions, enabled, trigger_keywords, create_date, modify_date)
|
||||
SELECT 'explain-a-record', 'Summarizing a record and who changed it.',
|
||||
'When the user asks about one record (what it is, its status, what changed, who changed it):
|
||||
1. Read it with get_record. If they ask what changed or who changed it, also call get_record_history.
|
||||
2. Summarize in plain language: what the record is, its current state, and the few fields that matter for the question. Use the field labels, not the field names.
|
||||
3. For history, list changes newest first as "date - who - field: old -> new", at most ten, and say if there are more.
|
||||
4. Link to the record.',
|
||||
TRUE, 'explain,summarize,summary,tell me about,what is,status,history,changed,who changed,audit', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
WHERE NOT EXISTS (SELECT 1 FROM assistant_skill WHERE name = 'explain-a-record');
|
||||
-- rollback DELETE FROM assistant_skill WHERE name = 'explain-a-record';
|
||||
|
||||
-- changeset qbit-assistant:defaults-5
|
||||
-- preconditions onFail:MARK_RAN onError:MARK_RAN
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM assistant_skill WHERE name = 'safe-changes'
|
||||
-- comment: Starter skill: proposing changes that the user confirms.
|
||||
INSERT INTO assistant_skill (name, description, instructions, enabled, trigger_keywords, create_date, modify_date)
|
||||
SELECT 'safe-changes', 'Proposing creates, updates and deletes safely.',
|
||||
'When the user asks you to create, change or delete records:
|
||||
1. If you have no create_record, update_record or delete_record tool for that table, say the assistant cannot change it and offer to open the screen where they can.
|
||||
2. Read the record first, then restate exactly what will change (field: old -> new) before proposing it.
|
||||
3. Propose one change at a time unless the user clearly asked for several. Changes wait for the user to confirm them in the panel; never say a change is done until a tool result says so.
|
||||
4. For many rows from a file, use the bulk load tools and always run bulk_load_dry_run and show its summary before bulk_load_run.',
|
||||
TRUE, 'change,update,set,edit,create,add,new,delete,remove,fix,rename,import,load', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
WHERE NOT EXISTS (SELECT 1 FROM assistant_skill WHERE name = 'safe-changes');
|
||||
-- rollback DELETE FROM assistant_skill WHERE name = 'safe-changes';
|
||||
|
||||
-- changeset qbit-assistant:defaults-6
|
||||
-- preconditions onFail:MARK_RAN onError:MARK_RAN
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM assistant_skill WHERE name = 'access-questions'
|
||||
-- comment: Starter skill: explaining missing or denied data without guessing.
|
||||
INSERT INTO assistant_skill (name, description, instructions, enabled, trigger_keywords, create_date, modify_date)
|
||||
SELECT 'access-questions', 'Explaining why data is missing or not available.',
|
||||
'When the user cannot find something, or a tool returns DENIED or NOT_FOUND:
|
||||
1. Say plainly that the information is not available to them through the assistant. Do not guess what it contains and do not try other tools to get around it.
|
||||
2. Explain the likely reason in one sentence: their permissions, a filter that excludes it, or the table being off for the assistant.
|
||||
3. Suggest the next step: check the filter, open the list screen, or ask an administrator for access.',
|
||||
TRUE, 'cannot see,can''t see,missing,not found,denied,access,permission,not allowed,hidden', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
WHERE NOT EXISTS (SELECT 1 FROM assistant_skill WHERE name = 'access-questions');
|
||||
-- rollback DELETE FROM assistant_skill WHERE name = 'access-questions';
|
||||
|
||||
-- changeset qbit-assistant:defaults-7
|
||||
-- preconditions onFail:MARK_RAN onError:MARK_RAN
|
||||
-- precondition-table-exists tableName:assistant_profile_skill
|
||||
-- comment: Offer the starter skills through the default profile (skills reach the model only through a profile). Pairs that already exist are left alone.
|
||||
INSERT INTO assistant_profile_skill (profile_id, skill_id, create_date, modify_date)
|
||||
SELECT p.id, s.id, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
FROM assistant_profile p, assistant_skill s
|
||||
WHERE p.name = 'default'
|
||||
AND s.name IN ('find-and-count', 'explain-a-record', 'safe-changes', 'access-questions')
|
||||
AND NOT EXISTS (SELECT 1 FROM assistant_profile_skill ps WHERE ps.profile_id = p.id AND ps.skill_id = s.id);
|
||||
-- rollback DELETE FROM assistant_profile_skill WHERE profile_id IN (SELECT id FROM assistant_profile WHERE name = 'default') AND skill_id IN (SELECT id FROM assistant_skill WHERE name IN ('find-and-count', 'explain-a-record', 'safe-changes', 'access-questions'));
|
||||
|
||||
-- changeset qbit-assistant:defaults-8
|
||||
-- preconditions onFail:MARK_RAN onError:MARK_RAN
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM assistant_budget WHERE name = 'Monthly spend (whole instance)'
|
||||
-- comment: A monthly cost budget for the whole instance: warn at 80%, switch to the cheapest allowed model (local first) at 100%, stop at 120%. Change the limit to suit; overrides raise it for a while.
|
||||
INSERT INTO assistant_budget (name, enabled, scope_type, period, metric, limit_value, warn_percent, at_limit_action, hard_ceiling_percent, notes, create_date, modify_date)
|
||||
SELECT 'Monthly spend (whole instance)', TRUE, 'INSTANCE', 'MONTH', 'COST_USD', 50, 80, 'DOWNGRADE', 120,
|
||||
'Starter budget. Warns everyone at 80% of $50 a month, switches to the cheapest allowed model at 100% (or stops when there is none), and stops at 120%. Set Downgrade Model to choose the fallback; add an override to raise it temporarily.',
|
||||
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
WHERE NOT EXISTS (SELECT 1 FROM assistant_budget WHERE name = 'Monthly spend (whole instance)');
|
||||
-- rollback DELETE FROM assistant_budget WHERE name = 'Monthly spend (whole instance)';
|
||||
|
||||
-- changeset qbit-assistant:defaults-9
|
||||
-- preconditions onFail:MARK_RAN onError:MARK_RAN
|
||||
-- precondition-table-exists tableName:assistant_model_price
|
||||
-- comment: Anthropic first-party list prices, USD per million tokens (2026-10). Rows that already exist (by type and model) are left alone. Empty cache prices use the standard multipliers (read 0.1x, 5 minute write 1.25x, 1 hour write 2x input).
|
||||
INSERT INTO assistant_model_price (provider_type, model, input_usd_per_m_tok, output_usd_per_m_tok, cache_read_usd_per_m_tok, cache_write5m_usd_per_m_tok, cache_write1h_usd_per_m_tok, notes, create_date, modify_date)
|
||||
SELECT v.provider_type, v.model, v.input_price, v.output_price, v.cache_read, v.cache_write5m, v.cache_write1h, v.notes, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
FROM (VALUES
|
||||
('anthropic', 'claude-fable-5-1', 10.00, 50.00, 0.25, 12.50, 20.00, 'Anthropic list price, 2026-10'),
|
||||
('anthropic', 'claude-fable-5', 10.00, 50.00, CAST(NULL AS NUMERIC), CAST(NULL AS NUMERIC), CAST(NULL AS NUMERIC), 'Anthropic list price, 2026-10'),
|
||||
('anthropic', 'claude-opus-5-5', 4.00, 20.00, 0.20, 5.00, 8.00, 'Anthropic list price, 2026-10'),
|
||||
('anthropic', 'claude-opus-5', 5.00, 25.00, 0.50, 6.25, 10.00, 'Anthropic list price, 2026-10'),
|
||||
('anthropic', 'claude-opus-4-8', 5.00, 25.00, 0.50, 6.25, 10.00, 'Anthropic list price, 2026-10'),
|
||||
('anthropic', 'claude-opus-4-7', 5.00, 25.00, 0.50, 6.25, 10.00, 'Anthropic list price, 2026-10'),
|
||||
('anthropic', 'claude-opus-4-6', 5.00, 25.00, 0.50, 6.25, 10.00, 'Anthropic list price, 2026-10'),
|
||||
('anthropic', 'claude-sonnet-5-5', 2.00, 10.00, 0.20, 2.50, 4.00, 'Anthropic list price, 2026-10'),
|
||||
('anthropic', 'claude-sonnet-5', 2.00, 10.00, 0.20, 2.50, 4.00, 'Anthropic list price, 2026-10'),
|
||||
('anthropic', 'claude-sonnet-4-6', 3.00, 15.00, 0.30, 3.75, 6.00, 'Anthropic list price, 2026-10'),
|
||||
('anthropic', 'claude-haiku-5-5', 0.10, 0.50, 0.01, 0.125, 0.20, 'Anthropic list price, 2026-10 (prompts up to 100K tokens)'),
|
||||
('anthropic', 'claude-haiku-4-5', 1.00, 5.00, 0.10, 1.25, 2.00, 'Anthropic list price, 2026-10')
|
||||
) AS v (provider_type, model, input_price, output_price, cache_read, cache_write5m, cache_write1h, notes)
|
||||
WHERE NOT EXISTS (SELECT 1 FROM assistant_model_price p WHERE p.provider_type = v.provider_type AND p.model = v.model);
|
||||
-- rollback SELECT 1; -- prices may have been edited; keep them
|
||||
|
||||
-- changeset qbit-assistant:defaults-10
|
||||
-- preconditions onFail:MARK_RAN onError:MARK_RAN
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM assistant_scheduled_task WHERE name = 'Example: weekly change summary'
|
||||
-- comment: A disabled example scheduled task that shows the shape of one. It never runs until an administrator sets the run-as user and an allowed credential variable and turns it on.
|
||||
INSERT INTO assistant_scheduled_task (name, enabled, prompt, run_as_user_id, credential_env_name, profile_id, cron_expression, timezone, output_mode, write_mode_requested, max_cost_usd_per_run, create_date, modify_date)
|
||||
SELECT 'Example: weekly change summary', FALSE,
|
||||
'Summarize what changed in the last 7 days in the tables I use most: counts of new and changed records per table, and anything that looks unusual. Include links to the lists.',
|
||||
'set-the-run-as-user-id', 'ASSISTANT_SCHEDULED_TOKEN', p.id, '0 0 8 ? * MON', 'UTC', 'RECORD', FALSE, 0.50,
|
||||
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
FROM (SELECT MIN(id) AS id FROM assistant_profile WHERE name = 'default') p
|
||||
WHERE NOT EXISTS (SELECT 1 FROM assistant_scheduled_task WHERE name = 'Example: weekly change summary');
|
||||
-- rollback DELETE FROM assistant_scheduled_task WHERE name = 'Example: weekly change summary' AND enabled = FALSE;
|
||||
@@ -0,0 +1,138 @@
|
||||
bundle: qbit-assistant
|
||||
version: 1
|
||||
layer: QBIT
|
||||
# Help for the Assistant app and every assistant_* table, in the QQQ help-bundle
|
||||
# format (qqq#1198). Each table entry says what the table is for and how its rows
|
||||
# get created, so an empty table explains itself. AssistantHelp applies it until
|
||||
# the core HelpBundles registry ships in a QQQ release (STAND-IN(core=#1198)).
|
||||
# Summaries stay under 120 characters (AssistantHelpTest).
|
||||
entries:
|
||||
- key: app:assistantAdmin;slot:help
|
||||
summary: "Set up and watch the in-app assistant: providers, budgets, profiles, skills, data access and usage."
|
||||
content: |
|
||||
The Setup card on the home lists every part of the assistant with its status and the next step. A new installation starts with a default profile, four starter skills, a monthly budget, a policy row, Anthropic prices and a disabled example scheduled task. Add a provider to start answering.
|
||||
|
||||
- key: table:assistant_provider;slot:help
|
||||
summary: "A model provider the assistant can call: its type, model, endpoint and the variable that holds its key."
|
||||
content: |
|
||||
**How rows get created:** an administrator adds one with New (pick the type and model, and name the environment variable that holds the API key; the key itself is never stored). The default provider answers questions; Test connection checks it and Discover models fills the model list.
|
||||
|
||||
- key: table:assistant_model;slot:help
|
||||
summary: "The models each provider offers, with what they support and their prices."
|
||||
content: |
|
||||
**How rows get created:** Discover models on a provider adds them, or an administrator adds one by hand. Budgets, fallbacks, routing and knowledge sources pick models from this list. Embedding models are used only for knowledge sources.
|
||||
|
||||
- key: table:assistant_model_price;slot:help
|
||||
summary: "List prices per provider type and model, used to work out what each call costs."
|
||||
content: |
|
||||
**How rows get created:** the qbit seeds the current Anthropic list prices; add a row for any other priced model. Every call's cost in Usage is computed from these prices, and cost budgets count that cost. A model with no price costs zero, which is right for local models.
|
||||
|
||||
- key: table:assistant_policy;slot:help
|
||||
summary: "The single policy row: kill switch, write confirmation, PII redaction, tool list and row limits."
|
||||
content: |
|
||||
**How rows get created:** the qbit seeds one `default` row. Only the first row is used. Empty values fall back to the code configuration. Turn on the kill switch to stop every answer at once.
|
||||
|
||||
- key: table:assistant_budget;slot:help
|
||||
summary: "Spending or token limits per instance, user, role, provider or model, with warn, downgrade and stop."
|
||||
content: |
|
||||
**How rows get created:** the qbit seeds a monthly budget for the whole instance ($50: warn at 80%, switch to the cheapest allowed model at 100%, stop at 120%). Add more for users, roles, providers or models. Every question is checked against every budget that applies before the model is called.
|
||||
|
||||
- key: table:assistant_budget_override;slot:help
|
||||
summary: "A temporary raise or unlimited window for one budget, with a reason and an expiry."
|
||||
content: |
|
||||
**How rows get created:** an administrator adds one when a budget is reached and work must continue. Empty means no budget is currently raised. Overrides expire on their own.
|
||||
|
||||
- key: table:assistant_budget_event;slot:help
|
||||
summary: "Each time a budget warned, downgraded or stopped someone, once per period and state."
|
||||
content: |
|
||||
**How rows get created:** written by the assistant when usage crosses a budget threshold; it also raises an alert. Empty means no budget has been reached yet.
|
||||
|
||||
- key: table:assistant_budget_usage;slot:help
|
||||
summary: "Accounting copy of every call's tokens and cost, kept for shared budgets."
|
||||
content: |
|
||||
**How rows get created:** written with every model call. Instance, provider and model budgets add up these rows. Empty means the assistant has not answered anything yet.
|
||||
|
||||
- key: table:assistant_profile;slot:help
|
||||
summary: "Who the assistant is in an app or for a role: name, tone, greeting, starter prompts and skills."
|
||||
content: |
|
||||
**How rows get created:** the qbit seeds a `default` profile that applies everywhere. Add a profile with an app name or a permission to change the assistant for that app or role; the most specific match wins. Profiles change tone and know-how only, never permissions.
|
||||
|
||||
- key: table:assistant_skill;slot:help
|
||||
summary: "Know-how text the assistant follows when a question matches its keywords or app."
|
||||
content: |
|
||||
**How rows get created:** the qbit seeds four starter skills (finding and counting, explaining a record, safe changes, access questions). Write your own for your business terms. A skill reaches the model only when it is linked to a profile and the question matches its keywords or app; it never grants tools or data.
|
||||
|
||||
- key: table:assistant_profile_skill;slot:help
|
||||
summary: "Which skills each profile offers to the model."
|
||||
content: |
|
||||
**How rows get created:** the qbit links the starter skills to the default profile. Add a row to offer a skill through another profile.
|
||||
|
||||
- key: table:assistant_data_access;slot:help
|
||||
summary: "What the assistant may do with each table or process: NONE, READ or READ and write."
|
||||
content: |
|
||||
**How rows get created:** a table with no row gets the default: READ (or NONE for sensitive tables), always limited by the user's own permissions; processes default to NONE. Use **Add rows for every table** to write those defaults out as rows you can edit, then raise a table to READ_WRITE to let the assistant propose changes there.
|
||||
|
||||
- key: table:assistant_rag_source;slot:help
|
||||
summary: "A table the assistant can search by meaning (knowledge source), with its fields and embedding model."
|
||||
content: |
|
||||
**How rows get created:** an administrator adds one: pick a table the assistant can read, its text fields and an enabled embedding model, then run Index knowledge source. The index keeps no record text; every hit is re-read as the asking user. Empty means search_knowledge is not offered.
|
||||
|
||||
- key: table:assistant_scheduled_task;slot:help
|
||||
summary: "A question the assistant answers on a schedule as a chosen user, with the result saved or sent."
|
||||
content: |
|
||||
**How rows get created:** the qbit seeds a disabled example. To use it, set the run-as user, an allowed credential variable and the schedule, then turn it on. Scheduled writes always wait for the user's confirmation in the panel.
|
||||
|
||||
- key: table:assistant_scheduled_run;slot:help
|
||||
summary: "One run of a scheduled task: when, status, cost and the conversation it produced."
|
||||
content: |
|
||||
**How rows get created:** written each time a scheduled task runs. Empty means no task has run yet; see Scheduled tasks.
|
||||
|
||||
- key: table:assistant_conversation;slot:help
|
||||
summary: "One chat between a user and the assistant, from the panel or a scheduled task."
|
||||
content: |
|
||||
**How rows get created:** created when someone asks the assistant a question. Users see only their own; administrators see all. Retention removes old ones.
|
||||
|
||||
- key: table:assistant_message;slot:help
|
||||
summary: "The messages of each conversation: questions, answers and tool results."
|
||||
content: |
|
||||
**How rows get created:** written as a conversation happens. Content is encrypted in HIPAA mode and removed by retention.
|
||||
|
||||
- key: table:assistant_turn;slot:help
|
||||
summary: "One question and its answer: status, model, tokens, cost, timing and routing."
|
||||
content: |
|
||||
**How rows get created:** written for every question the assistant answers. Use it to see failures, slow answers and costly questions.
|
||||
|
||||
- key: table:assistant_turn_event;slot:help
|
||||
summary: "The step-by-step event stream of each answer, for replay and troubleshooting."
|
||||
content: |
|
||||
**How rows get created:** written while an answer streams. Retention removes old events.
|
||||
|
||||
- key: table:assistant_tool_call;slot:help
|
||||
summary: "Every tool the assistant called: which, with what input, the result status and timing."
|
||||
content: |
|
||||
**How rows get created:** written for each tool call during an answer. Denied calls show where data access or permissions stopped the assistant.
|
||||
|
||||
- key: table:assistant_pending_action;slot:help
|
||||
summary: "A create, update or delete the assistant proposed, waiting for the user to confirm it."
|
||||
content: |
|
||||
**How rows get created:** written when the assistant proposes a change on a READ_WRITE table. It runs once, only after the user confirms it in the panel. Empty means no change has been proposed.
|
||||
|
||||
- key: table:assistant_feedback;slot:help
|
||||
summary: "Thumbs up or down and comments users gave on answers."
|
||||
content: |
|
||||
**How rows get created:** written when a user rates an answer in the panel. Empty means nobody has rated an answer yet.
|
||||
|
||||
- key: table:assistant_usage;slot:help
|
||||
summary: "One row per model call: tokens, cost, provider, model and purpose."
|
||||
content: |
|
||||
**How rows get created:** written with every model call, including connection tests. Costs come from Model prices.
|
||||
|
||||
- key: table:assistant_usage_daily;slot:help
|
||||
summary: "Daily token and cost totals per user and for the instance, for the configured limits."
|
||||
content: |
|
||||
**How rows get created:** updated after every answer. The policy's daily and monthly limits read these totals.
|
||||
|
||||
- key: table:assistant_data_audit;slot:help
|
||||
summary: "Where data went: which provider and model received which tables' data, with its classification."
|
||||
content: |
|
||||
**How rows get created:** written before each model or embedding call that carries application data. Use it to show that sensitive data went only to allowed providers.
|
||||
+398
@@ -0,0 +1,398 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Statement;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import com.kingsrook.qbits.assistant.engine.BudgetService;
|
||||
import com.kingsrook.qbits.assistant.engine.PolicyService;
|
||||
import com.kingsrook.qbits.assistant.engine.ProfileService;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantBudget;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantBudgetUsage;
|
||||
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.AssistantSkill;
|
||||
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
|
||||
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
|
||||
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The shipped starter defaults (db/liquibase/qbit-assistant/
|
||||
** assistant-defaults.changelog.sql): they apply to a fresh schema built from
|
||||
** docs/schema-postgres.sql, re-running inserts nothing and keeps an
|
||||
** administrator's edits, and the seeded rows change what the assistant does
|
||||
** (persona, skills offered to the model, budget warnings, policy).
|
||||
*******************************************************************************/
|
||||
class AssistantDefaultsChangelogTest extends BaseAssistantTest
|
||||
{
|
||||
static final String CHANGELOG = "/db/liquibase/qbit-assistant/assistant-defaults.changelog.sql";
|
||||
|
||||
/*******************************************************************************
|
||||
** The tables the changelog seeds; each must be created by the reference DDL.
|
||||
*******************************************************************************/
|
||||
static final Set<String> SEEDED_TABLES = Set.of("assistant_policy", "assistant_profile", "assistant_skill", "assistant_profile_skill",
|
||||
"assistant_budget", "assistant_model_price", "assistant_scheduled_task");
|
||||
|
||||
private static final Set<String> DDL_TABLES = Set.of("assistant_provider", "assistant_model", "assistant_model_price", "assistant_policy",
|
||||
"assistant_profile", "assistant_skill", "assistant_profile_skill", "assistant_budget", "assistant_scheduled_task");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fresh schema, seed twice: same rows; an edit survives.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void seedsAFreshSchemaInsertOnly() throws Exception
|
||||
{
|
||||
try(Connection connection = freshSchema())
|
||||
{
|
||||
runChangelog(connection);
|
||||
Map<String, Integer> first = counts(connection);
|
||||
assertThat(first).containsEntry("assistant_policy", 1).containsEntry("assistant_profile", 1).containsEntry("assistant_skill", 4)
|
||||
.containsEntry("assistant_profile_skill", 4).containsEntry("assistant_budget", 1).containsEntry("assistant_scheduled_task", 1);
|
||||
assertThat(first.get("assistant_model_price")).isEqualTo(12);
|
||||
|
||||
try(Statement statement = connection.createStatement())
|
||||
{
|
||||
statement.execute("UPDATE assistant_budget SET limit_value = 75 WHERE name = 'Monthly spend (whole instance)'");
|
||||
statement.execute("UPDATE assistant_profile SET display_name = 'Ada' WHERE name = 'default'");
|
||||
}
|
||||
|
||||
runChangelog(connection);
|
||||
assertThat(counts(connection)).isEqualTo(first);
|
||||
assertThat(scalar(connection, "SELECT limit_value FROM assistant_budget")).isEqualTo("75");
|
||||
assertThat(scalar(connection, "SELECT display_name FROM assistant_profile")).isEqualTo("Ada");
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM assistant_scheduled_task WHERE enabled = FALSE AND profile_id IS NOT NULL")).isEqualTo("1");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An existing policy row (the host's) is never joined by a second one.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void keepsAnExistingPolicy() throws Exception
|
||||
{
|
||||
try(Connection connection = freshSchema(); Statement statement = connection.createStatement())
|
||||
{
|
||||
statement.execute("INSERT INTO assistant_policy (name, kill_switch) VALUES ('host', TRUE)");
|
||||
runChangelog(connection);
|
||||
assertThat(scalar(connection, "SELECT COUNT(*) FROM assistant_policy")).isEqualTo("1");
|
||||
assertThat(scalar(connection, "SELECT name FROM assistant_policy")).isEqualTo("host");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every changeset guards itself: a precondition (host without the table
|
||||
** marks it ran) and an insert that skips rows that exist.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyChangesetIsGuarded() throws Exception
|
||||
{
|
||||
List<String> changesets = changesets(changelogText());
|
||||
assertThat(changesets).hasSizeGreaterThanOrEqualTo(SEEDED_TABLES.size());
|
||||
for(String changeset : changesets)
|
||||
{
|
||||
assertThat(changeset).contains("-- preconditions onFail:MARK_RAN onError:MARK_RAN").contains("-- rollback ").contains("NOT EXISTS");
|
||||
}
|
||||
for(String table : SEEDED_TABLES)
|
||||
{
|
||||
assertThat(changelogText()).contains("INSERT INTO " + table + " ");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The seeded rows drive the runtime: persona, skills reach the model's
|
||||
** request, the budget warns at 80%, and the policy is read.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void seededRowsChangeBehavior() throws Exception
|
||||
{
|
||||
try(Connection connection = freshSchema())
|
||||
{
|
||||
runChangelog(connection);
|
||||
copyIntoStore(connection);
|
||||
}
|
||||
|
||||
ProfileService profiles = config.getRuntime().getProfileService();
|
||||
profiles.invalidate();
|
||||
QContext.init(qInstance, AssistantTestFixture.sessionFor(AssistantTestFixture.EDITOR));
|
||||
ProfileService.Persona persona = profiles.persona(QContext.getQSession(), "content");
|
||||
assertThat(persona.profileId()).isNotNull();
|
||||
assertThat(persona.greeting()).startsWith("Hi! I can find and count records");
|
||||
assertThat(persona.starterPrompts()).contains("What changed in the last 7 days?");
|
||||
assertThat(profiles.relevantSkills(persona.profileId(), "How many posts were published this month?", "content"))
|
||||
.extracting(AssistantSkill::getName).contains("find-and-count").doesNotContain("safe-changes");
|
||||
|
||||
///////////////////////////////////////////////////////////////
|
||||
// a real turn: the matching skill is in the model's request //
|
||||
///////////////////////////////////////////////////////////////
|
||||
provider.thenText("There are 4.");
|
||||
runner.run(AssistantTestFixture.EDITOR, "How many posts were published this month?");
|
||||
assertThat(provider.getRequestJson()).last().asString().contains("find-and-count").contains("Always include a filterLinks item").doesNotContain("safe-changes");
|
||||
|
||||
///////////////////////////////////////////
|
||||
// the starter budget warns at 80% of $50 //
|
||||
///////////////////////////////////////////
|
||||
AssistantStore store = adminStore();
|
||||
store.insert(new AssistantBudgetUsage().withUsageId(900_001).withUserId(AssistantTestFixture.EDITOR).withOccurredAt(Instant.now())
|
||||
.withCostMicros(40_000_000L).withInputTokens(0L).withOutputTokens(0L));
|
||||
BudgetService.Decision decision = config.getRuntime().getBudgetService().evaluate(AssistantTestFixture.EDITOR, null, false, false);
|
||||
assertThat(decision.stopped()).isFalse();
|
||||
assertThat(decision.notices()).singleElement().asString().contains("80%", "Monthly spend (whole instance)");
|
||||
|
||||
////////////////////////////////////////////////
|
||||
// the seeded policy: writes wait, 50 rows max //
|
||||
////////////////////////////////////////////////
|
||||
PolicyService.EffectivePolicy policy = config.getRuntime().getPolicyService().current(true);
|
||||
assertThat(policy.killSwitch()).isFalse();
|
||||
assertThat(policy.tools().writesRequireConfirmation()).isTrue();
|
||||
assertThat(policy.tools().maxRowsPerCall()).isEqualTo(50);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Copy the seeded profile, skills, links, budget and policy into the
|
||||
** test instance's store (ids remapped).
|
||||
*******************************************************************************/
|
||||
private void copyIntoStore(Connection connection) throws Exception
|
||||
{
|
||||
AssistantStore store = adminStore();
|
||||
Map<Integer, Integer> profileIds = new HashMap<>();
|
||||
Map<Integer, Integer> skillIds = new HashMap<>();
|
||||
try(Statement statement = connection.createStatement())
|
||||
{
|
||||
try(ResultSet rs = statement.executeQuery("SELECT * FROM assistant_profile"))
|
||||
{
|
||||
while(rs.next())
|
||||
{
|
||||
AssistantProfile row = store.insert(new AssistantProfile().withName(rs.getString("name")).withDisplayName(rs.getString("display_name"))
|
||||
.withIcon(rs.getString("icon")).withPersonality(rs.getString("personality")).withGreeting(rs.getString("greeting"))
|
||||
.withStarterPrompts(rs.getString("starter_prompts")).withIsDefault(rs.getBoolean("is_default")).withEnabled(rs.getBoolean("enabled")));
|
||||
profileIds.put(rs.getInt("id"), row.getId());
|
||||
}
|
||||
}
|
||||
try(ResultSet rs = statement.executeQuery("SELECT * FROM assistant_skill"))
|
||||
{
|
||||
while(rs.next())
|
||||
{
|
||||
AssistantSkill row = store.insert(new AssistantSkill().withName(rs.getString("name")).withDescription(rs.getString("description"))
|
||||
.withInstructions(rs.getString("instructions")).withEnabled(rs.getBoolean("enabled")).withTriggerKeywords(rs.getString("trigger_keywords")));
|
||||
skillIds.put(rs.getInt("id"), row.getId());
|
||||
}
|
||||
}
|
||||
try(ResultSet rs = statement.executeQuery("SELECT * FROM assistant_profile_skill"))
|
||||
{
|
||||
while(rs.next())
|
||||
{
|
||||
store.insert(new AssistantProfileSkill().withProfileId(profileIds.get(rs.getInt("profile_id"))).withSkillId(skillIds.get(rs.getInt("skill_id"))));
|
||||
}
|
||||
}
|
||||
try(ResultSet rs = statement.executeQuery("SELECT * FROM assistant_budget"))
|
||||
{
|
||||
while(rs.next())
|
||||
{
|
||||
store.insert(new AssistantBudget().withName(rs.getString("name")).withEnabled(rs.getBoolean("enabled")).withScopeType(rs.getString("scope_type"))
|
||||
.withPeriod(rs.getString("period")).withMetric(rs.getString("metric")).withLimitValue(rs.getBigDecimal("limit_value"))
|
||||
.withWarnPercent(rs.getInt("warn_percent")).withAtLimitAction(rs.getString("at_limit_action")).withHardCeilingPercent(rs.getInt("hard_ceiling_percent")));
|
||||
}
|
||||
}
|
||||
try(ResultSet rs = statement.executeQuery("SELECT * FROM assistant_policy"))
|
||||
{
|
||||
while(rs.next())
|
||||
{
|
||||
store.insert(new AssistantPolicy().withName(rs.getString("name")).withKillSwitch(rs.getBoolean("kill_switch"))
|
||||
.withWritesRequireConfirmation(rs.getBoolean("writes_require_confirmation")).withRedactPii(rs.getBoolean("redact_pii"))
|
||||
.withMaxRowsPerCall(rs.getInt("max_rows_per_call")));
|
||||
}
|
||||
}
|
||||
}
|
||||
assertThat(new BigDecimal("50")).isEqualByComparingTo(store.budgets().get(0).getLimitValue());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private AssistantStore adminStore()
|
||||
{
|
||||
QContext.init(qInstance, AssistantTestFixture.ownerSession(AssistantTestFixture.ADMIN, config));
|
||||
return (config.getRuntime().getStore());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A fresh H2 (PostgreSQL mode) with the seeded tables from the reference DDL.
|
||||
*******************************************************************************/
|
||||
static Connection freshSchema() throws Exception
|
||||
{
|
||||
Connection connection = DriverManager.getConnection("jdbc:h2:mem:defaults" + System.nanoTime() + ";MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH");
|
||||
String ddl = Files.readString(Path.of("..", "docs", "schema-postgres.sql"), StandardCharsets.UTF_8);
|
||||
try(Statement statement = connection.createStatement())
|
||||
{
|
||||
for(String sql : statements(ddl))
|
||||
{
|
||||
Matcher m = Pattern.compile("^(CREATE TABLE|ALTER TABLE) (assistant_[a-z_]+)\\b(.*)", Pattern.DOTALL).matcher(sql);
|
||||
if(m.find() && DDL_TABLES.contains(m.group(2)) && (m.group(1).startsWith("CREATE") || m.group(3).strip().startsWith("ADD COLUMN")))
|
||||
{
|
||||
statement.execute(sql.replace("TIMESTAMPTZ", "TIMESTAMP WITH TIME ZONE"));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (connection);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run every changeset's statements (comments, preconditions and rollbacks
|
||||
** are comment lines, as Liquibase formatted SQL).
|
||||
*******************************************************************************/
|
||||
static void runChangelog(Connection connection) throws Exception
|
||||
{
|
||||
try(Statement statement = connection.createStatement())
|
||||
{
|
||||
for(String changeset : changesets(changelogText()))
|
||||
{
|
||||
for(String sql : statements(changeset))
|
||||
{
|
||||
statement.execute(sql);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
static String changelogText() throws Exception
|
||||
{
|
||||
try(InputStream stream = AssistantDefaultsChangelogTest.class.getResourceAsStream(CHANGELOG))
|
||||
{
|
||||
assertThat(stream).as(CHANGELOG + " is on the classpath").isNotNull();
|
||||
return (new String(stream.readAllBytes(), StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The text of each changeset (from its "-- changeset" line).
|
||||
*******************************************************************************/
|
||||
static List<String> changesets(String changelog)
|
||||
{
|
||||
List<String> rs = new ArrayList<>();
|
||||
String[] parts = changelog.split("(?m)^(?=-- changeset )");
|
||||
for(String part : parts)
|
||||
{
|
||||
if(part.startsWith("-- changeset "))
|
||||
{
|
||||
rs.add(part);
|
||||
}
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SQL statements, without comment lines, split at a ';' that ends a line.
|
||||
*******************************************************************************/
|
||||
static List<String> statements(String sql)
|
||||
{
|
||||
StringBuilder text = new StringBuilder();
|
||||
for(String line : sql.split("\\R"))
|
||||
{
|
||||
String stripped = line.replaceAll("\\s+--.*$", "");
|
||||
if(!line.strip().startsWith("--"))
|
||||
{
|
||||
text.append(stripped).append("\n");
|
||||
}
|
||||
}
|
||||
List<String> rs = new ArrayList<>();
|
||||
for(String statement : text.toString().split(";\\s*\\n"))
|
||||
{
|
||||
if(!statement.isBlank())
|
||||
{
|
||||
rs.add(statement.strip().replaceAll(";$", ""));
|
||||
}
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Map<String, Integer> counts(Connection connection) throws Exception
|
||||
{
|
||||
Map<String, Integer> rs = new HashMap<>();
|
||||
for(String table : SEEDED_TABLES)
|
||||
{
|
||||
rs.put(table, Integer.valueOf(scalar(connection, "SELECT COUNT(*) FROM " + table)));
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String scalar(Connection connection, String sql) throws Exception
|
||||
{
|
||||
try(Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery(sql))
|
||||
{
|
||||
rs.next();
|
||||
BigDecimal number = rs.getObject(1) instanceof BigDecimal bd ? bd.stripTrailingZeros() : null;
|
||||
return (number != null ? number.toPlainString() : rs.getString(1));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantHelp;
|
||||
import com.kingsrook.qbits.assistant.metadata.AssistantMetaDataBuilder;
|
||||
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Guardrail (owner requirement 2026-10-14: "all tables should be populated and
|
||||
** the qbit should be fully using them"). For every assistant_* table:
|
||||
** - runtime code (not just its model, metadata or home screen) reads or
|
||||
** writes it, so no table exists only on paper;
|
||||
** - it is either seeded by the shipped defaults changelog, or listed here
|
||||
** with why it starts empty;
|
||||
** - it has bundle help that says what it is for and how its rows get
|
||||
** created (shown on the table screen and when it is empty).
|
||||
** A new table fails this test until it is wired, seeded or explained.
|
||||
*******************************************************************************/
|
||||
class AssistantTablesGuardrailTest extends BaseAssistantTest
|
||||
{
|
||||
private static final Path MAIN = Path.of("src", "main", "java", "com", "kingsrook", "qbits", "assistant");
|
||||
|
||||
/*******************************************************************************
|
||||
** Tables that legitimately start empty, and where their rows come from.
|
||||
*******************************************************************************/
|
||||
static final Map<String, String> STARTS_EMPTY = Map.ofEntries(
|
||||
Map.entry("assistant_provider", "host connection config: needs the host's key variable (kof22 seeds its providers)"),
|
||||
Map.entry("assistant_model", "Discover models on a provider, or the host"),
|
||||
Map.entry("assistant_rag_source", "host-specific: a host table plus an embedding model"),
|
||||
Map.entry("assistant_data_access", "defaults apply without rows; 'Add rows for every table' writes them out"),
|
||||
Map.entry("assistant_budget_override", "an administrator, when a budget is reached"),
|
||||
Map.entry("assistant_budget_event", "runtime: a budget threshold is crossed"),
|
||||
Map.entry("assistant_budget_usage", "runtime: every model call"),
|
||||
Map.entry("assistant_scheduled_run", "runtime: a scheduled task runs"),
|
||||
Map.entry("assistant_conversation", "user content: someone asks a question"),
|
||||
Map.entry("assistant_message", "user content: the conversation"),
|
||||
Map.entry("assistant_turn", "runtime: every answer"),
|
||||
Map.entry("assistant_turn_event", "runtime: every answer's event stream"),
|
||||
Map.entry("assistant_tool_call", "runtime: every tool call"),
|
||||
Map.entry("assistant_pending_action", "runtime: a proposed change"),
|
||||
Map.entry("assistant_feedback", "user content: a rating"),
|
||||
Map.entry("assistant_usage", "runtime: every model call"),
|
||||
Map.entry("assistant_usage_daily", "runtime: after every answer"),
|
||||
Map.entry("assistant_data_audit", "runtime: every model call carrying data"));
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table is used by runtime code.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyTableIsUsedAtRuntime() throws Exception
|
||||
{
|
||||
Map<String, String> entityByTable = entityByTable();
|
||||
List<String> runtimeSource;
|
||||
try(Stream<Path> files = Files.walk(MAIN))
|
||||
{
|
||||
runtimeSource = files.filter(p -> p.toString().endsWith(".java"))
|
||||
.filter(p -> !p.startsWith(MAIN.resolve("model")) && !p.startsWith(MAIN.resolve("metadata")) && !p.startsWith(MAIN.resolve("home")))
|
||||
.map(AssistantTablesGuardrailTest::read).toList();
|
||||
}
|
||||
for(String table : assistantTables())
|
||||
{
|
||||
String entity = entityByTable.get(table);
|
||||
assertThat(entity).as("entity class for " + table).isNotNull();
|
||||
Pattern use = Pattern.compile("\\b" + entity + "(\\.TABLE_NAME|\\.class|\\(|::)");
|
||||
assertThat(runtimeSource.stream().anyMatch(source -> use.matcher(source).find()))
|
||||
.as(table + " (" + entity + ") must be read or written by runtime code, not only declared").isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table is seeded or explained.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyTableIsSeededOrExplained() throws Exception
|
||||
{
|
||||
String changelog = AssistantDefaultsChangelogTest.changelogText();
|
||||
for(String table : assistantTables())
|
||||
{
|
||||
boolean seeded = AssistantDefaultsChangelogTest.SEEDED_TABLES.contains(table);
|
||||
assertThat(seeded || STARTS_EMPTY.containsKey(table)).as(table + " must be seeded by the defaults changelog or listed in STARTS_EMPTY").isTrue();
|
||||
assertThat(seeded && STARTS_EMPTY.containsKey(table)).as(table + " cannot be both seeded and listed as starting empty").isFalse();
|
||||
assertThat(changelog.contains("INSERT INTO " + table + " ")).as(table + " seeded iff the changelog inserts it").isEqualTo(seeded);
|
||||
}
|
||||
assertThat(STARTS_EMPTY.keySet()).allMatch(table -> qInstance.getTable(table) != null, "STARTS_EMPTY names only real tables");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table and the app have bundle help that says how rows get created.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyTableHasEmptyStateHelp() throws Exception
|
||||
{
|
||||
assertThat(AssistantHelp.entries()).allSatisfy((key, entry) -> assertThat(entry.path("summary").asText().length()).as(key).isLessThanOrEqualTo(120));
|
||||
for(String table : assistantTables())
|
||||
{
|
||||
List<QHelpContent> help = qInstance.getTable(table).getHelpContent() == null ? null : qInstance.getTable(table).getHelpContent().get(AssistantHelp.SLOT);
|
||||
assertThat(help).as(table + " table help").isNotEmpty();
|
||||
assertThat(help.get(0).getContent()).as(table + " help says how rows get created").contains("How rows get created");
|
||||
}
|
||||
assertThat(qInstance.getApp(AssistantMetaDataBuilder.APP_NAME).getHelpContent()).containsKey(AssistantHelp.SLOT);
|
||||
assertThat(AssistantHelp.entries().keySet()).allMatch(key -> !key.startsWith("table:") || qInstance.getTable(key.substring(6, key.indexOf(';'))) != null,
|
||||
"help names only real tables");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private List<String> assistantTables()
|
||||
{
|
||||
List<String> tables = qInstance.getTables().values().stream().map(QTableMetaData::getName).filter(n -> n.startsWith("assistant_")).sorted().toList();
|
||||
assertThat(tables).hasSizeGreaterThan(20);
|
||||
return (tables);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** model/X.java declares TABLE_NAME = "x".
|
||||
*******************************************************************************/
|
||||
private static Map<String, String> entityByTable() throws Exception
|
||||
{
|
||||
Map<String, String> rs = new HashMap<>();
|
||||
Pattern p = Pattern.compile("TABLE_NAME\\s*=\\s*\"([a-z_]+)\"");
|
||||
try(Stream<Path> files = Files.list(MAIN.resolve("model")))
|
||||
{
|
||||
for(Path file : files.toList())
|
||||
{
|
||||
Matcher m = p.matcher(read(file));
|
||||
if(m.find())
|
||||
{
|
||||
rs.put(m.group(1), file.getFileName().toString().replace(".java", ""));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (rs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String read(Path path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (Files.readString(path, StandardCharsets.UTF_8));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new IllegalStateException(e));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* 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.home;
|
||||
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import com.kingsrook.qbits.assistant.access.DataAccessMode;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantBudget;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantDataAccess;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProfile;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantProfileSkill;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantSkill;
|
||||
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
|
||||
import com.kingsrook.qbits.assistant.processes.DataAccessDefaultsProcess;
|
||||
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
|
||||
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The home's Setup card and the "Add rows for every table" action.
|
||||
*******************************************************************************/
|
||||
class AssistantSetupTest extends BaseAssistantTest
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
** An empty installation says what to do next; filling the tables flips
|
||||
** each item to set up.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void setupCardTracksTheTables() throws Exception
|
||||
{
|
||||
Map<String, AssistantSetupStatus.Item> items = items();
|
||||
assertThat(items.keySet()).containsExactly("Provider", "Model prices", "Policy", "Profiles", "Skills", "Budgets", "Data access", "Knowledge sources", "Scheduled tasks");
|
||||
assertThat(items.get("Model prices").state()).isEqualTo(AssistantSetupStatus.State.TODO);
|
||||
assertThat(items.get("Budgets").state()).isEqualTo(AssistantSetupStatus.State.TODO);
|
||||
assertThat(items.get("Budgets").nextStep()).contains("monthly instance budget");
|
||||
assertThat(items.get("Profiles").state()).isEqualTo(AssistantSetupStatus.State.DEFAULT);
|
||||
assertThat(items.get("Data access").state()).isEqualTo(AssistantSetupStatus.State.DEFAULT);
|
||||
assertThat(items.get("Data access").nextStep()).contains("Add rows for every table");
|
||||
assertThat(items.get("Knowledge sources").state()).isEqualTo(AssistantSetupStatus.State.OPTIONAL);
|
||||
|
||||
AssistantStore store = adminStore();
|
||||
AssistantProfile profile = store.insert(new AssistantProfile().withName("default").withDisplayName("Assistant").withIsDefault(true).withEnabled(true));
|
||||
AssistantSkill skill = store.insert(new AssistantSkill().withName("terms").withInstructions("Our terms.").withEnabled(true));
|
||||
assertThat(items().get("Skills").state()).as("a skill no profile offers is flagged").isEqualTo(AssistantSetupStatus.State.TODO);
|
||||
store.insert(new AssistantProfileSkill().withProfileId(profile.getId()).withSkillId(skill.getId()));
|
||||
store.insert(new AssistantBudget().withName("Monthly").withScopeType("INSTANCE").withPeriod("MONTH").withMetric("COST_USD").withLimitValue(BigDecimal.TEN));
|
||||
|
||||
items = items();
|
||||
assertThat(items.get("Profiles").state()).isEqualTo(AssistantSetupStatus.State.OK);
|
||||
assertThat(items.get("Skills").state()).isEqualTo(AssistantSetupStatus.State.OK);
|
||||
assertThat(items.get("Budgets").state()).isEqualTo(AssistantSetupStatus.State.OK);
|
||||
|
||||
String html = AssistantHomeView.render(AssistantHomeData.load(config.getRuntime()), "/app/assistantAdmin");
|
||||
assertThat(html).contains(">Setup</h3>").contains("href=\"/app/assistantAdmin/assistant_model_price\"").contains("Needs setup").contains("Next: ");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The action writes the defaults out as rows (READ, or NONE for sensitive
|
||||
** tables), keeps existing rows, adds nothing the second time, and the
|
||||
** written rows are what DataAccessService then reads.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void addRowsForEveryTable() throws Exception
|
||||
{
|
||||
AssistantStore store = adminStore();
|
||||
store.insert(new AssistantDataAccess().withObjectType(AssistantDataAccess.TYPE_TABLE).withObjectName("media")
|
||||
.withMode(DataAccessMode.READ_WRITE.name()));
|
||||
|
||||
runAction();
|
||||
List<AssistantDataAccess> rows = adminStore().query(AssistantDataAccess.TABLE_NAME, new QQueryFilter(), AssistantDataAccess.class);
|
||||
long appTables = qInstance.getTables().keySet().stream().filter(t -> !t.startsWith("assistant_")).count();
|
||||
assertThat(rows).hasSize((int) appTables);
|
||||
assertThat(rows).noneMatch(r -> r.getObjectName().startsWith("assistant_"));
|
||||
assertThat(rows).filteredOn(r -> r.getObjectName().equals("media")).singleElement()
|
||||
.extracting(AssistantDataAccess::getMode).isEqualTo("READ_WRITE");
|
||||
for(AssistantDataAccess row : rows)
|
||||
{
|
||||
if(!"media".equals(row.getObjectName()))
|
||||
{
|
||||
assertThat(row.getNotes()).isEqualTo(DataAccessDefaultsProcess.NOTE);
|
||||
assertThat(config.getRuntime().getDataAccessService().forTable(row.getObjectName()).source()).as(row.getObjectName()).isEqualTo("setting");
|
||||
assertThat(config.getRuntime().getDataAccessService().forTable(row.getObjectName()).mode().name()).isEqualTo(row.getMode());
|
||||
}
|
||||
}
|
||||
assertThat(rows).anyMatch(r -> "READ".equals(r.getMode()));
|
||||
|
||||
runAction();
|
||||
assertThat(adminStore().query(AssistantDataAccess.TABLE_NAME, new QQueryFilter(), AssistantDataAccess.class)).hasSize(rows.size());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void runAction() throws Exception
|
||||
{
|
||||
adminStore();
|
||||
RunProcessInput input = new RunProcessInput();
|
||||
input.setProcessName(DataAccessDefaultsProcess.NAME);
|
||||
input.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP);
|
||||
new RunProcessAction().execute(input);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private Map<String, AssistantSetupStatus.Item> items() throws Exception
|
||||
{
|
||||
adminStore();
|
||||
return (AssistantHomeData.load(config.getRuntime()).getSetup().stream()
|
||||
.collect(Collectors.toMap(AssistantSetupStatus.Item::label, i -> i, (a, b) -> a, java.util.LinkedHashMap::new)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private AssistantStore adminStore()
|
||||
{
|
||||
QContext.init(qInstance, AssistantTestFixture.ownerSession(AssistantTestFixture.ADMIN, config));
|
||||
return (config.getRuntime().getStore());
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user