Added summarization action for meeting minutes (#8143)
Some checks are pending
CI / build (push) Waiting to run
CI / svelte-check (push) Blocked by required conditions
CI / formatting (push) Blocked by required conditions
CI / test (push) Blocked by required conditions
CI / uitest (push) Waiting to run
CI / uitest-pg (push) Waiting to run
CI / uitest-qms (push) Waiting to run
CI / uitest-workspaces (push) Waiting to run
CI / docker-build (push) Blocked by required conditions
CI / dist-build (push) Blocked by required conditions

* Added summarization action for meeting minutes

Signed-off-by: Victor Ilyushchenko <alt13ri@gmail.com>

* tweaks

Signed-off-by: Victor Ilyushchenko <alt13ri@gmail.com>

* fmt

Signed-off-by: Victor Ilyushchenko <alt13ri@gmail.com>

* ff

Signed-off-by: Victor Ilyushchenko <alt13ri@gmail.com>

---------

Signed-off-by: Victor Ilyushchenko <alt13ri@gmail.com>
This commit is contained in:
Victor Ilyushchenko 2025-03-06 17:50:14 +03:00 committed by GitHub
parent e8a91c2c03
commit 9d6048f06f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 355 additions and 34 deletions

View File

@ -118,6 +118,21 @@ function defineMessageActions (builder: Builder): void {
chunter.action.DeleteChatMessage chunter.action.DeleteChatMessage
) )
createAction(
builder,
{
action: chunter.actionImpl.SummarizeMessages,
label: chunter.string.SummarizeMessages,
icon: view.icon.Feather,
input: 'focus',
category: chunter.category.Chunter,
target: core.class.Doc,
context: { mode: ['context', 'browser'], group: 'tools' },
visibilityTester: chunter.function.CanSummarizeMessages
},
chunter.action.SummarizeMessages
)
createAction( createAction(
builder, builder,
{ {

View File

@ -56,7 +56,8 @@ export default mergeIds(chunterId, chunter, {
OpenInSidebar: '' as ViewAction, OpenInSidebar: '' as ViewAction,
TranslateMessage: '' as ViewAction, TranslateMessage: '' as ViewAction,
ShowOriginalMessage: '' as ViewAction, ShowOriginalMessage: '' as ViewAction,
StartConversation: '' as ViewAction StartConversation: '' as ViewAction,
SummarizeMessages: '' as ViewAction
}, },
category: { category: {
Chunter: '' as Ref<ActionCategory> Chunter: '' as Ref<ActionCategory>

View File

@ -48,6 +48,9 @@
const checkEmoji = (nodes: MarkupNode[]): boolean => { const checkEmoji = (nodes: MarkupNode[]): boolean => {
const matches: boolean[] = [] const matches: boolean[] = []
if (nodes.some((node) => node.type !== 'text')) {
return false
}
nodes.forEach((node) => { nodes.forEach((node) => {
const reg = node.text?.match(/\P{Emoji}/gu) const reg = node.text?.match(/\P{Emoji}/gu)
matches.push(reg != null && reg.length > 0 && [65039, 65038, 8205].every((code) => code !== reg[0].charCodeAt(0))) matches.push(reg != null && reg.length > 0 && [65039, 65038, 8205].every((code) => code !== reg[0].charCodeAt(0)))

View File

@ -12,16 +12,18 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
// //
import { concatLink, type Markup, type Ref } from '@hcengineering/core'
import { getMetadata } from '@hcengineering/platform'
import presentation from '@hcengineering/presentation'
import { import {
type ConnectMeetingRequest, type ConnectMeetingRequest,
type DisconnectMeetingRequest, type DisconnectMeetingRequest,
type SummarizeMessagesRequest,
type SummarizeMessagesResponse,
type TranslateRequest, type TranslateRequest,
type TranslateResponse type TranslateResponse
} from '@hcengineering/ai-bot' } from '@hcengineering/ai-bot'
import { type Class, concatLink, type Doc, type Markup, type Ref } from '@hcengineering/core'
import { type Room, type RoomLanguage } from '@hcengineering/love' import { type Room, type RoomLanguage } from '@hcengineering/love'
import { getMetadata } from '@hcengineering/platform'
import presentation from '@hcengineering/presentation'
import aiBot from './plugin' import aiBot from './plugin'
@ -54,6 +56,43 @@ export async function translate (text: Markup, lang: string): Promise<TranslateR
} }
} }
export async function summarizeMessages (
lang: string,
target: Ref<Doc>,
targetClass: Ref<Class<Doc>>
): Promise<SummarizeMessagesResponse | undefined> {
const url = getMetadata(aiBot.metadata.EndpointURL) ?? ''
const token = getMetadata(presentation.metadata.Token) ?? ''
if (url === '' || token === '') {
return undefined
}
try {
const req: SummarizeMessagesRequest = {
target,
targetClass,
lang
}
const resp = await fetch(concatLink(url, '/summarize'), {
method: 'POST',
headers: {
Authorization: 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify(req)
})
if (!resp.ok) {
return undefined
}
return (await resp.json()) as SummarizeMessagesResponse
} catch (error) {
console.error(error)
return undefined
}
}
export async function connectMeeting ( export async function connectMeeting (
roomId: Ref<Room>, roomId: Ref<Room>,
language: RoomLanguage, language: RoomLanguage,

View File

@ -15,7 +15,7 @@
import { Class, Doc, Markup, PersonId, Ref, Space, Timestamp } from '@hcengineering/core' import { Class, Doc, Markup, PersonId, Ref, Space, Timestamp } from '@hcengineering/core'
import { Room, RoomLanguage } from '@hcengineering/love' import { Room, RoomLanguage } from '@hcengineering/love'
import { Person } from '@hcengineering/contact' import { Contact, Person } from '@hcengineering/contact'
import { ChatMessage } from '@hcengineering/chunter' import { ChatMessage } from '@hcengineering/chunter'
export interface AIEventRequest { export interface AIEventRequest {
@ -35,6 +35,26 @@ export interface TranslateRequest {
lang: string lang: string
} }
export interface PersonMessage {
personRef: Ref<Contact>
personName: string
time: Timestamp
text: string
}
export interface SummarizeMessagesRequest {
lang: string
target: Ref<Doc>
targetClass: Ref<Class<Doc>>
}
export interface SummarizeMessagesResponse {
text: Markup
lang: string
}
export interface TranslateResponse { export interface TranslateResponse {
text: Markup text: Markup
lang: string lang: string

View File

@ -131,6 +131,7 @@
"ViewingThreadFromArchivedChannel": "Prohlížíte vlákno z archivovaného kanálu", "ViewingThreadFromArchivedChannel": "Prohlížíte vlákno z archivovaného kanálu",
"ViewingArchivedChannel": "Prohlížíte archivovaný kanál", "ViewingArchivedChannel": "Prohlížíte archivovaný kanál",
"OpenChatInSidebar": "Otevřít chat v postranním panelu", "OpenChatInSidebar": "Otevřít chat v postranním panelu",
"NoThreadsYet": "Zatím nejsou žádné vlákna." "NoThreadsYet": "Zatím nejsou žádné vlákna.",
"SummarizeMessages": "Shrnout diskuzi"
} }
} }

View File

@ -132,6 +132,7 @@
"ViewingArchivedChannel": "Sie sehen einen archivierten Kanal", "ViewingArchivedChannel": "Sie sehen einen archivierten Kanal",
"OpenChatInSidebar": "Chat in Seitenleiste öffnen", "OpenChatInSidebar": "Chat in Seitenleiste öffnen",
"ResolveThread": "Thread abschließen", "ResolveThread": "Thread abschließen",
"NoThreadsYet": "Es gibt noch keine Threads." "NoThreadsYet": "Es gibt noch keine Threads.",
"SummarizeMessages": "Diskussion zusammenfassen"
} }
} }

View File

@ -132,6 +132,7 @@
"ViewingArchivedChannel": "You are viewing an archived channel", "ViewingArchivedChannel": "You are viewing an archived channel",
"OpenChatInSidebar": "Open chat in sidebar", "OpenChatInSidebar": "Open chat in sidebar",
"ResolveThread": "Resolve", "ResolveThread": "Resolve",
"NoThreadsYet": "There are no threads yet." "NoThreadsYet": "There are no threads yet.",
"SummarizeMessages": "Summarize discussion"
} }
} }

View File

@ -131,6 +131,7 @@
"ViewingThreadFromArchivedChannel": "Estás viendo un hilo de un canal archivado", "ViewingThreadFromArchivedChannel": "Estás viendo un hilo de un canal archivado",
"ViewingArchivedChannel": "Estás viendo un canal archivado", "ViewingArchivedChannel": "Estás viendo un canal archivado",
"OpenChatInSidebar": "Abrir chat en la barra lateral", "OpenChatInSidebar": "Abrir chat en la barra lateral",
"NoThreadsYet": "No hay hilos todavía." "NoThreadsYet": "No hay hilos todavía.",
"SummarizeMessages": "Resumir la discusión"
} }
} }

View File

@ -131,6 +131,7 @@
"ViewingThreadFromArchivedChannel": "Vous consultez un fil de discussion d'un canal archivé", "ViewingThreadFromArchivedChannel": "Vous consultez un fil de discussion d'un canal archivé",
"ViewingArchivedChannel": "Vous consultez un canal archivé", "ViewingArchivedChannel": "Vous consultez un canal archivé",
"OpenChatInSidebar": "Ouvrir le chat dans la barre latérale", "OpenChatInSidebar": "Ouvrir le chat dans la barre latérale",
"NoThreadsYet": "Il n'y a pas encore de fils de discussion." "NoThreadsYet": "Il n'y a pas encore de fils de discussion.",
"SummarizeMessages": "Résumer la discussion"
} }
} }

View File

@ -131,6 +131,7 @@
"ViewingThreadFromArchivedChannel": "Stai visualizzando una discussione da un canale archiviato", "ViewingThreadFromArchivedChannel": "Stai visualizzando una discussione da un canale archiviato",
"ViewingArchivedChannel": "Stai visualizzando un canale archiviato", "ViewingArchivedChannel": "Stai visualizzando un canale archiviato",
"OpenChatInSidebar": "Apri chat nella barra laterale", "OpenChatInSidebar": "Apri chat nella barra laterale",
"NoThreadsYet": "Non ci sono ancora discussioni." "NoThreadsYet": "Non ci sono ancora discussioni.",
"SummarizeMessages": "Riassumere la discussione"
} }
} }

View File

@ -131,6 +131,7 @@
"ViewingThreadFromArchivedChannel": "Está a visualizar uma conversa em cadeia de um canal arquivado", "ViewingThreadFromArchivedChannel": "Está a visualizar uma conversa em cadeia de um canal arquivado",
"ViewingArchivedChannel": "Está a visualizar um canal arquivado", "ViewingArchivedChannel": "Está a visualizar um canal arquivado",
"OpenChatInSidebar": "Abrir chat na barra lateral", "OpenChatInSidebar": "Abrir chat na barra lateral",
"NoThreadsYet": "Não há conversas ainda." "NoThreadsYet": "Não há conversas ainda.",
"SummarizeMessages": "Resumir discussão"
} }
} }

View File

@ -132,6 +132,7 @@
"ViewingArchivedChannel": "Вы просматриваете архивированный канал", "ViewingArchivedChannel": "Вы просматриваете архивированный канал",
"OpenChatInSidebar": "Открыть чат в боковой панели", "OpenChatInSidebar": "Открыть чат в боковой панели",
"ResolveThread": "Пометить завершенным", "ResolveThread": "Пометить завершенным",
"NoThreadsYet": "Пока нет обсуждений." "NoThreadsYet": "Пока нет обсуждений.",
"SummarizeMessages": "Подвести итоги обсуждения"
} }
} }

View File

@ -131,6 +131,7 @@
"ViewingThreadFromArchivedChannel": "你正在查看已归档频道的线程", "ViewingThreadFromArchivedChannel": "你正在查看已归档频道的线程",
"ViewingArchivedChannel": "你正在查看已归档频道", "ViewingArchivedChannel": "你正在查看已归档频道",
"OpenChatInSidebar": "在侧边栏中打开聊天", "OpenChatInSidebar": "在侧边栏中打开聊天",
"NoThreadsYet": "还没有线程。" "NoThreadsYet": "还没有线程。",
"SummarizeMessages": "总结讨论"
} }
} }

View File

@ -66,6 +66,7 @@
"fast-equals": "^5.2.2", "fast-equals": "^5.2.2",
"svelte": "^4.2.19", "svelte": "^4.2.19",
"@hcengineering/text-editor-resources": "^0.6.0", "@hcengineering/text-editor-resources": "^0.6.0",
"@hcengineering/text-editor": "^0.6.0" "@hcengineering/text-editor": "^0.6.0",
"@hcengineering/love": "^0.6.0"
} }
} }

View File

@ -84,7 +84,9 @@ import {
translateMessage, translateMessage,
showOriginalMessage, showOriginalMessage,
canTranslateMessage, canTranslateMessage,
startConversationAction startConversationAction,
summarizeMessages,
canSummarizeMessages
} from './utils' } from './utils'
export { default as ChannelEmbeddedContent } from './components/ChannelEmbeddedContent.svelte' export { default as ChannelEmbeddedContent } from './components/ChannelEmbeddedContent.svelte'
@ -213,6 +215,7 @@ export default async (): Promise<Resources> => ({
CloseChatWidgetTab: closeChatWidgetTab, CloseChatWidgetTab: closeChatWidgetTab,
OpenChannelInSidebar: openChannelInSidebar, OpenChannelInSidebar: openChannelInSidebar,
CanTranslateMessage: canTranslateMessage, CanTranslateMessage: canTranslateMessage,
CanSummarizeMessages: canSummarizeMessages,
OpenThreadInSidebar: openThreadInSidebar, OpenThreadInSidebar: openThreadInSidebar,
LocationDataResolver: locationDataResolver LocationDataResolver: locationDataResolver
}, },
@ -226,6 +229,7 @@ export default async (): Promise<Resources> => ({
ReplyToThread: replyToThread, ReplyToThread: replyToThread,
OpenInSidebar: openChannelInSidebarAction, OpenInSidebar: openChannelInSidebarAction,
TranslateMessage: translateMessage, TranslateMessage: translateMessage,
SummarizeMessages: summarizeMessages,
ShowOriginalMessage: showOriginalMessage, ShowOriginalMessage: showOriginalMessage,
StartConversation: startConversationAction StartConversation: startConversationAction
} }

View File

@ -20,24 +20,26 @@ import activity, {
type DocUpdateMessage type DocUpdateMessage
} from '@hcengineering/activity' } from '@hcengineering/activity'
import { isReactionMessage } from '@hcengineering/activity-resources' import { isReactionMessage } from '@hcengineering/activity-resources'
import aiBot from '@hcengineering/ai-bot'
import { summarizeMessages as aiSummarizeMessages, translate as aiTranslate } from '@hcengineering/ai-bot-resources'
import { type Channel, type ChatMessage, type DirectMessage, type ThreadMessage } from '@hcengineering/chunter' import { type Channel, type ChatMessage, type DirectMessage, type ThreadMessage } from '@hcengineering/chunter'
import contact, { getName, getCurrentEmployee, type Employee, type Person } from '@hcengineering/contact' import contact, { getCurrentEmployee, getName, type Employee, type Person } from '@hcengineering/contact'
import { import {
PersonIcon,
employeeByAccountStore, employeeByAccountStore,
employeeByIdStore, employeeByIdStore,
PersonIcon,
personRefByAccountUuidStore personRefByAccountUuidStore
} from '@hcengineering/contact-resources' } from '@hcengineering/contact-resources'
import core, { import core, {
getCurrentAccount, getCurrentAccount,
notEmpty,
type AccountUuid,
type Class, type Class,
type Client, type Client,
type Doc, type Doc,
type Ref, type Ref,
type Space, type Space,
type Timestamp, type Timestamp
type AccountUuid,
notEmpty
} from '@hcengineering/core' } from '@hcengineering/core'
import notification, { type DocNotifyContext, type InboxNotification } from '@hcengineering/notification' import notification, { type DocNotifyContext, type InboxNotification } from '@hcengineering/notification'
import { import {
@ -45,19 +47,18 @@ import {
isActivityNotification, isActivityNotification,
isMentionNotification isMentionNotification
} from '@hcengineering/notification-resources' } from '@hcengineering/notification-resources'
import { translate, type Asset, getMetadata } from '@hcengineering/platform' import { getMetadata, translate, type Asset } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation' import { getClient } from '@hcengineering/presentation'
import { type AnySvelteComponent, languageStore } from '@hcengineering/ui' import { languageStore, type AnySvelteComponent } from '@hcengineering/ui'
import { classIcon, getDocLinkTitle, getDocTitle } from '@hcengineering/view-resources' import { classIcon, getDocLinkTitle, getDocTitle } from '@hcengineering/view-resources'
import { get, writable, type Unsubscriber } from 'svelte/store' import { get, writable, type Unsubscriber } from 'svelte/store'
import aiBot from '@hcengineering/ai-bot'
import { translate as aiTranslate } from '@hcengineering/ai-bot-resources'
import ChannelIcon from './components/ChannelIcon.svelte' import ChannelIcon from './components/ChannelIcon.svelte'
import DirectIcon from './components/DirectIcon.svelte' import DirectIcon from './components/DirectIcon.svelte'
import { openChannelInSidebar, resetChunterLocIfEqual } from './navigation' import { openChannelInSidebar, resetChunterLocIfEqual } from './navigation'
import chunter from './plugin' import chunter from './plugin'
import { shownTranslatedMessagesStore, translatedMessagesStore, translatingMessagesStore } from './stores' import { shownTranslatedMessagesStore, translatedMessagesStore, translatingMessagesStore } from './stores'
import love, { type MeetingMinutes } from '@hcengineering/love'
export async function getDmName (client: Client, space?: Space): Promise<string> { export async function getDmName (client: Client, space?: Space): Promise<string> {
if (space === undefined) { if (space === undefined) {
@ -531,6 +532,24 @@ export async function canTranslateMessage (): Promise<boolean> {
return url !== '' return url !== ''
} }
export async function summarizeMessages (doc: Doc): Promise<void> {
await aiSummarizeMessages(get(languageStore), doc._id, doc._class)
}
export async function canSummarizeMessages (doc: Doc): Promise<boolean> {
if (doc?._id === undefined) return false
const url = getMetadata(aiBot.metadata.EndpointURL) ?? ''
if (url === '') return false
const client = getClient()
const hierarchy = client.getHierarchy()
if (!hierarchy.isDerived(doc._class, love.class.MeetingMinutes)) return false
return ((doc as MeetingMinutes).transcription ?? 0) > 0
}
export async function startConversationAction (docs?: Employee | Employee[]): Promise<void> { export async function startConversationAction (docs?: Employee | Employee[]): Promise<void> {
if (docs === undefined) return if (docs === undefined) return
const employees = Array.isArray(docs) ? docs : [docs] const employees = Array.isArray(docs) ? docs : [docs]

View File

@ -203,7 +203,8 @@ export default plugin(chunterId, {
StartConversation: '' as IntlString, StartConversation: '' as IntlString,
ViewingThreadFromArchivedChannel: '' as IntlString, ViewingThreadFromArchivedChannel: '' as IntlString,
ViewingArchivedChannel: '' as IntlString, ViewingArchivedChannel: '' as IntlString,
OpenChatInSidebar: '' as IntlString OpenChatInSidebar: '' as IntlString,
SummarizeMessages: '' as IntlString
}, },
ids: { ids: {
DMNotification: '' as Ref<NotificationType>, DMNotification: '' as Ref<NotificationType>,
@ -221,11 +222,13 @@ export default plugin(chunterId, {
LeaveChannel: '' as Ref<Action>, LeaveChannel: '' as Ref<Action>,
RemoveChannel: '' as Ref<Action>, RemoveChannel: '' as Ref<Action>,
TranslateMessage: '' as Ref<Action>, TranslateMessage: '' as Ref<Action>,
SummarizeMessages: '' as Ref<Action>,
ShowOriginalMessage: '' as Ref<Action>, ShowOriginalMessage: '' as Ref<Action>,
CloseConversation: '' as Ref<Action> CloseConversation: '' as Ref<Action>
}, },
function: { function: {
CanTranslateMessage: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>, CanTranslateMessage: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
CanSummarizeMessages: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
OpenThreadInSidebar: '' as Resource< OpenThreadInSidebar: '' as Resource<
( (
_id: Ref<ActivityMessage>, _id: Ref<ActivityMessage>,

View File

@ -31,6 +31,7 @@ interface Config {
OpenAIModel: OpenAI.ChatModel OpenAIModel: OpenAI.ChatModel
OpenAIBaseUrl: string OpenAIBaseUrl: string
OpenAITranslateModel: OpenAI.ChatModel OpenAITranslateModel: OpenAI.ChatModel
OpenAISummaryModel: OpenAI.ChatModel
MaxContentTokens: number MaxContentTokens: number
MaxHistoryRecords: number MaxHistoryRecords: number
Port: number Port: number
@ -56,6 +57,7 @@ const config: Config = (() => {
OpenAIKey: process.env.OPENAI_API_KEY ?? '', OpenAIKey: process.env.OPENAI_API_KEY ?? '',
OpenAIModel: (process.env.OPENAI_MODEL ?? 'gpt-4o-mini') as OpenAI.ChatModel, OpenAIModel: (process.env.OPENAI_MODEL ?? 'gpt-4o-mini') as OpenAI.ChatModel,
OpenAITranslateModel: (process.env.OPENAI_TRANSLATE_MODEL ?? 'gpt-4o-mini') as OpenAI.ChatModel, OpenAITranslateModel: (process.env.OPENAI_TRANSLATE_MODEL ?? 'gpt-4o-mini') as OpenAI.ChatModel,
OpenAISummaryModel: (process.env.OPENAI_SUMMARY_MODEL ?? 'gpt-4o-mini') as OpenAI.ChatModel,
OpenAIBaseUrl: process.env.OPENAI_BASE_URL ?? '', OpenAIBaseUrl: process.env.OPENAI_BASE_URL ?? '',
MaxContentTokens: parseNumber(process.env.MAX_CONTENT_TOKENS) ?? 128 * 100, MaxContentTokens: parseNumber(process.env.MAX_CONTENT_TOKENS) ?? 128 * 100,
MaxHistoryRecords: parseNumber(process.env.MAX_HISTORY_RECORDS) ?? 500, MaxHistoryRecords: parseNumber(process.env.MAX_HISTORY_RECORDS) ?? 500,

View File

@ -13,32 +13,48 @@
// limitations under the License. // limitations under the License.
// //
import { isWorkspaceLoginInfo } from '@hcengineering/account-client'
import { import {
AIEventRequest, AIEventRequest,
ConnectMeetingRequest, ConnectMeetingRequest,
DisconnectMeetingRequest, DisconnectMeetingRequest,
IdentityResponse, IdentityResponse,
PersonMessage,
PostTranscriptRequest, PostTranscriptRequest,
SummarizeMessagesRequest,
SummarizeMessagesResponse,
TranslateRequest, TranslateRequest,
TranslateResponse TranslateResponse
} from '@hcengineering/ai-bot' } from '@hcengineering/ai-bot'
import { AccountUuid, MeasureContext, Ref, SocialId, type WorkspaceIds, type WorkspaceUuid } from '@hcengineering/core' import core, {
AccountUuid,
MeasureContext,
PersonId,
Ref,
SocialId,
SortingOrder,
toIdMap,
type WorkspaceIds,
type WorkspaceUuid
} from '@hcengineering/core'
import { Room } from '@hcengineering/love' import { Room } from '@hcengineering/love'
import { WorkspaceInfoRecord } from '@hcengineering/server-ai-bot' import { WorkspaceInfoRecord } from '@hcengineering/server-ai-bot'
import { getAccountClient } from '@hcengineering/server-client' import { getAccountClient } from '@hcengineering/server-client'
import { generateToken } from '@hcengineering/server-token' import { generateToken } from '@hcengineering/server-token'
import { htmlToMarkup, markupToJSON, jsonToHTML } from '@hcengineering/text' import { htmlToMarkup, jsonToHTML, jsonToMarkup, markupToJSON } from '@hcengineering/text'
import { isWorkspaceLoginInfo } from '@hcengineering/account-client'
import { encodingForModel } from 'js-tiktoken' import { encodingForModel } from 'js-tiktoken'
import OpenAI from 'openai' import OpenAI from 'openai'
import chunter from '@hcengineering/chunter'
import { StorageAdapter } from '@hcengineering/server-core' import { StorageAdapter } from '@hcengineering/server-core'
import { buildStorageFromConfig, storageConfigFromEnv } from '@hcengineering/server-storage' import { buildStorageFromConfig, storageConfigFromEnv } from '@hcengineering/server-storage'
import { markdownToMarkup, markupToMarkdown } from '@hcengineering/text-markdown'
import config from './config' import config from './config'
import { DbStorage } from './storage' import { DbStorage } from './storage'
import { WorkspaceClient } from './workspace/workspaceClient'
import { translateHtml } from './utils/openai'
import { tryAssignToWorkspace } from './utils/account' import { tryAssignToWorkspace } from './utils/account'
import { summarizeMessages, translateHtml } from './utils/openai'
import { WorkspaceClient } from './workspace/workspaceClient'
import contact, { Contact, getName } from '@hcengineering/contact'
const CLOSE_INTERVAL_MS = 10 * 60 * 1000 // 10 minutes const CLOSE_INTERVAL_MS = 10 * 60 * 1000 // 10 minutes
@ -204,6 +220,109 @@ export class AIControl {
} }
} }
async summarizeMessages (
workspace: WorkspaceUuid,
req: SummarizeMessagesRequest
): Promise<SummarizeMessagesResponse | undefined> {
if (this.openai === undefined) return
if (req.target === undefined || req.targetClass === undefined) return
const wsClient = await this.getWorkspaceClient(workspace)
if (wsClient === undefined) return
const opClient = await wsClient.opClient
if (opClient === undefined) return
const client = wsClient.client
if (client === undefined) return
const target = await client.findOne(req.targetClass, { _id: req.target })
if (target === undefined) return
const messages = await client.findAll(
chunter.class.ChatMessage,
{
attachedTo: target._id,
collection: { $in: ['messages', 'transcription'] }
},
{
sort: { createdOn: SortingOrder.Ascending },
limit: 5000
}
)
const personIds = new Set<PersonId>()
for (const m of messages) {
if (m.createdBy !== undefined) personIds.add(m.createdBy)
}
const identities = await client.findAll(contact.class.SocialIdentity, { key: { $in: Array.from(personIds) } })
const contacts = await client.findAll(contact.class.Contact, { _id: { $in: identities.map((i) => i.attachedTo) } })
const contactById = toIdMap(contacts)
const contactByPersonId = new Map<PersonId, Contact>()
for (const identity of identities) {
const contact = contactById.get(identity.attachedTo)
if (contact !== undefined) contactByPersonId.set(identity.key, contact)
}
const messagesToSummarize: PersonMessage[] = []
for (const m of messages) {
const author = m.createdBy
if (author === undefined) continue
const contact = contactByPersonId.get(author)
if (contact === undefined) continue
const personName = getName(client.getHierarchy(), contact)
const text = markupToMarkdown(markupToJSON(m.message))
const lastPiece = messagesToSummarize[messagesToSummarize.length - 1]
if (lastPiece?.personRef === contact._id) {
lastPiece.text += (m.collection === 'transcription' ? ' ' : '\n') + text
} else {
messagesToSummarize.push({
personRef: contact._id,
personName,
time: m.createdOn ?? 0,
text
})
}
}
const summary = await summarizeMessages(this.openai, messagesToSummarize, req.lang)
if (summary === undefined) return
const summaryMarkup = jsonToMarkup(markdownToMarkup(summary))
const lastMessage = await client.findOne(
chunter.class.ChatMessage,
{
attachedTo: target._id,
collection: { $in: ['messages', 'transcription', 'summary'] }
},
{
sort: { createdOn: SortingOrder.Descending },
limit: 1
}
)
const op = opClient.apply(undefined, 'AISummarizeMessagesRequestEvent')
if (lastMessage?.collection === 'summary' && lastMessage.createdBy === opClient.user) {
await op.update(lastMessage, { message: summaryMarkup, editedOn: Date.now() })
} else {
await op.addCollection(chunter.class.ChatMessage, core.space.Workspace, target._id, target._class, 'summary', {
message: summaryMarkup
})
}
await op.commit()
return {
text: summaryMarkup,
lang: req.lang
}
}
async processEvent (workspace: WorkspaceUuid, events: AIEventRequest[]): Promise<void> { async processEvent (workspace: WorkspaceUuid, events: AIEventRequest[]): Promise<void> {
if (this.openai === undefined) return if (this.openai === undefined) return

View File

@ -21,7 +21,8 @@ import {
ConnectMeetingRequest, ConnectMeetingRequest,
DisconnectMeetingRequest, DisconnectMeetingRequest,
AIEventRequest, AIEventRequest,
PostTranscriptRequest PostTranscriptRequest,
SummarizeMessagesRequest
} from '@hcengineering/ai-bot' } from '@hcengineering/ai-bot'
import { extractToken } from '@hcengineering/server-client' import { extractToken } from '@hcengineering/server-client'
import { MeasureContext } from '@hcengineering/core' import { MeasureContext } from '@hcengineering/core'
@ -73,6 +74,23 @@ export function createServer (controller: AIControl, ctx: MeasureContext): Expre
}) })
) )
app.post(
'/summarize',
wrapRequest(async (req, res, token) => {
if (req.body == null || Array.isArray(req.body) || typeof req.body !== 'object') {
throw new ApiError(400)
}
const response = await controller.summarizeMessages(token.workspace, req.body as SummarizeMessagesRequest)
if (response === undefined) {
throw new ApiError(500)
}
res.status(200)
res.json(response)
})
)
app.post( app.post(
'/connect', '/connect',
wrapRequest(async (_, res, token) => { wrapRequest(async (_, res, token) => {

View File

@ -13,11 +13,13 @@
// limitations under the License. // limitations under the License.
// //
import { AccountUuid, Ref } from '@hcengineering/core'
import { countTokens } from '@hcengineering/openai' import { countTokens } from '@hcengineering/openai'
import { Tiktoken } from 'js-tiktoken' import { Tiktoken } from 'js-tiktoken'
import OpenAI from 'openai' import OpenAI from 'openai'
import { AccountUuid } from '@hcengineering/core'
import { PersonMessage } from '@hcengineering/ai-bot'
import contact, { Contact } from '@hcengineering/contact'
import config from '../config' import config from '../config'
import { HistoryRecord } from '../types' import { HistoryRecord } from '../types'
import { WorkspaceClient } from '../workspace/workspaceClient' import { WorkspaceClient } from '../workspace/workspaceClient'
@ -25,7 +27,7 @@ import { getTools } from './tools'
export async function translateHtml (client: OpenAI, html: string, lang: string): Promise<string | undefined> { export async function translateHtml (client: OpenAI, html: string, lang: string): Promise<string | undefined> {
const response = await client.chat.completions.create({ const response = await client.chat.completions.create({
model: config.OpenAITranslateModel, model: config.OpenAISummaryModel,
messages: [ messages: [
{ {
role: 'system', role: 'system',
@ -41,6 +43,72 @@ export async function translateHtml (client: OpenAI, html: string, lang: string)
return response.choices[0].message.content ?? undefined return response.choices[0].message.content ?? undefined
} }
export async function summarizeMessages (
client: OpenAI,
messages: PersonMessage[],
lang: string
): Promise<string | undefined> {
const personToName = new Map<Ref<Contact>, string>()
for (const m of messages) {
if (!personToName.has(m.personRef)) {
personToName.set(m.personRef, m.personName)
}
}
const nameUsage = new Map<string, number>()
for (const [personRef, name] of personToName) {
const idx = nameUsage.get(name) ?? 0
if (idx > 0) {
personToName.set(personRef, name + ` no.${idx}`)
}
nameUsage.set(name, idx + 1)
}
const text = messages.map((p) => '---\n\n@' + p.personName + '\n' + p.text).join('\n\n')
const prompt = `Generate a summary from the provided sequence of messages by creating separate bullet lists for each participant, ensuring that each bullet point includes only the key points, problems and further work plans without any chit-chat, and clearly label each participant so that their individual contributions are distinctly summarized.
Use following structure for output:
**@Participant Name**
- Key point 1
- Key point 2
- ...
**@Participant Name**
- Key point 1
- ...
Don't introduce any other elements of the structure.
If a bullet point implies a reference to another participant include a reference according to this format: **@Participant Name**
The response should be translated into ${lang} regardless of the original language. Don't translate the names of the participants and leave them exactly as they appear in the text.`
const response = await client.chat.completions.create({
model: config.OpenAIModel,
messages: [
{
role: 'system',
content: prompt
},
// We could also pack them into separate messages,
// but for now it seems that this option is more preferable
{
role: 'user',
content: text
}
]
})
let responseText = response.choices[0].message.content ?? undefined
if (responseText === undefined) return
const classURI = encodeURIComponent(contact.class.Contact)
for (const [personRef, name] of personToName) {
const idURI = encodeURIComponent(personRef)
const nameURI = encodeURIComponent(name)
const refString = `[](ref://?_class=${classURI}&_id=${idURI}&label=${nameURI})`
responseText = responseText.replaceAll(`**@${name}**`, refString)
}
return responseText
}
export async function createChatCompletion ( export async function createChatCompletion (
client: OpenAI, client: OpenAI,
message: OpenAI.ChatCompletionMessageParam, message: OpenAI.ChatCompletionMessageParam,