Skip to content
This repository has been archived by the owner on Dec 10, 2024. It is now read-only.

Commit

Permalink
feat(analytics): AI analytics summary
Browse files Browse the repository at this point in the history
  • Loading branch information
jhdcruz committed Nov 24, 2024
1 parent d13894f commit 191b68b
Show file tree
Hide file tree
Showing 8 changed files with 311 additions and 4 deletions.
Binary file modified bun.lockb
Binary file not shown.
2 changes: 1 addition & 1 deletion next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ const nextConfig: NextConfig = {
config.resolve.alias = {
...config.resolve.alias,
'onnxruntime-node$': false,
'@huggingface/transformers': false,
sharp$: false,
pdfkit$: false,
};

return config;
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
"framer-motion": "11.11.17",
"little-date": "1.0.0",
"next": "15.0.4-canary.25",
"openai": "4.73.0",
"pdf-lib": "1.17.1",
"pdfkit": "0.15.1",
"qrcode": "1.5.4",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,13 @@ const StatsRingComponent = ({ id }: { id: string }) => {
shadow="xs"
withBorder
>
<SummaryText id={id!} rating={data!} />
<SummaryText
beneficiaries={beneficiaries}
id={id!}
implementers={implementers}
partners={partners}
ratings={data!}
/>
</Paper>
</Grid.Col>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { memo, useEffect, useState } from 'react';
import {
Button,
Group,
Text,
Tooltip,
Spoiler,
TypographyStylesProvider,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import {
IconCheck,
IconFileTextAi,
IconFileTextSpark,
} from '@tabler/icons-react';
import sanitizeHtml from 'sanitize-html';
import { createBrowserClient } from '@/libs/supabase/client';
import { triggerSummary } from './analytics.actions';
import type { RatingsProps } from './StatRatings';
import utilStyles from '@/styles/Utilties.module.css';

function Summary({
id,
ratings,
partners,
implementers,
beneficiaries,
}: {
id: string;
ratings: RatingsProps;
partners: number;
implementers: number;
beneficiaries: number;
}) {
const [summary, setSummary] = useState<string>('');
const [loading, setLoading] = useState<boolean>(false);

const total = partners + implementers + beneficiaries;

const handleGenerate = async () => {
setLoading(true);
notifications.show({
id: 'summary',
loading: true,
title: 'Generating summary',
message: 'Please wait...',
withBorder: true,
autoClose: false,
});

await triggerSummary(id, ratings, {
partners,
implementers,
beneficiaries,
total,
});

void fetchSummary(id);

notifications.update({
id: 'summary',
loading: false,
icon: <IconCheck />,
message: 'Summary generated successfully',
withBorder: true,
autoClose: 4000,
});

setLoading(false);
};

const fetchSummary = async (activity: string) => {
setLoading(true);
const supabase = createBrowserClient();

const { data } = await supabase
.from('analytics_metadata')
.select('content, updated_at')
.eq('activity_id', activity)
.eq('type', 'summary')
.limit(1)
.maybeSingle();

setSummary(data?.content ?? '');
setLoading(false);
};

useEffect(() => {
void fetchSummary(id);
}, [id]);

return (
<>
<Group justify="space-between">
<Group align="flex-end" gap="xs">
<Text fw={700} fz="lg">
Evaluation Summary
</Text>
</Group>

<Group gap="xs">
<Tooltip
label="Generate a summary report sparingly, preferably after the evaluation period."
multiline
withArrow
>
<Button
disabled={loading || total === 0}
leftSection={<IconFileTextSpark size={14} />}
loaderProps={{ type: 'dots' }}
onClick={() => handleGenerate()}
size="xs"
variant="light"
>
{summary === '' ? 'Generate' : 'Regenerate'}
</Button>
</Tooltip>

<IconFileTextAi
className={utilStyles.icon}
size="1.4rem"
stroke={1.5}
/>
</Group>
</Group>

<Spoiler hideLabel="Show less" maxHeight={158} showLabel="Show more">
{summary ? (
<TypographyStylesProvider mt="xs">
<article
dangerouslySetInnerHTML={{ __html: sanitizeHtml(summary) }}
/>
</TypographyStylesProvider>
) : (
<div>
<Text c="dimmed" fs="italic" my="xs" size="sm">
No generated summary yet.
</Text>
</div>
)}
</Spoiler>
</>
);
}

export const SummaryText = memo(Summary);
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
'use server';

import { tasks } from '@trigger.dev/sdk/v3';
import type { generateSummary } from '@/trigger/generate-summary';
import type { RatingsProps } from './StatRatings';

export async function triggerSummary(
id: string,
ratings: RatingsProps,
{
partners,
implementers,
beneficiaries,
}: {
partners: number;
implementers: number;
beneficiaries: number;
total: number;
},
) {
const total = partners + implementers + beneficiaries;

const generated = await tasks.triggerAndPoll<typeof generateSummary>(
'generate-summary',
{
activityId: id,
ratings,
count: {
partners,
implementers,
beneficiaries,
total,
},
},
{
pollIntervalMs: 60000, // 1 minute
metadata: { activity: id },
},
);

return generated;
}
98 changes: 98 additions & 0 deletions src/trigger/generate-summary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { logger, task, envvars } from '@trigger.dev/sdk/v3';
import OpenAI from 'openai';
import { createAdminClient } from '@/libs/supabase/admin-client';
import type { EmotionsResponse } from '@/libs/huggingface/types';
import type { RatingsProps } from '@/app/portal/activities/_components/ActivityAnalytics/StatRatings';
import { aggregateEmotions } from '@/utils/json-restructure';

/**
* Generate summary text for an activity.
*
* @param activity - activity name.
* @param rating - activity ratings.
*/
export const generateSummary = task({
id: 'generate-summary',
machine: {
preset: 'medium-1x',
},
run: async (payload: {
activityId: string;
ratings: RatingsProps;
count: {
partners: number;
implementers: number;
beneficiaries: number;
total: number;
};
}) => {
const { ratings, count, activityId } = payload;

await envvars.retrieve('SUPABASE_URL');
await envvars.retrieve('SUPABASE_SERVICE_KEY');
const supabase = createAdminClient();

const { data: emotionsRes, error } = await supabase
.from('activity_feedback')
.select('type, score_emotions->emotions')
.eq('activity_id', activityId)
.not('score_emotions', 'is', null)
.returns<EmotionsResponse[]>();

if (error) {
logger.error('Unable to get evaluation emotions', { error });
}

const emotions = aggregateEmotions(emotionsRes!, true, true);

// ignore summary when no responses

const ghToken = await envvars.retrieve('GITHUB_PAT');
const gptEndpoint = 'https://models.inference.ai.azure.com';
const gptModel = 'gpt-4o-mini';

const client = new OpenAI({ baseURL: gptEndpoint, apiKey: ghToken.value });

const response = await client.chat.completions.create({
messages: [
{
role: 'system',
content: 'You are a professional data analyst and secretary.',
},
{
role: 'user',
content:
'Write a single-paragraph consisting of at least 5 sentences summary report of the feedback evaluation results provided below. Add basic html tags to format the text and highlight important parts, no links.',
},
{
role: 'user',
content: `Here are ratings score from the evaluation: Partners gave a score of ${ratings?.partners?.score ?? 0}% from ${count.partners} partners, ${ratings?.implementers?.score ?? 0}% from ${count.implementers} for implementers, and ${ratings?.beneficiaries?.score ?? 0}% from ${count.beneficiaries} for beneficiaries. Totaling to ${ratings?.total} total score across ${count.implementers + count.partners + count.beneficiaries} responses.`,
},
{
role: 'user',
content: `The evaluation feedback also shows that the evaluators have ${emotions?.length} distinct emotions, with the top 10 emotions being ${emotions
.slice(0, 9)
.map((emotion) => emotion.label)
.join(', ')}.`,
},
],
model: gptModel,
});

const summary = response.choices[0].message.content;

logger.info('Saving generated result', { summary });

// save to db
const { error: dbError } = await supabase.from('analytics_metadata').upsert(
{ activity_id: activityId, type: 'summary', content: summary },
{
onConflict: 'activity_id, type',
},
);

if (dbError) {
logger.error('Unable to save generated summary', { dbError });
}
},
});
18 changes: 16 additions & 2 deletions src/utils/json-restructure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,9 @@ export interface CategorizedEmotions {
export function aggregateEmotions(
data: EmotionsResponse[],
showNeutral: boolean = false,
sortDescending: boolean = false,
): CategorizedEmotions[] {
return data.reduce<CategorizedEmotions[]>((acc, response) => {
const result = data.reduce<CategorizedEmotions[]>((acc, response) => {
if (!response.type) {
throw new Error(
'Feedback type is missing, please inform ITSO team with the URL.',
Expand Down Expand Up @@ -92,6 +93,18 @@ export function aggregateEmotions(

return acc;
}, []);

if (sortDescending) {
result.sort((a, b) => {
const sumA =
(a.beneficiaries ?? 0) + (a.partners ?? 0) + (a.implementers ?? 0);
const sumB =
(b.beneficiaries ?? 0) + (b.partners ?? 0) + (b.implementers ?? 0);
return sumB - sumA;
});
}

return result;
}

/**
Expand Down Expand Up @@ -127,8 +140,9 @@ export function aggregateSentiments(
export function aggregateCommonEmotions(
data: EmotionsResponse[],
showNeutral: boolean = false,
sortDescending: boolean = false,
): CategorizedEmotions[] {
const emotions = aggregateEmotions(data, showNeutral);
const emotions = aggregateEmotions(data, showNeutral, sortDescending);

return emotions.filter(
(emotion) =>
Expand Down

0 comments on commit 191b68b

Please sign in to comment.