refactor: budget alerts through core QAlerts on QQQ RC.5 (#28) #31

Merged
KofTwentyTwo merged 1 commits from fix/core-alerts-rc5 into develop 2026-10-14 00:34:21 +00:00
10 changed files with 128 additions and 101 deletions

No files matched your search

+2
View File
@@ -11,6 +11,8 @@
## Unreleased - P-d
- Require QQQ RC.5 and send budget alerts directly through core QAlerts, with admin-visible alert type metadata and core sink tests.
- Admin budgets, expiring overrides, deduplicated warning/downgrade/stop events.
- Per-dispatch limits preserving PHI routing and existing caps.
- Home budget cards, 30-day cost/token bars and provider breakdowns.
+2 -2
View File
@@ -385,7 +385,7 @@ Apache 2.0.
## Data classification and HIPAA mode
On QQQ 4.1.0-RC.4, classify application tables/fields in the host configuration:
On QQQ 4.1.0-RC.5, classify application tables/fields in the host configuration:
```java
import com.kingsrook.qbits.assistant.classification.DataClassification;
@@ -455,7 +455,7 @@ extra allowance or temporary unlimited use from the budget's Overrides child lis
with a required reason and expiry; the session sets the grantor.
Budget state changes appear once per scope/period/state in the Events child list,
as panel notices and, when the optional QQQ Alerts SPI is installed, bell alerts.
as panel notices and, through core QAlerts, bell alerts when an alerts sink is installed (logging otherwise).
The Assistant home shows budgets, overrides, daily cost and tokens, and breakdowns
by user, model, provider and tool. Existing `AssistantBudgets`/policy limits and
provider monthly caps continue to apply independently of these rows and overrides.
+2
View File
@@ -28,6 +28,8 @@ All configuration remains assistant.admin-only, with AI exposure NONE.
## P-d: data-driven budgets
Requires QQQ 4.1.0-RC.5 for the core Alerts API and alert type metadata.
Create `assistant_budget`, `assistant_budget_override`, `assistant_budget_event`
and `assistant_budget_usage`
using the P-d DDL in [schema-postgres.sql](docs/schema-postgres.sql) before deploying.
+4 -2
View File
@@ -387,8 +387,10 @@ AI exposure NONE is not enough, because the assistant acts as the user and an ad
reason, session grantor and expiry; they never waive the other limit layers.
- Start-turn checks use the existing 429 BUDGET path through PolicyService.
Every dispatch, including strict retries, evaluates current usage and overrides.
Events deduplicate WARNED, DOWNGRADED and STOPPED; optional reflective QAlerts
signals accompany panel notices without a compile-time Alerts dependency.
Events deduplicate WARNED, DOWNGRADED and STOPPED.
- QQQ RC.5 core QAlerts sends budget signals alongside panel notices, with three
admin-visible alert types and a default logging sink. Resolution awaits active
alert/recurrence tracking so overrides cannot clear shared alerts incorrectly.
- Explicit registry model selection leaves provider defaults untouched. Automatic
downgrades prefer LOCAL, then price. PHI eligibility and destination provider caps
still apply; a missing eligible target stops the turn.
+2 -2
View File
@@ -193,7 +193,7 @@
</build>
<profiles>
<!-- The assistant needs QQQ 4.1.0-RC.4: the AI tool kernel (package -->
<!-- The assistant needs QQQ 4.1.0-RC.5: the AI tool kernel (package -->
<!-- actions.ai, qqq#1078) and the developer panels (qqq#1076). Until -->
<!-- qbit-build-parent imports the 4.1 BOM, this -->
<!-- profile is active by default and imports qqq-bom-pom ahead of the -->
@@ -205,7 +205,7 @@
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.4</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.5</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -28,6 +28,9 @@ import com.kingsrook.qbits.assistant.metadata.AssistantRecordScreens;
import com.kingsrook.qbits.assistant.processes.DiscoverModelsProcess;
import com.kingsrook.qbits.assistant.processes.TestConnectionProcess;
import com.kingsrook.qbits.assistant.processes.TestModelConnectionProcess;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
import com.kingsrook.qbits.assistant.model.AssistantBudget;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
@@ -155,6 +158,9 @@ public class AssistantQBitProducer implements QBitMetaDataProducer<AssistantQBit
qInstance.addTable(customized);
tables.add(customized);
}
qInstance.add(budgetAlertType("warning", "Assistant budget warning", "Assistant usage has reached the budget warning threshold.", AlertSeverity.WARNING));
qInstance.add(budgetAlertType("downgraded", "Assistant budget downgraded", "Assistant usage has reached the budget limit and switched to a cheaper model.", AlertSeverity.WARNING));
qInstance.add(budgetAlertType("stopped", "Assistant budget stopped", "Assistant dispatch has stopped because a budget limit was reached.", AlertSeverity.ERROR));
qInstance.addPossibleValueSource(builder.buildConversationPossibleValueSource());
AssistantRecordScreens.buildPossibleValueSources().forEach(qInstance::addPossibleValueSource);
AssistantRecordScreens.buildSettingsPossibleValueSources(qBitConfig).forEach(qInstance::addPossibleValueSource);
@@ -185,6 +191,18 @@ public class AssistantQBitProducer implements QBitMetaDataProducer<AssistantQBit
/*******************************************************************************
** Declare budget alerts as top-level core metadata for admin visibility.
*******************************************************************************/
private QAlertTypeMetaData budgetAlertType(String state, String label, String description, AlertSeverity severity)
{
return (new QAlertTypeMetaData().withName("assistant.budget." + state).withLabel(label).withDescription(description)
.withDefaultSeverity(severity).withHowToFix("Raise the budget or grant an override in Assistant → Budgets")
.withSourceQBitName(ARTIFACT_ID).withVisibilityPermission("assistant.admin").withSubjectTableName(AssistantBudget.TABLE_NAME));
}
/*******************************************************************************
** Build the runtime and register the routes.
*******************************************************************************/
@@ -17,23 +17,16 @@
package com.kingsrook.qbits.assistant.engine;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import java.lang.reflect.Method;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
/*******************************************************************************
** Optional Alerts producer SPI, with log-only fallback and no dependency.
** Budget signals through core's non-throwing Alerts API and default logging sink.
*******************************************************************************/
public final class BudgetAlerts
{
private static final QLogger LOG = QLogger.getLogger(BudgetAlerts.class);
private static final String QALERTS = "com.kingsrook.qqq.backend.core.alerts.QAlerts";
private static final String ALERT_SIGNAL = "com.kingsrook.qqq.backend.core.alerts.AlertSignal";
private static final String SEVERITY = "com.kingsrook.qqq.backend.core.alerts.AlertSeverity";
/*******************************************************************************
** Utility class.
*******************************************************************************/
@@ -44,55 +37,10 @@ public final class BudgetAlerts
/*******************************************************************************
** True when the Alerts SPI is on the classpath.
** Raise one deduplicated budget transition through the configured core sink.
*******************************************************************************/
public static boolean available()
public static void raise(String type, String dedupKey, AlertSeverity severity, String title, String summary)
{
try
{
Class.forName(QALERTS, false, BudgetAlerts.class.getClassLoader());
return (true);
}
catch(ClassNotFoundException | LinkageError e)
{
return (false);
}
QAlerts.raise(new AlertSignal(type).withDedupKey(dedupKey).withSeverity(severity).withTitle(title).withSummary(summary));
}
/*******************************************************************************
** QAlerts.raise(new AlertSignal(type).withDedupKey(..).withSeverity(..)
** .withTitle(..).withSummary(..)), or a log line.
*******************************************************************************/
public static void raise(String type, String dedupKey, String severity, String title, String summary)
{
LOG.info("Assistant budget alert", logPair("type", type), logPair("dedupKey", dedupKey), logPair("title", title));
if(!available())
{
return;
}
try
{
ClassLoader loader = BudgetAlerts.class.getClassLoader();
Class<?> signalType = Class.forName(ALERT_SIGNAL, true, loader);
Object signal = signalType.getConstructor(String.class).newInstance(type);
signal = signalType.getMethod("withDedupKey", String.class).invoke(signal, dedupKey);
Class<?> severityType = Class.forName(SEVERITY, true, loader);
@SuppressWarnings({"unchecked", "rawtypes"})
Object level = Enum.valueOf((Class<? extends Enum>) severityType.asSubclass(Enum.class), severity);
signal = signalType.getMethod("withSeverity", severityType).invoke(signal, level);
signal = signalType.getMethod("withTitle", String.class).invoke(signal, title);
signal = signalType.getMethod("withSummary", String.class).invoke(signal, summary);
Method raise = Class.forName(QALERTS, true, loader).getMethod("raise", signalType);
raise.invoke(null, signal);
}
catch(Exception | LinkageError e)
{
LOG.info("Could not raise a assistant budget alert through the Alerts SPI", logPair("type", type), logPair("reason", e.getClass().getSimpleName()));
}
}
}
@@ -24,6 +24,7 @@ import com.kingsrook.qbits.assistant.model.AssistantBudgetUsage;
import com.kingsrook.qbits.assistant.model.AssistantModel;
import com.kingsrook.qbits.assistant.model.AssistantUsage;
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
@@ -48,21 +49,6 @@ public class BudgetService
private final AssistantStore store;
private final ProviderService providers;
private final Clock clock;
private final AlertRaiser alerts;
/*******************************************************************************
** Injectable alert seam; production uses the optional Alerts producer SPI.
*******************************************************************************/
@FunctionalInterface
public interface AlertRaiser
{
/*******************************************************************************
** Attempt to raise one deduplicated transition.
*******************************************************************************/
void raise(String type, String key, String severity, String title, String summary);
}
@@ -104,21 +90,10 @@ public class BudgetService
** Production constructor.
*******************************************************************************/
public BudgetService(AssistantStore store, ProviderService providers, Clock clock)
{
this(store, providers, clock, BudgetAlerts::raise);
}
/*******************************************************************************
** Constructor with an alert seam for tests.
*******************************************************************************/
public BudgetService(AssistantStore store, ProviderService providers, Clock clock, AlertRaiser alerts)
{
this.store = store;
this.providers = providers;
this.clock = clock;
this.alerts = alerts;
}
@@ -318,6 +293,10 @@ public class BudgetService
/*******************************************************************************
** The persisted unique key is also the warning/alert deduplication record.
** Resolution on period rollover or override recovery needs an active-alert
** lifecycle: these immutable events cannot re-raise a resolved state within
** the same period, and user-specific overrides must not resolve shared alerts.
** Track active keys and recurrence before calling QAlerts.resolve here.
*******************************************************************************/
private boolean transition(Snapshot snapshot, String state, String userId, String message) throws QException
{
@@ -333,8 +312,8 @@ public class BudgetService
case "DOWNGRADED" -> "downgraded";
default -> "stopped";
};
alerts.raise(type, snapshot.budget().getId() + ":" + snapshot.scopeKey() + ":" + snapshot.periodStart(),
"STOPPED".equals(state) ? "ERROR" : "WARNING", "Assistant budget " + snapshot.budget().getName(), message);
BudgetAlerts.raise(type, snapshot.budget().getId() + ":" + snapshot.scopeKey() + ":" + snapshot.periodStart(),
"STOPPED".equals(state) ? AlertSeverity.ERROR : AlertSeverity.WARNING, "Assistant budget " + snapshot.budget().getName(), message);
return (true);
}
@@ -26,6 +26,9 @@ import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionCheckResult;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
@@ -103,6 +106,28 @@ class AssistantQBitProducerTest extends BaseAssistantTest
/*******************************************************************************
** Budget types are discoverable through core with admin-only visibility.
*******************************************************************************/
@Test
void budgetAlertTypes()
{
var types = AlertsInstanceMetaData.of(qInstance).getAlertTypes();
assertThat(types).containsOnlyKeys("assistant.budget.warning", "assistant.budget.downgraded", "assistant.budget.stopped");
for(QAlertTypeMetaData type : types.values())
{
assertThat(type.getLabel()).isNotBlank();
assertThat(type.getDescription()).isNotBlank();
assertThat(type.getDefaultSeverity()).isEqualTo(type.getName().endsWith("stopped") ? AlertSeverity.ERROR : AlertSeverity.WARNING);
assertThat(type.getHowToFix()).isEqualTo("Raise the budget or grant an override in Assistant → Budgets");
assertThat(type.getSourceQBitName()).isEqualTo("assistant");
assertThat(type.getVisibilityPermission()).isEqualTo("assistant.admin");
assertThat(type.getSubjectTableName()).isEqualTo("assistant_budget");
}
}
/*******************************************************************************
** The admin app and tables: visible only with assistant.admin.
*******************************************************************************/
@@ -35,6 +35,13 @@ import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
import com.kingsrook.qbits.assistant.provider.ModelUsage;
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
import com.kingsrook.qqq.backend.core.alerts.LoggingAlertSink;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReferenceWithProperties;
import com.kingsrook.qqq.backend.core.model.metadata.code.InitializableViaCodeReference;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
@@ -172,12 +179,45 @@ class BudgetServiceTest extends BaseAssistantTest
/*******************************************************************************
** Fixed-clock evaluator and alert seam.
** Fixed-clock evaluator with the production core sink configuration.
*******************************************************************************/
private BudgetService service(List<String> alerts)
private BudgetService service(List<AlertSignal> alerts)
{
return (new BudgetService(store(), config.getRuntime().getProviderService(), Clock.fixed(NOW, ZoneOffset.UTC),
(type, key, severity, title, summary) -> alerts.add(type + ":" + severity)));
AlertsInstanceMetaData.ofOrWithNew(qInstance).withSink(new QCodeReferenceWithProperties(TestAlertSink.class, Map.of("signals", (ArrayList<AlertSignal>) alerts)));
return (new BudgetService(store(), config.getRuntime().getProviderService(), Clock.fixed(NOW, ZoneOffset.UTC)));
}
/*******************************************************************************
** Core loads a separate sink per instance; captured signals stay test-local.
*******************************************************************************/
public static class TestAlertSink extends LoggingAlertSink implements InitializableViaCodeReference
{
private List<AlertSignal> signals;
/*******************************************************************************
** Receive this test's capture list from the configured code reference.
*******************************************************************************/
@Override
@SuppressWarnings("unchecked")
public void initialize(QCodeReference codeReference)
{
signals = (List<AlertSignal>) ((QCodeReferenceWithProperties) codeReference).getProperties().get("signals");
}
/*******************************************************************************
** Capture the signal delivered through core.
*******************************************************************************/
@Override
public void raise(AlertSignal signal)
{
signals.add(signal);
}
}
@@ -188,15 +228,22 @@ class BudgetServiceTest extends BaseAssistantTest
@Test
void warningBoundaryAndDedup() throws Exception
{
budget();
AssistantBudget budget = budget();
usage(790_000, NOW);
List<String> alerts = new ArrayList<>();
List<AlertSignal> alerts = new ArrayList<>();
BudgetService service = service(alerts);
assertThat(service.evaluate(USER, null, false, false).notices()).isEmpty();
usage(10_000, NOW);
assertThat(service.evaluate(USER, null, false, false).notices()).singleElement().asString().contains("80%", "Per user daily");
assertThat(service.evaluate(USER, null, false, false).notices()).isEmpty();
assertThat(alerts).containsExactly("assistant.budget.warning:WARNING");
assertThat(alerts).singleElement().satisfies(signal ->
{
assertThat(signal.getAlertTypeName()).isEqualTo("assistant.budget.warning");
assertThat(signal.getDedupKey()).isEqualTo(budget.getId() + ":USER:" + USER + ":2026-10-13T00:00:00Z");
assertThat(signal.getSeverity()).isEqualTo(AlertSeverity.WARNING);
assertThat(signal.getTitle()).isEqualTo("Assistant budget Per user daily");
assertThat(signal.getSummary()).contains("80%", "Per user daily");
});
assertThat(store().query(AssistantBudgetEvent.TABLE_NAME, new QQueryFilter(), AssistantBudgetEvent.class)).singleElement()
.extracting(AssistantBudgetEvent::getState).isEqualTo("WARNED");
}
@@ -215,10 +262,14 @@ class BudgetServiceTest extends BaseAssistantTest
AssistantModel cheap = store().insert(new AssistantModel().withProviderId(local.getId()).withModelId("cheap").withEnabled(true).withInputUsdPerMTok(BigDecimal.ONE));
store().insert(new AssistantModel().withProviderId(cloud.getId()).withModelId("main").withEnabled(true).withInputUsdPerMTok(BigDecimal.ZERO));
usage(1_000_000, NOW);
BudgetService service = service(new ArrayList<>());
List<AlertSignal> alerts = new ArrayList<>();
BudgetService service = service(alerts);
ProviderService.Active active = config.getRuntime().getProviderService().resolve("main");
assertThat(service.evaluate(USER, active, false, false).active().providerId()).isEqualTo(local.getId());
assertThat(service.evaluate(USER, active, true, false).stopped()).isTrue();
assertThat(alerts).extracting(AlertSignal::getAlertTypeName).containsExactly("assistant.budget.downgraded", "assistant.budget.stopped");
assertThat(alerts).extracting(AlertSignal::getSeverity).containsExactly(AlertSeverity.WARNING, AlertSeverity.ERROR);
assertThat(alerts).extracting(AlertSignal::getDedupKey).containsOnly(budget.getId() + ":USER:" + USER + ":2026-10-13T00:00:00Z");
store().updateFields(AssistantBudget.TABLE_NAME, new QRecord().withValue("id", budget.getId()).withValue("downgradeModelId", cheap.getId()));
assertThat(service.evaluate(USER, active, false, false).active().model()).isEqualTo("cheap");
}