diff --git a/pom.xml b/pom.xml index c61cb3e..f6871d7 100644 --- a/pom.xml +++ b/pom.xml @@ -105,6 +105,12 @@ qqq-backend-module-rdbms test + + + com.kingsrook.qqq + qqq-middleware-javalin + test + com.h2database h2 diff --git a/src/test/java/com/kingsrook/qbits/marketing/ScriptedModelProvider.java b/src/test/java/com/kingsrook/qbits/marketing/ScriptedModelProvider.java index fab4ecc..819230c 100644 --- a/src/test/java/com/kingsrook/qbits/marketing/ScriptedModelProvider.java +++ b/src/test/java/com/kingsrook/qbits/marketing/ScriptedModelProvider.java @@ -38,7 +38,7 @@ import com.kingsrook.qqq.backend.core.utils.JsonUtils; /******************************************************************************* ** Synchronous scripted responses and request snapshots for marketing tests. *******************************************************************************/ -class ScriptedModelProvider implements AssistantModelProvider +public class ScriptedModelProvider implements AssistantModelProvider { private final String name; private final Deque steps = new ArrayDeque<>(); @@ -51,7 +51,7 @@ class ScriptedModelProvider implements AssistantModelProvider /******************************************************************************* ** Register under the name selected by the test configuration. *******************************************************************************/ - ScriptedModelProvider(String name) + public ScriptedModelProvider(String name) { this.name = name; } @@ -61,7 +61,7 @@ class ScriptedModelProvider implements AssistantModelProvider /******************************************************************************* ** Queue a complete answer. *******************************************************************************/ - ScriptedModelProvider thenText(String text) + public ScriptedModelProvider thenText(String text) { return (thenRespond(request -> new ModelResponse().withParts(List.of(ModelPart.text(text))).withStopReason(ModelResponse.STOP_END_TURN))); } @@ -71,7 +71,7 @@ class ScriptedModelProvider implements AssistantModelProvider /******************************************************************************* ** Queue a single tool call. *******************************************************************************/ - ScriptedModelProvider thenToolUse(String toolName, Map input) + public ScriptedModelProvider thenToolUse(String toolName, Map input) { return (thenToolUses(null, List.of(new Object[] { toolName, input }))); } @@ -82,7 +82,7 @@ class ScriptedModelProvider implements AssistantModelProvider ** Queue parallel tool calls using the test fixtures' name/input pairs. *******************************************************************************/ @SuppressWarnings("unchecked") - ScriptedModelProvider thenToolUses(String preamble, List calls) + public ScriptedModelProvider thenToolUses(String preamble, List calls) { return (thenRespond(request -> { @@ -104,7 +104,7 @@ class ScriptedModelProvider implements AssistantModelProvider /******************************************************************************* ** Queue a provider failure, including usage already incurred. *******************************************************************************/ - ScriptedModelProvider thenError(ModelProviderException.Kind kind, String message) + public ScriptedModelProvider thenError(ModelProviderException.Kind kind, String message) { return (thenRespond(request -> { @@ -117,7 +117,7 @@ class ScriptedModelProvider implements AssistantModelProvider /******************************************************************************* ** Queue a response that can inspect the request or change test state. *******************************************************************************/ - ScriptedModelProvider thenRespond(Step step) + public ScriptedModelProvider thenRespond(Step step) { steps.add(step); return (this); @@ -128,7 +128,7 @@ class ScriptedModelProvider implements AssistantModelProvider /******************************************************************************* ** Set usage for budget and cost assertions. *******************************************************************************/ - ScriptedModelProvider withUsagePerCall(ModelUsage usage) + public ScriptedModelProvider withUsagePerCall(ModelUsage usage) { usagePerCall = usage; return (this); diff --git a/src/test/java/com/kingsrook/qbits/marketing/demo/MarketingDemoServer.java b/src/test/java/com/kingsrook/qbits/marketing/demo/MarketingDemoServer.java new file mode 100644 index 0000000..728cdf6 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/marketing/demo/MarketingDemoServer.java @@ -0,0 +1,317 @@ +/* + * 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.marketing.demo; + + +import java.io.InputStream; +import java.io.Serializable; +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.Statement; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.assistant.spi.ModelUsage; +import com.kingsrook.qbits.marketing.MarketingQBitConfig; +import com.kingsrook.qbits.marketing.MarketingQBitProducer; +import com.kingsrook.qbits.marketing.MarketingStore; +import com.kingsrook.qbits.marketing.ScriptedModelProvider; +import com.kingsrook.qbits.marketing.agent.MarketingAgentService; +import com.kingsrook.qbits.marketing.agent.tools.MarketingTools; +import com.kingsrook.qbits.marketing.model.MarketingCampaign; +import com.kingsrook.qbits.marketing.model.MarketingChannel; +import com.kingsrook.qbits.marketing.model.MarketingClaim; +import com.kingsrook.qbits.marketing.model.MarketingContent; +import com.kingsrook.qbits.marketing.model.MarketingOutcomeEvent; +import com.kingsrook.qbits.marketing.model.MarketingProduct; +import com.kingsrook.qbits.marketing.model.enums.AgentTask; +import com.kingsrook.qbits.marketing.model.enums.ClaimStatus; +import com.kingsrook.qbits.marketing.model.enums.FunnelStage; +import com.kingsrook.qbits.marketing.processes.MarketingActions; +import com.kingsrook.qbits.marketing.processes.MarketingTableProcesses; +import com.kingsrook.qbits.marketing.security.MarketingPermissions; +import com.kingsrook.qqq.backend.core.actions.processes.QProcessCallbackFactory; +import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +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.QAuthenticationType; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.branding.QBrandingMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.model.session.QUser; +import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleCustomizerInterface; +import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager; +import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData; +import com.kingsrook.qqq.middleware.javalin.QApplicationJavalinServer; + + +/******************************************************************************* + ** A local demo of the Marketing qbit for screenshots and manual checks (not a + ** test, not shipped): an in-memory H2 database created from the shipped DDL + ** (src/test/resources/marketing-h2.sql), a mock login as a user holding every + ** marketing permission, the verified starter data, and some history made + ** through the real services: a readiness check, an agent draft (offline, + ** scripted model), review, approval, scheduling, a controlled test execution + ** with its attempt and test delivery, and a few outcome events. + ** + ** Run it (from the repository root), then the Next UI dev server against it: + ** + ** ./mvnw -q test-compile + ** ./mvnw -q dependency:build-classpath -Dmdep.outputFile=target/cp.txt -Dmdep.includeScope=test + ** java -cp "target/test-classes;target/classes;$(cat target/cp.txt)" com.kingsrook.qbits.marketing.demo.MarketingDemoServer + ** + ** (Use ':' instead of ';' in the classpath on Linux/macOS. The server listens + ** on port 8000, or MARKETING_DEMO_PORT.) + ** + ** QQQ_BACKEND_URL=http://localhost:8000 pnpm dev (in qqq-frontend-next; port 3000) + *******************************************************************************/ +public class MarketingDemoServer extends AbstractQQQApplication +{ + static final String BACKEND = "demo"; + static final String USER = "dana"; + + private static final String BODY = "QQQ 4.0 is on Maven Central: you can try it with the quickstart in a few minutes. Java 21, Docker and Bash are required."; + + + + /******************************************************************************* + ** Start the demo. + *******************************************************************************/ + public static void main(String[] args) throws Exception + { + MarketingDemoServer application = new MarketingDemoServer(); + new QApplicationJavalinServer(application) + .withServeFrontendNext(false) + .withServeFrontendMaterialDashboard(false) + .withPort(Integer.parseInt(System.getenv().getOrDefault("MARKETING_DEMO_PORT", "8000"))) + .start(); + } + + + + /******************************************************************************* + ** The demo instance (seeded on definition). + *******************************************************************************/ + @Override + public QInstance defineQInstance() throws QException + { + QInstance qInstance = new QInstance(); + RDBMSBackendMetaData backend = new RDBMSBackendMetaData().withName(BACKEND).withVendor("h2").withHostName("mem").withDatabaseName("marketing_demo").withUsername("sa"); + backend.setJdbcUrl(ConnectionManager.getJdbcUrl(backend) + ";DATABASE_TO_UPPER=FALSE;DB_CLOSE_DELAY=-1"); + qInstance.addBackend(backend); + qInstance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK) + .withCustomizer(new QCodeReference(DemoSessionCustomizer.class))); + qInstance.setBranding(new QBrandingMetaData().withAppName("Marketing demo").withCompanyName("QRun-IO")); + qInstance.setEnforcePermissionsInCoreActions(true); + + createTables(backend); + + ScriptedModelProvider model = new ScriptedModelProvider("scripted").withUsagePerCall(new ModelUsage().withInputTokens(1200L).withOutputTokens(300L)); + new MarketingQBitProducer().withQBitConfig(new MarketingQBitConfig().withBackendName(BACKEND).withModelProvider(model).withModel("scripted-model")).produce(qInstance); + + seed(qInstance, model); + return (qInstance); + } + + + + /******************************************************************************* + ** Create the tables from the shipped H2 DDL. + *******************************************************************************/ + static void createTables(RDBMSBackendMetaData backend) throws QException + { + try(Connection connection = ConnectionManager.getConnection(backend); Statement statement = connection.createStatement(); + InputStream ddl = MarketingDemoServer.class.getResourceAsStream("/marketing-h2.sql")) + { + String sql = new String(ddl.readAllBytes(), StandardCharsets.UTF_8); + for(String part : sql.split(";")) + { + String trimmed = part.lines().filter(l -> !l.trim().startsWith("--")).reduce("", (a, b) -> a + "\n" + b).trim(); + if(!trimmed.isEmpty()) + { + statement.execute(trimmed); + } + } + } + catch(Exception e) + { + throw (new QException("Could not create the demo tables", e)); + } + } + + + + /******************************************************************************* + ** Starter data, then history through the real services and processes. + *******************************************************************************/ + static void seed(QInstance qInstance, ScriptedModelProvider model) throws QException + { + try + { + QContext.init(qInstance, DemoSessionCustomizer.sessionFor(USER)); + + //////////////////////////////////////////////////////////////// + // the qbit's own "Load Verified Starter Data" table action // + //////////////////////////////////////////////////////////////// + requireNoException(new RunProcessAction().execute(new RunProcessInput().withProcessName(MarketingTableProcesses.LOAD_STARTER_DATA) + .withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP)), "starter data"); + + QRecord product = first(MarketingProduct.TABLE_NAME, "slug", "qqq"); + QRecord campaign = first(MarketingCampaign.TABLE_NAME, "name", "QQQ 4.0: from quickstart to your first table"); + QRecord sink = first(MarketingChannel.TABLE_NAME, "name", "Controlled test sink"); + Integer campaignId = campaign.getValueInteger("id"); + + requireNoException(runAction(MarketingActions.CHECK_CAMPAIGN_READINESS, campaignId), "campaign readiness"); + requireNoException(runAction(MarketingActions.CHECK_CAMPAIGN_READINESS, first(MarketingCampaign.TABLE_NAME, "name", "gclo 1.1 for Windows: mirror a whole GitHub org").getValue("id")), "campaign readiness"); + + /////////////////////////////////////////////////////////////////////////// + // Draft with Agent, offline: the scripted model reads, then saves a draft // + /////////////////////////////////////////////////////////////////////////// + Integer released = MarketingStore.query(MarketingClaim.TABLE_NAME, new QQueryFilter( + new QFilterCriteria("productId", QCriteriaOperator.EQUALS, product.getValue("id")), + new QFilterCriteria("status", QCriteriaOperator.EQUALS, ClaimStatus.RELEASED.getId()))).get(0).getValueInteger("id"); + model.thenToolUses("Reading the brief first.", List.of( + new Object[] { MarketingTools.GET_CAMPAIGN_BRIEF, Map.of("campaign_id", campaignId) }, + new Object[] { MarketingTools.LIST_PRODUCT_CLAIMS, Map.of("product_id", product.getValueInteger("id")) }, + new Object[] { MarketingTools.LIST_CHANNELS, Map.of() })) + .thenToolUse(MarketingTools.SAVE_DRAFT, Map.of("campaign_id", campaignId, "channel_id", sink.getValueInteger("id"), "title", "Try QQQ 4.0 in a few minutes", + "body", BODY, "claim_ids", List.of(released))) + .thenText("I saved one draft for review on the controlled test sink."); + new MarketingAgentService().run(AgentTask.DRAFT_CONTENT, campaignId, null, null); + + /////////////////////////////////////////////////////////////////////////// + // a person schedules it, submits it, approves it and schedules it; the // + // worker then runs the due execution on the controlled test sink // + /////////////////////////////////////////////////////////////////////////// + Integer contentId = first(MarketingContent.TABLE_NAME, "campaignId", campaignId).getValueInteger("id"); + MarketingStore.update(MarketingContent.TABLE_NAME, new QRecord().withValue("id", contentId).withValue("scheduledFor", Instant.now().minusSeconds(5))); + requireNoException(runAction(MarketingActions.SUBMIT_FOR_REVIEW, contentId), "submit for review"); + requireNoException(runAction(MarketingActions.APPROVE, contentId), "approve"); + requireNoException(runAction(MarketingActions.SCHEDULE, contentId), "schedule"); + requireNoException(new RunProcessAction().execute(new RunProcessInput().withProcessName(MarketingTableProcesses.RUN_DUE_EXECUTIONS) + .withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP)), "run due executions"); + + /////////////////////////////////////////////////// + // a second piece of content, waiting for review // + /////////////////////////////////////////////////// + Integer waiting = MarketingStore.insert(new MarketingContent().withCampaignId(campaignId).withChannelId(sink.getValueInteger("id")).withTitle("Your first table, step by step") + .withBody("The quickstart takes you from an empty project to your first table. Java 21, Docker and Bash are required.") + .withScheduledFor(Instant.now().plusSeconds(3600L * 24))).getValueInteger("id"); + requireNoException(runAction(MarketingActions.SUBMIT_FOR_REVIEW, waiting), "submit for review"); + + ///////////////////////////////////////////////////////////////////////// + // controlled fixture events (test data, never counted as real numbers) // + ///////////////////////////////////////////////////////////////////////// + for(int i = 1; i <= 5; i++) + { + MarketingStore.insert(new MarketingOutcomeEvent().withCampaignId(campaignId).withStage(FunnelStage.AWARENESS.getId()).withEventType("try_page_visit") + .withSource("fixture").withExternalEventId("visit-" + i).withActorKey("visitor-" + (i % 3)).withIsTestData(true)); + } + MarketingStore.insert(new MarketingOutcomeEvent().withCampaignId(campaignId).withStage(FunnelStage.ENGAGEMENT.getId()).withEventType("quickstart_opened") + .withSource("fixture").withExternalEventId("open-1").withActorKey("visitor-1").withIsTestData(true)); + } + finally + { + QContext.clear(); + } + } + + + + /******************************************************************************* + ** The first record matching one field. + *******************************************************************************/ + private static QRecord first(String table, String field, Serializable value) throws QException + { + return (MarketingStore.queryBy(table, field, value).get(0)); + } + + + + /******************************************************************************* + ** Run a record action headlessly on one record. + *******************************************************************************/ + private static RunProcessOutput runAction(String processName, Serializable id) throws QException + { + return (new RunProcessAction().execute(new RunProcessInput().withProcessName(processName).withCallback(QProcessCallbackFactory.forPrimaryKey("id", id)) + .withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP))); + } + + + + /******************************************************************************* + ** Fail the demo start-up if a seeding step failed. + *******************************************************************************/ + private static void requireNoException(RunProcessOutput output, String what) throws QException + { + if(output.getException().isPresent()) + { + throw (new QException("Demo seeding failed at [" + what + "]", output.getException().get())); + } + } + + + + /******************************************************************************* + ** Every mock session is Dana, who holds every marketing permission. + *******************************************************************************/ + public static class DemoSessionCustomizer implements QAuthenticationModuleCustomizerInterface + { + /*************************************************************************** + ** + ***************************************************************************/ + @Override + public void customizeSession(QInstance qInstance, QSession qSession, Map context) + { + QSession granted = sessionFor(USER); + qSession.setUser(granted.getUser()); + qSession.setPermissions(granted.getPermissions()); + } + + + + /*************************************************************************** + ** + ***************************************************************************/ + @Override + public void customizeAutomatedSessionForUser(QInstance qInstance, QSession automatedSessionForUser, Serializable userId) + { + automatedSessionForUser.setPermissions(sessionFor(String.valueOf(userId)).getPermissions()); + } + + + + /*************************************************************************** + ** Dana's session. + ***************************************************************************/ + static QSession sessionFor(String userId) + { + QSession session = new QSession().withUser(new QUser().withIdReference(userId).withFullName("Dana Demo")); + session.setPermissions(new java.util.HashSet<>(MarketingPermissions.ALL)); + return (session); + } + } +}