Skip to content
Draft
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
18 changes: 18 additions & 0 deletions apis/api-gateway/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,7 @@ type Mutation @join__type(graph: API_ANALYTICS) @join__type(graph: API_JOURNEYS
integrationGoogleCreate(input: IntegrationGoogleCreateInput!) : IntegrationGoogle! @join__field(graph: API_JOURNEYS_MODERN)
integrationGoogleUpdate(id: ID!, input: IntegrationGoogleUpdateInput!) : IntegrationGoogle! @join__field(graph: API_JOURNEYS_MODERN)
integrationDelete(id: ID!) : Integration! @join__field(graph: API_JOURNEYS_MODERN)
journeyAiEdit(input: JourneyAiEditInput!) : JourneyAiEditResult! @join__field(graph: API_JOURNEYS_MODERN)
journeyAiTranslateCreate(input: JourneyAiTranslateInput!) : Journey! @join__field(graph: API_JOURNEYS_MODERN)
createJourneyEventsExportLog(input: JourneyEventsExportLogInput!) : JourneyEventsExportLog! @join__field(graph: API_JOURNEYS_MODERN)
journeyLanguageAiDetect(input: MutationJourneyLanguageAiDetectInput!) : Boolean! @join__field(graph: API_JOURNEYS_MODERN)
Expand Down Expand Up @@ -2388,6 +2389,11 @@ type GoogleSheetsSync @join__type(graph: API_JOURNEYS_MODERN) {
journey: Journey!
}

type JourneyAiEditResult @join__type(graph: API_JOURNEYS_MODERN) {
reply: String
proposedJourney: Json
}

type JourneyAiTranslateProgress @join__type(graph: API_JOURNEYS_MODERN) {
"""
Translation progress as a percentage (0-100)
Expand Down Expand Up @@ -4803,6 +4809,13 @@ input IntegrationGoogleUpdateInput @join__type(graph: API_JOURNEYS_MODERN) {
redirectUri: String!
}

input JourneyAiEditInput @join__type(graph: API_JOURNEYS_MODERN) {
journeyId: ID!
message: String!
history: [MessageHistoryItem!]
selectedCardId: String
}

input JourneyAiTranslateInput @join__type(graph: API_JOURNEYS_MODERN) {
journeyId: ID!
name: String!
Expand Down Expand Up @@ -4860,6 +4873,11 @@ input LinkActionInput @join__type(graph: API_JOURNEYS_MODERN) {
parentStepId: String
}

input MessageHistoryItem @join__type(graph: API_JOURNEYS_MODERN) {
role: String!
content: String!
}

input MultiselectBlockCreateInput @join__type(graph: API_JOURNEYS_MODERN) {
id: ID
journeyId: ID!
Expand Down
18 changes: 18 additions & 0 deletions apis/api-journeys-modern/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -823,6 +823,18 @@ type Journey
journeyCollections: [JourneyCollection!]!
}

input JourneyAiEditInput {
journeyId: ID!
message: String!
history: [MessageHistoryItem!]
selectedCardId: String
}

type JourneyAiEditResult {
reply: String
proposedJourney: Json
}

input JourneyAiTranslateInput {
journeyId: ID!
name: String!
Expand Down Expand Up @@ -1233,6 +1245,11 @@ input LinkActionInput {

union MediaVideo = MuxVideo | Video | YouTube

input MessageHistoryItem {
role: String!
content: String!
}

enum MessagePlatform {
facebook
telegram
Expand Down Expand Up @@ -1393,6 +1410,7 @@ type Mutation {
integrationGoogleCreate(input: IntegrationGoogleCreateInput!): IntegrationGoogle!
integrationGoogleUpdate(id: ID!, input: IntegrationGoogleUpdateInput!): IntegrationGoogle!
integrationDelete(id: ID!): Integration!
journeyAiEdit(input: JourneyAiEditInput!): JourneyAiEditResult!
journeyAiTranslateCreate(input: JourneyAiTranslateInput!): Journey!
createJourneyEventsExportLog(input: JourneyEventsExportLogInput!): JourneyEventsExportLog!
journeyLanguageAiDetect(input: MutationJourneyLanguageAiDetectInput!): Boolean!
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ export async function getSimpleJourney(
}
}
})
if (!journey) throw new Error('Journey not found')
if (!journey)
throw new GraphQLError('Journey not found', {
extensions: { code: 'NOT_FOUND' }
})

const simpleJourney = simplifyJourney(journey)
const result = journeySimpleSchema.safeParse(simpleJourney)
Expand Down
1 change: 1 addition & 0 deletions apis/api-journeys-modern/src/schema/journeyAiEdit/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import './journeyAiEdit'
215 changes: 215 additions & 0 deletions apis/api-journeys-modern/src/schema/journeyAiEdit/journeyAiEdit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import { google } from '@ai-sdk/google'
import { ModelMessage, NoObjectGeneratedError, generateObject } from 'ai'
import { GraphQLError } from 'graphql'
import { z } from 'zod'

import { prisma } from '@core/prisma/journeys/client'
import {
JourneySimple,
JourneySimpleCard,
journeySimpleSchema
} from '@core/shared/ai/journeySimpleTypes'
import { hardenPrompt } from '@core/shared/ai/prompts'

import { Action, ability, subject } from '../../lib/auth/ability'
import { builder } from '../builder'
import { getSimpleJourney } from '../journey/simple/getSimpleJourney'

import { buildSystemPrompt } from './prompts'

// Return type for the mutation
interface JourneyAiEditResult {
reply: string
proposedJourney: JourneySimple | null
}

const JourneyAiEditResultRef = builder.objectRef<JourneyAiEditResult>(
'JourneyAiEditResult'
)

builder.objectType(JourneyAiEditResultRef, {
fields: (t) => ({
reply: t.string({
resolve: (parent) => parent.reply
}),
proposedJourney: t.field({
type: 'Json',
nullable: true,
resolve: (parent) => parent.proposedJourney
})
})
})

const MessageHistoryItem = builder.inputType('MessageHistoryItem', {
fields: (t) => ({
role: t.string({ required: true }),
content: t.string({ required: true })
})
})

// Input type
const JourneyAiEditInput = builder.inputType('JourneyAiEditInput', {
fields: (t) => ({
journeyId: t.id({ required: true }),
message: t.string({ required: true }),
history: t.field({ type: [MessageHistoryItem], required: false }),
selectedCardId: t.string({ required: false })
})
})

// Use the base schema (no superRefine) so Zod validation doesn't reject
// model output that has minor issues (e.g. empty video urls on non-video cards).
// We sanitize the output ourselves after generation.
const journeyAiEditSchema = z.object({
reply: z
.string()
.describe(
'Plain language explanation of what was changed and why, or suggestions/answer if no changes'
),
journey: journeySimpleSchema
.nullable()
.describe(
'Full updated journey with all changes applied, or null if no structural changes are needed'
)
})

// Strip video fields with empty URLs that the model sometimes emits on non-video cards
function sanitizeJourney(journey: JourneySimple): JourneySimple {
return {
...journey,
cards: journey.cards.map((card): JourneySimpleCard => {
if (card.video != null && !card.video.url) {
const { video: _video, ...rest } = card
return rest
}
return card
})
}
}

builder.mutationField('journeyAiEdit', (t) =>
t.withAuth({ isAuthenticated: true }).field({
type: JourneyAiEditResultRef,
nullable: false,
args: {
input: t.arg({
type: JourneyAiEditInput,
required: true
})
},
resolve: async (_parent, { input }, context) => {
// 1. Validate message length
if (input.message.length > 2000) {
throw new GraphQLError(
'Message exceeds maximum length of 2000 characters',
{
extensions: { code: 'BAD_USER_INPUT' }
}
)
}

// 2. Fetch journey and validate ACL
const dbJourney = await prisma.journey.findUnique({
where: { id: input.journeyId },
include: {
userJourneys: true,
team: { include: { userTeams: true } }
}
})

if (!dbJourney) {
throw new GraphQLError('journey not found', {
extensions: { code: 'NOT_FOUND' }
})
}

if (
!ability(Action.Update, subject('Journey', dbJourney), context.user)
) {
throw new GraphQLError(
'user does not have permission to update journey',
{ extensions: { code: 'FORBIDDEN' } }
)
}

// 3. Fetch simple journey representation
let currentJourney: JourneySimple
try {
currentJourney = await getSimpleJourney(input.journeyId)
} catch (error) {
if (error instanceof GraphQLError) throw error
console.error('journeyAiEdit: failed to load journey', error)
throw new GraphQLError(
'Failed to load journey data. Please try again.',
{
extensions: { code: 'INTERNAL_SERVER_ERROR' }
}
)
}

// 4. Prune history to last 10 turns
const prunedHistory = (input.history ?? []).slice(-10).map((m) => ({
role: m.role as 'user' | 'assistant',
content: m.content
}))

// 5. Harden user message
const hardenedMessage = hardenPrompt(input.message)

// 6. Build system prompt
const systemPrompt = buildSystemPrompt(
currentJourney,
input.selectedCardId ?? undefined
)

// 7. Call generateObject
let aiResult: z.infer<typeof journeyAiEditSchema>
try {
const { object } = await generateObject({
model: google('gemini-2.0-flash'),
system: systemPrompt,
messages: [
...prunedHistory,
{ role: 'user', content: hardenedMessage }
],
schema: journeyAiEditSchema,
maxRetries: 2,
abortSignal: AbortSignal.timeout(30_000)
})
aiResult = object
} catch (error) {
if (error instanceof NoObjectGeneratedError) {
console.error('journeyAiEdit: NoObjectGeneratedError', {
journeyId: input.journeyId,
rawOutput: error.text
})
return {
reply:
'Something went wrong generating a response. Please try rephrasing your request.',
proposedJourney: null
}
}
console.error('journeyAiEdit: generateObject error', error)
return {
reply: 'Something went wrong. Please try again.',
proposedJourney: null
}
}

// 8. Audit log
console.log('journeyAiEdit audit', {
userId: context.user.id,
journeyId: input.journeyId,
timestamp: new Date().toISOString(),
hadProposal: aiResult.journey != null
})

// 9. Return result
const rawJourney = aiResult.journey
return {
reply: aiResult.reply,
proposedJourney: rawJourney != null ? sanitizeJourney(rawJourney) : null
}
}
})
)
74 changes: 74 additions & 0 deletions apis/api-journeys-modern/src/schema/journeyAiEdit/prompts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { JourneySimple } from '@core/shared/ai/journeySimpleTypes'

export function buildSystemPrompt(
journey: JourneySimple,
selectedCardId?: string
): string {
const screenCount = journey.cards.length

// Find selected card if provided
const selectedCard = selectedCardId
? journey.cards.find((c) => c.id === selectedCardId)
: undefined
const selectedIndex = selectedCard
? journey.cards.indexOf(selectedCard) + 1
: undefined

let prompt = `You are an AI assistant that helps users edit journey content.

IMPORTANT LANGUAGE RULES:
- NEVER use technical terms like "block", "step", "card", "StepBlock", "CardBlock", or any internal type names
- Say "screen" or "slide" when referring to a section of the journey
- Say "intro screen", "first screen", "last screen", etc. for positions
- Describe changes in plain language: "I've updated your intro text" not "I modified card-1"
- Reply in the same language the user writes in

OUTPUT CONTRACT:
- When making changes, return the COMPLETE updated journey JSON in the journey field
- When only giving advice or answering questions, set journey to null
- Always include a clear, plain-language explanation in the reply field

CURRENT JOURNEY STATE:
Title: ${journey.title}
Description: ${journey.description}
Screens: ${screenCount} ${screenCount === 1 ? 'screen' : 'screens'}

Full journey JSON (for your internal use — never mention this structure to users):
${JSON.stringify(journey, null, 2)}
`

if (selectedCard !== undefined && selectedIndex !== undefined) {
const heading = selectedCard.heading ?? '(no heading)'
prompt += `
SELECTED SCREEN CONTEXT:
The user is currently viewing screen ${selectedIndex}: "${heading}".
References to "this screen", "here", "the current one", or "this slide" mean this specific screen.
`
}

prompt += `
CONTENT TYPES AVAILABLE:
- Title/heading text
- Body text
- Image (displayed on the screen)
- Background image (fills the entire screen background)
- Video (YouTube URL — if a screen has a video, it can only contain a video and a next-screen link)
- Button (navigates to next screen or external URL)
- Multiple-choice poll (each option navigates to a specific screen or URL)

CONSTRAINTS:
- A screen with a video can ONLY have a video and a link to the next screen — no heading, text, buttons, or polls
- Every screen must have a way to go to the next screen (button, poll options, video's defaultNextCard, or defaultNextCard)
- When returning a full updated journey, include ALL screens — not just the changed ones
- Journey cards must always have valid navigation so users can always progress

BEHAVIORAL RULES:
- Act on additive and editing requests immediately
- Ask for confirmation before bulk deletions (e.g., "delete all screens")
- If a journey is empty (no screens), offer to add an introduction screen
- Describe what you changed in plain terms
- Do not mention internal IDs or field names to users
`

return prompt
}
1 change: 1 addition & 0 deletions apis/api-journeys-modern/src/schema/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import './host'
import './googleSheetsSync'
import './integration'
import './journey'
import './journeyAiEdit'
import './journeyAiTranslate'
import './journeyCollection'
import './journeyEventsExportLog'
Expand Down
Loading
Loading