diff --git a/api/src/chat/services/bot.service.spec.ts b/api/src/chat/services/bot.service.spec.ts index 366ae7f3..0a216593 100644 --- a/api/src/chat/services/bot.service.spec.ts +++ b/api/src/chat/services/bot.service.spec.ts @@ -217,7 +217,7 @@ describe('BotService', () => { ]); }); - afterEach(jest.clearAllMocks); + afterEach(jest.resetAllMocks); afterAll(closeInMongodConnection); describe('startConversation', () => { afterAll(() => { diff --git a/api/src/chat/services/bot.service.ts b/api/src/chat/services/bot.service.ts index 835547e2..ca5f71d6 100644 --- a/api/src/chat/services/bot.service.ts +++ b/api/src/chat/services/bot.service.ts @@ -11,6 +11,8 @@ import { EventEmitter2 } from '@nestjs/event-emitter'; import { BotStatsType } from '@/analytics/schemas/bot-stats.schema'; import EventWrapper from '@/channel/lib/EventWrapper'; +import { HelperService } from '@/helper/helper.service'; +import { FlowEscape, HelperType } from '@/helper/types'; import { LoggerService } from '@/logger/logger.service'; import { SettingService } from '@/setting/services/setting.service'; @@ -24,7 +26,7 @@ import { OutgoingMessageFormat, StdOutgoingMessageEnvelope, } from '../schemas/types/message'; -import { BlockOptions, FallbackOptions } from '../schemas/types/options'; +import { FallbackOptions } from '../schemas/types/options'; import { BlockService } from './block.service'; import { ConversationService } from './conversation.service'; @@ -39,6 +41,7 @@ export class BotService { private readonly conversationService: ConversationService, private readonly subscriberService: SubscriberService, private readonly settingService: SettingService, + private readonly helperService: HelperService, ) {} /** @@ -244,10 +247,12 @@ export class BotService { /** * Handles advancing the conversation to the specified *next* block. * - * 1. Updates “popular blocks” stats. - * 2. Persists the updated conversation context. - * 3. Triggers the next block. - * 4. Ends the conversation if an unrecoverable error occurs. + * @param convo - The current conversation object containing context and state. + * @param next - The next block to proceed to in the conversation flow. + * @param event - The incoming event that triggered the conversation flow. + * @param fallback - Boolean indicating if this is a fallback response in case no appropriate reply was found. + * + * @returns A promise that resolves to a boolean indicating whether the next block was successfully triggered. */ async proceedToNextBlock( convo: ConversationFull, @@ -258,10 +263,7 @@ export class BotService { // Increment stats about popular blocks this.eventEmitter.emit('hook:stats:entry', BotStatsType.popular, next.name); this.logger.debug( - 'Proceeding to next block ', - next.id, - ' for conversation ', - convo.id, + `Proceeding to next block ${next.id} for conversation ${convo.id}`, ); try { @@ -348,45 +350,16 @@ export class BotService { ) { try { let fallback = false; - const currentBlock = convo.current; - const fallbackOptions: BlockOptions['fallback'] = convo.current?.options - ?.fallback - ? convo.current.options.fallback - : { - active: false, - max_attempts: 0, - message: [], - }; - - // We will avoid having multiple matches when we are not at the start of a conversation - // and only if local fallback is enabled - const canHaveMultipleMatches = !fallbackOptions.active; - // Find the next block that matches - const nextBlocks = await this.blockService.findAndPopulate({ - _id: { $in: convo.next.map(({ id }) => id) }, - }); - const matchedBlock = await this.blockService.match( - nextBlocks, - event, - canHaveMultipleMatches, - ); - // If there is no match in next block then loopback (current fallback) - // This applies only to text messages + there's a max attempt to be specified + this.logger.debug('Handling ongoing conversation message ...', convo.id); + const matchedBlock = await this.findNextMatchingBlock(convo, event); let fallbackBlock: BlockFull | undefined = undefined; if (!matchedBlock && this.shouldAttemptLocalFallback(convo, event)) { - // Trigger block fallback - // NOTE : current is not populated, this may cause some anomaly - fallbackBlock = { - ...currentBlock, - nextBlocks: convo.next, - // If there's labels, they should be already have been assigned - assign_labels: [], - trigger_labels: [], - attachedBlock: null, - category: null, - previousBlocks: [], - }; - fallback = true; + const fallbackResult = await this.handleFlowEscapeFallback( + convo, + event, + ); + fallbackBlock = fallbackResult.nextBlock; + fallback = fallbackResult.fallback; } const next = matchedBlock || fallbackBlock; @@ -410,6 +383,91 @@ export class BotService { } } + /** + * Handles the flow escape fallback logic for a conversation. + * + * This method adjudicates the flow escape event and helps determine the next block to execute based on the helper's response. + * It can coerce the event to a specific next block, create a new context, or reprompt the user with a fallback message. + * If the helper cannot handle the flow escape, it returns a fallback block with the current conversation's state. + * + * @param convo - The current conversation object. + * @param event - The incoming event that triggered the fallback. + * + * @returns An object containing the next block to execute (if any) and a flag indicating if a fallback should occur. + */ + async handleFlowEscapeFallback( + convo: ConversationFull, + event: EventWrapper, + ): Promise<{ nextBlock?: BlockFull; fallback: boolean }> { + const currentBlock = convo.current; + const fallbackOptions: FallbackOptions = + this.blockService.getFallbackOptions(currentBlock); + const fallbackBlock: BlockFull = { + ...currentBlock, + nextBlocks: convo.next, + assign_labels: [], + trigger_labels: [], + attachedBlock: null, + category: null, + previousBlocks: [], + }; + + try { + const helper = await this.helperService.getDefaultHelper( + HelperType.FLOW_ESCAPE, + ); + + if (!helper.canHandleFlowEscape(currentBlock)) { + return { nextBlock: fallbackBlock, fallback: true }; + } + + // Adjudicate the flow escape event + this.logger.debug( + `Adjudicating flow escape for block '${currentBlock.id}' in conversation '${convo.id}'.`, + ); + const result = await helper.adjudicate(event, currentBlock); + + switch (result.action) { + case FlowEscape.Action.COERCE: { + // Coerce the option to the next block + this.logger.debug(`Coercing option to the next block ...`, convo.id); + const proxiedEvent = new Proxy(event, { + get(target, prop, receiver) { + if (prop === 'getText') { + return () => result.coercedOption + ''; + } + return Reflect.get(target, prop, receiver); + }, + }); + const matchedBlock = await this.findNextMatchingBlock( + convo, + proxiedEvent, + ); + return { nextBlock: matchedBlock, fallback: false }; + } + + case FlowEscape.Action.NEW_CTX: + return { nextBlock: undefined, fallback: false }; + + case FlowEscape.Action.REPROMPT: + default: + if (result.repromptMessage) { + fallbackBlock.options.fallback = { + ...fallbackOptions, + message: [result.repromptMessage], + }; + } + return { nextBlock: fallbackBlock, fallback: true }; + } + } catch (err) { + this.logger.warn( + 'Unable to handle flow escape, using default local fallback ...', + err, + ); + return { nextBlock: fallbackBlock, fallback: true }; + } + } + /** * Determines if the incoming message belongs to an active conversation and processes it accordingly. * If an active conversation is found, the message is handled as part of that conversation. diff --git a/api/src/helper/helper.module.ts b/api/src/helper/helper.module.ts index 365e6d78..f60fd4e3 100644 --- a/api/src/helper/helper.module.ts +++ b/api/src/helper/helper.module.ts @@ -1,5 +1,5 @@ /* - * Copyright © 2024 Hexastack. All rights reserved. + * Copyright © 2025 Hexastack. All rights reserved. * * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. @@ -10,6 +10,7 @@ import { HttpModule } from '@nestjs/axios'; import { Global, Module } from '@nestjs/common'; import { InjectDynamicProviders } from 'nestjs-dynamic-providers'; +import { CmsModule } from '@/cms/cms.module'; import { NlpModule } from '@/nlp/nlp.module'; import { HelperController } from './helper.controller'; @@ -25,7 +26,7 @@ import { HelperService } from './helper.service'; 'dist/.hexabot/custom/extensions/helpers/**/*.helper.js', ) @Module({ - imports: [HttpModule, NlpModule], + imports: [HttpModule, NlpModule, CmsModule], controllers: [HelperController], providers: [HelperService], exports: [HelperService], diff --git a/api/src/helper/lib/base-flow-escape-helper.ts b/api/src/helper/lib/base-flow-escape-helper.ts index 5c93aea9..a6ecbc6a 100644 --- a/api/src/helper/lib/base-flow-escape-helper.ts +++ b/api/src/helper/lib/base-flow-escape-helper.ts @@ -36,7 +36,7 @@ export default abstract class BaseFlowEscapeHelper< * @param _blockMessage - The block message to check. * @returns - Whether the helper can handle the flow escape for the given block message. */ - abstract canHandleFlowEscape(_blockMessage: T): boolean; + abstract canHandleFlowEscape(block: T): boolean; /** * Adjudicates the flow escape event. @@ -46,7 +46,7 @@ export default abstract class BaseFlowEscapeHelper< * @returns - A promise that resolves to a FlowEscape.AdjudicationResult. */ abstract adjudicate( - _event: EventWrapper, - _block: T, + event: EventWrapper, + block: T, ): Promise; } diff --git a/api/src/utils/test/mocks/block.ts b/api/src/utils/test/mocks/block.ts index 0de1196f..dbb258ee 100644 --- a/api/src/utils/test/mocks/block.ts +++ b/api/src/utils/test/mocks/block.ts @@ -81,7 +81,7 @@ const position = { y: 0, }; -export const baseBlockInstance = { +export const baseBlockInstance: Partial = { trigger_labels: [labelMock], assign_labels: [labelMock], options: blockOptions, @@ -90,7 +90,7 @@ export const baseBlockInstance = { position, builtin: true, attachedBlock: null, - category: undefined, + category: null, previousBlocks: [], trigger_channels: [], nextBlocks: [],