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

feat: generate a summary of meeting summaries #10017

Merged
merged 30 commits into from
Aug 7, 2024
Merged
Changes from 7 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
05250cf
add generateInsight mutation
nickoferrall Jul 1, 2024
c4ef355
replace shortUrls with real urls
nickoferrall Jul 1, 2024
450b9c2
handle teamId arg and replace short meeting ids
nickoferrall Jul 2, 2024
c806eca
add orgId arg to generateInsight
nickoferrall Jul 2, 2024
e262f9f
filter meetings more efficiently
nickoferrall Jul 3, 2024
3e4aff4
return wins and challenges from generateInsight
nickoferrall Jul 3, 2024
b0b042e
Merge branch 'feat/generate-insight' into feat/generate-team-insight
nickoferrall Jul 16, 2024
14a34dc
generate insight
nickoferrall Jul 17, 2024
7792681
implement addInsight migration
nickoferrall Jul 17, 2024
7c02ca4
check for existingInsight
nickoferrall Jul 17, 2024
91e56b0
start summary of summaries
nickoferrall Jul 18, 2024
a0f2607
include links to discussions
nickoferrall Jul 18, 2024
adc15ac
update prompt
nickoferrall Jul 22, 2024
3b939b1
return summary if exists
nickoferrall Jul 23, 2024
cebc7b3
update prompt and clean up processing getTopics meetingId
nickoferrall Jul 23, 2024
e15d1e7
remove generated files
nickoferrall Jul 23, 2024
23448b1
remove meetingSummary yaml file
nickoferrall Jul 23, 2024
fde4584
update short meeting date
nickoferrall Jul 23, 2024
555ec41
Merge branch 'master' into feat/summary-of-summaries
nickoferrall Jul 23, 2024
ca5c6fc
move addInsight migration after merging master
nickoferrall Jul 23, 2024
6e5aeeb
fix insight start end date insert
nickoferrall Jul 23, 2024
5b1964e
return prev insight if dates and teamid exist
nickoferrall Jul 24, 2024
bc7b351
update generate insight prompt to reduce jargon
nickoferrall Jul 24, 2024
f9e3449
update migration to make wins and challenges non null
nickoferrall Jul 24, 2024
aeb8fa8
accept prompt as arg in generateInsight
nickoferrall Jul 26, 2024
cc9768c
update migration order
nickoferrall Jul 26, 2024
a3ba8d0
remove meetings from generateInsight query
nickoferrall Jul 26, 2024
5c191ef
use number.isNaN instead
nickoferrall Jul 26, 2024
9920f55
update userPrompt type
nickoferrall Jul 31, 2024
7c858cd
Merge branch 'master' into feat/summary-of-summaries
nickoferrall Jul 31, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions codegen.json
Original file line number Diff line number Diff line change
@@ -70,6 +70,7 @@
"File": "./types/File#TFile",
"GcalIntegration": "./types/GcalIntegration#GcalIntegrationSource",
"GenerateGroupsSuccess": "./types/GenerateGroupsSuccess#GenerateGroupsSuccessSource",
"GenerateInsightSuccess": "./types/GenerateInsightSuccess#GenerateInsightSuccessSource",
"GetTemplateSuggestionSuccess": "./types/GetTemplateSuggestionSuccess#GetTemplateSuggestionSuccessSource",
"IntegrationProviderOAuth2": "../../postgres/queries/getIntegrationProvidersByIds#TIntegrationProvider",
"InviteToTeamPayload": "./types/InviteToTeamPayload#InviteToTeamPayloadSource",
39 changes: 39 additions & 0 deletions packages/client/mutations/GenerateInsightMutation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import graphql from 'babel-plugin-relay/macro'
import {commitMutation} from 'react-relay'
import {GenerateInsightMutation as TGenerateInsightMutation} from '../__generated__/GenerateInsightMutation.graphql'
import {StandardMutation} from '../types/relayMutations'

graphql`
fragment GenerateInsightMutation_team on GenerateInsightSuccess {
wins
challenges
}
`

const mutation = graphql`
mutation GenerateInsightMutation($teamId: ID!) {
generateInsight(teamId: $teamId) {
... on ErrorPayload {
error {
message
}
}
...GenerateInsightMutation_team @relay(mask: false)
}
}
`

const GenerateInsightMutation: StandardMutation<TGenerateInsightMutation> = (
atmosphere,
variables,
{onError, onCompleted}
) => {
return commitMutation<TGenerateInsightMutation>(atmosphere, {
mutation,
variables,
onCompleted,
onError
})
}

export default GenerateInsightMutation
271 changes: 271 additions & 0 deletions packages/server/graphql/public/mutations/generateInsight.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
import yaml from 'js-yaml'
import fs from 'node:fs'
import getRethink from '../../../database/rethinkDriver'
import MeetingRetrospective from '../../../database/types/MeetingRetrospective'
import getKysely from '../../../postgres/getKysely'
import OpenAIServerManager from '../../../utils/OpenAIServerManager'
import sendToSentry from '../../../utils/sendToSentry'
import standardError from '../../../utils/standardError'
import {MutationResolvers} from '../resolverTypes'

const generateInsight: MutationResolvers['generateInsight'] = async (
_source,
{teamId},
{authToken, dataLoader, socketId: mutatorId}
) => {
console.log('🚀 ~ teamId:', teamId)
const getComments = async (reflectionGroupId: string) => {
const IGNORE_COMMENT_USER_IDS = ['parabolAIUser']
const pg = getKysely()
const discussion = await pg
.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) => {
return a.createdAt.getTime() < b.createdAt.getTime() ? -1 : 1
})
const comments = await Promise.all(
rootComments.map(async (comment) => {
const {createdBy, isAnonymous, plaintextContent} = comment
const creator = await dataLoader.get('users').loadNonNull(createdBy)
const commentAuthor = isAnonymous ? 'Anonymous' : creator.preferredName
const commentReplies = await Promise.all(
humanComments
.filter((c) => c.threadParentId === comment.id)
.sort((a, b) => {
return a.createdAt.getTime() < b.createdAt.getTime() ? -1 : 1
})
.map(async (reply) => {
const {createdBy, isAnonymous, plaintextContent} = reply
const creator = await dataLoader.get('users').loadNonNull(createdBy)
const replyAuthor = isAnonymous ? 'Anonymous' : creator.preferredName
return {
text: plaintextContent,
author: replyAuthor
}
})
)
const res = {
text: plaintextContent,
author: commentAuthor,
replies: commentReplies
}
if (res.replies.length === 0) {
delete (res as any).commentReplies
}
return res
})
)
return comments
}

const getTopicJSON = async (teamId: string, startDate: Date, endDate: Date) => {
const r = await getRethink()
const MIN_REFLECTION_COUNT = 3
const MIN_MILLISECONDS = 60 * 1000 // 1 minute
const rawMeetings = await r
.table('NewMeeting')
.getAll(teamId, {index: 'teamId'})
.filter(
(row: any) =>
row('meetingType')
.eq('retrospective')
.and(row('createdAt').ge(startDate))
.and(row('createdAt').le(endDate))
.and(row('reflectionCount').gt(MIN_REFLECTION_COUNT))
// .and(r.table('MeetingMember').getAll(row('id'), {index: 'meetingId'}).count().gt(1))
// .and(row('endedAt').sub(row('createdAt')).gt(MIN_MILLISECONDS))
)
.run()
console.log('🚀 ~ rawMeetings:', rawMeetings)

const meetings = await Promise.all(
rawMeetings.map(async (meeting) => {
const {
id: meetingId,
disableAnonymity,
name: meetingName,
createdAt: meetingDate
} = meeting as MeetingRetrospective
const rawReflectionGroups = await dataLoader
.get('retroReflectionGroupsByMeetingId')
.load(meetingId)
const reflectionGroups = Promise.all(
rawReflectionGroups
// for performance since it's really slow!
// .filter((g) => g.voterIds.length > 0)
.map(async (group) => {
const {id: reflectionGroupId, voterIds, title} = group
const [comments, rawReflections] = await Promise.all([
getComments(reflectionGroupId),
dataLoader.get('retroReflectionsByGroupId').load(group.id)
])
const reflections = await Promise.all(
rawReflections.map(async (reflection) => {
const {promptId, creatorId, plaintextContent} = reflection
const [prompt, creator] = await Promise.all([
dataLoader.get('reflectPrompts').load(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
}
})
)
const res = {
voteCount: voterIds.length,
title: title,
comments,
reflections,
meetingName,
date: meetingDate,
meetingId
}

if (!res.comments || !res.comments.length) {
delete (res as any).comments
}
return res
})
)
return reflectionGroups
})
)
return meetings.flat()
}

const startDate = new Date('2024-01-01')
// const endDate = new Date('2024-04-01')
const endDate = new Date()

const inTopics = await getTopicJSON(teamId, startDate, endDate)
if (!inTopics.length) {
return standardError(new Error('Not enough data to generate insight.'))
}
fs.writeFileSync(`./topics_${teamId}.json`, JSON.stringify(inTopics))

const rawTopics = JSON.parse(fs.readFileSync(`./topics_${teamId}.json`, 'utf-8')) as Awaited<
ReturnType<typeof getTopicJSON>
>
const hotTopics = rawTopics
// .filter((t) => t.voteCount > 2)
// .sort((a, b) => (a.voteCount > b.voteCount ? -1 : 1))

type IDLookup = Record<string, string | Date>
const idLookup = {
meeting: {} as IDLookup,
date: {} as IDLookup
}

const idGenerator = {
meeting: 1
}

const shortTokenedTopics = hotTopics.map((t) => {
const {date, meetingId} = t
const shortMeetingId = `m${idGenerator.meeting++}`
const shortMeetingDate = new Date(date).toISOString().split('T')[0]
idLookup.meeting[shortMeetingId] = meetingId
idLookup.date[shortMeetingId] = date
return {
...t,
date: shortMeetingDate,
meetingId: shortMeetingId
}
})
// fs.writeFileSync('./topics_target_short.json', JSON.stringify(shortTokenedTopics))
const yamlData = yaml.dump(shortTokenedTopics, {
noCompatMode: true // This option ensures compatibility mode is off
})
fs.writeFileSync(`./topics_${teamId}_short.yml`, yamlData)

const meetingURL = 'https://action.parabol.co/meet/'

const summarizingPrompt = `
You are a management consultant who needs to discover behavioral trends for a given team.
Below is a list of reflection topics in YAML format from meetings over the last 3 months.
You should describe the situation in two sections with exactly 3 bullet points each.
The first section should describe the team's positive behavior in bullet points. One bullet point should cite a direct quote from the meeting, attributing it to the person who wrote it.
The second section should pick out one or two examples of the team's negative behavior and you should cite a direct quote from the meeting, attributing it to the person who wrote it.
When citing the quote, include the meetingId in the format of ${meetingURL}[meetingId].
For each topic, mention how many votes it has.
Be sure that each author is only mentioned once.
Return the output as a JSON object with the following structure:
{
"wins": ["bullet point 1", "bullet point 2", "bullet point 3"],
"challenges": ["bullet point 1", "bullet point 2", "bullet point 3"]
}
Your tone should be kind and professional. No yapping.
`

const openAI = new OpenAIServerManager()
const batch = await openAI.batchChatCompletion(summarizingPrompt, yamlData)
console.log('🚀 ~ batch:', batch)
if (!batch) {
return standardError(new Error('Unable to generate insight.'))
}

const processLines = (lines: string[], meetingURL: string): string => {
return lines
.map((line) => {
if (line.includes(meetingURL)) {
let processedLine = line
const regex = new RegExp(`${meetingURL}\\S+`, 'g')
const matches = processedLine.match(regex) || []

let isValid = true
matches.forEach((match) => {
let shortMeetingId = match.split(meetingURL)[1].split(/[),\s]/)[0] // Split by closing parenthesis, comma, or space
const actualMeetingId = shortMeetingId && (idLookup.meeting[shortMeetingId] as string)

if (shortMeetingId && actualMeetingId) {
processedLine = processedLine.replace(shortMeetingId, actualMeetingId)
} else {
const error = new Error(
`AI hallucinated. Unable to find meetingId for ${shortMeetingId}. Line: ${line}`
)
sendToSentry(error)
isValid = false
}
})
return isValid ? processedLine : '' // Return empty string if invalid
}
return line
})
.filter((line) => line.trim() !== '')
.join('\n')
}

const processSection = (section: string[]): string => {
return section
.map((item) => {
const lines = item.split('\n')
return processLines(lines, meetingURL)
})
.filter((processedItem) => processedItem.trim() !== '')
.join('\n')
}

const wins = processSection(batch.wins)
const challenges = processSection(batch.challenges)

console.log('🚀 ~ Wins:', wins)
console.log('🚀 ~ Challenges:', challenges)

const data = {wins, challenges}
return data
}

export default generateInsight
1 change: 1 addition & 0 deletions packages/server/graphql/public/permissions.ts
Original file line number Diff line number Diff line change
@@ -32,6 +32,7 @@ const permissionMap: PermissionMap<Resolvers> = {
// don't check isAuthenticated for acceptTeamInvitation here because there are special cases handled in the resolver
acceptTeamInvitation: rateLimit({perMinute: 50, perHour: 100}),
createImposterToken: isSuperUser,
generateInsight: isSuperUser,
loginWithGoogle: and(
not(isEnvVarTrue('AUTH_GOOGLE_DISABLED')),
rateLimit({perMinute: 50, perHour: 500})
23 changes: 23 additions & 0 deletions packages/server/graphql/public/typeDefs/generateInsight.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
extend type Mutation {
"""
Generate an insight for a team
"""
generateInsight(teamId: ID!): GenerateInsightPayload!
}

"""
Return value for generateInsight, which could be an error
"""
union GenerateInsightPayload = ErrorPayload | GenerateInsightSuccess

type GenerateInsightSuccess {
"""
The insights generated focusing on the wins of the team
"""
wins: String!

"""
The insights generated focusing on the challenges team are facing
"""
challenges: String!
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export type GenerateInsightSuccessSource = {
wins: string
challenges: string
}
2 changes: 1 addition & 1 deletion packages/server/package.json
Original file line number Diff line number Diff line change
@@ -121,7 +121,7 @@
"node-pg-migrate": "^5.9.0",
"nodemailer": "^6.9.9",
"oauth-1.0a": "^2.2.6",
"openai": "^4.24.1",
"openai": "^4.52.2",
"openapi-fetch": "^0.9.7",
"oy-vey": "^0.12.1",
"parabol-client": "7.38.7",
Loading