forked from ParabolInc/parabol
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
chore: use more detailed AI Summary for meetings (ParabolInc#10501)
- Loading branch information
1 parent
2e8346e
commit b783f55
Showing
16 changed files
with
853 additions
and
847 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
62 changes: 62 additions & 0 deletions
62
packages/server/graphql/mutations/generateRetroSummaries.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,62 @@ | ||
import {sql} from 'kysely' | ||
import {selectNewMeetings} from '../../postgres/select' | ||
import {RetrospectiveMeeting} from '../../postgres/types/Meeting' | ||
import standardError from '../../utils/standardError' | ||
import {MutationResolvers} from '../public/resolverTypes' | ||
import {generateRetroSummary} from './helpers/generateRetroSummary' | ||
|
||
const generateRetroSummaries: MutationResolvers['generateRetroSummaries'] = async ( | ||
_source, | ||
{teamIds, prompt}, | ||
{dataLoader} | ||
) => { | ||
const MIN_SECONDS = 60 | ||
const MIN_REFLECTION_COUNT = 3 | ||
|
||
const endDate = new Date() | ||
const twoYearsAgo = new Date() | ||
twoYearsAgo.setFullYear(endDate.getFullYear() - 2) | ||
|
||
const rawMeetingsWithAnyMembers = await selectNewMeetings() | ||
.where('teamId', 'in', teamIds) | ||
.where('meetingType', '=', 'retrospective') | ||
.where('createdAt', '>=', twoYearsAgo) | ||
.where('createdAt', '<=', endDate) | ||
.where('reflectionCount', '>', MIN_REFLECTION_COUNT) | ||
.where(sql<boolean>`EXTRACT(EPOCH FROM ("endedAt" - "createdAt")) > ${MIN_SECONDS}`) | ||
.$narrowType<RetrospectiveMeeting>() | ||
.execute() | ||
|
||
const allMeetingMembers = await dataLoader | ||
.get('meetingMembersByMeetingId') | ||
.loadMany(rawMeetingsWithAnyMembers.map(({id}) => id)) | ||
|
||
const rawMeetings = rawMeetingsWithAnyMembers.filter((_, idx) => { | ||
const meetingMembers = allMeetingMembers[idx] | ||
return Array.isArray(meetingMembers) && meetingMembers.length > 1 | ||
}) | ||
|
||
if (rawMeetings.length === 0) { | ||
return standardError(new Error('No valid meetings found')) | ||
} | ||
|
||
const updatedMeetingIds = await Promise.all( | ||
rawMeetings.map(async (meeting) => { | ||
const newSummary = await generateRetroSummary(meeting.id, dataLoader, prompt as string) | ||
if (!newSummary) return null | ||
return meeting.id | ||
}) | ||
) | ||
|
||
const filteredMeetingIds = updatedMeetingIds.filter( | ||
(meetingId): meetingId is string => meetingId !== null | ||
) | ||
|
||
if (filteredMeetingIds.length === 0) { | ||
return standardError(new Error('No summaries were generated')) | ||
} | ||
|
||
return {meetingIds: filteredMeetingIds} | ||
} | ||
|
||
export default generateRetroSummaries |
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
41 changes: 41 additions & 0 deletions
41
packages/server/graphql/mutations/helpers/generateRetroSummary.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,41 @@ | ||
import yaml from 'js-yaml' | ||
import getKysely from '../../../postgres/getKysely' | ||
import OpenAIServerManager from '../../../utils/OpenAIServerManager' | ||
import {DataLoaderWorker} from '../../graphql' | ||
import canAccessAI from './canAccessAI' | ||
import {transformRetroToAIFormat} from './transformRetroToAIFormat' | ||
|
||
export const generateRetroSummary = async ( | ||
meetingId: string, | ||
dataLoader: DataLoaderWorker, | ||
prompt?: string | ||
): Promise<string | null> => { | ||
const meeting = await dataLoader.get('newMeetings').loadNonNull(meetingId) | ||
const {teamId} = meeting | ||
|
||
const team = await dataLoader.get('teams').loadNonNull(teamId) | ||
const isAISummaryAccessible = await canAccessAI(team, 'retrospective', dataLoader) | ||
if (!isAISummaryAccessible) return null | ||
|
||
const transformedMeeting = await transformRetroToAIFormat(meetingId, dataLoader) | ||
if (!transformedMeeting || transformedMeeting.length === 0) { | ||
return null | ||
} | ||
|
||
const yamlData = yaml.dump(transformedMeeting, { | ||
noCompatMode: true | ||
}) | ||
|
||
const manager = new OpenAIServerManager() | ||
const newSummary = await manager.generateSummary(yamlData, prompt) | ||
if (!newSummary) return null | ||
|
||
const pg = getKysely() | ||
await pg | ||
.updateTable('NewMeeting') | ||
.set({summary: newSummary}) | ||
.where('id', '=', meetingId) | ||
.execute() | ||
|
||
return newSummary | ||
} |
40 changes: 0 additions & 40 deletions
40
packages/server/graphql/mutations/helpers/generateWholeMeetingSummary.ts
This file was deleted.
Oops, something went wrong.
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
134 changes: 134 additions & 0 deletions
134
packages/server/graphql/mutations/helpers/transformRetroToAIFormat.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,134 @@ | ||
import getKysely from '../../../postgres/getKysely' | ||
import getPhase from '../../../utils/getPhase' | ||
import {DataLoaderWorker} from '../../graphql' | ||
|
||
const getComments = async (reflectionGroupId: string, dataLoader: DataLoaderWorker) => { | ||
const IGNORE_COMMENT_USER_IDS = ['parabolAIUser'] | ||
const discussion = await getKysely() | ||
.selectFrom('Discussion') | ||
.selectAll() | ||
.where('discussionTopicId', '=', reflectionGroupId) | ||
.limit(1) | ||
.executeTakeFirst() | ||
if (!discussion) return null | ||
const {id: discussionId} = discussion | ||
const rawComments = await dataLoader.get('commentsByDiscussionId').load(discussionId) | ||
const humanComments = rawComments.filter((c) => !IGNORE_COMMENT_USER_IDS.includes(c.createdBy!)) | ||
const rootComments = humanComments.filter((c) => !c.threadParentId) | ||
rootComments.sort((a, b) => (a.createdAt.getTime() < b.createdAt.getTime() ? -1 : 1)) | ||
|
||
const comments = await Promise.all( | ||
rootComments.map(async (comment) => { | ||
const {createdBy, isAnonymous, plaintextContent} = comment | ||
const creator = createdBy ? await dataLoader.get('users').loadNonNull(createdBy) : null | ||
const commentAuthor = isAnonymous || !creator ? 'Anonymous' : creator.preferredName | ||
const commentReplies = await Promise.all( | ||
humanComments | ||
.filter((c) => c.threadParentId === comment.id) | ||
.sort((a, b) => (a.createdAt.getTime() < b.createdAt.getTime() ? -1 : 1)) | ||
.map(async (reply) => { | ||
const {createdBy, isAnonymous, plaintextContent} = reply | ||
const creator = createdBy ? await dataLoader.get('users').loadNonNull(createdBy) : null | ||
const replyAuthor = isAnonymous || !creator ? 'Anonymous' : creator.preferredName | ||
return {text: plaintextContent, author: replyAuthor} | ||
}) | ||
) | ||
return {text: plaintextContent, author: commentAuthor, replies: commentReplies} | ||
}) | ||
) | ||
return comments | ||
} | ||
|
||
export const transformRetroToAIFormat = async (meetingId: string, dataLoader: DataLoaderWorker) => { | ||
const meeting = await dataLoader.get('newMeetings').loadNonNull(meetingId) | ||
const {disableAnonymity, name: meetingName, createdAt: meetingDate} = meeting | ||
const rawReflectionGroups = await dataLoader | ||
.get('retroReflectionGroupsByMeetingId') | ||
.load(meetingId) | ||
|
||
const reflectionGroups = await Promise.all( | ||
rawReflectionGroups | ||
.filter((g) => g.voterIds.length > 1) | ||
.map(async (group) => { | ||
const {id: reflectionGroupId, voterIds, title} = group | ||
const [comments, rawReflections, discussion] = await Promise.all([ | ||
getComments(reflectionGroupId, dataLoader), | ||
dataLoader.get('retroReflectionsByGroupId').load(group.id), | ||
dataLoader.get('discussions').load(reflectionGroupId) | ||
]) | ||
|
||
const tasks = discussion | ||
? await dataLoader.get('tasksByDiscussionId').load(discussion.id) | ||
: [] | ||
|
||
const discussPhase = getPhase(meeting.phases, 'discuss') | ||
const {stages} = discussPhase | ||
const stageIdx = stages | ||
.sort((a, b) => (a.sortOrder < b.sortOrder ? -1 : 1)) | ||
.findIndex((stage) => stage.discussionId === discussion?.id) | ||
const discussionIdx = stageIdx + 1 | ||
|
||
const reflections = await Promise.all( | ||
rawReflections.map(async (reflection) => { | ||
const {promptId, creatorId, plaintextContent} = reflection | ||
const [prompt, creator] = await Promise.all([ | ||
dataLoader.get('reflectPrompts').loadNonNull(promptId), | ||
creatorId ? dataLoader.get('users').loadNonNull(creatorId) : null | ||
]) | ||
const {question} = prompt | ||
const creatorName = disableAnonymity && creator ? creator.preferredName : 'Anonymous' | ||
return { | ||
prompt: question, | ||
author: creatorName, | ||
text: plaintextContent, | ||
discussionId: discussionIdx | ||
} | ||
}) | ||
) | ||
|
||
const formattedTasks = | ||
tasks && tasks.length > 0 | ||
? await Promise.all( | ||
tasks.map(async (task) => { | ||
const {createdBy, plaintextContent} = task | ||
const creator = createdBy | ||
? await dataLoader.get('users').loadNonNull(createdBy) | ||
: null | ||
const taskAuthor = creator ? creator.preferredName : 'Anonymous' | ||
return { | ||
text: plaintextContent, | ||
author: taskAuthor | ||
} | ||
}) | ||
) | ||
: undefined | ||
|
||
const shortMeetingDate = new Date(meetingDate).toISOString().split('T')[0] | ||
const content = { | ||
voteCount: voterIds.length, | ||
title, | ||
comments, | ||
tasks: formattedTasks, | ||
reflections, | ||
meetingName, | ||
date: shortMeetingDate, | ||
meetingId, | ||
discussionId: discussionIdx | ||
} as { | ||
comments?: typeof comments | ||
tasks?: typeof formattedTasks | ||
[key: string]: any | ||
} | ||
|
||
if (!content.comments?.length) { | ||
delete content.comments | ||
} | ||
if (!content.tasks?.length) { | ||
delete content.tasks | ||
} | ||
return content | ||
}) | ||
) | ||
|
||
return reflectionGroups | ||
} |
Oops, something went wrong.