diff --git a/packages/cli/package.json b/packages/cli/package.json index 0fb88d97398..11834c47029 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@botpress/cli", - "version": "4.17.0", + "version": "4.17.1", "description": "Botpress CLI", "scripts": { "build": "pnpm run bundle && pnpm run template:gen", diff --git a/packages/cli/src/command-implementations/profile-commands.ts b/packages/cli/src/command-implementations/profile-commands.ts index 4948c378c15..f51bd328d3a 100644 --- a/packages/cli/src/command-implementations/profile-commands.ts +++ b/packages/cli/src/command-implementations/profile-commands.ts @@ -5,20 +5,26 @@ import * as errors from '../errors' import * as utils from '../utils' import { GlobalCache, GlobalCommand, ProfileCredentials } from './global-command' +type ProfileEntry = ProfileCredentials & { + name: string +} + export type ActiveProfileCommandDefinition = typeof commandDefinitions.profiles.subcommands.active export class ActiveProfileCommand extends GlobalCommand { public async run(): Promise { let activeProfileName = await this.globalCache.get('activeProfile') if (!activeProfileName) { - this.logger.log(`No active profile set, defaulting to ${consts.defaultProfileName}`) + this.logger.debug(`No active profile set, defaulting to ${consts.defaultProfileName}`) activeProfileName = consts.defaultProfileName await this.globalCache.set('activeProfile', activeProfileName) } const profile = await this.readProfileFromFS(activeProfileName) + const profileEntry: ProfileEntry = { name: activeProfileName, ...profile } + this.logger.log('Active profile:') - this.logger.json({ [activeProfileName]: profile }) + this.logger.json(profileEntry) } } @@ -26,14 +32,14 @@ export type ListProfilesCommandDefinition = typeof commandDefinitions.profiles.s export class ListProfilesCommand extends GlobalCommand { public async run(): Promise { const profiles = await this.readProfilesFromFS() - if (Object.keys(profiles).length === 0) { + const profileEntries: ProfileEntry[] = Object.entries(profiles).map(([name, profile]) => ({ name, ...profile })) + if (!profileEntries.length) { this.logger.log('No profiles found') return } const activeProfileName = await this.globalCache.get('activeProfile') - const profileNames = Object.keys(profiles) this.logger.log(`Active profile: '${chalk.bold(activeProfileName)}'`) - this.logger.json(profileNames) + this.logger.json(profileEntries) } } diff --git a/plugins/conversation-insights/package.json b/plugins/conversation-insights/package.json index c610f823546..9a860969683 100644 --- a/plugins/conversation-insights/package.json +++ b/plugins/conversation-insights/package.json @@ -2,10 +2,18 @@ "name": "@botpresshub/conversation-insights", "scripts": { "check:type": "tsc --noEmit", - "build": "bp build" + "build": "bp add -y && bp build" }, "private": true, "dependencies": { - "@botpress/sdk": "workspace:*" + "@botpress/sdk": "workspace:*", + "json5": "^2.2.3", + "jsonrepair": "^3.10.0" + }, + "devDependencies": { + "@botpresshub/llm": "workspace:*" + }, + "bpDependencies": { + "llm": "../../interfaces/llm" } } diff --git a/plugins/conversation-insights/plugin.definition.ts b/plugins/conversation-insights/plugin.definition.ts index 528f3f6a8ad..7bec41af788 100644 --- a/plugins/conversation-insights/plugin.definition.ts +++ b/plugins/conversation-insights/plugin.definition.ts @@ -1,8 +1,12 @@ import { PluginDefinition, z } from '@botpress/sdk' +import llm from './bp_modules/llm' export default new PluginDefinition({ name: 'conversation-insights', - version: '0.1.3', + version: '0.2.1', + configuration: { + schema: z.object({ modelId: z.string() }), + }, conversation: { tags: { title: { title: 'Title', description: 'The title of the conversation.' }, @@ -18,12 +22,14 @@ export default new PluginDefinition({ title: 'Participant count', description: 'The count of users having participated in the conversation, including the bot. Type: int', }, - isDirty: { - title: 'Dirty', - description: 'Signifies whether the conversation has had a new message since last refresh', - }, }, }, - // TODO: replace this event with a workflow - events: { updateTitleAndSummary: { schema: z.object({}) } }, + events: { + updateSummary: { + schema: z.object({}), + }, + }, + interfaces: { + llm, + }, }) diff --git a/plugins/conversation-insights/src/generate-content.ts b/plugins/conversation-insights/src/generate-content.ts new file mode 100644 index 00000000000..91a2d1eae66 --- /dev/null +++ b/plugins/conversation-insights/src/generate-content.ts @@ -0,0 +1,34 @@ +import * as sdk from '@botpress/sdk' +import JSON5 from 'json5' +import { jsonrepair } from 'jsonrepair' +import { OutputFormat } from './summary-prompt' +import * as bp from '.botpress' + +export type LLMInput = bp.interfaces.llm.actions.generateContent.input.Input +export type LLMOutput = bp.interfaces.llm.actions.generateContent.output.Output + +export type LLMMessage = LLMInput['messages'][number] +export type LLMChoice = LLMOutput['choices'][number] + +type PredictResponse = { + success: boolean + json: OutputFormat +} + +const tryParseJson = (str: string) => { + try { + return JSON5.parse(jsonrepair(str)) + } catch { + return str + } +} + +export const parseLLMOutput = (output: LLMOutput): PredictResponse => { + const mappedChoices: LLMChoice['content'][] = output.choices.map((choice) => choice.content) + if (!mappedChoices[0]) throw new sdk.RuntimeError('Could not parse LLM output') + const firstChoice = mappedChoices[0] + return { + success: true, + json: tryParseJson(firstChoice.toString()), + } +} diff --git a/plugins/conversation-insights/src/index.ts b/plugins/conversation-insights/src/index.ts index e4aaefbb9b3..172804b2661 100644 --- a/plugins/conversation-insights/src/index.ts +++ b/plugins/conversation-insights/src/index.ts @@ -1,42 +1,38 @@ +import * as sdk from '@botpress/sdk' +import * as summaryUpdater from './summaryUpdater' +import * as updateScheduler from './summaryUpdateScheduler' +import * as types from './types' import * as bp from '.botpress' +type CommonProps = types.CommonProps + const plugin = new bp.Plugin({ actions: {}, }) -// TODO: generate a type for CommonProps in the CLI / SDK -type CommonProps = - | bp.HookHandlerProps['after_incoming_message'] - | bp.HookHandlerProps['after_outgoing_message'] - | bp.EventHandlerProps - plugin.on.afterIncomingMessage('*', async (props) => { const { conversation } = await props.client.getConversation({ id: props.data.conversationId }) - await _onNewMessage({ ...props, conversation, isDirty: true }) + const { message_count } = await _onNewMessage({ ...props, conversation }) + + if (updateScheduler.isTimeToUpdate(message_count)) { + props.client.createEvent({ payload: {}, type: 'updateSummary', conversationId: props.data.conversationId }) + } + return undefined }) plugin.on.afterOutgoingMessage('*', async (props) => { const { conversation } = await props.client.getConversation({ id: props.data.message.conversationId }) - await _onNewMessage({ ...props, conversation, isDirty: false }) + await _onNewMessage({ ...props, conversation }) return undefined }) -plugin.on.event('updateTitleAndSummary', async (props) => { - const conversations = await props.client.listConversations({ tags: { isDirty: 'true' } }) - - for (const conversation of conversations.conversations) { - const messages = await props.client.listMessages({ conversationId: conversation.id }) - const newMessages = messages.messages.map((message) => message.payload.text) - await _updateTitleAndSummary({ ...props, conversationId: conversation.id, messages: newMessages }) - } -}) - type OnNewMessageProps = CommonProps & { conversation: bp.ClientOutputs['getConversation']['conversation'] - isDirty: boolean } -const _onNewMessage = async (props: OnNewMessageProps) => { +const _onNewMessage = async ( + props: OnNewMessageProps +): Promise<{ message_count: number; participant_count: number }> => { const message_count = props.conversation.tags.message_count ? parseInt(props.conversation.tags.message_count) + 1 : 1 const participant_count = await props.client @@ -46,29 +42,28 @@ const _onNewMessage = async (props: OnNewMessageProps) => { const tags = { message_count: message_count.toString(), participant_count: participant_count.toString(), - isDirty: props.isDirty ? 'true' : 'false', } await props.client.updateConversation({ id: props.conversation.id, tags, }) + return { message_count, participant_count } } -type UpdateTitleAndSummaryProps = CommonProps & { - conversationId: string - messages: string[] -} -const _updateTitleAndSummary = async (props: UpdateTitleAndSummaryProps) => { - await props.client.updateConversation({ - id: props.conversationId, - tags: { - // TODO: use the cognitive client / service to generate a title and summary - title: 'The conversation title!', - summary: 'This is normally where the conversation summary would be.', - isDirty: 'false', - }, +plugin.on.event('updateSummary', async (props) => { + const messages = await props.client.listMessages({ conversationId: props.event.conversationId }) + const newMessages: string[] = messages.messages.map((message) => message.payload.text) + if (!props.event.conversationId) { + throw new sdk.RuntimeError(`The conversationId cannot be null when calling the event '${props.event.type}'`) + } + const conversation = await props.client.getConversation({ id: props.event.conversationId }) + + await summaryUpdater.updateTitleAndSummary({ + ...props, + conversation: conversation.conversation, + messages: newMessages, }) -} +}) export default plugin diff --git a/plugins/conversation-insights/src/summary-prompt.ts b/plugins/conversation-insights/src/summary-prompt.ts new file mode 100644 index 00000000000..2f60caa3c02 --- /dev/null +++ b/plugins/conversation-insights/src/summary-prompt.ts @@ -0,0 +1,93 @@ +import { z } from '@botpress/sdk' +import { LLMInput } from './generate-content' + +export type OutputFormat = z.infer +export const OutputFormat = z.object({ + title: z.string().describe('A fitting title for the conversation'), + summary: z.string().describe('A short summary of the conversation'), +}) + +export type InputFormat = z.infer +export const InputFormat = z.array(z.string()) + +const formatMessages = ( + messages: string[], + context: PromptArgs['context'] +): { role: 'user' | 'assistant'; content: string } => { + return { + role: 'user', + content: JSON.stringify( + { + previousTitle: context.previousTitle, + previousSummary: context.previousSummary, + messages: messages.reverse(), + }, + null, + 2 + ), + } +} + +export type PromptArgs = { + messages: string[] + model: { id: string } + context: { previousSummary?: string; previousTitle?: string } +} +export const createPrompt = (args: PromptArgs): LLMInput => ({ + responseFormat: 'json_object', + temperature: 0, + systemPrompt: ` +You are a conversation summarizer. +You will be given: +- A previous title and summary +- An array of USER MESSAGES + +Your task is to produce a title and summary that best describe the overall conversation. + +Return your response only in valid JSON using the following type: + +\`\`\`json +{ + "title": "string", // A concise, fitting title for the conversation + "summary": "string" // A short summary capturing the main topic or request +} +\`\`\` + +Instructions: + +- Consider the previous title when creating the new one — keep it if still relevant, or update it if needed. +- Focus on the main subject of the conversation. +- Make the title short and descriptive (few words). +- Keep the summary concise (one or two sentences). +- Do not include extra commentary, formatting, or explanation outside the JSON output. +- The messages are in order, which means the most recent ones are at the end of the list. + +Example: + +Input: + +\`\`\`json +{ + "previousTitle": "Used cars", + "previousSummary": "The user is talking abous a used Toyota Matrix", + "messages": [ + "What mileage should I expect from a car that was made two years ago?", + "What price should I expect from a car manufactured in 2011?", + "What should I look out for when buying a secondhand Toyota Matrix?", + "I am looking to buy a used car, what would you recommend?", + ] +} +\`\`\` + +Output: + +\`\`\`json +{ + "title": "Used cars", + "summary": "The user is seeking advice on purchasing a used car." +} +\`\`\` +`.trim(), + messages: [formatMessages(args.messages, args.context)], + model: args.model, +}) diff --git a/plugins/conversation-insights/src/summaryUpdateScheduler.ts b/plugins/conversation-insights/src/summaryUpdateScheduler.ts new file mode 100644 index 00000000000..aa1a176682b --- /dev/null +++ b/plugins/conversation-insights/src/summaryUpdateScheduler.ts @@ -0,0 +1,7 @@ +const INITIAL_UPDATE_COUNT = 3 //will update every message for the first INITIAL_UPDATE_COUNT messages +const UPDATE_INTERVAL = 5 //after that, will update every UPDATE_INTERVAL messages + +export const isTimeToUpdate = (message_count: number): boolean => { + if (message_count <= INITIAL_UPDATE_COUNT) return true + return message_count % UPDATE_INTERVAL === 0 +} diff --git a/plugins/conversation-insights/src/summaryUpdater.ts b/plugins/conversation-insights/src/summaryUpdater.ts new file mode 100644 index 00000000000..84963078a19 --- /dev/null +++ b/plugins/conversation-insights/src/summaryUpdater.ts @@ -0,0 +1,44 @@ +import * as gen from './generate-content' +import * as summarizer from './summary-prompt' +import * as types from './types' +import * as bp from '.botpress' + +type CommonProps = types.CommonProps + +type UpdateTitleAndSummaryProps = CommonProps & { + conversation: bp.MessageHandlerProps['conversation'] + messages: string[] +} +export const updateTitleAndSummary = async (props: UpdateTitleAndSummaryProps) => { + const prompt = summarizer.createPrompt({ + messages: props.messages, + model: { id: props.configuration.modelId }, + context: { previousTitle: props.conversation.tags.title, previousSummary: props.conversation.tags.summary }, + }) + + let attemptCount = 0 + const maxRetries = 3 + + let llmOutput = await props.actions.llm.generateContent(prompt) + let parsed = gen.parseLLMOutput(llmOutput) + + while (!parsed.success && attemptCount < maxRetries) { + props.logger.debug(`Attempt ${attemptCount + 1}: The LLM output did not respect the schema.`, parsed.json) + llmOutput = await props.actions.llm.generateContent(prompt) + parsed = gen.parseLLMOutput(llmOutput) + attemptCount++ + } + + if (!parsed.success) { + props.logger.debug(`The LLM output did not respect the schema after ${attemptCount} retries.`, parsed.json) + return + } + + await props.client.updateConversation({ + id: props.conversation.id, + tags: { + title: parsed.json.title, + summary: parsed.json.summary, + }, + }) +} diff --git a/plugins/conversation-insights/src/types.ts b/plugins/conversation-insights/src/types.ts new file mode 100644 index 00000000000..3141bc3f4b4 --- /dev/null +++ b/plugins/conversation-insights/src/types.ts @@ -0,0 +1,7 @@ +import * as bp from '.botpress' + +// TODO: generate a type for CommonProps in the CLI / SDK +export type CommonProps = + | bp.HookHandlerProps['after_incoming_message'] + | bp.HookHandlerProps['after_outgoing_message'] + | bp.EventHandlerProps diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1aafe998a79..8fb193f3cc3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2589,6 +2589,16 @@ importers: '@botpress/sdk': specifier: workspace:* version: link:../../packages/sdk + json5: + specifier: ^2.2.3 + version: 2.2.3 + jsonrepair: + specifier: ^3.10.0 + version: 3.10.0 + devDependencies: + '@botpresshub/llm': + specifier: workspace:* + version: link:../../interfaces/llm plugins/file-synchronizer: dependencies: