From c0aa104bcfbf1adede9a0b81d526931224e24901 Mon Sep 17 00:00:00 2001 From: Peter Beverloo Date: Thu, 2 Jan 2025 22:11:44 +0000 Subject: [PATCH] feat: Generative AI work for reaching out to previous volunteers --- .../[team]/retention/RetentionDataTable.tsx | 45 ++++++++++++++--- .../events/[event]/[team]/retention/page.tsx | 7 ++- app/admin/system/ai/page.tsx | 6 +++ .../system/ai/prompt/[prompt]/AiExplorer.tsx | 6 +++ app/api/ai/generatePrompt.ts | 29 ++++++++++- .../ai/prompts/RemindParticipationPrompt.ts | 49 +++++++++++++++++++ app/api/ai/updateSettings.ts | 3 ++ app/lib/Settings.ts | 1 + 8 files changed, 136 insertions(+), 10 deletions(-) create mode 100644 app/api/ai/prompts/RemindParticipationPrompt.ts diff --git a/app/admin/events/[event]/[team]/retention/RetentionDataTable.tsx b/app/admin/events/[event]/[team]/retention/RetentionDataTable.tsx index 9da6d245..7acb762d 100644 --- a/app/admin/events/[event]/[team]/retention/RetentionDataTable.tsx +++ b/app/admin/events/[event]/[team]/retention/RetentionDataTable.tsx @@ -7,8 +7,12 @@ import Link from 'next/link'; import { default as MuiLink } from '@mui/material/Link'; import Chip from '@mui/material/Chip'; +import IconButton from '@mui/material/IconButton'; +import MailOutlineIcon from '@mui/icons-material/MailOutline'; +import Stack from '@mui/material/Stack'; import Tooltip from '@mui/material/Tooltip'; import Typography from '@mui/material/Typography'; +import WhatsAppIcon from '@mui/icons-material/WhatsApp'; import type { RetentionContext, RetentionRowModel } from '@app/api/admin/retention/[[...id]]/route'; import { type RemoteDataTableColumn, RemoteDataTable } from '@app/admin/components/RemoteDataTable'; @@ -17,6 +21,11 @@ import { type RemoteDataTableColumn, RemoteDataTable } from '@app/admin/componen * Props accepted by the component. */ export type RetentionDataTableProps = RetentionContext & { + /** + * Whether the WhatsApp integration should be enabled for direct outreach. + */ + enableWhatsApp: boolean; + /** * Leaders to whom a retention action can be assigned. */ @@ -131,6 +140,7 @@ export function RetentionDataTable(props: RetentionDataTableProps) { }, }, { + display: 'flex', field: 'assigneeName', headerName: 'Assignee', editable: true, @@ -144,11 +154,29 @@ export function RetentionDataTable(props: RetentionDataTableProps) { if (!!params.value) return params.value; + if (params.row.status !== 'Unknown') { + return ( + + Unassigned + + ); + } + return ( - - Unassigned - + + + Unassigned + + + + + { props.enableWhatsApp && + + + } + ); } }, @@ -174,8 +202,11 @@ export function RetentionDataTable(props: RetentionDataTableProps) { ]; return ( - + <> + + ); } diff --git a/app/admin/events/[event]/[team]/retention/page.tsx b/app/admin/events/[event]/[team]/retention/page.tsx index 18ca0c21..4425a9b3 100644 --- a/app/admin/events/[event]/[team]/retention/page.tsx +++ b/app/admin/events/[event]/[team]/retention/page.tsx @@ -19,7 +19,7 @@ import db, { tRetention, tRoles, tUsersEvents, tUsers } from '@lib/database'; * events are interested in participating in the upcoming event. */ export default async function EventTeamRetentionPage(props: NextPageParams<'event' | 'team'>) { - const { event, team, user } = await verifyAccessAndFetchPageInfo(props.params); + const { access, event, team, user } = await verifyAccessAndFetchPageInfo(props.params); const usersEventJoin = tUsersEvents.forUseInLeftJoin(); @@ -55,6 +55,8 @@ export default async function EventTeamRetentionPage(props: NextPageParams<'even .orderBy(tUsers.lastName, 'asc') .executeSelectMany(); + const enableWhatsApp = access.can('volunteer.pii') || access.can('volunteer.account'); + return ( <> @@ -73,7 +75,8 @@ export default async function EventTeamRetentionPage(props: NextPageParams<'even claim a volunteer, which you can do by double clicking on cells in the the "Assignee" or "Notes" columns. - + ) diff --git a/app/admin/system/ai/page.tsx b/app/admin/system/ai/page.tsx index 34cb67f8..b7637259 100644 --- a/app/admin/system/ai/page.tsx +++ b/app/admin/system/ai/page.tsx @@ -32,6 +32,7 @@ export default async function AiPage() { 'gen-ai-intention-change-team', 'gen-ai-intention-reinstate-participation', 'gen-ai-intention-reject-volunteer', + 'gen-ai-intention-remind-participation', ]); // intentions: @@ -61,6 +62,11 @@ export default async function AiPage() { intention: settings['gen-ai-intention-reinstate-participation'] ?? '', setting: 'gen-ai-intention-reinstate-participation', }, + { + label: 'Participation (reminder)', + intention: settings['gen-ai-intention-remind-participation'] ?? '', + setting: 'gen-ai-intention-remind-participation', + }, ]; return ( diff --git a/app/admin/system/ai/prompt/[prompt]/AiExplorer.tsx b/app/admin/system/ai/prompt/[prompt]/AiExplorer.tsx index 210d0df1..0c088a65 100644 --- a/app/admin/system/ai/prompt/[prompt]/AiExplorer.tsx +++ b/app/admin/system/ai/prompt/[prompt]/AiExplorer.tsx @@ -108,6 +108,12 @@ export function AiExplorer(props: AiExplorerProps) { team: 'crew', }, + remindParticipation: { + userId: /* Nienke= */ 24, + event: '2025', + team: 'crew', + }, + overrides: { intention: data.intention, systemInstructions: data.systemInstructions, diff --git a/app/api/ai/generatePrompt.ts b/app/api/ai/generatePrompt.ts index d043960d..1a400eb9 100644 --- a/app/api/ai/generatePrompt.ts +++ b/app/api/ai/generatePrompt.ts @@ -11,6 +11,7 @@ import { ApproveApplicationPrompt } from './prompts/ApproveApplicationPrompt'; import { CancelParticipationPrompt } from './prompts/CancelParticipationPrompt'; import { ReinstateParticipationPrompt } from './prompts/ReinstateParticipationPrompt'; import { RejectApplicationPrompt } from './prompts/RejectApplicationPrompt'; +import { RemindParticipationPrompt } from './prompts/RemindParticipationPrompt'; import { TeamChangePrompt } from './prompts/TeamChangePrompt'; import { createVertexAIClient } from '@lib/integrations/vertexai'; @@ -30,6 +31,7 @@ export const kGeneratePromptDefinition = z.object({ 'change-team', 'reinstate-participation', 'reject-volunteer', + 'remind-participation', ]), /** @@ -92,6 +94,15 @@ export const kGeneratePromptDefinition = z.object({ team: z.string(), }).optional(), + /** + * Parameters that can be passed when the `type` equals `remind-participation`. + */ + remindParticipation: z.object({ + userId: z.number(), + event: z.string(), + team: z.string(), + }).optional(), + }), response: z.strictObject({ /** @@ -259,12 +270,28 @@ export async function generatePrompt(request: Request, props: ActionProps): Prom break; + case 'remind-participation': + if (!request.remindParticipation) + notFound(); + + prompt = new RemindParticipationPrompt({ + event: request.remindParticipation.event, + intention, + language: request.language, + sourceUserId: props.user.userId, + systemInstructions, + targetUserId: request.remindParticipation.userId, + team: request.remindParticipation.team, + }); + + break; + default: return { success: false, error: 'This type of prompt is not yet supported.' }; } const client = await createVertexAIClient(); - const result = await prompt.generate(client); + const result = await prompt!.generate(client); return { success: true, diff --git a/app/api/ai/prompts/RemindParticipationPrompt.ts b/app/api/ai/prompts/RemindParticipationPrompt.ts new file mode 100644 index 00000000..7ed983e7 --- /dev/null +++ b/app/api/ai/prompts/RemindParticipationPrompt.ts @@ -0,0 +1,49 @@ +// Copyright 2025 Peter Beverloo & AnimeCon. All rights reserved. +// Use of this source code is governed by a MIT license that can be found in the LICENSE file. + +import type { TeamEventPromptContext, TeamEventPromptParams } from './TeamEventPrompt'; +import { TeamEventPrompt } from './TeamEventPrompt'; +import { formatDate } from '@lib/Temporal'; + +/** + * Prompt that can be used to convey to a volunteer that we liked working with them in a past event + * and would like to work with them again. + */ +export class RemindParticipationPrompt extends TeamEventPrompt { + constructor(params: TeamEventPromptParams) { + super('gen-ai-intention-remind-participation', params); + } + + override composeSubject(context: TeamEventPromptContext, language: string): string { + return `${context.event.name} ${context.team.name}`; + } + + override composeMessage(context: TeamEventPromptContext): string[] { + const message = super.composeMessage(context); + + message.push('Before anything else, say that you hope that they are doing well.'); + message.push( + 'Express, in a professional manner, that we appreciated working with them at the ' + + 'most recent previous event.'); + + message.push('You are reaching out because we have started organising the next event.'); + message.push( + 'We noticed that their name is still missing from the list of volunteers for the ' + + 'upcoming event.'); + + if (context.event.startTime && context.event.endTime) { + message.push( + `The festival starts on ${formatDate(context.event.startTime, 'YYYY-MM-DD')}, ` + + `and ends on ${formatDate(context.event.endTime, 'YYYY-MM-DD')}.`); + } + + if (context.event.location) { + message.push(`The festival will take place in ${context.event.location}.`); + } + + message.push('Ask if they would be interested in participating again.'); + message.push(`If so, they can sign up on https://${context.team.domain}/registration/`); + + return message; + } +} diff --git a/app/api/ai/updateSettings.ts b/app/api/ai/updateSettings.ts index 14af52fe..74eaafb7 100644 --- a/app/api/ai/updateSettings.ts +++ b/app/api/ai/updateSettings.ts @@ -33,6 +33,7 @@ export const kUpdateAiSettingsDefinition = z.object({ 'gen-ai-intention-change-team': z.string(), 'gen-ai-intention-reinstate-participation': z.string(), 'gen-ai-intention-reject-volunteer': z.string(), + 'gen-ai-intention-remind-participation': z.string(), }).optional(), }), response: z.strictObject({ @@ -85,6 +86,8 @@ export async function updateSettings(request: Request, props: ActionProps): Prom request.prompts['gen-ai-intention-reinstate-participation'], 'gen-ai-intention-reject-volunteer': request.prompts['gen-ai-intention-reject-volunteer'], + 'gen-ai-intention-remind-participation': + request.prompts['gen-ai-intention-remind-participation'], }); await Log({ diff --git a/app/lib/Settings.ts b/app/lib/Settings.ts index 30fc4a6f..34cf6410 100644 --- a/app/lib/Settings.ts +++ b/app/lib/Settings.ts @@ -63,6 +63,7 @@ type SettingsMap = { 'gen-ai-intention-change-team': string; 'gen-ai-intention-reinstate-participation': string; 'gen-ai-intention-reject-volunteer': string; + 'gen-ai-intention-remind-participation': string; // --------------------------------------------------------------------------------------------- // Integration settings