This repository has been archived by the owner on Dec 10, 2024. It is now read-only.
-
-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(analytics): AI analytics summary
- Loading branch information
Showing
8 changed files
with
311 additions
and
4 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
146 changes: 146 additions & 0 deletions
146
src/app/portal/activities/_components/ActivityAnalytics/SummaryText.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); |
42 changes: 42 additions & 0 deletions
42
src/app/portal/activities/_components/ActivityAnalytics/analytics.actions.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }); | ||
} | ||
}, | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters