Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
16 changes: 11 additions & 5 deletions packages/cli/src/command-implementations/profile-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,35 +5,41 @@ 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<ActiveProfileCommandDefinition> {
public async run(): Promise<void> {
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)
}
}

export type ListProfilesCommandDefinition = typeof commandDefinitions.profiles.subcommands.list
export class ListProfilesCommand extends GlobalCommand<ListProfilesCommandDefinition> {
public async run(): Promise<void> {
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)
}
}

Expand Down
12 changes: 10 additions & 2 deletions plugins/conversation-insights/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
20 changes: 13 additions & 7 deletions plugins/conversation-insights/plugin.definition.ts
Original file line number Diff line number Diff line change
@@ -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.' },
Expand All @@ -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,
},
})
34 changes: 34 additions & 0 deletions plugins/conversation-insights/src/generate-content.ts
Original file line number Diff line number Diff line change
@@ -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()),
}
}
65 changes: 30 additions & 35 deletions plugins/conversation-insights/src/index.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
93 changes: 93 additions & 0 deletions plugins/conversation-insights/src/summary-prompt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { z } from '@botpress/sdk'
import { LLMInput } from './generate-content'

export type OutputFormat = z.infer<typeof OutputFormat>
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<typeof InputFormat>
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,
})
7 changes: 7 additions & 0 deletions plugins/conversation-insights/src/summaryUpdateScheduler.ts
Original file line number Diff line number Diff line change
@@ -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
}
44 changes: 44 additions & 0 deletions plugins/conversation-insights/src/summaryUpdater.ts
Original file line number Diff line number Diff line change
@@ -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,
},
})
}
Loading
Loading