diff --git a/packages/chat-client/package.json b/packages/chat-client/package.json index c7a05f6ff3c..86d371e7e1b 100644 --- a/packages/chat-client/package.json +++ b/packages/chat-client/package.json @@ -1,6 +1,6 @@ { "name": "@botpress/chat", - "version": "0.5.1", + "version": "0.5.2", "description": "Botpress Chat API Client", "main": "./dist/index.cjs", "module": "./dist/index.mjs", diff --git a/packages/chat-client/src/client.ts b/packages/chat-client/src/client.ts index 4f67158d005..f48ef91b967 100644 --- a/packages/chat-client/src/client.ts +++ b/packages/chat-client/src/client.ts @@ -4,6 +4,7 @@ import * as consts from './consts' import * as errors from './errors' import { apiVersion, Client as AutoGeneratedClient } from './gen/client' import jwt from './jsonwebtoken' +import { AsyncCollection } from './listing' import { SignalListener } from './signal-listener' import * as types from './types' @@ -103,6 +104,23 @@ export class Client implements IClient { public readonly createEvent: IClient['createEvent'] = (x) => this._call('createEvent', x) public readonly getEvent: IClient['getEvent'] = (x) => this._call('getEvent', x) + public get list() { + return { + conversations: (props: types.ClientRequests['listConversations']) => + new AsyncCollection(({ nextToken }) => + this.listConversations({ nextToken, ...props }).then((r) => ({ ...r, items: r.conversations })) + ), + messages: (props: types.ClientRequests['listMessages']) => + new AsyncCollection(({ nextToken }) => + this.listMessages({ nextToken, ...props }).then((r) => ({ ...r, items: r.messages })) + ), + participants: (props: types.ClientRequests['listParticipants']) => + new AsyncCollection(({ nextToken }) => + this.listParticipants({ nextToken, ...props }).then((r) => ({ ...r, items: r.participants })) + ), + } + } + public readonly listenConversation: IClient['listenConversation'] = async ({ id, 'x-user-key': userKey }) => { const signalListener = await SignalListener.listen({ url: this._apiUrl, @@ -257,4 +275,15 @@ export class AuthenticatedClient implements IAuthenticatedClient { this._client.createEvent({ 'x-user-key': this.user.key, ...x }) public readonly getEvent: IAuthenticatedClient['getEvent'] = (x) => this._client.getEvent({ 'x-user-key': this.user.key, ...x }) + + public get list() { + return { + conversations: (x: types.AuthenticatedClientRequests['listConversations']) => + this._client.list.conversations({ 'x-user-key': this.user.key, ...x }), + messages: (x: types.AuthenticatedClientRequests['listMessages']) => + this._client.list.messages({ 'x-user-key': this.user.key, ...x }), + participants: (x: types.AuthenticatedClientRequests['listParticipants']) => + this._client.list.participants({ 'x-user-key': this.user.key, ...x }), + } + } } diff --git a/packages/chat-client/src/listing.ts b/packages/chat-client/src/listing.ts new file mode 100644 index 00000000000..fe8d2bf239b --- /dev/null +++ b/packages/chat-client/src/listing.ts @@ -0,0 +1,29 @@ +export type PageLister = (t: { nextToken?: string }) => Promise<{ items: R[]; meta: { nextToken?: string } }> +export class AsyncCollection { + public constructor(private _list: PageLister) {} + + public async *[Symbol.asyncIterator]() { + let nextToken: string | undefined + do { + const { items, meta } = await this._list({ nextToken }) + nextToken = meta.nextToken + for (const item of items) { + yield item + } + } while (nextToken) + } + + public async collect(props: { limit?: number } = {}) { + const limit = props.limit ?? Number.POSITIVE_INFINITY + const arr: T[] = [] + let count = 0 + for await (const item of this) { + arr.push(item) + count++ + if (count >= limit) { + break + } + } + return arr + } +} diff --git a/packages/cli/package.json b/packages/cli/package.json index a8ef0f14240..02fb1b404ae 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -22,7 +22,7 @@ "types": "dist/index.d.ts", "dependencies": { "@apidevtools/json-schema-ref-parser": "^11.7.0", - "@botpress/chat": "0.5.1", + "@botpress/chat": "0.5.2", "@botpress/client": "1.25.0", "@botpress/sdk": "4.15.11", "@bpinternal/const": "^0.1.0", diff --git a/plugins/conversation-insights/package.json b/plugins/conversation-insights/package.json index ec6edfb6ec7..505624f5036 100644 --- a/plugins/conversation-insights/package.json +++ b/plugins/conversation-insights/package.json @@ -6,9 +6,9 @@ }, "private": true, "dependencies": { + "@botpress/cognitive": "workspace:*", "@botpress/sdk": "workspace:*", "browser-or-node": "^2.1.1", - "json5": "^2.2.3", "jsonrepair": "^3.10.0" }, "devDependencies": { diff --git a/plugins/conversation-insights/plugin.definition.ts b/plugins/conversation-insights/plugin.definition.ts index ebc657ae913..10a777a599d 100644 --- a/plugins/conversation-insights/plugin.definition.ts +++ b/plugins/conversation-insights/plugin.definition.ts @@ -1,12 +1,10 @@ import { PluginDefinition, z } from '@botpress/sdk' -import llm from './bp_modules/llm' export default new PluginDefinition({ name: 'conversation-insights', - version: '0.3.2', + version: '0.4.0', configuration: { schema: z.object({ - modelId: z.string().describe('The AI model id (ex: gpt-4.1-nano-2025-04-14)'), aiEnabled: z.boolean().default(true).describe('Set to true to enable title, summary and sentiment ai generation'), }), }, @@ -42,9 +40,6 @@ export default new PluginDefinition({ }, }, workflows: { updateAllConversations: { input: { schema: z.object({}) }, output: { schema: z.object({}) } } }, - interfaces: { - llm, - }, __advanced: { useLegacyZuiTransformer: true, }, diff --git a/plugins/conversation-insights/src/prompt/parse-content.test.ts b/plugins/conversation-insights/src/prompt/parse-content.test.ts new file mode 100644 index 00000000000..084fa87c19e --- /dev/null +++ b/plugins/conversation-insights/src/prompt/parse-content.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest' +import { parseLLMOutput } from './parse-content' +import * as sdk from '@botpress/sdk' +import { z } from '@botpress/sdk' +import * as cognitive from '@botpress/cognitive' + +const COGNITIVE_OUTPUT = (content: string): cognitive.GenerateContentOutput => ({ + provider: 'test-provider', + model: 'test-model', + botpress: { cost: 0 }, + id: '', + usage: { + inputCost: 0, + inputTokens: 0, + outputTokens: 0, + outputCost: 0, + }, + choices: [{ content, index: 0, role: 'assistant', stopReason: 'other' }], +}) + +const CONTENT_PARSE_SCHEMA = z.object({ foo: z.string(), bar: z.number() }) + +describe('parseLLMOutput', () => { + it('valid json parsing is successful', () => { + const output = COGNITIVE_OUTPUT(`{"foo": "hello", "bar": 42}`) + + const result = parseLLMOutput>({ schema: CONTENT_PARSE_SCHEMA, ...output }) + + expect(result.success).toBe(true) + }) + + it('invalid json parsing throws an error', () => { + const output = COGNITIVE_OUTPUT(`not a json`) + + expect(() => { + parseLLMOutput({ schema: CONTENT_PARSE_SCHEMA, ...output }) + }).toThrowError(sdk.ZodError) + }) + + it('empty choices parsing throws an error', () => { + expect(() => parseLLMOutput({ choices: [] } as any)).toThrow(sdk.RuntimeError) + }) + + it('valid json with whitespaces parsing is successful', () => { + const output = COGNITIVE_OUTPUT(` { "foo": "bar", "bar": 123 } `) + + const result = parseLLMOutput>({ schema: CONTENT_PARSE_SCHEMA, ...output }) + + expect(result.success).toBe(true) + }) +}) diff --git a/plugins/conversation-insights/src/prompt/parse-content.ts b/plugins/conversation-insights/src/prompt/parse-content.ts index bef79cea140..39651c55f9a 100644 --- a/plugins/conversation-insights/src/prompt/parse-content.ts +++ b/plugins/conversation-insights/src/prompt/parse-content.ts @@ -1,33 +1,29 @@ +import * as cognitive from '@botpress/cognitive' import * as sdk from '@botpress/sdk' -import JSON5 from 'json5' import { jsonrepair } from 'jsonrepair' -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 LLMInput = cognitive.GenerateContentInput -export type LLMMessage = LLMInput['messages'][number] -export type LLMChoice = LLMOutput['choices'][number] +type LLMChoice = cognitive.GenerateContentOutput['choices'][number] export type PredictResponse = { success: boolean json: T } -const tryParseJson = (str: string) => { - try { - return JSON5.parse(jsonrepair(str)) - } catch { - return str - } +const parseJson = (expectedSchema: sdk.ZodSchema, str: string): T => { + const repaired = jsonrepair(str) + const parsed = JSON.parse(repaired) + return expectedSchema.parse(parsed) } -export const parseLLMOutput = (output: LLMOutput): PredictResponse => { - const mappedChoices: LLMChoice['content'][] = output.choices.map((choice) => choice.content) +type ParseLLMOutputProps = cognitive.GenerateContentOutput & { schema: sdk.ZodSchema } +export const parseLLMOutput = (props: ParseLLMOutputProps): PredictResponse => { + const mappedChoices: LLMChoice['content'][] = props.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()), + json: parseJson(props.schema, firstChoice.toString()), } } diff --git a/plugins/conversation-insights/src/prompt/prompt.ts b/plugins/conversation-insights/src/prompt/prompt.ts index 6f4c39c525c..bc4d2bece07 100644 --- a/plugins/conversation-insights/src/prompt/prompt.ts +++ b/plugins/conversation-insights/src/prompt/prompt.ts @@ -34,7 +34,6 @@ const formatMessages = ( export type PromptArgs = { systemPrompt: string messages: bp.MessageHandlerProps['message'][] - model: { id: string } context: object botId: string } @@ -43,5 +42,4 @@ export const createPrompt = (args: PromptArgs): LLMInput => ({ temperature: 0, systemPrompt: args.systemPrompt.trim(), messages: formatMessages(args.messages, args.context, args.botId), - model: args.model, }) diff --git a/plugins/conversation-insights/src/prompt/summary-prompt.ts b/plugins/conversation-insights/src/prompt/summary-prompt.ts index d72d91bfe72..8f0fd4e0361 100644 --- a/plugins/conversation-insights/src/prompt/summary-prompt.ts +++ b/plugins/conversation-insights/src/prompt/summary-prompt.ts @@ -2,8 +2,8 @@ import { z } from '@botpress/sdk' import { LLMInput } from './parse-content' import * as prompt from './prompt' -export type OutputFormat = z.infer -export const OutputFormat = z.object({ +export type SummaryOutput = z.infer +export const SummaryOutput = z.object({ title: z.string().describe('A fitting title for the conversation'), summary: z.string().describe('A short summary of the conversation'), }) diff --git a/plugins/conversation-insights/src/tagsUpdater.ts b/plugins/conversation-insights/src/tagsUpdater.ts index 99345180811..bbdb5b797a4 100644 --- a/plugins/conversation-insights/src/tagsUpdater.ts +++ b/plugins/conversation-insights/src/tagsUpdater.ts @@ -1,3 +1,5 @@ +import * as cognitive from '@botpress/cognitive' +import * as sdk from '@botpress/sdk' import * as gen from './prompt/parse-content' import * as sentiment from './prompt/sentiment-prompt' import * as summarizer from './prompt/summary-prompt' @@ -9,32 +11,35 @@ type CommonProps = types.CommonProps type UpdateTitleAndSummaryProps = CommonProps & { conversation: bp.MessageHandlerProps['conversation'] messages: bp.MessageHandlerProps['message'][] + client: cognitive.BotpressClientLike } export const updateTitleAndSummary = async (props: UpdateTitleAndSummaryProps) => { const summaryPrompt = summarizer.createPrompt({ messages: props.messages, botId: props.ctx.botId, - model: { id: props.configuration.modelId }, context: { previousTitle: props.conversation.tags.title, previousSummary: props.conversation.tags.summary }, }) - const parsedSummary = await _generateContentWithRetries({ + const parsedSummary = await _generateContentWithRetries({ actions: props.actions, logger: props.logger, prompt: summaryPrompt, + client: props.client, + schema: summarizer.SummaryOutput, }) const sentimentPrompt = sentiment.createPrompt({ messages: props.messages, botId: props.ctx.botId, context: { previousSentiment: props.conversation.tags.sentiment }, - model: { id: props.configuration.modelId }, }) const parsedSentiment = await _generateContentWithRetries({ actions: props.actions, logger: props.logger, prompt: sentimentPrompt, + client: props.client, + schema: sentiment.SentimentAnalysisOutput, }) await props.client.updateConversation({ @@ -53,18 +58,24 @@ type ParsePromptProps = { actions: UpdateTitleAndSummaryProps['actions'] logger: UpdateTitleAndSummaryProps['logger'] prompt: gen.LLMInput + client: cognitive.BotpressClientLike + schema: sdk.ZodSchema } const _generateContentWithRetries = async (props: ParsePromptProps): Promise> => { let attemptCount = 0 const maxRetries = 3 - let llmOutput = await props.actions.llm.generateContent(props.prompt) - let parsed = gen.parseLLMOutput(llmOutput) + const cognitiveClient = new cognitive.Cognitive({ client: props.client, __experimental_beta: true }) + let llmOutput = await cognitiveClient.generateContent(props.prompt) + let parsed = gen.parseLLMOutput({ schema: props.schema, ...llmOutput.output }) 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(props.prompt) - parsed = gen.parseLLMOutput(llmOutput) + props.logger.debug( + `Attempt ${attemptCount + 1}: The LLM output did not respect the schema. It submitted: `, + parsed.json + ) + llmOutput = await cognitiveClient.generateContent(props.prompt) + parsed = gen.parseLLMOutput({ schema: props.schema, ...llmOutput.output }) attemptCount++ } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc845bf4588..74afd35e2fa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2160,7 +2160,7 @@ importers: specifier: ^11.7.0 version: 11.7.0 '@botpress/chat': - specifier: 0.5.1 + specifier: 0.5.2 version: link:../chat-client '@botpress/client': specifier: 1.25.0 @@ -2730,15 +2730,15 @@ importers: plugins/conversation-insights: dependencies: + '@botpress/cognitive': + specifier: workspace:* + version: link:../../packages/cognitive '@botpress/sdk': specifier: workspace:* version: link:../../packages/sdk browser-or-node: specifier: ^2.1.1 version: 2.1.1 - json5: - specifier: ^2.2.3 - version: 2.2.3 jsonrepair: specifier: ^3.10.0 version: 3.10.0