Skip to content

Commit

Permalink
chore: use more detailed AI Summary for meetings (ParabolInc#10501)
Browse files Browse the repository at this point in the history
  • Loading branch information
nickoferrall authored Nov 26, 2024
1 parent 2e8346e commit b783f55
Show file tree
Hide file tree
Showing 16 changed files with 853 additions and 847 deletions.
2 changes: 1 addition & 1 deletion codegen.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
"File": "../public/types/File#TFile",
"FlagConversionModalPayload": "./types/FlagConversionModalPayload#FlagConversionModalPayloadSource",
"FlagOverLimitPayload": "./types/FlagOverLimitPayload#FlagOverLimitPayloadSource",
"GenerateMeetingSummarySuccess": "./types/GenerateMeetingSummarySuccess#GenerateMeetingSummarySuccessSource",
"LoginsPayload": "./types/LoginsPayload#LoginsPayloadSource",
"MeetingTemplate": "../../database/types/MeetingTemplate#default as IMeetingTemplate",
"NewFeatureBroadcast": "../../postgres/types/index#NewFeature",
Expand Down Expand Up @@ -90,6 +89,7 @@
"GcalIntegration": "./types/GcalIntegration#GcalIntegrationSource",
"GenerateGroupsSuccess": "./types/GenerateGroupsSuccess#GenerateGroupsSuccessSource",
"GenerateInsightSuccess": "./types/GenerateInsightSuccess#GenerateInsightSuccessSource",
"GenerateRetroSummariesSuccess": "./types/GenerateRetroSummariesSuccess#GenerateRetroSummariesSuccessSource",
"GetTemplateSuggestionSuccess": "./types/GetTemplateSuggestionSuccess#GetTemplateSuggestionSuccessSource",
"GitHubIntegration": "../../postgres/queries/getGitHubAuthByUserIdTeamId#GitHubAuth",
"GitLabIntegration": "./types/GitLabIntegration#GitLabIntegrationSource",
Expand Down
62 changes: 62 additions & 0 deletions packages/server/graphql/mutations/generateRetroSummaries.ts
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
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const generateDiscussionSummary = async (
const tasksContent = tasks.map(({plaintextContent}) => plaintextContent)
const contentToSummarize = [...commentsContent, ...tasksContent]
if (contentToSummarize.length <= 1) return
const summary = await manager.getSummary(contentToSummarize, 'discussion thread')
const summary = await manager.getSummary(contentToSummarize)
if (!summary) return
await updateDiscussions({summary}, discussionId)
// when we end the meeting, we don't wait for the OpenAI response as we want to see the meeting summary immediately, so publish the subscription
Expand Down
41 changes: 41 additions & 0 deletions packages/server/graphql/mutations/helpers/generateRetroSummary.ts
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
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ import {InternalContext} from '../../graphql'
import isValid from '../../isValid'
import sendNewMeetingSummary from './endMeeting/sendNewMeetingSummary'
import gatherInsights from './gatherInsights'
import {generateRetroSummary} from './generateRetroSummary'
import generateWholeMeetingSentimentScore from './generateWholeMeetingSentimentScore'
import generateWholeMeetingSummary from './generateWholeMeetingSummary'
import handleCompletedStage from './handleCompletedStage'
import {IntegrationNotifier} from './notifications/IntegrationNotifier'
import removeEmptyTasks from './removeEmptyTasks'
Expand All @@ -36,20 +36,18 @@ const summarizeRetroMeeting = async (meeting: RetrospectiveMeeting, context: Int
const {dataLoader} = context
const {id: meetingId, phases, teamId, recallBotId} = meeting
const pg = getKysely()
const [reflectionGroups, reflections, sentimentScore] = await Promise.all([
const [reflectionGroups, reflections, sentimentScore, transcription] = await Promise.all([
dataLoader.get('retroReflectionGroupsByMeetingId').load(meetingId),
dataLoader.get('retroReflectionsByMeetingId').load(meetingId),
generateWholeMeetingSentimentScore(meetingId, dataLoader)
generateWholeMeetingSentimentScore(meetingId, dataLoader),
getTranscription(recallBotId),
generateRetroSummary(meetingId, dataLoader)
])
const discussPhase = getPhase(phases, 'discuss')
const {stages} = discussPhase
const discussionIds = stages.map((stage) => stage.discussionId)

const reflectionGroupIds = reflectionGroups.map(({id}) => id)
const [summary, transcription] = await Promise.all([
generateWholeMeetingSummary(discussionIds, meetingId, teamId, dataLoader),
getTranscription(recallBotId)
])
const commentCounts = (
await dataLoader.get('commentCountByDiscussionId').loadMany(discussionIds)
).filter(isValid)
Expand All @@ -67,7 +65,6 @@ const summarizeRetroMeeting = async (meeting: RetrospectiveMeeting, context: Int
topicCount: reflectionGroupIds.length,
reflectionCount: reflections.length,
sentimentScore,
summary,
transcription
})
.where('id', '=', meetingId)
Expand Down
134 changes: 134 additions & 0 deletions packages/server/graphql/mutations/helpers/transformRetroToAIFormat.ts
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
}
Loading

0 comments on commit b783f55

Please sign in to comment.