Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/api/src/app/ai/ai.module.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { ResourceValidatorService } from '@novu/application-generic';
import { AiChatRepository, SnapshotRepository } from '@novu/dal';
import { GetEnvironmentTags } from '../environments-v2/usecases/get-environment-tags';
import { IntegrationModule } from '../integrations/integrations.module';
import { SharedModule } from '../shared/shared.module';
import { WorkflowModule } from '../workflows-v2/workflow.module';
Expand Down Expand Up @@ -29,6 +30,7 @@ const USE_CASES = [
UpsertChatUseCase,
ResourceValidatorService,
CheckpointerService,
GetEnvironmentTags,
];

const REPOSITORIES = [AiChatRepository, SnapshotRepository];
Expand Down
59 changes: 52 additions & 7 deletions apps/api/src/app/ai/prompts/step.prompt.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { z } from 'zod';
import { editStepInputSchema, stepInputSchema } from '../schemas/steps-control.schema';
import { DraftWorkflowState } from '../tools';
import { JSONSchemaDto } from '../../shared/dtos/json-schema.dto';
import { editStepInputSchema, stepInputSchema, updateStepConditionsInputSchema } from '../schemas/steps-control.schema';
import { formatVariableSchemaForPrompt } from '../utils/variable-schema.utils';
import { getVariableSchemaPrompt } from './general.prompt';
import { EXAMPLE_BLOCK_EDITOR_JSON } from './maily-blocks';
Expand Down Expand Up @@ -183,8 +183,7 @@ Common patterns:
- Throttle by key (e.g., "payload.alertType") for grouped limits`,
};

export function buildStepSystemPrompt(basePrompt: string, draftState: DraftWorkflowState): string {
const variableSchema = draftState.getFullVariableSchema();
export function buildStepSystemPrompt(basePrompt: string, variableSchema: JSONSchemaDto): string {
const variableSchemaPrompt = formatVariableSchemaForPrompt(variableSchema);

if (variableSchemaPrompt) {
Expand All @@ -208,10 +207,8 @@ Keep the same editorType (block or html for email) and structure. Only update th
export function buildEditStepSystemPrompt(
basePrompt: string,
currentControlValues: Record<string, unknown>,
draftState: DraftWorkflowState
variableSchema: JSONSchemaDto
): string {
const workflow = draftState.getWorkflow();
const variableSchema = workflow?.payloadSchema ?? draftState.getFullVariableSchema();
const variableSchemaPrompt = formatVariableSchemaForPrompt(variableSchema);
const currentContentJson = JSON.stringify(currentControlValues, null, 2);

Expand All @@ -230,3 +227,51 @@ ${variableSection}`;
export function buildEditStepUserPrompt(input: z.infer<typeof editStepInputSchema>): string {
return `Step ID: ${input.stepId}\nEdit intent: ${input.intent}`;
}

export const STEP_CONDITION_PROMPT = `Generate a JSONLogic condition for step execution.
## When to use
- Step executes when condition evaluates to true
- Use null to remove the condition (step always executes)
## Merge vs Replace
- ADD/EXTEND: When user says "add", "also", "and", "in addition" - combine existing condition with new using AND: { "and": [existingCondition, newCondition] }
- REPLACE: When user says "change to", "update to", "set to", "replace with" - return the new condition entirely, ignore existing
- REMOVE: When user says "remove", "delete", "clear" - return null
## Variable reference format
Use "var" for variable references: { "var": "path.to.value" }
- payload.*: trigger payload (e.g., payload.amount, payload.priority)
- subscriber.*: subscriber data (e.g., subscriber.firstName, subscriber.isOnline)
- steps.*: previous step state (e.g., steps.welcome-in-app.read, steps.welcome-in-app.seen)
## Common patterns
- Subscriber offline: { "==": [{ "var": "subscriber.isOnline" }, "false"] }
- In-App not read: { "==": [{ "var": "steps.{stepId}.read" }, "false"] }
- In-App not seen: { "==": [{ "var": "steps.{stepId}.seen" }, "false"] }
- Payload value equals: { "==": [{ "var": "payload.priority" }, "high"] }
- Payload value not equals: { "!=": [{ "var": "payload.priority" }, "low"] }
- AND: { "and": [condition1, condition2] }
- OR: { "or": [condition1, condition2] }
- NOT: { "!": [condition] }
## Output
Return only the skip field: JSONLogic object or null.`;

export function buildUpdateStepConditionsSystemPrompt(previousStepIds: string[], existingCondition: unknown): string {
const stepsContext =
previousStepIds.length > 0
? `\n## Previous steps (use these stepIds in steps.* references)\n${previousStepIds.map((id) => `- ${id}`).join('\n')}`
: '';

const existingContext =
existingCondition != null
? `\n## Current condition (merge with AND when user wants to add, replace entirely when user wants to change)\n\`\`\`json\n${JSON.stringify(existingCondition, null, 2)}\n\`\`\``
: '\n## Current condition: none (step always executes)';

return `${STEP_CONDITION_PROMPT}${existingContext}${stepsContext}`;
}

export function buildUpdateStepConditionsUserPrompt(input: z.infer<typeof updateStepConditionsInputSchema>): string {
return `Step ID: ${input.stepId}\nCondition intent: ${input.intent}`;
}
7 changes: 5 additions & 2 deletions apps/api/src/app/ai/prompts/workflow.prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,11 @@ For EDITING existing workflows (when user asks to change content, metadata, or r
1. Call ${AiWorkflowToolsNameEnum.RETRIEVE_ORGANIZATION_META} to get available channels
2. Use ${AiWorkflowToolsNameEnum.SET_WORKFLOW_METADATA} to change workflow name, description, tags, severity
3. Use ${AiWorkflowToolsNameEnum.EDIT_STEP_CONTENT} to modify step content (e.g., "edit the email to include X")
4. Use ${AiWorkflowToolsNameEnum.ADD_STEP} to add steps
5. Use ${AiWorkflowToolsNameEnum.REMOVE_STEP} to remove a step
4. Use ${AiWorkflowToolsNameEnum.UPDATE_STEP_CONDITIONS} to add, change, or remove step conditions (e.g., "only send when offline")
5. Use ${AiWorkflowToolsNameEnum.ADD_STEP} to add steps
6. Use ${AiWorkflowToolsNameEnum.REMOVE_STEP} to remove a step
7. Use ${AiWorkflowToolsNameEnum.MOVE_STEP} to move a step
8. Use ${AiWorkflowToolsNameEnum.ADD_STEP_IN_BETWEEN} to add a step in between two existing steps
</workflow>

<output_format>
Expand Down
61 changes: 54 additions & 7 deletions apps/api/src/app/ai/schemas/steps-control.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,26 +86,35 @@ const aiJsonLogicComparisonSchema = z.union([
.describe('Check if value exists in array'),
]);

const aiJsonLogicConditionSchema = z.lazy(() =>
z.union([
/**
* Unrolled recursive condition schema (3 levels deep) to satisfy
* OpenAI structured output requirements — z.lazy() produces schemas
* without a 'type' key on recursive items which OpenAI rejects.
*/
function buildConditionLevel(innerSchema: z.ZodType) {
return z.union([
z
.object({
and: z.array(aiJsonLogicConditionSchema).min(1).describe('Array of conditions that must ALL be true'),
and: z.array(innerSchema).min(1).describe('Array of conditions that must ALL be true'),
})
.describe('Logical AND - all conditions must be true'),
z
.object({
or: z.array(aiJsonLogicConditionSchema).min(1).describe('Array of conditions where at least ONE must be true'),
or: z.array(innerSchema).min(1).describe('Array of conditions where at least ONE must be true'),
})
.describe('Logical OR - at least one condition must be true'),
z
.object({
'!': z.array(aiJsonLogicConditionSchema).length(1).describe('Single condition to negate'),
'!': z.array(innerSchema).length(1).describe('Single condition to negate'),
})
.describe('Logical NOT - negates the condition'),
aiJsonLogicComparisonSchema,
])
) as z.ZodType<JsonLogicCondition>;
]);
}

const aiJsonLogicConditionLevel0 = aiJsonLogicComparisonSchema;
const aiJsonLogicConditionLevel1 = buildConditionLevel(aiJsonLogicConditionLevel0);
const aiJsonLogicConditionSchema: z.ZodType<JsonLogicCondition> = buildConditionLevel(aiJsonLogicConditionLevel1);

export const aiSkipConditionSchema = z
.union([aiJsonLogicConditionSchema, aiJsonLogicVarSchema])
Expand Down Expand Up @@ -301,6 +310,18 @@ export const stepInputSchema = z.object({

export const editStepInputSchema = z.object({
stepId: z.string().describe('Unique step identifier of the step to edit'),
type: z
.enum([
StepTypeEnum.IN_APP,
StepTypeEnum.EMAIL,
StepTypeEnum.PUSH,
StepTypeEnum.CHAT,
StepTypeEnum.SMS,
StepTypeEnum.DELAY,
StepTypeEnum.DIGEST,
StepTypeEnum.THROTTLE,
])
.describe('Type of the step to edit'),
intent: z.string().describe('Description of the change the user wants to make'),
});

Expand All @@ -309,6 +330,32 @@ export const removeStepInputSchema = z.object({
reason: z.string().describe('Brief reason for removing the step'),
});

export const moveStepInputSchema = z.object({
stepId: z.string().describe('Unique step identifier of the step to move'),
toIndex: z.number().int().min(0).describe('Target 0-based index position in the workflow steps array'),
});

export const addStepInBetweenInputSchema = stepInputSchema.extend({
afterStepId: z
.string()
.describe(
'Step ID of the step after which to insert the new step. The new step will be placed immediately after this step.'
),
});

export const updateStepConditionsInputSchema = z.object({
stepId: z.string().describe('Unique step identifier of the step to update'),
intent: z
.string()
.describe(
'Description of the condition to apply (e.g., "only send when subscriber is offline", "skip if In-App was already read", "remove condition")'
),
});

export const updateStepConditionsOutputSchema = z.object({
skip: aiSkipConditionSchema,
});

export const emailStepOutputSchema = z.object({
stepId: z.string().describe('Unique step identifier (lowercase, kebab-case, e.g., "welcome-email")'),
name: z.string().min(1).max(100).describe('Human readable step name, never in kebab-case'),
Expand Down
Loading
Loading