Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: use more detailed AI Summary for meetings #10501

Merged
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 (
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 I think the name is misleading. I would assume this function does just generate a summary for a meeting and returns it. Instead it updates the table.
I would prefer if we'd change it to updateRetroSummary and also remove the return type, so it's less likely to be used wrongly.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like updateRetroSummary suggests it already exists and we're updating it, but we're creating a new summary here.

Maybe addRetroSummary instead of generateRetroSummary? I like that generate describes that this is where it's being created.

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'
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code comes from the previous generateMeetingSummary file

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
Loading