Skip to content

Commit

Permalink
feat: Generative AI work for reaching out to previous volunteers
Browse files Browse the repository at this point in the history
  • Loading branch information
beverloo committed Jan 2, 2025
1 parent 0abc939 commit c0aa104
Show file tree
Hide file tree
Showing 8 changed files with 136 additions and 10 deletions.
45 changes: 38 additions & 7 deletions app/admin/events/[event]/[team]/retention/RetentionDataTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -17,6 +21,11 @@ import { type RemoteDataTableColumn, RemoteDataTable } from '@app/admin/componen
* Props accepted by the <RetentionDataTable> 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.
*/
Expand Down Expand Up @@ -131,6 +140,7 @@ export function RetentionDataTable(props: RetentionDataTableProps) {
},
},
{
display: 'flex',
field: 'assigneeName',
headerName: 'Assignee',
editable: true,
Expand All @@ -144,11 +154,29 @@ export function RetentionDataTable(props: RetentionDataTableProps) {
if (!!params.value)
return params.value;

if (params.row.status !== 'Unknown') {
return (
<Typography component="span" variant="body2"
sx={{ color: 'text.disabled', fontStyle: 'italic' }}>
Unassigned
</Typography>
);
}

return (
<Typography component="span" variant="body2"
sx={{ color: 'text.disabled', fontStyle: 'italic' }}>
Unassigned
</Typography>
<Stack direction="row" alignItems="center">
<Typography component="span" variant="body2"
sx={{ color: 'text.disabled', fontStyle: 'italic', mr: 1 }}>
Unassigned
</Typography>
<IconButton size="small">
<MailOutlineIcon color="action" fontSize="inherit" />
</IconButton>
{ props.enableWhatsApp &&
<IconButton size="small">
<WhatsAppIcon color="success" fontSize="inherit" />
</IconButton> }
</Stack>
);
}
},
Expand All @@ -174,8 +202,11 @@ export function RetentionDataTable(props: RetentionDataTableProps) {
];

return (
<RemoteDataTable columns={columns} endpoint="/api/admin/retention" enableUpdate
context={{ event: props.event, team: props.team }} refreshOnUpdate
defaultSort={{ field: 'id', sort: 'asc' }} pageSize={100} disableFooter />
<>
<RemoteDataTable columns={columns} endpoint="/api/admin/retention" enableUpdate
context={{ event: props.event, team: props.team }} refreshOnUpdate
defaultSort={{ field: 'id', sort: 'asc' }} pageSize={100}
disableFooter />
</>
);
}
7 changes: 5 additions & 2 deletions app/admin/events/[event]/[team]/retention/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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 (
<>
<Collapse in={!!assignedVolunteers.length} unmountOnExit>
Expand All @@ -73,7 +75,8 @@ export default async function EventTeamRetentionPage(props: NextPageParams<'even
<em> claim</em> a volunteer, which you can do by double clicking on cells in the
the "Assignee" or "Notes" columns.
</Alert>
<RetentionDataTable event={event.slug} leaders={leaders} team={team.slug} />
<RetentionDataTable enableWhatsApp={enableWhatsApp}
event={event.slug} leaders={leaders} team={team.slug} />
</Paper>
</>
)
Expand Down
6 changes: 6 additions & 0 deletions app/admin/system/ai/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
]);

// <AiPromptContext> intentions:
Expand Down Expand Up @@ -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 (
Expand Down
6 changes: 6 additions & 0 deletions app/admin/system/ai/prompt/[prompt]/AiExplorer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
29 changes: 28 additions & 1 deletion app/api/ai/generatePrompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -30,6 +31,7 @@ export const kGeneratePromptDefinition = z.object({
'change-team',
'reinstate-participation',
'reject-volunteer',
'remind-participation',
]),

/**
Expand Down Expand Up @@ -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({
/**
Expand Down Expand Up @@ -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,
Expand Down
49 changes: 49 additions & 0 deletions app/api/ai/prompts/RemindParticipationPrompt.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
3 changes: 3 additions & 0 deletions app/api/ai/updateSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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({
Expand Down
1 change: 1 addition & 0 deletions app/lib/Settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit c0aa104

Please sign in to comment.