From bb5d029e68d99c6eeb9ec2cc317053fec51590f6 Mon Sep 17 00:00:00 2001 From: yassinedorbozgithub Date: Mon, 2 Jun 2025 07:16:52 +0100 Subject: [PATCH 01/52] feat(api): add strict Setting types --- api/src/setting/schemas/types.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/api/src/setting/schemas/types.ts b/api/src/setting/schemas/types.ts index 5d1b6bc2..1e99713d 100644 --- a/api/src/setting/schemas/types.ts +++ b/api/src/setting/schemas/types.ts @@ -6,6 +6,8 @@ * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). */ +import { BaseSchema } from '@/utils/generics/base-schema'; + import { Setting } from './setting.schema'; export enum SettingType { @@ -128,3 +130,17 @@ export type AnySetting = | MultipleAttachmentSetting; export type SettingDict = { [group: string]: Setting[] }; + +export type StrictSetting< + U, + E extends object = object, + K extends keyof BaseSchema = keyof BaseSchema, +> = U extends any + ? { + [P in keyof U as P extends K + ? never + : U[P] extends never + ? never + : P]: U[P]; + } & E + : never; From 4b42c03fb9d04c58a917e4aa9267cc73fbb4fb6b Mon Sep 17 00:00:00 2001 From: yassinedorbozgithub Date: Mon, 2 Jun 2025 07:17:25 +0100 Subject: [PATCH 02/52] feat(api): add strict Setting types to channels --- api/src/channel/types.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/api/src/channel/types.ts b/api/src/channel/types.ts index 0c654f19..7269a38c 100644 --- a/api/src/channel/types.ts +++ b/api/src/channel/types.ts @@ -6,14 +6,14 @@ * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). */ -import { SettingCreateDto } from '@/setting/dto/setting.dto'; +import { AnySetting, StrictSetting } from '@/setting/schemas/types'; import { HyphenToUnderscore } from '@/utils/types/extension'; export type ChannelName = `${string}-channel`; -export type ChannelSetting = Omit< - SettingCreateDto, - 'group' | 'weight' -> & { - group: HyphenToUnderscore; -}; +export type ChannelSetting = StrictSetting< + AnySetting, + { + group: HyphenToUnderscore; + } +>; From 801a60b331f9ac6d59b2f89686c8f50588592219 Mon Sep 17 00:00:00 2001 From: yassinedorbozgithub Date: Mon, 2 Jun 2025 07:17:35 +0100 Subject: [PATCH 03/52] feat(api): add strict Setting types to helpers --- api/src/helper/types.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/api/src/helper/types.ts b/api/src/helper/types.ts index fd373f85..a6c841e0 100644 --- a/api/src/helper/types.ts +++ b/api/src/helper/types.ts @@ -6,7 +6,7 @@ * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). */ -import { SettingCreateDto } from '@/setting/dto/setting.dto'; +import { AnySetting, StrictSetting } from '@/setting/schemas/types'; import { HyphenToUnderscore } from '@/utils/types/extension'; import BaseHelper from './lib/base-helper'; @@ -116,9 +116,9 @@ export type HelperRegistry = Map< Map >; -export type HelperSetting = Omit< - SettingCreateDto, - 'group' | 'weight' -> & { - group: HyphenToUnderscore; -}; +export type HelperSetting = StrictSetting< + AnySetting, + { + group: HyphenToUnderscore; + } +>; From c15c30895ff655749bf0ee679defdffd7a2ce224 Mon Sep 17 00:00:00 2001 From: yassinedorbozgithub Date: Mon, 2 Jun 2025 07:17:48 +0100 Subject: [PATCH 04/52] feat(api): add strict Setting types to plugins --- api/src/plugins/types.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/src/plugins/types.ts b/api/src/plugins/types.ts index d296de6d..09a3beac 100644 --- a/api/src/plugins/types.ts +++ b/api/src/plugins/types.ts @@ -10,7 +10,7 @@ import { ChannelEvent } from '@/channel/lib/EventWrapper'; import { BlockCreateDto } from '@/chat/dto/block.dto'; import { Block } from '@/chat/schemas/block.schema'; import { Conversation } from '@/chat/schemas/conversation.schema'; -import { SettingCreateDto } from '@/setting/dto/setting.dto'; +import { AnySetting, StrictSetting } from '@/setting/schemas/types'; export type PluginName = `${string}-plugin`; @@ -23,7 +23,7 @@ export interface CustomBlocks {} type BlockAttrs = Partial & { name: string }; -export type PluginSetting = Omit; +export type PluginSetting = StrictSetting; export type PluginBlockTemplate = Omit< BlockAttrs, From 0c0e3a6d9995aa214e091f77e04d6fd6346df5da Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Mon, 2 Jun 2025 09:12:56 +0100 Subject: [PATCH 05/52] fix: remove default console channel trigger --- api/src/chat/services/block.service.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/api/src/chat/services/block.service.ts b/api/src/chat/services/block.service.ts index d5a20d7a..4e9b376d 100644 --- a/api/src/chat/services/block.service.ts +++ b/api/src/chat/services/block.service.ts @@ -12,7 +12,6 @@ import { OnEvent } from '@nestjs/event-emitter'; import EventWrapper from '@/channel/lib/EventWrapper'; import { ChannelName } from '@/channel/types'; import { ContentService } from '@/cms/services/content.service'; -import { CONSOLE_CHANNEL_NAME } from '@/extensions/channels/console/settings'; import { NLU } from '@/helper/types'; import { I18nService } from '@/i18n/services/i18n.service'; import { LanguageService } from '@/i18n/services/language.service'; @@ -86,7 +85,7 @@ export class BlockService extends BaseService< return ( !b.trigger_channels || b.trigger_channels.length === 0 || - [...b.trigger_channels, CONSOLE_CHANNEL_NAME].includes(channel) + b.trigger_channels.includes(channel) ); }); } From 8c32d9af53826b701129c01e94b53c9d0a69896f Mon Sep 17 00:00:00 2001 From: yassinedorbozgithub Date: Mon, 2 Jun 2025 09:16:13 +0100 Subject: [PATCH 06/52] fix(api): apply feedback --- api/src/channel/types.ts | 13 +++++-------- api/src/helper/types.ts | 10 ++++------ api/src/plugins/types.ts | 4 ++-- api/src/setting/schemas/types.ts | 4 ++-- 4 files changed, 13 insertions(+), 18 deletions(-) diff --git a/api/src/channel/types.ts b/api/src/channel/types.ts index 7269a38c..18d8348d 100644 --- a/api/src/channel/types.ts +++ b/api/src/channel/types.ts @@ -1,19 +1,16 @@ /* - * 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. * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). */ -import { AnySetting, StrictSetting } from '@/setting/schemas/types'; +import { ExtensionSetting } from '@/setting/schemas/types'; import { HyphenToUnderscore } from '@/utils/types/extension'; export type ChannelName = `${string}-channel`; -export type ChannelSetting = StrictSetting< - AnySetting, - { - group: HyphenToUnderscore; - } ->; +export type ChannelSetting = ExtensionSetting<{ + group: HyphenToUnderscore; +}>; diff --git a/api/src/helper/types.ts b/api/src/helper/types.ts index a6c841e0..658c95c3 100644 --- a/api/src/helper/types.ts +++ b/api/src/helper/types.ts @@ -6,7 +6,7 @@ * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). */ -import { AnySetting, StrictSetting } from '@/setting/schemas/types'; +import { ExtensionSetting } from '@/setting/schemas/types'; import { HyphenToUnderscore } from '@/utils/types/extension'; import BaseHelper from './lib/base-helper'; @@ -116,9 +116,7 @@ export type HelperRegistry = Map< Map >; -export type HelperSetting = StrictSetting< - AnySetting, - { +export type HelperSetting = + ExtensionSetting<{ group: HyphenToUnderscore; - } ->; + }>; diff --git a/api/src/plugins/types.ts b/api/src/plugins/types.ts index 09a3beac..0f4be5ed 100644 --- a/api/src/plugins/types.ts +++ b/api/src/plugins/types.ts @@ -10,7 +10,7 @@ import { ChannelEvent } from '@/channel/lib/EventWrapper'; import { BlockCreateDto } from '@/chat/dto/block.dto'; import { Block } from '@/chat/schemas/block.schema'; import { Conversation } from '@/chat/schemas/conversation.schema'; -import { AnySetting, StrictSetting } from '@/setting/schemas/types'; +import { ExtensionSetting } from '@/setting/schemas/types'; export type PluginName = `${string}-plugin`; @@ -23,7 +23,7 @@ export interface CustomBlocks {} type BlockAttrs = Partial & { name: string }; -export type PluginSetting = StrictSetting; +export type PluginSetting = ExtensionSetting; export type PluginBlockTemplate = Omit< BlockAttrs, diff --git a/api/src/setting/schemas/types.ts b/api/src/setting/schemas/types.ts index 1e99713d..a0aee11c 100644 --- a/api/src/setting/schemas/types.ts +++ b/api/src/setting/schemas/types.ts @@ -131,9 +131,9 @@ export type AnySetting = export type SettingDict = { [group: string]: Setting[] }; -export type StrictSetting< - U, +export type ExtensionSetting< E extends object = object, + U extends AnySetting = AnySetting, K extends keyof BaseSchema = keyof BaseSchema, > = U extends any ? { From e0f3464392ed59b289495ae8132292b394c34fed Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Mon, 2 Jun 2025 09:31:14 +0100 Subject: [PATCH 07/52] fix: update jsdoc --- api/src/chat/services/block.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/chat/services/block.service.ts b/api/src/chat/services/block.service.ts index 4e9b376d..fe88a274 100644 --- a/api/src/chat/services/block.service.ts +++ b/api/src/chat/services/block.service.ts @@ -68,7 +68,7 @@ export class BlockService extends BaseService< * * This function ensures that only blocks that are either: * - Not restricted to specific trigger channels (`trigger_channels` is undefined or empty), or - * - Explicitly allow the given channel (or the console channel) + * - Explicitly allow the given channel * * are included in the returned array. * From 420f81d853ac8ee8bb4852e4e37dd1d8b5a89290 Mon Sep 17 00:00:00 2001 From: yassinedorbozgithub Date: Mon, 2 Jun 2025 10:40:06 +0100 Subject: [PATCH 08/52] fix(api): update contentField name DTO --- api/src/cms/dto/contentType.dto.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/api/src/cms/dto/contentType.dto.ts b/api/src/cms/dto/contentType.dto.ts index be03e245..70fddc4c 100644 --- a/api/src/cms/dto/contentType.dto.ts +++ b/api/src/cms/dto/contentType.dto.ts @@ -14,7 +14,6 @@ import { IsNotEmpty, IsOptional, IsString, - Matches, Validate, ValidateNested, } from 'class-validator'; @@ -27,7 +26,6 @@ import { ValidateRequiredFields } from '../validators/validate-required-fields.v export class ContentField { @IsString() @IsNotEmpty() - @Matches(/^[a-z][a-z_0-9]*$/) name: string; @IsString() From 8d67cf69cd8f5918049cf79fc934f4cde1debe7a Mon Sep 17 00:00:00 2001 From: yassinedorbozgithub Date: Mon, 2 Jun 2025 10:42:13 +0100 Subject: [PATCH 09/52] fix(frontend): support contentType default name value --- .../src/components/content-types/ContentTypeForm.tsx | 2 ++ .../components/content-types/components/FieldInput.tsx | 10 +++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/content-types/ContentTypeForm.tsx b/frontend/src/components/content-types/ContentTypeForm.tsx index 759408e8..da74b069 100644 --- a/frontend/src/components/content-types/ContentTypeForm.tsx +++ b/frontend/src/components/content-types/ContentTypeForm.tsx @@ -130,6 +130,8 @@ export const ContentTypeForm: FC> = ({ gap={2} > >; setValue: UseFormSetValue>; + defaultLabel?: string; + defaultName?: string; }) => { const { t } = useTranslate(); const label = useWatch({ @@ -41,7 +45,11 @@ export const FieldInput = ({ }); useEffect(() => { - setValue(`fields.${index}.name`, label ? slugify(label) : ""); + if (defaultLabel && defaultName !== slugify(defaultLabel)) { + defaultName && setValue(`fields.${index}.name`, defaultName); + } else { + setValue(`fields.${index}.name`, label ? slugify(label) : ""); + } }, [label, setValue, index]); return ( From 461a5b4195d9ac1b27fa6c30344f9cbb1c47ff78 Mon Sep 17 00:00:00 2001 From: medchedli Date: Mon, 2 Jun 2025 11:13:06 +0100 Subject: [PATCH 10/52] fix: rename CSS class for outgoing message content to use CustomContent --- frontend/src/components/inbox/inbox.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/inbox/inbox.css b/frontend/src/components/inbox/inbox.css index 416e3570..44614130 100644 --- a/frontend/src/components/inbox/inbox.css +++ b/frontend/src/components/inbox/inbox.css @@ -20,7 +20,7 @@ div .cs-message--outgoing .cs-message__content { background-color: var(--cs-message-outgoing-color) !important; } -div .cs-message--outgoing .cs-message__text-content { +div .cs-message--outgoing .cs-message__custom-content { color: var(--cs-message-outgoing-text-color) !important; } From 3881646e49b53bf4aa84fadd333a97d725a4075d Mon Sep 17 00:00:00 2001 From: medchedli Date: Mon, 2 Jun 2025 11:51:12 +0100 Subject: [PATCH 11/52] fix: rename CSS class for attachment viewer to use CustomContent --- frontend/src/components/inbox/components/AttachmentViewer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/inbox/components/AttachmentViewer.tsx b/frontend/src/components/inbox/components/AttachmentViewer.tsx index b28c3a77..e3f38760 100644 --- a/frontend/src/components/inbox/components/AttachmentViewer.tsx +++ b/frontend/src/components/inbox/components/AttachmentViewer.tsx @@ -87,7 +87,7 @@ const componentMap: { [key in FileType]: FC } = { {props.name} From 98d79488a97a4293d780b64936ec7699452f7459 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Mon, 2 Jun 2025 15:16:02 +0100 Subject: [PATCH 12/52] build: v2.2.9 --- api/package-lock.json | 4 ++-- api/package.json | 2 +- frontend/package.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- widget/package.json | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/api/package-lock.json b/api/package-lock.json index f0e04971..093fb4b0 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -1,12 +1,12 @@ { "name": "hexabot", - "version": "2.2.8", + "version": "2.2.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hexabot", - "version": "2.2.8", + "version": "2.2.9", "hasInstallScript": true, "license": "AGPL-3.0-only", "dependencies": { diff --git a/api/package.json b/api/package.json index 2372f76e..c506d3c1 100644 --- a/api/package.json +++ b/api/package.json @@ -1,6 +1,6 @@ { "name": "hexabot", - "version": "2.2.8", + "version": "2.2.9", "description": "Hexabot is a solution for creating and managing chatbots across multiple channels, leveraging AI for advanced conversational capabilities. It provides a user-friendly interface for building, training, and deploying chatbots with integrated support for various messaging platforms.", "author": "Hexastack", "license": "AGPL-3.0-only", diff --git a/frontend/package.json b/frontend/package.json index 3f72d1cd..45936a5b 100755 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "hexabot-ui", "private": true, - "version": "2.2.8", + "version": "2.2.9", "description": "Hexabot is a solution for creating and managing chatbots across multiple channels, leveraging AI for advanced conversational capabilities. It provides a user-friendly interface for building, training, and deploying chatbots with integrated support for various messaging platforms.", "author": "Hexastack", "license": "AGPL-3.0-only", diff --git a/package-lock.json b/package-lock.json index 74561d1f..1484c969 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,7 +45,7 @@ }, "frontend": { "name": "hexabot-ui", - "version": "2.2.8", + "version": "2.2.9", "license": "AGPL-3.0-only", "dependencies": { "@chatscope/chat-ui-kit-react": "^2.0.3", @@ -10308,7 +10308,7 @@ }, "widget": { "name": "hexabot-chat-widget", - "version": "2.2.8", + "version": "2.2.9", "license": "AGPL-3.0-only", "dependencies": { "autolinker": "^4.1.5", diff --git a/package.json b/package.json index 47badcec..63b291da 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "frontend", "widget" ], - "version": "2.2.8", + "version": "2.2.9", "description": "Hexabot is a solution for creating and managing chatbots across multiple channels, leveraging AI for advanced conversational capabilities. It provides a user-friendly interface for building, training, and deploying chatbots with integrated support for various messaging platforms.", "author": "Hexastack", "license": "AGPL-3.0-only", diff --git a/widget/package.json b/widget/package.json index 13284dac..58320d75 100644 --- a/widget/package.json +++ b/widget/package.json @@ -1,6 +1,6 @@ { "name": "hexabot-chat-widget", - "version": "2.2.8", + "version": "2.2.9", "description": "Hexabot is a solution for creating and managing chatbots across multiple channels, leveraging AI for advanced conversational capabilities. It provides a user-friendly interface for building, training, and deploying chatbots with integrated support for various messaging platforms.", "author": "Hexastack", "license": "AGPL-3.0-only", From 8f0def3ffbb0a61ed34393300bec20183ab63b45 Mon Sep 17 00:00:00 2001 From: medchedli Date: Tue, 3 Jun 2025 10:25:10 +0100 Subject: [PATCH 13/52] fix: update autoLink logic to always process both received and sent messages --- widget/src/components/messages/TextMessage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/widget/src/components/messages/TextMessage.tsx b/widget/src/components/messages/TextMessage.tsx index f1eb49ea..68d63fe3 100644 --- a/widget/src/components/messages/TextMessage.tsx +++ b/widget/src/components/messages/TextMessage.tsx @@ -28,7 +28,7 @@ const TextMessage: React.FC = ({ message }) => { }, [message]); const autoLink = () => { - if (message.direction === Direction.received && messageTextRef.current) { + if (messageTextRef.current) { const text = messageTextRef.current.innerText; messageTextRef.current.innerHTML = Autolinker.link(text, { From 4eb2f0f94196dc1c25fe94d4b79edb01a26ce030 Mon Sep 17 00:00:00 2001 From: medchedli Date: Tue, 3 Jun 2025 10:36:23 +0100 Subject: [PATCH 14/52] fix: remove eslint disable comment for autoLink effect dependency --- widget/src/components/messages/TextMessage.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/widget/src/components/messages/TextMessage.tsx b/widget/src/components/messages/TextMessage.tsx index 68d63fe3..5e36b018 100644 --- a/widget/src/components/messages/TextMessage.tsx +++ b/widget/src/components/messages/TextMessage.tsx @@ -24,7 +24,6 @@ const TextMessage: React.FC = ({ message }) => { useEffect(() => { autoLink(); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [message]); const autoLink = () => { From f86fc1a1795cc5dd07012589a1f8fd3c2a605ff4 Mon Sep 17 00:00:00 2001 From: medchedli Date: Tue, 3 Jun 2025 11:55:51 +0100 Subject: [PATCH 15/52] fix: prevent duplicate links in nextBlocks when updating block --- .../components/visual-editor/v2/Diagrams.tsx | 46 ++++++++++--------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/frontend/src/components/visual-editor/v2/Diagrams.tsx b/frontend/src/components/visual-editor/v2/Diagrams.tsx index d9060e41..b530efe0 100644 --- a/frontend/src/components/visual-editor/v2/Diagrams.tsx +++ b/frontend/src/components/visual-editor/v2/Diagrams.tsx @@ -291,30 +291,34 @@ const Diagrams = () => { entity.getSourcePort().getOptions()?.label === BlockPorts.nextBlocksOutPort ) { - const nextBlocks = [ - ...(previousData?.nextBlocks || []), - ...(targetId ? [targetId] : []), - ]; + // Only add the link if targetId exists, skip if targetId is null + if (!targetId) { + return; + } + // Only add the link if targetId doesn't already exist in nextBlocks + if (!previousData?.nextBlocks?.includes(targetId)) { + const nextBlocks = [...(previousData?.nextBlocks || []), targetId]; - updateBlock( - { - id: sourceId, - params: { - nextBlocks, + updateBlock( + { + id: sourceId, + params: { + nextBlocks, + }, }, - }, - { - onSuccess(data) { - if (data.id) - updateCachedBlock({ - id: targetId, - payload: { - previousBlocks: [data.id as any], - }, - }); + { + onSuccess(data) { + if (data.id) + updateCachedBlock({ + id: targetId, + payload: { + previousBlocks: [data.id as any], + }, + }); + }, }, - }, - ); + ); + } } else if ( // @ts-expect-error undefined attr entity.getSourcePort().getOptions().label === From 208ecbb5b8de25a9efeb54845b6d49fd586a52f0 Mon Sep 17 00:00:00 2001 From: medchedli Date: Tue, 3 Jun 2025 17:01:15 +0100 Subject: [PATCH 16/52] fix: update cache for new subscribers to use unfiltered query parameters --- .../src/components/inbox/hooks/useInfiniteLiveSubscribers.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/inbox/hooks/useInfiniteLiveSubscribers.ts b/frontend/src/components/inbox/hooks/useInfiniteLiveSubscribers.ts index e4f8f626..65f2a64e 100644 --- a/frontend/src/components/inbox/hooks/useInfiniteLiveSubscribers.ts +++ b/frontend/src/components/inbox/hooks/useInfiniteLiveSubscribers.ts @@ -73,8 +73,9 @@ export const useInfiniteLiveSubscribers = (props: { if (event.op === "newSubscriber") { const { result } = normalizeAndCache(event.profile); + // Only update the unfiltered (all-subscribers) cache queryClient.setQueryData( - [QueryType.infinite, EntityType.SUBSCRIBER, params], + [QueryType.infinite, EntityType.SUBSCRIBER, { where: {} }], (oldData) => { if (oldData) { const data = oldData as InfiniteData; From 286d21e069baa560b804fe19ca53f867682662bf Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Wed, 4 Jun 2025 17:32:30 +0100 Subject: [PATCH 17/52] refactor: rename populate --- .../chat/repositories/subscriber.repository.ts | 2 +- api/src/utils/generics/base-repository.ts | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/api/src/chat/repositories/subscriber.repository.ts b/api/src/chat/repositories/subscriber.repository.ts index f6eb4d57..23d375ef 100644 --- a/api/src/chat/repositories/subscriber.repository.ts +++ b/api/src/chat/repositories/subscriber.repository.ts @@ -139,7 +139,7 @@ export class SubscriberRepository extends BaseRepository< * @returns The found subscriber entity with populated fields. */ async findOneByForeignIdAndPopulate(id: string): Promise { - const query = this.findByForeignIdQuery(id).populate(this.populate); + const query = this.findByForeignIdQuery(id).populate(this.populatePaths); const [result] = await this.execute(query, SubscriberFull); return result; } diff --git a/api/src/utils/generics/base-repository.ts b/api/src/utils/generics/base-repository.ts index ccfabb09..9244d13c 100644 --- a/api/src/utils/generics/base-repository.ts +++ b/api/src/utils/generics/base-repository.ts @@ -94,14 +94,14 @@ export abstract class BaseRepository< constructor( readonly model: Model, private readonly cls: new () => T, - protected readonly populate: P[] = [], + protected readonly populatePaths: P[] = [], protected readonly clsPopulate?: new () => TFull, ) { this.registerLifeCycleHooks(); } canPopulate(populate: string[]): boolean { - return populate.some((p) => this.populate.includes(p as P)); + return populate.some((p) => this.populatePaths.includes(p as P)); } getEventName(suffix: EHook) { @@ -303,7 +303,7 @@ export abstract class BaseRepository< ): Promise { this.ensureCanPopulate(); const query = this.findOneQuery(criteria, projection).populate( - this.populate, + this.populatePaths, ); return await this.executeOne(query, this.clsPopulate!); } @@ -375,7 +375,7 @@ export abstract class BaseRepository< } private ensureCanPopulate(): void { - if (!this.populate || !this.clsPopulate) { + if (!this.populatePaths || !this.clsPopulate) { throw new Error('Cannot populate query'); } } @@ -403,13 +403,13 @@ export abstract class BaseRepository< this.ensureCanPopulate(); if (Array.isArray(pageQuery)) { const query = this.findQuery(filters, pageQuery, projection).populate( - this.populate, + this.populatePaths, ); return await this.execute(query, this.clsPopulate!); } const query = this.findQuery(filters, pageQuery, projection).populate( - this.populate, + this.populatePaths, ); return await this.execute(query, this.clsPopulate!); } @@ -426,7 +426,7 @@ export abstract class BaseRepository< async findAllAndPopulate(sort?: QuerySortDto): Promise { this.ensureCanPopulate(); - const query = this.findAllQuery(sort).populate(this.populate); + const query = this.findAllQuery(sort).populate(this.populatePaths); return await this.execute(query, this.clsPopulate!); } @@ -463,7 +463,7 @@ export abstract class BaseRepository< ): Promise { this.ensureCanPopulate(); const query = this.findPageQuery(filters, pageQuery).populate( - this.populate, + this.populatePaths, ); return await this.execute(query, this.clsPopulate!); } From e8bf38440a81c2b836e41ea461e0d20c31592120 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Wed, 4 Jun 2025 17:20:22 +0100 Subject: [PATCH 18/52] feat: add zod validation pipe --- api/src/utils/pipes/zod.pipe.ts | 59 +++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 api/src/utils/pipes/zod.pipe.ts diff --git a/api/src/utils/pipes/zod.pipe.ts b/api/src/utils/pipes/zod.pipe.ts new file mode 100644 index 00000000..d2c66727 --- /dev/null +++ b/api/src/utils/pipes/zod.pipe.ts @@ -0,0 +1,59 @@ +/* + * 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. + * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). + */ + +import { + ArgumentMetadata, + BadRequestException, + Injectable, + PipeTransform, +} from '@nestjs/common'; +import { ZodError, ZodTypeAny } from 'zod'; + +/** + * Validates a single query-parameter with a given Zod schema. + * + * @example + * // Controller usage + * @Get() + * listUsers( + * @Query(new ZodQueryParamPipe(z.coerce.number().int().min(1))) query: any, + * ) { + * // query.page is guaranteed to be a positive integer number + * } + */ +@Injectable() +export class ZodQueryParamPipe implements PipeTransform { + constructor( + private readonly schema: ZodTypeAny, + private readonly accessor?: (query: any) => any, + ) {} + + async transform(query: any, metadata: ArgumentMetadata) { + const payload = this.accessor ? this.accessor(query) : query; + // We care only about query params + if (typeof payload === 'undefined' || metadata.type !== 'query') { + return payload; + } + + const parsed = this.schema.safeParse(payload); + + if (!parsed.success) { + // Optionally format the error for client readability + const error = parsed.error as ZodError; + throw new BadRequestException({ + statusCode: 400, + error: 'Bad Request', + message: `Validation failed for query param`, + details: error.flatten(), + }); + } + + // Return a new query object with the parsed value injected + return parsed.data; + } +} From ad684676a7161c3914a698d3a03df4a758593f6c Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Wed, 4 Jun 2025 17:25:47 +0100 Subject: [PATCH 19/52] feat: add support to filter samples by entities/values --- .../nlp/controllers/nlp-sample.controller.ts | 36 +++- .../nlp/repositories/nlp-sample.repository.ts | 170 +++++++++++++++++- api/src/nlp/services/nlp-entity.service.ts | 15 ++ api/src/nlp/services/nlp-sample.service.ts | 66 ++++++- api/src/nlp/services/nlp-value.service.ts | 15 ++ api/src/utils/generics/base-repository.ts | 45 ++++- .../inputs/NlpPatternSelect.tsx | 23 ++- .../components/nlp/components/NlpSample.tsx | 24 ++- .../form/inputs/triggers/PatternInput.tsx | 1 + 9 files changed, 383 insertions(+), 12 deletions(-) diff --git a/api/src/nlp/controllers/nlp-sample.controller.ts b/api/src/nlp/controllers/nlp-sample.controller.ts index 949e8e86..a94de961 100644 --- a/api/src/nlp/controllers/nlp-sample.controller.ts +++ b/api/src/nlp/controllers/nlp-sample.controller.ts @@ -29,17 +29,21 @@ import { import { FileInterceptor } from '@nestjs/platform-express'; import { CsrfCheck } from '@tekuconcept/nestjs-csrf'; import { Response } from 'express'; +import { z } from 'zod'; +import { NlpPattern, nlpPatternSchema } from '@/chat/schemas/types/pattern'; import { HelperService } from '@/helper/helper.service'; import { HelperType } from '@/helper/types'; import { LanguageService } from '@/i18n/services/language.service'; import { CsrfInterceptor } from '@/interceptors/csrf.interceptor'; +import { Roles } from '@/utils/decorators/roles.decorator'; import { BaseController } from '@/utils/generics/base-controller'; import { DeleteResult } from '@/utils/generics/base-repository'; import { PageQueryDto } from '@/utils/pagination/pagination-query.dto'; import { PageQueryPipe } from '@/utils/pagination/pagination-query.pipe'; import { PopulatePipe } from '@/utils/pipes/populate.pipe'; import { SearchFilterPipe } from '@/utils/pipes/search-filter.pipe'; +import { ZodQueryParamPipe } from '@/utils/pipes/zod.pipe'; import { TFilterQuery } from '@/utils/types/filter.types'; import { NlpSampleDto, TNlpSampleDto } from '../dto/nlp-sample.dto'; @@ -177,6 +181,7 @@ export class NlpSampleController extends BaseController< * * @returns The count of samples that match the filters. */ + @Roles('public') @Get('count') async filterCount( @Query( @@ -184,8 +189,18 @@ export class NlpSampleController extends BaseController< allowedFields: ['text', 'type', 'language'], }), ) - filters?: TFilterQuery, + filters: TFilterQuery = {}, + @Query( + new ZodQueryParamPipe( + z.array(nlpPatternSchema), + (q) => q?.where?.patterns, + ), + ) + patterns: NlpPattern[] = [], ) { + if (patterns.length) { + return await this.nlpSampleService.countByPatterns({ filters, patterns }); + } return await this.count(filters); } @@ -276,6 +291,7 @@ export class NlpSampleController extends BaseController< * @returns A paginated list of NLP samples. */ @Get() + @Roles('public') async findPage( @Query(PageQueryPipe) pageQuery: PageQueryDto, @Query(PopulatePipe) populate: string[], @@ -285,7 +301,25 @@ export class NlpSampleController extends BaseController< }), ) filters: TFilterQuery, + @Query( + new ZodQueryParamPipe( + z.array(nlpPatternSchema), + (q) => q?.where?.patterns, + ), + ) + patterns: NlpPattern[] = [], ) { + if (patterns.length) { + return this.canPopulate(populate) + ? await this.nlpSampleService.findByPatternsAndPopulate( + { filters, patterns }, + pageQuery, + ) + : await this.nlpSampleService.findByPatterns( + { filters, patterns }, + pageQuery, + ); + } return this.canPopulate(populate) ? await this.nlpSampleService.findAndPopulate(filters, pageQuery) : await this.nlpSampleService.find(filters, pageQuery); diff --git a/api/src/nlp/repositories/nlp-sample.repository.ts b/api/src/nlp/repositories/nlp-sample.repository.ts index 9da6eab3..25e51fb9 100644 --- a/api/src/nlp/repositories/nlp-sample.repository.ts +++ b/api/src/nlp/repositories/nlp-sample.repository.ts @@ -8,15 +8,27 @@ import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; -import { Document, Model, Query } from 'mongoose'; +import { plainToClass } from 'class-transformer'; +import { + Aggregate, + Document, + Model, + PipelineStage, + ProjectionType, + Query, + Types, +} from 'mongoose'; import { BaseRepository, DeleteResult } from '@/utils/generics/base-repository'; +import { PageQueryDto } from '@/utils/pagination/pagination-query.dto'; import { TFilterQuery } from '@/utils/types/filter.types'; import { TNlpSampleDto } from '../dto/nlp-sample.dto'; +import { NlpSampleEntity } from '../schemas/nlp-sample-entity.schema'; import { NLP_SAMPLE_POPULATE, NlpSample, + NlpSampleDocument, NlpSampleFull, NlpSamplePopulate, } from '../schemas/nlp-sample.schema'; @@ -32,11 +44,167 @@ export class NlpSampleRepository extends BaseRepository< > { constructor( @InjectModel(NlpSample.name) readonly model: Model, + @InjectModel(NlpSampleEntity.name) + readonly sampleEntityModel: Model, private readonly nlpSampleEntityRepository: NlpSampleEntityRepository, ) { super(model, NlpSample, NLP_SAMPLE_POPULATE, NlpSampleFull); } + buildFindByEntitiesStages({ + filters, + entityIds, + valueIds, + }: { + filters: TFilterQuery; + entityIds: Types.ObjectId[]; + valueIds: Types.ObjectId[]; + }): PipelineStage[] { + return [ + // pick link docs whose entity / value matches a pattern + { + $match: { + ...(entityIds.length && { entity: { $in: entityIds } }), + ...(valueIds.length && { value: { $in: valueIds } }), + }, + }, + + // join to the real sample *and* apply sample-side filters early + { + $lookup: { + from: 'nlpsamples', + let: { sampleId: '$sample' }, + pipeline: [ + { + $match: { + $expr: { $eq: ['$_id', '$$sampleId'] }, + ...(filters?.$and + ? { + $and: filters.$and?.map((condition) => { + if ('language' in condition && condition.language) { + return { + language: new Types.ObjectId(condition.language), + }; + } + return condition; + }), + } + : {}), + }, + }, + ], + as: 'sample', + }, + }, + { $unwind: '$sample' }, + ]; + } + + findByEntitiesAggregation( + criterias: { + filters: TFilterQuery; + entityIds: Types.ObjectId[]; + valueIds: Types.ObjectId[]; + }, + page?: PageQueryDto, + projection?: ProjectionType, + ): Aggregate { + return this.sampleEntityModel.aggregate([ + ...this.buildFindByEntitiesStages(criterias), + + // promote the sample document + { $replaceRoot: { newRoot: '$sample' } }, + + // sort / skip / limit + ...this.buildPaginationPipelineStages(page), + + // projection + ...(projection + ? [ + { + $project: + typeof projection === 'string' + ? { [projection]: 1 } + : projection, + }, + ] + : []), + ]); + } + + async findByEntities( + criterias: { + filters: TFilterQuery; + entityIds: Types.ObjectId[]; + valueIds: Types.ObjectId[]; + }, + page?: PageQueryDto, + projection?: ProjectionType, + ): Promise { + const aggregation = this.findByEntitiesAggregation( + criterias, + page, + projection, + ); + + const resultSet = await aggregation.exec(); + return resultSet.map((doc) => + plainToClass(NlpSample, doc, this.transformOpts), + ); + } + + async findByEntitiesAndPopulate( + criterias: { + filters: TFilterQuery; + entityIds: Types.ObjectId[]; + valueIds: Types.ObjectId[]; + }, + page?: PageQueryDto, + projection?: ProjectionType, + ): Promise { + const aggregation = this.findByEntitiesAggregation( + criterias, + page, + projection, + ); + + const docs = await aggregation.exec(); + + const populatedResultSet = await this.populate(docs); + + return populatedResultSet.map((doc) => + plainToClass(NlpSampleFull, doc, this.transformOpts), + ); + } + + countByEntitiesAggregation(criterias: { + filters: TFilterQuery; + entityIds: Types.ObjectId[]; + valueIds: Types.ObjectId[]; + }): Aggregate<{ count: number }[]> { + return this.sampleEntityModel.aggregate<{ count: number }>([ + ...this.buildFindByEntitiesStages(criterias), + + // Collapse duplicates: one bucket per unique sample + { $group: { _id: '$sample._id' } }, + + // Final count + { $count: 'count' }, + ]); + } + + async countByEntities(criterias: { + filters: TFilterQuery; + entityIds: Types.ObjectId[]; + valueIds: Types.ObjectId[]; + }): Promise<{ count: number }> { + const aggregation = this.countByEntitiesAggregation(criterias); + + const [result] = await aggregation.exec(); + + return { count: result?.count || 0 }; + } + /** * Deletes NLP sample entities associated with the provided criteria before deleting the sample itself. * diff --git a/api/src/nlp/services/nlp-entity.service.ts b/api/src/nlp/services/nlp-entity.service.ts index 0876f3c1..897e841f 100644 --- a/api/src/nlp/services/nlp-entity.service.ts +++ b/api/src/nlp/services/nlp-entity.service.ts @@ -10,7 +10,9 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; import { Cache } from 'cache-manager'; +import { Types } from 'mongoose'; +import { NlpPattern } from '@/chat/schemas/types/pattern'; import { NLP_MAP_CACHE_KEY } from '@/utils/constants/cache'; import { Cacheable } from '@/utils/decorators/cacheable.decorator'; import { BaseService } from '@/utils/generics/base-service'; @@ -72,6 +74,19 @@ export class NlpEntityService extends BaseService< return await this.repository.updateOne(id, { weight: updatedWeight }); } + async findObjectIdsByPatterns(patterns: NlpPattern[]) { + // resolve pattern → ids (kept here because it uses other services) + return ( + await this.find({ + name: { + $in: patterns + .filter((p) => p.match === 'entity') + .map((p) => p.entity), + }, + }) + ).map((e) => new Types.ObjectId(e.id)); + } + /** * Stores new entities based on the sample text and sample entities. * Deletes all values relative to this entity before deleting the entity itself. diff --git a/api/src/nlp/services/nlp-sample.service.ts b/api/src/nlp/services/nlp-sample.service.ts index 665f8cc7..700e508b 100644 --- a/api/src/nlp/services/nlp-sample.service.ts +++ b/api/src/nlp/services/nlp-sample.service.ts @@ -12,14 +12,16 @@ import { NotFoundException, } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; -import { Document, Query } from 'mongoose'; +import { Document, ProjectionType, Query } from 'mongoose'; import Papa from 'papaparse'; import { Message } from '@/chat/schemas/message.schema'; +import { NlpPattern } from '@/chat/schemas/types/pattern'; import { Language } from '@/i18n/schemas/language.schema'; import { LanguageService } from '@/i18n/services/language.service'; import { DeleteResult } from '@/utils/generics/base-repository'; import { BaseService } from '@/utils/generics/base-service'; +import { PageQueryDto } from '@/utils/pagination/pagination-query.dto'; import { TFilterQuery, THydratedDocument } from '@/utils/types/filter.types'; import { NlpSampleEntityCreateDto } from '../dto/nlp-sample-entity.dto'; @@ -35,6 +37,7 @@ import { NlpSampleEntityValue, NlpSampleState } from '../schemas/types'; import { NlpEntityService } from './nlp-entity.service'; import { NlpSampleEntityService } from './nlp-sample-entity.service'; +import { NlpValueService } from './nlp-value.service'; @Injectable() export class NlpSampleService extends BaseService< @@ -47,6 +50,7 @@ export class NlpSampleService extends BaseService< readonly repository: NlpSampleRepository, private readonly nlpSampleEntityService: NlpSampleEntityService, private readonly nlpEntityService: NlpEntityService, + private readonly nlpValueService: NlpValueService, private readonly languageService: LanguageService, ) { super(repository); @@ -279,6 +283,66 @@ export class NlpSampleService extends BaseService< } } + async findByPatterns( + { + filters, + patterns, + }: { + filters: TFilterQuery; + patterns: NlpPattern[]; + }, + page?: PageQueryDto, + projection?: ProjectionType, + ): Promise { + return await this.repository.findByEntities( + { + filters, + entityIds: + await this.nlpEntityService.findObjectIdsByPatterns(patterns), + valueIds: await this.nlpValueService.findObjectIdsByPatterns(patterns), + }, + page, + projection, + ); + } + + async findByPatternsAndPopulate( + { + filters, + patterns, + }: { + filters: TFilterQuery; + patterns: NlpPattern[]; + }, + page?: PageQueryDto, + projection?: ProjectionType, + ): Promise { + return await this.repository.findByEntitiesAndPopulate( + { + filters, + entityIds: + await this.nlpEntityService.findObjectIdsByPatterns(patterns), + valueIds: await this.nlpValueService.findObjectIdsByPatterns(patterns), + }, + page, + projection, + ); + } + + async countByPatterns({ + filters, + patterns, + }: { + filters: TFilterQuery; + patterns: NlpPattern[]; + }): Promise<{ count: number }> { + return await this.repository.countByEntities({ + filters, + entityIds: await this.nlpEntityService.findObjectIdsByPatterns(patterns), + valueIds: await this.nlpValueService.findObjectIdsByPatterns(patterns), + }); + } + @OnEvent('hook:message:preCreate') async handleNewMessage(doc: THydratedDocument) { // If message is sent by the user then add it as an inbox sample diff --git a/api/src/nlp/services/nlp-value.service.ts b/api/src/nlp/services/nlp-value.service.ts index 87988140..021ff3d1 100644 --- a/api/src/nlp/services/nlp-value.service.ts +++ b/api/src/nlp/services/nlp-value.service.ts @@ -7,7 +7,9 @@ */ import { forwardRef, Inject, Injectable } from '@nestjs/common'; +import { Types } from 'mongoose'; +import { NlpPattern } from '@/chat/schemas/types/pattern'; import { DeleteResult } from '@/utils/generics/base-repository'; import { BaseService } from '@/utils/generics/base-service'; import { PageQueryDto } from '@/utils/pagination/pagination-query.dto'; @@ -42,6 +44,19 @@ export class NlpValueService extends BaseService< super(repository); } + async findObjectIdsByPatterns(patterns: NlpPattern[]) { + // resolve pattern → ids (kept here because it uses other services) + return ( + await this.find({ + value: { + $in: patterns + .map((p) => (p.match === 'value' ? p.value : null)) + .filter(Boolean), + }, + }) + ).map((v) => new Types.ObjectId(v.id)); + } + /** * Deletes an NLP value by its ID, cascading any dependent data. * diff --git a/api/src/utils/generics/base-repository.ts b/api/src/utils/generics/base-repository.ts index 9244d13c..d73f58bb 100644 --- a/api/src/utils/generics/base-repository.ts +++ b/api/src/utils/generics/base-repository.ts @@ -19,6 +19,7 @@ import { FlattenMaps, HydratedDocument, Model, + PipelineStage, ProjectionType, Query, SortOrder, @@ -31,6 +32,7 @@ import { LoggerService } from '@/logger/logger.service'; import { TFilterQuery, TFlattenOption, + THydratedDocument, TQueryOptions, } from '@/utils/types/filter.types'; @@ -81,9 +83,13 @@ export abstract class BaseRepository< U extends Omit = Omit, D = Document, > { - private readonly transformOpts = { excludePrefixes: ['_', 'password'] }; + protected readonly transformOpts = { excludePrefixes: ['_', 'password'] }; - private readonly leanOpts = { virtuals: true, defaults: true, getters: true }; + protected readonly leanOpts = { + virtuals: true, + defaults: true, + getters: true, + }; @Inject(EventEmitter2) readonly eventEmitter: EventEmitter2; @@ -643,4 +649,39 @@ export abstract class BaseRepository< ): Promise { // Nothing ... } + + buildPaginationPipelineStages(page?: PageQueryDto): PipelineStage[] { + if (!page) return []; + + const stages: PipelineStage[] = []; + + if (page.sort) { + const [field, dir] = page.sort; + stages.push({ + $sort: { + [field]: + typeof dir === 'number' + ? dir + : ['asc', 'ascending'].includes(dir as string) + ? 1 + : -1, + } as Record, + }); + } + + if (page.skip) stages.push({ $skip: page.skip }); + if (page.limit) stages.push({ $limit: page.limit }); + + return stages; + } + + async populate(docs: THydratedDocument[]) { + return await this.model.populate( + docs, + this.populatePaths.map((path) => ({ + path, + options: { lean: true }, + })), + ); + } } diff --git a/frontend/src/app-components/inputs/NlpPatternSelect.tsx b/frontend/src/app-components/inputs/NlpPatternSelect.tsx index 142a5d0f..39e4c4b9 100644 --- a/frontend/src/app-components/inputs/NlpPatternSelect.tsx +++ b/frontend/src/app-components/inputs/NlpPatternSelect.tsx @@ -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. @@ -17,7 +17,7 @@ import { Typography, useTheme, } from "@mui/material"; -import Autocomplete from "@mui/material/Autocomplete"; +import Autocomplete, { AutocompleteProps } from "@mui/material/Autocomplete"; import { forwardRef, SyntheticEvent, useRef } from "react"; import { Input } from "@/app-components/inputs/Input"; @@ -30,13 +30,24 @@ import { NlpPattern } from "@/types/block.types"; import { INlpEntity } from "@/types/nlp-entity.types"; import { INlpValue } from "@/types/nlp-value.types"; -type NlpPatternSelectProps = { +interface NlpPatternSelectProps + extends Omit< + AutocompleteProps, + | "onChange" + | "value" + | "options" + | "multiple" + | "disabled" + | "renderTags" + | "renderOptions" + | "renderInput" + > { patterns: NlpPattern[]; onChange: (patterns: NlpPattern[]) => void; -}; +} const NlpPatternSelect = ( - { patterns, onChange }: NlpPatternSelectProps, + { patterns, onChange, ...props }: NlpPatternSelectProps, ref, ) => { const inputRef = useRef(null); @@ -116,8 +127,8 @@ const NlpPatternSelect = ( return ( ("all"); const [language, setLanguage] = useState(undefined); + const [patterns, setPatterns] = useState([]); const hasPermission = useHasPermission(); const getNlpEntityFromCache = useGetFromCache(EntityType.NLP_ENTITY); const getNlpValueFromCache = useGetFromCache(EntityType.NLP_VALUE); @@ -86,11 +89,14 @@ export default function NlpSample() { EntityType.NLP_SAMPLE_ENTITY, ); const getLanguageFromCache = useGetFromCache(EntityType.LANGUAGE); - const { onSearch, searchPayload, searchText } = useSearch( + const { onSearch, searchPayload, searchText } = useSearch< + INlpSample & { patterns: NlpPattern[] } + >( { $eq: [ ...(type !== "all" ? [{ type }] : []), ...(language ? [{ language }] : []), + ...(patterns ? [{ patterns }] : []), ], $iLike: ["text"], }, @@ -425,6 +431,22 @@ export default function NlpSample() { + + { + setPatterns(patterns); + }} + fullWidth={true} + /> + diff --git a/frontend/src/components/visual-editor/form/inputs/triggers/PatternInput.tsx b/frontend/src/components/visual-editor/form/inputs/triggers/PatternInput.tsx index 8d0b6b3f..2caa02d7 100644 --- a/frontend/src/components/visual-editor/form/inputs/triggers/PatternInput.tsx +++ b/frontend/src/components/visual-editor/form/inputs/triggers/PatternInput.tsx @@ -103,6 +103,7 @@ const PatternInput: FC = ({ )} {["payload", "content", "menu"].includes(patternType) ? ( From 5c2ecaf8fc6c6b94a647b6f6caf6a0d12223df73 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Wed, 4 Jun 2025 18:17:17 +0100 Subject: [PATCH 20/52] fix: remove public role --- api/src/nlp/controllers/nlp-sample.controller.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/api/src/nlp/controllers/nlp-sample.controller.ts b/api/src/nlp/controllers/nlp-sample.controller.ts index a94de961..7caf121d 100644 --- a/api/src/nlp/controllers/nlp-sample.controller.ts +++ b/api/src/nlp/controllers/nlp-sample.controller.ts @@ -36,7 +36,6 @@ import { HelperService } from '@/helper/helper.service'; import { HelperType } from '@/helper/types'; import { LanguageService } from '@/i18n/services/language.service'; import { CsrfInterceptor } from '@/interceptors/csrf.interceptor'; -import { Roles } from '@/utils/decorators/roles.decorator'; import { BaseController } from '@/utils/generics/base-controller'; import { DeleteResult } from '@/utils/generics/base-repository'; import { PageQueryDto } from '@/utils/pagination/pagination-query.dto'; @@ -181,7 +180,6 @@ export class NlpSampleController extends BaseController< * * @returns The count of samples that match the filters. */ - @Roles('public') @Get('count') async filterCount( @Query( @@ -291,7 +289,6 @@ export class NlpSampleController extends BaseController< * @returns A paginated list of NLP samples. */ @Get() - @Roles('public') async findPage( @Query(PageQueryPipe) pageQuery: PageQueryDto, @Query(PopulatePipe) populate: string[], From de6d30e854701f3832fc77f1fd487fd57749e5d4 Mon Sep 17 00:00:00 2001 From: medchedli Date: Wed, 4 Jun 2025 18:43:09 +0100 Subject: [PATCH 21/52] fix: apply feedback --- .../components/visual-editor/v2/Diagrams.tsx | 61 +++++++++++-------- 1 file changed, 34 insertions(+), 27 deletions(-) diff --git a/frontend/src/components/visual-editor/v2/Diagrams.tsx b/frontend/src/components/visual-editor/v2/Diagrams.tsx index b530efe0..f6708e09 100644 --- a/frontend/src/components/visual-editor/v2/Diagrams.tsx +++ b/frontend/src/components/visual-editor/v2/Diagrams.tsx @@ -264,6 +264,19 @@ const Diagrams = () => { return; } + const sourceId = entity.getSourcePort().getParent().getOptions() + .id as string; + const targetId = entity.getTargetPort().getParent().getOptions() + .id as string; + const previousData = getBlockFromCache(sourceId!); + + // Only add the link if targetId doesn't already exist in nextBlocks + if (previousData?.nextBlocks?.includes(targetId)) { + model.removeLink(link); + + return; + } + link.setLocked(true); link.registerListener({ selectionChanged(event: any) { @@ -280,12 +293,6 @@ const Diagrams = () => { } }); - const sourceId = entity.getSourcePort().getParent().getOptions() - .id as string; - const targetId = entity.getTargetPort().getParent().getOptions() - .id as string; - const previousData = getBlockFromCache(sourceId!); - if ( // @ts-expect-error undefined attr entity.getSourcePort().getOptions()?.label === @@ -295,30 +302,30 @@ const Diagrams = () => { if (!targetId) { return; } - // Only add the link if targetId doesn't already exist in nextBlocks - if (!previousData?.nextBlocks?.includes(targetId)) { - const nextBlocks = [...(previousData?.nextBlocks || []), targetId]; + const nextBlocks = [ + ...(previousData?.nextBlocks || []), + ...(targetId ? [targetId] : []), + ]; - updateBlock( - { - id: sourceId, - params: { - nextBlocks, - }, + updateBlock( + { + id: sourceId, + params: { + nextBlocks, }, - { - onSuccess(data) { - if (data.id) - updateCachedBlock({ - id: targetId, - payload: { - previousBlocks: [data.id as any], - }, - }); - }, + }, + { + onSuccess(data) { + if (data.id) + updateCachedBlock({ + id: targetId, + payload: { + previousBlocks: [data.id as any], + }, + }); }, - ); - } + }, + ); } else if ( // @ts-expect-error undefined attr entity.getSourcePort().getOptions().label === From e89d948f37b78309986347237da6f02a3913023e Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Thu, 5 Jun 2025 14:56:36 +0100 Subject: [PATCH 22/52] feat: enhance search ux --- api/src/chat/schemas/types/pattern.ts | 28 +- .../controllers/nlp-sample.controller.spec.ts | 27 ++ .../nlp/controllers/nlp-sample.controller.ts | 45 +-- .../nlp/repositories/nlp-sample.repository.ts | 164 ++++++--- api/src/nlp/services/nlp-entity.service.ts | 15 - api/src/nlp/services/nlp-sample.service.ts | 173 +++++---- api/src/nlp/services/nlp-value.service.ts | 26 +- api/src/utils/generics/base-repository.ts | 338 ++++++++++++++++++ .../utils/test/fixtures/nlpsampleentity.ts | 4 +- frontend/public/locales/en/translation.json | 2 +- frontend/public/locales/fr/translation.json | 2 +- .../inputs/NlpPatternSelect.tsx | 38 +- .../src/app-components/tables/DataGrid.tsx | 2 +- .../components/nlp/components/NlpSample.tsx | 11 +- frontend/src/hooks/crud/useFind.tsx | 23 +- frontend/src/types/block.types.ts | 11 +- 16 files changed, 709 insertions(+), 200 deletions(-) diff --git a/api/src/chat/schemas/types/pattern.ts b/api/src/chat/schemas/types/pattern.ts index 48df5efe..520f2e6a 100644 --- a/api/src/chat/schemas/types/pattern.ts +++ b/api/src/chat/schemas/types/pattern.ts @@ -18,19 +18,27 @@ export const payloadPatternSchema = z.object({ export type PayloadPattern = z.infer; +export const nlpEntityMatchPatternSchema = z.object({ + entity: z.string(), + match: z.literal('entity'), +}); + +export type NlpEntityMatchPattern = z.infer; + +export const nlpValueMatchPatternSchema = z.object({ + entity: z.string(), + match: z.literal('value'), + value: z.string(), +}); + +export type NlpValueMatchPattern = z.infer; + export const nlpPatternSchema = z.discriminatedUnion('match', [ - z.object({ - entity: z.string(), - match: z.literal('entity'), - }), - z.object({ - entity: z.string(), - match: z.literal('value'), - value: z.string(), - }), + nlpEntityMatchPatternSchema, + nlpValueMatchPatternSchema, ]); -export type NlpPattern = z.infer; +export type NlpPattern = NlpEntityMatchPattern | NlpValueMatchPattern; export const stringRegexPatternSchema = z.string().refine( (value) => { diff --git a/api/src/nlp/controllers/nlp-sample.controller.spec.ts b/api/src/nlp/controllers/nlp-sample.controller.spec.ts index 031c5dac..21436ba5 100644 --- a/api/src/nlp/controllers/nlp-sample.controller.spec.ts +++ b/api/src/nlp/controllers/nlp-sample.controller.spec.ts @@ -10,6 +10,7 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { BadRequestException, NotFoundException } from '@nestjs/common'; import { MongooseModule } from '@nestjs/mongoose'; +import { NlpValueMatchPattern } from '@/chat/schemas/types/pattern'; import { HelperService } from '@/helper/helper.service'; import { LanguageRepository } from '@/i18n/repositories/language.repository'; import { Language, LanguageModel } from '@/i18n/schemas/language.schema'; @@ -439,4 +440,30 @@ describe('NlpSampleController', () => { ).rejects.toThrow(NotFoundException); }); }); + + describe('filterCount', () => { + it('should count the nlp samples without patterns', async () => { + const filters = { text: 'Hello' }; + const result = await nlpSampleController.filterCount(filters, []); + expect(result).toEqual({ count: 1 }); + }); + + it('should count the nlp samples with patterns', async () => { + const filters = { text: 'Hello' }; + const patterns: NlpValueMatchPattern[] = [ + { entity: 'intent', match: 'value', value: 'greeting' }, + ]; + const result = await nlpSampleController.filterCount(filters, patterns); + expect(result).toEqual({ count: 1 }); + }); + + it('should return zero count when no samples match the filters and patterns', async () => { + const filters = { text: 'Nonexistent' }; + const patterns: NlpValueMatchPattern[] = [ + { entity: 'intent', match: 'value', value: 'nonexistent' }, + ]; + const result = await nlpSampleController.filterCount(filters, patterns); + expect(result).toEqual({ count: 0 }); + }); + }); }); diff --git a/api/src/nlp/controllers/nlp-sample.controller.ts b/api/src/nlp/controllers/nlp-sample.controller.ts index 7caf121d..b5213932 100644 --- a/api/src/nlp/controllers/nlp-sample.controller.ts +++ b/api/src/nlp/controllers/nlp-sample.controller.ts @@ -31,7 +31,10 @@ import { CsrfCheck } from '@tekuconcept/nestjs-csrf'; import { Response } from 'express'; import { z } from 'zod'; -import { NlpPattern, nlpPatternSchema } from '@/chat/schemas/types/pattern'; +import { + NlpValueMatchPattern, + nlpValueMatchPatternSchema, +} from '@/chat/schemas/types/pattern'; import { HelperService } from '@/helper/helper.service'; import { HelperType } from '@/helper/types'; import { LanguageService } from '@/i18n/services/language.service'; @@ -190,16 +193,19 @@ export class NlpSampleController extends BaseController< filters: TFilterQuery = {}, @Query( new ZodQueryParamPipe( - z.array(nlpPatternSchema), + z.array(nlpValueMatchPatternSchema), (q) => q?.where?.patterns, ), ) - patterns: NlpPattern[] = [], + patterns: NlpValueMatchPattern[] = [], ) { - if (patterns.length) { - return await this.nlpSampleService.countByPatterns({ filters, patterns }); - } - return await this.count(filters); + const count = await this.nlpSampleService.countByPatterns({ + filters, + patterns, + }); + return { + count, + }; } /** @@ -300,26 +306,21 @@ export class NlpSampleController extends BaseController< filters: TFilterQuery, @Query( new ZodQueryParamPipe( - z.array(nlpPatternSchema), + z.array(nlpValueMatchPatternSchema), (q) => q?.where?.patterns, ), ) - patterns: NlpPattern[] = [], + patterns: NlpValueMatchPattern[] = [], ) { - if (patterns.length) { - return this.canPopulate(populate) - ? await this.nlpSampleService.findByPatternsAndPopulate( - { filters, patterns }, - pageQuery, - ) - : await this.nlpSampleService.findByPatterns( - { filters, patterns }, - pageQuery, - ); - } return this.canPopulate(populate) - ? await this.nlpSampleService.findAndPopulate(filters, pageQuery) - : await this.nlpSampleService.find(filters, pageQuery); + ? await this.nlpSampleService.findByPatternsAndPopulate( + { filters, patterns }, + pageQuery, + ) + : await this.nlpSampleService.findByPatterns( + { filters, patterns }, + pageQuery, + ); } /** diff --git a/api/src/nlp/repositories/nlp-sample.repository.ts b/api/src/nlp/repositories/nlp-sample.repository.ts index 25e51fb9..c7b2e90a 100644 --- a/api/src/nlp/repositories/nlp-sample.repository.ts +++ b/api/src/nlp/repositories/nlp-sample.repository.ts @@ -32,6 +32,7 @@ import { NlpSampleFull, NlpSamplePopulate, } from '../schemas/nlp-sample.schema'; +import { NlpValue } from '../schemas/nlp-value.schema'; import { NlpSampleEntityRepository } from './nlp-sample-entity.repository'; @@ -51,70 +52,128 @@ export class NlpSampleRepository extends BaseRepository< super(model, NlpSample, NLP_SAMPLE_POPULATE, NlpSampleFull); } + /** + * Build the aggregation stages that restrict a *nlpSampleEntities* collection + * to links which: + * 1. Reference all of the supplied `values`, and + * 2. Whose document satisfies the optional `filters`. + * + * @param criterias Object with: + * @param criterias.filters Extra filters to be applied on *nlpsamples*. + * @param criterias.entities Entity documents whose IDs should match `entity`. + * @param criterias.values Value documents whose IDs should match `value`. + * @returns Array of aggregation `PipelineStage`s ready to be concatenated + * into a larger pipeline. + */ buildFindByEntitiesStages({ filters, - entityIds, - valueIds, + values, }: { filters: TFilterQuery; - entityIds: Types.ObjectId[]; - valueIds: Types.ObjectId[]; + values: NlpValue[]; }): PipelineStage[] { + const requiredPairs = values.map(({ id, entity }) => ({ + entity: new Types.ObjectId(entity), + value: new Types.ObjectId(id), + })); + return [ - // pick link docs whose entity / value matches a pattern + // Apply sample-side filters early { $match: { - ...(entityIds.length && { entity: { $in: entityIds } }), - ...(valueIds.length && { value: { $in: valueIds } }), + ...(filters?.$and + ? { + $and: filters.$and?.map((condition) => { + if ('language' in condition && condition.language) { + return { + language: new Types.ObjectId( + condition.language as string, + ), + }; + } + return condition; + }), + } + : {}), }, }, - // join to the real sample *and* apply sample-side filters early + // Fetch the entities for each sample { $lookup: { - from: 'nlpsamples', - let: { sampleId: '$sample' }, + from: 'nlpsampleentities', + localField: '_id', // nlpsamples._id + foreignField: 'sample', // nlpsampleentities.sample + as: 'sampleentities', pipeline: [ { $match: { - $expr: { $eq: ['$_id', '$$sampleId'] }, - ...(filters?.$and - ? { - $and: filters.$and?.map((condition) => { - if ('language' in condition && condition.language) { - return { - language: new Types.ObjectId(condition.language), - }; - } - return condition; - }), - } - : {}), + $or: requiredPairs, }, }, ], - as: 'sample', }, }, - { $unwind: '$sample' }, + + // Filter out empty or less matching + { + $match: { + $expr: { + $gte: [{ $size: '$sampleentities' }, requiredPairs.length], + }, + }, + }, + + // Collapse each link into an { entity, value } object + { + $addFields: { + entities: { + $ifNull: [ + { + $map: { + input: '$sampleentities', + as: 's', + in: { entity: '$$s.entity', value: '$$s.value' }, + }, + }, + [], + ], + }, + }, + }, + + // Keep only the samples whose `entities` array ⊇ `requiredPairs` + { + $match: { + $expr: { + $eq: [ + requiredPairs.length, // target size + { + $size: { + $setIntersection: ['$entities', requiredPairs], + }, + }, + ], + }, + }, + }, + + //drop helper array if you don’t need it downstream + { $project: { entities: 0, sampleentities: 0 } }, ]; } findByEntitiesAggregation( criterias: { filters: TFilterQuery; - entityIds: Types.ObjectId[]; - valueIds: Types.ObjectId[]; + values: NlpValue[]; }, page?: PageQueryDto, projection?: ProjectionType, ): Aggregate { - return this.sampleEntityModel.aggregate([ + return this.model.aggregate([ ...this.buildFindByEntitiesStages(criterias), - // promote the sample document - { $replaceRoot: { newRoot: '$sample' } }, - // sort / skip / limit ...this.buildPaginationPipelineStages(page), @@ -135,8 +194,7 @@ export class NlpSampleRepository extends BaseRepository< async findByEntities( criterias: { filters: TFilterQuery; - entityIds: Types.ObjectId[]; - valueIds: Types.ObjectId[]; + values: NlpValue[]; }, page?: PageQueryDto, projection?: ProjectionType, @@ -153,11 +211,18 @@ export class NlpSampleRepository extends BaseRepository< ); } + /** + * Find NLP samples by entities and populate them with their related data. + * + * @param criterias - Criteria containing filters and values to match. + * @param page - Optional pagination parameters. + * @param projection - Optional projection to limit fields returned. + * @returns Promise resolving to an array of populated NlpSampleFull objects. + */ async findByEntitiesAndPopulate( criterias: { filters: TFilterQuery; - entityIds: Types.ObjectId[]; - valueIds: Types.ObjectId[]; + values: NlpValue[]; }, page?: PageQueryDto, projection?: ProjectionType, @@ -177,32 +242,41 @@ export class NlpSampleRepository extends BaseRepository< ); } + /** + * Build an aggregation pipeline that counts NLP samples satisfying: + * – the extra `filters` (passed to `$match` later on), and + * – All of the supplied `entities` / `values`. + * + * @param criterias `{ filters, entities, values }` + * @returns Un-executed aggregation cursor. + */ countByEntitiesAggregation(criterias: { filters: TFilterQuery; - entityIds: Types.ObjectId[]; - valueIds: Types.ObjectId[]; + values: NlpValue[]; }): Aggregate<{ count: number }[]> { - return this.sampleEntityModel.aggregate<{ count: number }>([ + return this.model.aggregate<{ count: number }>([ ...this.buildFindByEntitiesStages(criterias), - // Collapse duplicates: one bucket per unique sample - { $group: { _id: '$sample._id' } }, - // Final count { $count: 'count' }, ]); } + /** + * Returns the count of samples by filters, entities and/or values + * + * @param criterias `{ filters, entities, values }` + * @returns Promise resolving to `{ count: number }`. + */ async countByEntities(criterias: { filters: TFilterQuery; - entityIds: Types.ObjectId[]; - valueIds: Types.ObjectId[]; - }): Promise<{ count: number }> { + values: NlpValue[]; + }): Promise { const aggregation = this.countByEntitiesAggregation(criterias); const [result] = await aggregation.exec(); - return { count: result?.count || 0 }; + return result?.count || 0; } /** diff --git a/api/src/nlp/services/nlp-entity.service.ts b/api/src/nlp/services/nlp-entity.service.ts index 897e841f..0876f3c1 100644 --- a/api/src/nlp/services/nlp-entity.service.ts +++ b/api/src/nlp/services/nlp-entity.service.ts @@ -10,9 +10,7 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; import { Cache } from 'cache-manager'; -import { Types } from 'mongoose'; -import { NlpPattern } from '@/chat/schemas/types/pattern'; import { NLP_MAP_CACHE_KEY } from '@/utils/constants/cache'; import { Cacheable } from '@/utils/decorators/cacheable.decorator'; import { BaseService } from '@/utils/generics/base-service'; @@ -74,19 +72,6 @@ export class NlpEntityService extends BaseService< return await this.repository.updateOne(id, { weight: updatedWeight }); } - async findObjectIdsByPatterns(patterns: NlpPattern[]) { - // resolve pattern → ids (kept here because it uses other services) - return ( - await this.find({ - name: { - $in: patterns - .filter((p) => p.match === 'entity') - .map((p) => p.entity), - }, - }) - ).map((e) => new Types.ObjectId(e.id)); - } - /** * Stores new entities based on the sample text and sample entities. * Deletes all values relative to this entity before deleting the entity itself. diff --git a/api/src/nlp/services/nlp-sample.service.ts b/api/src/nlp/services/nlp-sample.service.ts index 700e508b..31d3c009 100644 --- a/api/src/nlp/services/nlp-sample.service.ts +++ b/api/src/nlp/services/nlp-sample.service.ts @@ -16,7 +16,7 @@ import { Document, ProjectionType, Query } from 'mongoose'; import Papa from 'papaparse'; import { Message } from '@/chat/schemas/message.schema'; -import { NlpPattern } from '@/chat/schemas/types/pattern'; +import { NlpValueMatchPattern } from '@/chat/schemas/types/pattern'; import { Language } from '@/i18n/schemas/language.schema'; import { LanguageService } from '@/i18n/services/language.service'; import { DeleteResult } from '@/utils/generics/base-repository'; @@ -56,6 +56,117 @@ export class NlpSampleService extends BaseService< super(repository); } + /** + * Retrieve samples that satisfy `filters` **and** reference any entity / value + * contained in `patterns`. + * + * The pattern list is first resolved via `NlpEntityService.findByPatterns` + * and `NlpValueService.findByPatterns`, then delegated to + * `repository.findByEntities`. + * + * @param criterias `{ filters, patterns }` + * @param page Optional paging / sorting descriptor. + * @param projection Optional Mongo projection. + * @returns Promise resolving to the matching samples. + */ + async findByPatterns( + { + filters, + patterns, + }: { + filters: TFilterQuery; + patterns: NlpValueMatchPattern[]; + }, + page?: PageQueryDto, + projection?: ProjectionType, + ): Promise { + const values = + patterns.length > 0 + ? await this.nlpValueService.findByPatterns(patterns) + : []; + + if (values.length === 0) { + return await this.repository.find(filters, page, projection); + } + + return await this.repository.findByEntities( + { + filters, + values, + }, + page, + projection, + ); + } + + /** + * Same as `findByPatterns`, but also populates all relations declared + * in the repository (`populatePaths`). + * + * @param criteras `{ filters, patterns }` + * @param page Optional paging / sorting descriptor. + * @param projection Optional Mongo projection. + * @returns Promise resolving to the populated samples. + */ + async findByPatternsAndPopulate( + { + filters, + patterns, + }: { + filters: TFilterQuery; + patterns: NlpValueMatchPattern[]; + }, + page?: PageQueryDto, + projection?: ProjectionType, + ): Promise { + const values = + patterns.length > 0 + ? await this.nlpValueService.findByPatterns(patterns) + : []; + + if (values.length === 0) { + return await this.repository.findAndPopulate(filters, page, projection); + } + + return await this.repository.findByEntitiesAndPopulate( + { + filters, + values, + }, + page, + projection, + ); + } + + /** + * Count how many samples satisfy `filters` and reference any entity / value + * present in `patterns`. + * + * @param param0 `{ filters, patterns }` + * @returns Promise resolving to `{ count }`. + */ + async countByPatterns({ + filters, + patterns, + }: { + filters: TFilterQuery; + patterns: NlpValueMatchPattern[]; + }): Promise { + const values = + patterns.length > 0 + ? await this.nlpValueService.findByPatterns(patterns) + : []; + + if (values.length === 0) { + return await this.repository.count(filters); + } + + return await this.repository.countByEntities({ + filters, + values, + }); + } + /** * Fetches the samples and entities for a given sample type. * @@ -283,66 +394,6 @@ export class NlpSampleService extends BaseService< } } - async findByPatterns( - { - filters, - patterns, - }: { - filters: TFilterQuery; - patterns: NlpPattern[]; - }, - page?: PageQueryDto, - projection?: ProjectionType, - ): Promise { - return await this.repository.findByEntities( - { - filters, - entityIds: - await this.nlpEntityService.findObjectIdsByPatterns(patterns), - valueIds: await this.nlpValueService.findObjectIdsByPatterns(patterns), - }, - page, - projection, - ); - } - - async findByPatternsAndPopulate( - { - filters, - patterns, - }: { - filters: TFilterQuery; - patterns: NlpPattern[]; - }, - page?: PageQueryDto, - projection?: ProjectionType, - ): Promise { - return await this.repository.findByEntitiesAndPopulate( - { - filters, - entityIds: - await this.nlpEntityService.findObjectIdsByPatterns(patterns), - valueIds: await this.nlpValueService.findObjectIdsByPatterns(patterns), - }, - page, - projection, - ); - } - - async countByPatterns({ - filters, - patterns, - }: { - filters: TFilterQuery; - patterns: NlpPattern[]; - }): Promise<{ count: number }> { - return await this.repository.countByEntities({ - filters, - entityIds: await this.nlpEntityService.findObjectIdsByPatterns(patterns), - valueIds: await this.nlpValueService.findObjectIdsByPatterns(patterns), - }); - } - @OnEvent('hook:message:preCreate') async handleNewMessage(doc: THydratedDocument) { // If message is sent by the user then add it as an inbox sample diff --git a/api/src/nlp/services/nlp-value.service.ts b/api/src/nlp/services/nlp-value.service.ts index 021ff3d1..0b916bdc 100644 --- a/api/src/nlp/services/nlp-value.service.ts +++ b/api/src/nlp/services/nlp-value.service.ts @@ -7,9 +7,8 @@ */ import { forwardRef, Inject, Injectable } from '@nestjs/common'; -import { Types } from 'mongoose'; -import { NlpPattern } from '@/chat/schemas/types/pattern'; +import { NlpValueMatchPattern } from '@/chat/schemas/types/pattern'; import { DeleteResult } from '@/utils/generics/base-repository'; import { BaseService } from '@/utils/generics/base-service'; import { PageQueryDto } from '@/utils/pagination/pagination-query.dto'; @@ -44,17 +43,18 @@ export class NlpValueService extends BaseService< super(repository); } - async findObjectIdsByPatterns(patterns: NlpPattern[]) { - // resolve pattern → ids (kept here because it uses other services) - return ( - await this.find({ - value: { - $in: patterns - .map((p) => (p.match === 'value' ? p.value : null)) - .filter(Boolean), - }, - }) - ).map((v) => new Types.ObjectId(v.id)); + /** + * Fetch values whose `value` field matches the patterns provided. + * + * @param patterns Pattern list + * @returns Promise resolving to the matching values. + */ + async findByPatterns(patterns: NlpValueMatchPattern[]) { + return await this.find({ + value: { + $in: patterns.map((p) => p.value), + }, + }); } /** diff --git a/api/src/utils/generics/base-repository.ts b/api/src/utils/generics/base-repository.ts index d73f58bb..6258247e 100644 --- a/api/src/utils/generics/base-repository.ts +++ b/api/src/utils/generics/base-repository.ts @@ -106,15 +106,50 @@ export abstract class BaseRepository< this.registerLifeCycleHooks(); } + /** + * Determine whether at least one of the requested populate paths + * is supported by the repository. + * + * @param populate Array of path strings supplied by the caller. + * @returns `true` if any item appears in `this.populatePaths`, else `false`. + */ canPopulate(populate: string[]): boolean { return populate.some((p) => this.populatePaths.includes(p as P)); } + /** + * Build the canonical event name used by the repository’s event-bus hooks. + * + * Format: `hook::` + * where `` is the lower-cased class name and `` is an + * `EHook` value such as `"preCreate"` or `"postUpdate"`. + * + * @param suffix Lifecycle-hook suffix. + * @returns A type-safe event name string. + */ getEventName(suffix: EHook) { const entity = this.cls.name.toLocaleLowerCase(); return `hook:${entity}:${suffix}` as `hook:${IHookEntities}:${TNormalizedEvents}`; } + /** + * Wire all Mongoose lifecycle hooks to the repository’s overridable + * `pre-/post-*` methods **and** to the domain event bus. + * + * For the current repository (`this.cls.name`) the method: + * 1. Retrieves the hook definitions from `LifecycleHookManager`. + * 2. Registers handlers for: + * • `validate.pre / validate.post` → `preCreateValidate` / `postCreateValidate` + * • `save.pre / save.post` → `preCreate` / `postCreate` + * • `deleteOne.* deleteMany.*` → `preDelete` / `postDelete` + * • `findOneAndUpdate.*` → `preUpdate` / `postUpdate` + * • `updateMany.*` → `preUpdateMany` / `postUpdateMany` + * 3. Emits the corresponding domain events (`EHook.*`) via `eventEmitter` + * after each repository callback. + * + * If no hooks are registered for the current class, a console warning is + * issued and the method exits gracefully. + */ private registerLifeCycleHooks(): void { const repository = this; const hooks = LifecycleHookManager.getHooks(this.cls.name); @@ -258,6 +293,19 @@ export abstract class BaseRepository< }); } + /** + * Execute a `find`-style query, convert each lean result to `cls`, and return + * the transformed list. + * + * - The query is run with `lean(this.leanOpts)` for performance. + * - Each plain object is passed through `plainToClass` using + * `this.transformOpts`. + * + * @template R Result type – typically the populated or base DTO class. + * @param query Mongoose query returning an array of documents. + * @param cls Constructor used by `plainToClass` for transformation. + * @returns Promise resolving to an array of class instances. + */ protected async execute>( query: Query, cls: new () => R, @@ -266,6 +314,19 @@ export abstract class BaseRepository< return resultSet.map((doc) => plainToClass(cls, doc, this.transformOpts)); } + /** + * Execute a single-document query, convert the result to `cls`, + * and return it (or `null`). + * + * - Uses `lean(this.leanOpts)` for performance. + * - Falls back to `this.transformOpts` when `options` is not provided. + * + * @template R Result type – typically the populated or base DTO class. + * @param query Mongoose query expected to return one document. + * @param cls Constructor used by `plainToClass`. + * @param options Optional `ClassTransformOptions` overriding defaults. + * @returns Promise resolving to a class instance or `null`. + */ protected async executeOne>( query: Query, cls: new () => R, @@ -275,6 +336,18 @@ export abstract class BaseRepository< return plainToClass(cls, doc, options ?? this.transformOpts); } + /** + * Build a `findOne`/`findById` query. + * + * - `criteria` may be an `_id` string or any Mongo filter; + * an empty / falsy value is **not allowed** (throws). + * - Optional `projection` is forwarded unchanged. + * + * @param criteria Document `_id` **or** Mongo filter. + * @param projection Optional Mongo projection. + * @throws Error when `criteria` is empty. + * @returns Un-executed Mongoose query. + */ protected findOneQuery( criteria: string | TFilterQuery, projection?: ProjectionType, @@ -289,6 +362,18 @@ export abstract class BaseRepository< : this.model.findOne>(criteria, projection); } + /** + * Retrieve a single document and convert it to `this.cls`. + * + * - Returns `null` immediately when `criteria` is falsy. + * - Optional `options` are passed to `plainToClass`. + * - Optional `projection` limits returned fields. + * + * @param criteria Document `_id` **or** Mongo filter. + * @param options Class-transform options. + * @param projection Optional Mongo projection. + * @returns Promise resolving to the found entity or `null`. + */ async findOne( criteria: string | TFilterQuery, options?: ClassTransformOptions, @@ -303,6 +388,16 @@ export abstract class BaseRepository< return await this.executeOne(query, this.cls, options); } + /** + * Retrieve a single document with all `populatePaths` relations resolved. + * + * - Throws if population is not configured. + * - Returns `null` when nothing matches `criteria`. + * + * @param criteria Document `_id` **or** Mongo filter. + * @param projection Optional Mongo projection. + * @returns Promise resolving to the populated entity or `null`. + */ async findOneAndPopulate( criteria: string | TFilterQuery, projection?: ProjectionType, @@ -329,6 +424,17 @@ export abstract class BaseRepository< projection?: ProjectionType, ): Query; + /** + * Build an un-executed `find` query with optional pagination, sorting, + * and projection. + * + * The returned query can be further chained or passed to `execute`. + * + * @param filter Mongo selector for the documents. + * @param pageQuery Sort tuple **or** paging object (optional). + * @param projection Mongo projection (optional). + * @returns A Mongoose `find` query with `skip`, `limit`, and `sort` applied. + */ protected findQuery( filter: TFilterQuery, pageQuery?: QuerySortDto | PageQueryDto, @@ -366,6 +472,20 @@ export abstract class BaseRepository< projection?: ProjectionType, ): Promise; + /** + * Find documents matching `filter`. + * + * - `pageQuery` may be: + * * a **sort descriptor** (`QuerySortDto`) ‒ an array of `[field, dir]` + * * a **paging object** (`PageQueryDto`) ‒ `{ limit, skip, sort }` + * - Optional `projection` is forwarded to `findQuery`. + * - Delegates execution to `this.execute`, mapping raw docs to `this.cls`. + * + * @param filter Mongo filter selecting documents. + * @param pageQuery Sort descriptor **or** paging object. + * @param projection Optional Mongo projection. + * @returns Promise resolving to the found documents. + */ async find( filter: TFilterQuery, pageQuery?: QuerySortDto | PageQueryDto, @@ -380,6 +500,14 @@ export abstract class BaseRepository< return await this.execute(query, this.cls); } + /** + * Ensure that population is possible for the current repository. + * + * Throws when either `populatePaths` or `clsPopulate` is not configured, + * preventing accidental calls to population-aware methods. + * + * @throws Error if population cannot be performed. + */ private ensureCanPopulate(): void { if (!this.populatePaths || !this.clsPopulate) { throw new Error('Cannot populate query'); @@ -401,6 +529,20 @@ export abstract class BaseRepository< projection?: ProjectionType, ): Promise; + /** + * Find documents that match `filters` and return them with the relations + * in `populatePaths` resolved. + * + * - `pageQuery` can be either a sort descriptor (`QuerySortDto`) or a full + * paging object (`PageQueryDto`). + * - Optional `projection` is forwarded to `findQuery`. + * - Throws if the repository is not configured for population. + * + * @param filters Mongo filter. + * @param pageQuery Sort or paging information. + * @param projection Optional Mongo projection. + * @returns Promise resolving to the populated documents. + */ async findAndPopulate( filters: TFilterQuery, pageQuery?: QuerySortDto | PageQueryDto, @@ -420,16 +562,37 @@ export abstract class BaseRepository< return await this.execute(query, this.clsPopulate!); } + /** + * Build an un-executed query that selects **all** documents, + * applies `sort`, and disables pagination (`limit` / `skip` = 0). + * + * @param sort Optional sort descriptor. + * @returns Mongoose `find` query. + */ protected findAllQuery( sort?: QuerySortDto, ): Query { return this.findQuery({}, { limit: 0, skip: 0, sort }); } + /** + * Retrieve every document in the collection, optionally sorted. + * + * @param sort Optional sort descriptor. + * @returns Promise resolving to the documents. + */ async findAll(sort?: QuerySortDto): Promise { return await this.find({}, { limit: 0, skip: 0, sort }); } + /** + * Retrieve every document with all `populatePaths` relations resolved. + * + * - Throws if population is not configured. + * + * @param sort Optional sort descriptor. + * @returns Promise resolving to the populated documents. + */ async findAllAndPopulate(sort?: QuerySortDto): Promise { this.ensureCanPopulate(); const query = this.findAllQuery(sort).populate(this.populatePaths); @@ -474,14 +637,38 @@ export abstract class BaseRepository< return await this.execute(query, this.clsPopulate!); } + /** + * Return the total number of documents in the collection + * (uses MongoDB’s `estimatedDocumentCount` for speed). + * + * @returns Promise resolving to the estimated document count. + */ async countAll(): Promise { return await this.model.estimatedDocumentCount().exec(); } + /** + * Count documents that match the given criteria + * (falls back to all documents when `criteria` is omitted). + * + * @param criteria Optional Mongo filter. + * @returns Promise resolving to the exact document count. + */ async count(criteria?: TFilterQuery): Promise { return await this.model.countDocuments(criteria).exec(); } + /** + * Persist a single document and return it as an instance of `this.cls`. + * + * Internally: + * 1. `model.create()` inserts the raw DTO. + * 2. The Mongoose document is converted to a plain object with `leanOpts`. + * 3. `plainToClass()` transforms that object into the domain class. + * + * @param dto Data-transfer object describing the new record. + * @returns A hydrated instance of the domain class. + */ async create(dto: DtoInfer): Promise { const doc = await this.model.create(dto); @@ -492,6 +679,12 @@ export abstract class BaseRepository< ); } + /** + * Persist an array of documents at once and map each result to `this.cls`. + * + * @param dtoArray Array of DTOs to insert. + * @returns Array of domain-class instances in the same order as `dtoArray`. + */ async createMany( dtoArray: DtoInfer[], ): Promise { @@ -502,6 +695,21 @@ export abstract class BaseRepository< ); } + /** + * Update a **single** document and return the modified version. + * + * Behaviour : + * - `criteria` may be an `_id` string or any Mongo filter object. + * - `dto` is applied via `$set`; when `options.shouldFlatten` is true the + * payload is flattened (e.g. `"a.b": value`) before the update. + * - Fires the `pre|postUpdateValidate` hooks + events. + * - Throws if nothing matches the criteria or if `dto` is empty. + * + * @param criteria `_id` or filter selecting the target document. + * @param dto Partial update payload. + * @param options `new`, `upsert`, `shouldFlatten`, … (forwarded to Mongoose). + * @returns The updated document (with `new: true` by default). + */ async updateOne>( criteria: string | TFilterQuery, dto: UpdateQuery>, @@ -550,6 +758,18 @@ export abstract class BaseRepository< return result; } + /** + * Update **many** documents at once. + * + * - Applies `$set` with the supplied `dto`. + * - When `options.shouldFlatten` is true, flattens the payload first. + * - Does **not** run the validation / event hooks (use `updateOne` for that). + * + * @param filter Mongo filter selecting the documents to update. + * @param dto Update payload. + * @param options `{ shouldFlatten?: boolean }`. + * @returns MongoDB `UpdateWriteOpResult` describing the operation outcome. + */ async updateMany>( filter: TFilterQuery, dto: UpdateQuery, @@ -560,6 +780,17 @@ export abstract class BaseRepository< }); } + /** + * Remove **one** document, unless it is marked as `builtin: true`. + * + * If `criteria` is a string, it is treated as the document’s `_id`; + * otherwise it is used as a full Mongo filter. + * The filter is automatically augmented with `{ builtin: { $ne: true } }` + * to protect built-in records from deletion. + * + * @param criteria Document `_id` or Mongo filter. + * @returns Promise that resolves to Mongo’s `DeleteResult`. + */ async deleteOne(criteria: string | TFilterQuery): Promise { const filter = typeof criteria === 'string' ? { _id: criteria } : criteria; @@ -568,10 +799,25 @@ export abstract class BaseRepository< .exec(); } + /** + * Remove **many** documents that match `criteria`, excluding those flagged + * with `builtin: true`. + * + * @param criteria Mongo filter describing the set to delete. + * @returns Promise that resolves to Mongo’s `DeleteResult`. + */ async deleteMany(criteria: TFilterQuery): Promise { return await this.model.deleteMany({ ...criteria, builtin: { $ne: true } }); } + /** + * Runs *before* create-validation logic. + * Override to perform domain-specific checks; throw to abort. + * + * @param _doc The document that will be created. + * @param _filterCriteria Optional additional criteria (e.g. conditional create). + * @param _updates Optional update pipeline when upserting. + */ async preCreateValidate( _doc: HydratedDocument, _filterCriteria?: FilterQuery, @@ -580,10 +826,23 @@ export abstract class BaseRepository< // Nothing ... } + /** + * Called *after* create-validation passes, + * but before persistence. Override for side-effects (audit logs, events, …). + * + * @param _validated The validated (not yet saved) document. + */ async postCreateValidate(_validated: HydratedDocument): Promise { // Nothing ... } + /** + * Runs *before* validating a single-document update. + * Override to enforce custom rules; throw to abort. + * + * @param _filterCriteria Query criteria used to locate the document. + * @param _updates Update payload or aggregation pipeline. + */ async preUpdateValidate( _filterCriteria: FilterQuery, _updates: UpdateWithAggregationPipeline | UpdateQuery, @@ -591,6 +850,13 @@ export abstract class BaseRepository< // Nothing ... } + /** + * Called *after* an update payload is validated, + * just before it is applied. + * + * @param _filterCriteria Same criteria passed to the update. + * @param _updates The validated update payload. + */ async postUpdateValidate( _filterCriteria: FilterQuery, _updates: UpdateWithAggregationPipeline | UpdateQuery, @@ -598,14 +864,33 @@ export abstract class BaseRepository< // Nothing ... } + /** + * Rxecutes immediately before persisting a new document. + * Use to inject defaults, timestamps, or derive fields. + * + * @param _doc The document about to be saved. + */ async preCreate(_doc: HydratedDocument): Promise { // Nothing ... } + /** + * Fires right after a document is saved. + * Useful for emitting events or refreshing caches. + * + * @param _created The newly created document. + */ async postCreate(_created: HydratedDocument): Promise { // Nothing ... } + /** + * Runs before a `findOneAndUpdate` operation. + * + * @param _query The Mongoose query object. + * @param _criteria Original filter criteria. + * @param _updates Update payload or pipeline. + */ async preUpdate( _query: Query, _criteria: TFilterQuery, @@ -614,6 +899,13 @@ export abstract class BaseRepository< // Nothing ... } + /** + * Runs before an `updateMany` operation. + * + * @param _query The Mongoose query object. + * @param _criteria Filter criteria. + * @param _updates Update payload or pipeline. + */ async preUpdateMany( _query: Query, _criteria: TFilterQuery, @@ -622,6 +914,12 @@ export abstract class BaseRepository< // Nothing ... } + /** + * Fires after an `updateMany` completes. + * + * @param _query The originating query. + * @param _updated Mongoose result object. + */ async postUpdateMany( _query: Query, _updated: any, @@ -629,6 +927,12 @@ export abstract class BaseRepository< // Nothing ... } + /** + * Fires after a `findOneAndUpdate` completes. + * + * @param _query The originating query. + * @param _updated The updated document. + */ async postUpdate( _query: Query, _updated: T, @@ -636,6 +940,12 @@ export abstract class BaseRepository< // Nothing ... } + /** + * Runs before a `deleteOne` or `deleteMany`. + * + * @param _query The Mongoose query object. + * @param _criteria Filter criteria. + */ async preDelete( _query: Query, _criteria: TFilterQuery, @@ -643,6 +953,12 @@ export abstract class BaseRepository< // Nothing ... } + /** + * Fires after a `deleteOne` or `deleteMany` completes. + * + * @param _query The originating query. + * @param _result MongoDB `DeleteResult`. + */ async postDelete( _query: Query, _result: DeleteResult, @@ -650,6 +966,21 @@ export abstract class BaseRepository< // Nothing ... } + /** + * Translate a `PageQueryDto` into MongoDB aggregation stages. + * + * Creates, in order: + * 1. **$sort** – when `page.sort` is provided. Accepts `1 | -1 | 'asc' | 'desc'` + * (plus `'ascending' | 'descending'`) and normalises them to `1` or `-1`. + * 2. **$skip** – when `page.skip` > 0. + * 3. **$limit** – when `page.limit` > 0. + * + * If `page` is omitted, an empty array is returned so callers can safely + * spread the result into a pipeline without extra checks. + * + * @param page Optional pagination/sort descriptor. + * @returns Array of `$sort`, `$skip`, and `$limit` stages in the correct order. + */ buildPaginationPipelineStages(page?: PageQueryDto): PipelineStage[] { if (!page) return []; @@ -675,6 +1006,13 @@ export abstract class BaseRepository< return stages; } + /** + * Populates the provided Mongoose documents with the relations listed in + * `this.populatePaths`, returning lean (plain) objects. + * + * @param docs Hydrated documents to enrich. + * @returns Promise resolving to the populated docs. + */ async populate(docs: THydratedDocument[]) { return await this.model.populate( docs, diff --git a/api/src/utils/test/fixtures/nlpsampleentity.ts b/api/src/utils/test/fixtures/nlpsampleentity.ts index a474227e..8d5d2447 100644 --- a/api/src/utils/test/fixtures/nlpsampleentity.ts +++ b/api/src/utils/test/fixtures/nlpsampleentity.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. @@ -28,7 +28,7 @@ export const nlpSampleEntityFixtures: NlpSampleEntityCreateDto[] = [ { sample: '2', entity: '0', - value: '2', + value: '3', }, { sample: '3', diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index fd7b1ec1..5173d80f 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -491,7 +491,7 @@ "original_text": "Original Text", "inputs": "Inputs", "outputs": "Outputs", - "any": "- Any -", + "any": "Any", "full_name": "First and last name", "password": "Password" }, diff --git a/frontend/public/locales/fr/translation.json b/frontend/public/locales/fr/translation.json index 0e746c98..8bc2f29f 100644 --- a/frontend/public/locales/fr/translation.json +++ b/frontend/public/locales/fr/translation.json @@ -492,7 +492,7 @@ "original_text": "Texte par défaut", "inputs": "Ports d'entrée", "outputs": "Ports de sortie", - "any": "- Toutes -", + "any": "Toutes", "full_name": "Nom et Prénom", "password": "Mot de passe" }, diff --git a/frontend/src/app-components/inputs/NlpPatternSelect.tsx b/frontend/src/app-components/inputs/NlpPatternSelect.tsx index 39e4c4b9..8ba42786 100644 --- a/frontend/src/app-components/inputs/NlpPatternSelect.tsx +++ b/frontend/src/app-components/inputs/NlpPatternSelect.tsx @@ -44,10 +44,11 @@ interface NlpPatternSelectProps > { patterns: NlpPattern[]; onChange: (patterns: NlpPattern[]) => void; + noneLabel?: string; } const NlpPatternSelect = ( - { patterns, onChange, ...props }: NlpPatternSelectProps, + { patterns, onChange, noneLabel = "", ...props }: NlpPatternSelectProps, ref, ) => { const inputRef = useRef(null); @@ -91,23 +92,29 @@ const NlpPatternSelect = ( valueId: string, ): void => { const newSelection = patterns.slice(0); - const update = newSelection.find(({ entity: e }) => e === name); + const idx = newSelection.findIndex(({ entity: e }) => e === name); - if (!update) { + if (idx === -1) { throw new Error("Unable to find nlp entity"); } if (valueId === id) { - update.match = "entity"; - update.value = name; + newSelection[idx] = { + entity: newSelection[idx].entity, + match: "entity", + }; } else { const value = getNlpValueFromCache(valueId); if (!value) { throw new Error("Unable to find nlp value in cache"); } - update.match = "value"; - update.value = value.value; + + newSelection[idx] = { + entity: newSelection[idx].entity, + match: "value", + value: value.value, + }; } onChange(newSelection); @@ -119,10 +126,11 @@ const NlpPatternSelect = ( ); } - const defaultValue = - options.filter(({ name }) => - patterns.find(({ entity: entityName }) => entityName === name), - ) || {}; + const defaultValue = patterns + .map(({ entity: entityName }) => + options.find(({ name }) => entityName === name), + ) + .filter(Boolean) as INlpEntity[]; return ( getNlpValueFromCache(vId), ) as INlpValue[]; - const selectedValue = patterns.find( - (e) => e.entity === name, - )?.value; + const currentPattern = patterns.find((e) => e.entity === name); + const selectedValue = + currentPattern?.match === "value" ? currentPattern.value : null; const { id: selectedId = id } = nlpValues.find(({ value }) => value === selectedValue) || {}; @@ -204,7 +212,7 @@ const NlpPatternSelect = ( } if (option === id) { - return t("label.any"); + return `- ${noneLabel || t("label.any")} -`; } return option; diff --git a/frontend/src/app-components/tables/DataGrid.tsx b/frontend/src/app-components/tables/DataGrid.tsx index 8816ae72..25ddbc61 100644 --- a/frontend/src/app-components/tables/DataGrid.tsx +++ b/frontend/src/app-components/tables/DataGrid.tsx @@ -75,7 +75,7 @@ export const DataGrid = ({ slots={slots} slotProps={{ loadingOverlay: { - variant: "linear-progress", + variant: "skeleton", noRowsVariant: "skeleton", }, }} diff --git a/frontend/src/components/nlp/components/NlpSample.tsx b/frontend/src/components/nlp/components/NlpSample.tsx index e2d0f127..9cd5de67 100644 --- a/frontend/src/components/nlp/components/NlpSample.tsx +++ b/frontend/src/components/nlp/components/NlpSample.tsx @@ -96,7 +96,10 @@ export default function NlpSample() { $eq: [ ...(type !== "all" ? [{ type }] : []), ...(language ? [{ language }] : []), - ...(patterns ? [{ patterns }] : []), + // We send only value match patterns + ...(patterns + ? [{ patterns: patterns.filter(({ match }) => match === "value") }] + : []), ], $iLike: ["text"], }, @@ -218,6 +221,7 @@ export default function NlpSample() { {row.entities .map((e) => getSampleEntityFromCache(e) as INlpSampleEntity) .filter((e) => !!e) + .sort((a, b) => String(a.entity).localeCompare(String(b.entity))) .map((entity) => ( { - setPatterns(patterns); - }} + onChange={setPatterns} fullWidth={true} + noneLabel={t("label.select")} /> diff --git a/frontend/src/hooks/crud/useFind.tsx b/frontend/src/hooks/crud/useFind.tsx index 04c25b1a..d1580082 100644 --- a/frontend/src/hooks/crud/useFind.tsx +++ b/frontend/src/hooks/crud/useFind.tsx @@ -56,24 +56,27 @@ export const useFind = < entity, ); const getFromCache = useGetFromCache(entity); - const { data: total } = useCount(entity, params["where"], { + const countQuery = useCount(entity, params["where"], { enabled: hasCount, }); const { dataGridPaginationProps, pageQueryPayload } = usePagination( - total?.count, + countQuery.data?.count, initialPaginationState, initialSortState, hasCount, ); const normalizedParams = { ...pageQueryPayload, ...(params || {}) }; - const enabled = !!total || !hasCount; + const enabled = !!countQuery.data || !hasCount; const { data: ids, ...normalizedQuery } = useQuery({ enabled, queryFn: async () => { - const data = await api.find( - normalizedParams, - format === Format.FULL && (POPULATE_BY_TYPE[entity] as P), - ); + const data = + !hasCount || (hasCount && !!countQuery.data?.count) + ? await api.find( + normalizedParams, + format === Format.FULL && (POPULATE_BY_TYPE[entity] as P), + ) + : []; const { result } = normalizeAndCache(data); return result; @@ -100,7 +103,11 @@ export const useFind = < dataGridProps: { ...dataGridPaginationProps, rows: data || [], - loading: normalizedQuery.isLoading || normalizedQuery.isFetching, + loading: + normalizedQuery.isLoading || + normalizedQuery.isFetching || + countQuery.isLoading || + countQuery.isFetching, }, }; }; diff --git a/frontend/src/types/block.types.ts b/frontend/src/types/block.types.ts index 0a4917d0..9c7fa913 100644 --- a/frontend/src/types/block.types.ts +++ b/frontend/src/types/block.types.ts @@ -68,12 +68,19 @@ export interface PayloadPattern { type?: PayloadType; } -export type NlpPattern = { +export type NlpEntityMatchPattern = { entity: string; - match: "value" | "entity"; + match: "entity"; +}; + +export type NlpValueMatchPattern = { + entity: string; + match: "value"; value: string; }; +export type NlpPattern = NlpEntityMatchPattern | NlpValueMatchPattern; + export type Pattern = null | string | PayloadPattern | NlpPattern[]; export type PatternType = From 7b13bd07ba588962b275c0663ae8b2543d69c6f7 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Thu, 5 Jun 2025 15:13:07 +0100 Subject: [PATCH 23/52] test: add unit tests for the nlp sample repo --- .../nlp-sample.repository.spec.ts | 169 +++++++++++++++++- 1 file changed, 163 insertions(+), 6 deletions(-) diff --git a/api/src/nlp/repositories/nlp-sample.repository.spec.ts b/api/src/nlp/repositories/nlp-sample.repository.spec.ts index 7aea6bc1..190e1c7d 100644 --- a/api/src/nlp/repositories/nlp-sample.repository.spec.ts +++ b/api/src/nlp/repositories/nlp-sample.repository.spec.ts @@ -7,9 +7,11 @@ */ import { MongooseModule } from '@nestjs/mongoose'; +import { Types } from 'mongoose'; import { LanguageRepository } from '@/i18n/repositories/language.repository'; import { Language, LanguageModel } from '@/i18n/schemas/language.schema'; +import { PageQueryDto } from '@/utils/pagination/pagination-query.dto'; import { nlpSampleFixtures } from '@/utils/test/fixtures/nlpsample'; import { installNlpSampleEntityFixtures } from '@/utils/test/fixtures/nlpsampleentity'; import { getPageQuery } from '@/utils/test/pagination'; @@ -29,13 +31,16 @@ import { NlpSampleFull, NlpSampleModel, } from '../schemas/nlp-sample.schema'; +import { NlpValue, NlpValueModel } from '../schemas/nlp-value.schema'; import { NlpSampleEntityRepository } from './nlp-sample-entity.repository'; import { NlpSampleRepository } from './nlp-sample.repository'; +import { NlpValueRepository } from './nlp-value.repository'; describe('NlpSampleRepository', () => { let nlpSampleRepository: NlpSampleRepository; let nlpSampleEntityRepository: NlpSampleEntityRepository; + let nlpValueRepository: NlpValueRepository; let languageRepository: LanguageRepository; let nlpSampleEntity: NlpSampleEntity | null; let noNlpSample: NlpSample | null; @@ -48,21 +53,28 @@ describe('NlpSampleRepository', () => { MongooseModule.forFeature([ NlpSampleModel, NlpSampleEntityModel, + NlpValueModel, LanguageModel, ]), ], providers: [ NlpSampleRepository, NlpSampleEntityRepository, + NlpValueRepository, LanguageRepository, ], }); - [nlpSampleRepository, nlpSampleEntityRepository, languageRepository] = - await getMocks([ - NlpSampleRepository, - NlpSampleEntityRepository, - LanguageRepository, - ]); + [ + nlpSampleRepository, + nlpSampleEntityRepository, + nlpValueRepository, + languageRepository, + ] = await getMocks([ + NlpSampleRepository, + NlpSampleEntityRepository, + NlpValueRepository, + LanguageRepository, + ]); noNlpSample = await nlpSampleRepository.findOne({ text: 'No' }); nlpSampleEntity = await nlpSampleEntityRepository.findOne({ sample: noNlpSample!.id, @@ -141,4 +153,149 @@ describe('NlpSampleRepository', () => { expect(sampleEntities.length).toEqual(0); }); }); + + describe('findByEntities', () => { + it('should return mapped NlpSample instances for matching entities', async () => { + const filters = {}; + const values = await nlpValueRepository.find({ value: 'greeting' }); + + const result = await nlpSampleRepository.findByEntities({ + filters, + values, + }); + expect(result).toHaveLength(2); + expect(result[0]).toBeInstanceOf(NlpSample); + expect(result[0].text).toBe('Hello'); + }); + + it('should return an empty array if no samples match', async () => { + const filters = {}; + const values = [ + { + id: new Types.ObjectId().toHexString(), + entity: new Types.ObjectId().toHexString(), + value: 'nonexistent', + }, + ] as NlpValue[]; + + const result = await nlpSampleRepository.findByEntities({ + filters, + values, + }); + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(0); + }); + }); + + describe('findByEntitiesAndPopulate', () => { + it('should return populated NlpSampleFull instances for matching entities', async () => { + const filters = {}; + const values = await nlpValueRepository.find({ value: 'greeting' }); + + const result = await nlpSampleRepository.findByEntitiesAndPopulate({ + filters, + values, + }); + + expect(result.length).toBe(2); + result.forEach((sample) => { + expect(sample).toBeInstanceOf(NlpSampleFull); + expect(sample.entities).toBeDefined(); + expect(Array.isArray(sample.entities)).toBe(true); + expect(sample.language).toBeDefined(); + }); + }); + + it('should return an empty array if no samples match', async () => { + const filters = {}; + const values = [ + { + id: new Types.ObjectId().toHexString(), + entity: new Types.ObjectId().toHexString(), + value: 'nonexistent', + }, + ] as NlpValue[]; + + const result = await nlpSampleRepository.findByEntitiesAndPopulate({ + filters, + values, + }); + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(0); + }); + + it('should support pagination and projection', async () => { + const filters = {}; + const values = await nlpValueRepository.find({ value: 'greeting' }); + const page = { + limit: 1, + skip: 0, + sort: ['text', 'asc'], + } as PageQueryDto; + const projection = { text: 1 }; + + const result = await nlpSampleRepository.findByEntitiesAndPopulate( + { filters, values }, + page, + projection, + ); + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(1); + if (result.length > 0) { + expect(result[0]).toHaveProperty('text'); + } + }); + }); + + describe('countByEntities', () => { + it('should return the correct count for matching entities', async () => { + const filters = {}; + const values = await nlpValueRepository.find({ value: 'greeting' }); + + const count = await nlpSampleRepository.countByEntities({ + filters, + values, + }); + + expect(typeof count).toBe('number'); + expect(count).toBe(2); + }); + + it('should return 0 if no samples match', async () => { + const filters = {}; + const values = [ + { + id: new Types.ObjectId().toHexString(), + entity: new Types.ObjectId().toHexString(), + value: 'nonexistent', + }, + ] as NlpValue[]; + + const count = await nlpSampleRepository.countByEntities({ + filters, + values, + }); + + expect(count).toBe(0); + }); + + it('should respect filters (e.g. language)', async () => { + const values = await nlpValueRepository.find({ value: 'greeting' }); + const language = languages[0]; + const filters = { language: language.id }; + + const count = await nlpSampleRepository.countByEntities({ + filters, + values, + }); + + // Should be <= total greeting samples, and >= 0 + expect(typeof count).toBe('number'); + expect(count).toBeGreaterThanOrEqual(0); + expect(count).toBeLessThanOrEqual(2); + }); + }); }); From 1d837ca51d9b425a109c31c8e42d3c8aa23d32dc Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Thu, 5 Jun 2025 15:29:33 +0100 Subject: [PATCH 24/52] test: add unit tests --- .../controllers/nlp-sample.controller.spec.ts | 34 ++++ .../nlp/repositories/nlp-sample.repository.ts | 7 +- .../nlp/services/nlp-sample.service.spec.ts | 151 ++++++++++++++++++ api/src/nlp/services/nlp-sample.service.ts | 41 ++--- 4 files changed, 212 insertions(+), 21 deletions(-) diff --git a/api/src/nlp/controllers/nlp-sample.controller.spec.ts b/api/src/nlp/controllers/nlp-sample.controller.spec.ts index 21436ba5..464f3276 100644 --- a/api/src/nlp/controllers/nlp-sample.controller.spec.ts +++ b/api/src/nlp/controllers/nlp-sample.controller.spec.ts @@ -182,6 +182,40 @@ describe('NlpSampleController', () => { })), ); }); + + it('should find nlp samples with patterns', async () => { + const pageQuery = getPageQuery({ sort: ['text', 'desc'] }); + const patterns: NlpValueMatchPattern[] = [ + { entity: 'intent', match: 'value', value: 'greeting' }, + ]; + const result = await nlpSampleController.findPage( + pageQuery, + ['language', 'entities'], + {}, + patterns, + ); + // Should only return samples matching the pattern + const nlpSamples = await nlpSampleService.findByPatternsAndPopulate( + { filters: {}, patterns }, + pageQuery, + ); + expect(result).toEqualPayload(nlpSamples); + }); + + it('should return empty array if no samples match the patterns', async () => { + const pageQuery = getPageQuery({ sort: ['text', 'desc'] }); + const patterns: NlpValueMatchPattern[] = [ + { entity: 'intent', match: 'value', value: 'nonexistent' }, + ]; + const result = await nlpSampleController.findPage( + pageQuery, + ['language', 'entities'], + {}, + patterns, + ); + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(0); + }); }); describe('count', () => { diff --git a/api/src/nlp/repositories/nlp-sample.repository.ts b/api/src/nlp/repositories/nlp-sample.repository.ts index c7b2e90a..13c38544 100644 --- a/api/src/nlp/repositories/nlp-sample.repository.ts +++ b/api/src/nlp/repositories/nlp-sample.repository.ts @@ -78,9 +78,12 @@ export class NlpSampleRepository extends BaseRepository< })); return [ - // Apply sample-side filters early { $match: { + // @todo: think of a better way to handle language to objectId conversion + // This is a workaround for the fact that language is stored as an ObjectId + // in the database, but we want to filter by its string representation. + ...filters, ...(filters?.$and ? { $and: filters.$and?.map((condition) => { @@ -266,7 +269,7 @@ export class NlpSampleRepository extends BaseRepository< * Returns the count of samples by filters, entities and/or values * * @param criterias `{ filters, entities, values }` - * @returns Promise resolving to `{ count: number }`. + * @returns Promise resolving to the count. */ async countByEntities(criterias: { filters: TFilterQuery; diff --git a/api/src/nlp/services/nlp-sample.service.spec.ts b/api/src/nlp/services/nlp-sample.service.spec.ts index d1c3798c..40e76b4e 100644 --- a/api/src/nlp/services/nlp-sample.service.spec.ts +++ b/api/src/nlp/services/nlp-sample.service.spec.ts @@ -10,9 +10,11 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { BadRequestException, NotFoundException } from '@nestjs/common'; import { MongooseModule } from '@nestjs/mongoose'; +import { NlpValueMatchPattern } from '@/chat/schemas/types/pattern'; import { LanguageRepository } from '@/i18n/repositories/language.repository'; import { Language, LanguageModel } from '@/i18n/schemas/language.schema'; import { LanguageService } from '@/i18n/services/language.service'; +import { PageQueryDto } from '@/utils/pagination/pagination-query.dto'; import { nlpSampleFixtures } from '@/utils/test/fixtures/nlpsample'; import { installNlpSampleEntityFixtures } from '@/utils/test/fixtures/nlpsampleentity'; import { getPageQuery } from '@/utils/test/pagination'; @@ -360,4 +362,153 @@ describe('NlpSampleService', () => { expect(extractSpy).not.toHaveBeenCalled(); }); }); + + describe('findByPatterns', () => { + it('should return samples matching the given patterns', async () => { + // Assume pattern: entity 'intent', value 'greeting' + const patterns: NlpValueMatchPattern[] = [ + { entity: 'intent', match: 'value', value: 'greeting' }, + ]; + + const result = await nlpSampleService.findByPatterns( + { filters: {}, patterns }, + undefined, + ); + + expect(Array.isArray(result)).toBe(true); + expect(result[0].text).toBe('Hello'); + }); + + it('should return an empty array if no samples match the patterns', async () => { + const patterns: NlpValueMatchPattern[] = [ + { entity: 'intent', match: 'value', value: 'nonexistent' }, + ]; + + const result = await nlpSampleService.findByPatterns( + { filters: {}, patterns }, + undefined, + ); + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(0); + }); + + it('should support pagination', async () => { + const patterns: NlpValueMatchPattern[] = [ + { entity: 'intent', match: 'value', value: 'greeting' }, + ]; + const page: PageQueryDto = { + limit: 1, + skip: 0, + sort: ['text', 'asc'], + }; + + const result = await nlpSampleService.findByPatterns( + { filters: {}, patterns }, + page, + ); + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(1); + }); + }); + + describe('findByPatternsAndPopulate', () => { + it('should return populated NlpSampleFull instances for matching patterns', async () => { + const patterns: NlpValueMatchPattern[] = [ + { entity: 'intent', match: 'value', value: 'greeting' }, + ]; + + const result = await nlpSampleService.findByPatternsAndPopulate( + { filters: {}, patterns }, + undefined, + ); + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + result.forEach((sample) => { + expect(sample).toBeInstanceOf(NlpSampleFull); + expect(sample.entities).toBeDefined(); + expect(Array.isArray(sample.entities)).toBe(true); + expect(sample.language).toBeDefined(); + }); + }); + + it('should return an empty array if no samples match the patterns', async () => { + const patterns: NlpValueMatchPattern[] = [ + { entity: 'intent', match: 'value', value: 'nonexistent' }, + ]; + + const result = await nlpSampleService.findByPatternsAndPopulate( + { filters: {}, patterns }, + undefined, + ); + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(0); + }); + + it('should support pagination and projection', async () => { + const patterns: NlpValueMatchPattern[] = [ + { entity: 'intent', match: 'value', value: 'greeting' }, + ]; + const page: PageQueryDto = { + limit: 1, + skip: 0, + sort: ['text', 'asc'], + }; + + const result = await nlpSampleService.findByPatternsAndPopulate( + { filters: {}, patterns }, + page, + ); + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(1); + }); + }); + + describe('countByPatterns', () => { + it('should return the correct count for matching patterns', async () => { + const patterns: NlpValueMatchPattern[] = [ + { entity: 'intent', match: 'value', value: 'greeting' }, + ]; + + const count = await nlpSampleService.countByPatterns({ + filters: {}, + patterns, + }); + + expect(typeof count).toBe('number'); + expect(count).toBe(2); + }); + + it('should return 0 if no samples match the patterns', async () => { + const patterns: NlpValueMatchPattern[] = [ + { entity: 'intent', match: 'value', value: 'nonexistent' }, + ]; + + const count = await nlpSampleService.countByPatterns({ + filters: {}, + patterns, + }); + + expect(count).toBe(0); + }); + + it('should respect filters (e.g. language)', async () => { + const patterns: NlpValueMatchPattern[] = [ + { entity: 'intent', match: 'value', value: 'greeting' }, + ]; + const filters = { text: 'Hello' }; + + const count = await nlpSampleService.countByPatterns({ + filters, + patterns, + }); + + expect(typeof count).toBe('number'); + expect(count).toBe(1); + }); + }); }); diff --git a/api/src/nlp/services/nlp-sample.service.ts b/api/src/nlp/services/nlp-sample.service.ts index 31d3c009..94de75fb 100644 --- a/api/src/nlp/services/nlp-sample.service.ts +++ b/api/src/nlp/services/nlp-sample.service.ts @@ -80,15 +80,16 @@ export class NlpSampleService extends BaseService< page?: PageQueryDto, projection?: ProjectionType, ): Promise { - const values = - patterns.length > 0 - ? await this.nlpValueService.findByPatterns(patterns) - : []; - - if (values.length === 0) { + if (!patterns.length) { return await this.repository.find(filters, page, projection); } + const values = await this.nlpValueService.findByPatterns(patterns); + + if (!values.length) { + return []; + } + return await this.repository.findByEntities( { filters, @@ -119,15 +120,16 @@ export class NlpSampleService extends BaseService< page?: PageQueryDto, projection?: ProjectionType, ): Promise { - const values = - patterns.length > 0 - ? await this.nlpValueService.findByPatterns(patterns) - : []; - - if (values.length === 0) { + if (!patterns.length) { return await this.repository.findAndPopulate(filters, page, projection); } + const values = await this.nlpValueService.findByPatterns(patterns); + + if (!values.length) { + return []; + } + return await this.repository.findByEntitiesAndPopulate( { filters, @@ -143,7 +145,7 @@ export class NlpSampleService extends BaseService< * present in `patterns`. * * @param param0 `{ filters, patterns }` - * @returns Promise resolving to `{ count }`. + * @returns Promise resolving to the count. */ async countByPatterns({ filters, @@ -152,15 +154,16 @@ export class NlpSampleService extends BaseService< filters: TFilterQuery; patterns: NlpValueMatchPattern[]; }): Promise { - const values = - patterns.length > 0 - ? await this.nlpValueService.findByPatterns(patterns) - : []; - - if (values.length === 0) { + if (!patterns.length) { return await this.repository.count(filters); } + const values = await this.nlpValueService.findByPatterns(patterns); + + if (!values.length) { + return 0; + } + return await this.repository.countByEntities({ filters, values, From 74beb9426c1e361ee6ed13f44d067f2045042606 Mon Sep 17 00:00:00 2001 From: yassinedorbozgithub Date: Thu, 5 Jun 2025 15:42:00 +0100 Subject: [PATCH 25/52] fix(frontend): apply feedback --- .../content-types/ContentTypeForm.tsx | 75 ++++++++++--------- .../content-types/components/FieldInput.tsx | 42 ++++++----- frontend/src/types/content-type.types.ts | 3 +- 3 files changed, 62 insertions(+), 58 deletions(-) diff --git a/frontend/src/components/content-types/ContentTypeForm.tsx b/frontend/src/components/content-types/ContentTypeForm.tsx index da74b069..d91bbb8c 100644 --- a/frontend/src/components/content-types/ContentTypeForm.tsx +++ b/frontend/src/components/content-types/ContentTypeForm.tsx @@ -8,7 +8,7 @@ import AddIcon from "@mui/icons-material/Add"; import { Button } from "@mui/material"; -import { FC, Fragment, useEffect } from "react"; +import { FC, Fragment, useMemo } from "react"; import { useFieldArray, useForm } from "react-hook-form"; import { ContentContainer, ContentItem } from "@/app-components/dialogs"; @@ -19,30 +19,45 @@ import { useToast } from "@/hooks/useToast"; import { useTranslate } from "@/hooks/useTranslate"; import { EntityType } from "@/services/types"; import { ComponentFormProps } from "@/types/common/dialogs.types"; -import { ContentFieldType, IContentType } from "@/types/content-type.types"; +import { + ContentFieldType, + IContentType, + IContentTypeAttributes, +} from "@/types/content-type.types"; +import { generateId } from "@/utils/generateId"; import { FieldInput } from "./components/FieldInput"; import { FIELDS_FORM_DEFAULT_VALUES, READ_ONLY_FIELDS } from "./constants"; export const ContentTypeForm: FC> = ({ - data: { defaultValues: contentType }, + data: { defaultValues: contentTypeWithoutId }, Wrapper = Fragment, WrapperProps, ...rest }) => { + const contentType = useMemo( + () => + contentTypeWithoutId && { + ...contentTypeWithoutId, + fields: contentTypeWithoutId?.fields?.map((field) => ({ + ...field, + uuid: generateId(), + })), + }, + [contentTypeWithoutId], + ); const { toast } = useToast(); const { t } = useTranslate(); const { - reset, control, register, setValue, formState: { errors }, handleSubmit, - } = useForm>({ - defaultValues: { - name: contentType?.name || "", - fields: contentType?.fields || FIELDS_FORM_DEFAULT_VALUES, + } = useForm({ + defaultValues: contentType || { + name: "", + fields: FIELDS_FORM_DEFAULT_VALUES, }, }); const { append, fields, remove } = useFieldArray({ @@ -67,17 +82,14 @@ export const ContentTypeForm: FC> = ({ EntityType.CONTENT_TYPE, options, ); - const onSubmitForm = (params) => { - const labelCounts: Record = params.fields.reduce( - (acc, field) => { - if (!field.label.trim()) return acc; - acc[field.label] = (acc[field.label] || 0) + 1; + const onSubmitForm = (params: IContentTypeAttributes) => { + const labelCounts = params.fields?.reduce((acc, field) => { + if (!field.label.trim()) return acc; + acc[field.label] = (acc[field.label] || 0) + 1; - return acc; - }, - {} as Record, - ); - const hasDuplicates = Object.values(labelCounts).some( + return acc; + }, {} as Record); + const hasDuplicates = Object.values(labelCounts || {}).some( (count: number) => count > 1, ); @@ -87,24 +99,13 @@ export const ContentTypeForm: FC> = ({ return; } - if (contentType) { + if (contentType?.id) { updateContentType({ id: contentType.id, params }); } else { createContentType(params); } }; - useEffect(() => { - if (contentType) { - reset({ - name: contentType.name, - fields: contentType.fields || FIELDS_FORM_DEFAULT_VALUES, - }); - } else { - reset({ name: "", fields: FIELDS_FORM_DEFAULT_VALUES }); - } - }, [contentType, reset]); - return (
@@ -130,12 +131,8 @@ export const ContentTypeForm: FC> = ({ gap={2} > @@ -145,7 +142,11 @@ export const ContentTypeForm: FC> = ({ startIcon={} variant="contained" onClick={() => - append({ label: "", name: "", type: ContentFieldType.TEXT }) + append({ + label: "", + name: "", + type: ContentFieldType.TEXT, + }) } > {t("button.add")} diff --git a/frontend/src/components/content-types/components/FieldInput.tsx b/frontend/src/components/content-types/components/FieldInput.tsx index 06d519af..4c07603c 100644 --- a/frontend/src/components/content-types/components/FieldInput.tsx +++ b/frontend/src/components/content-types/components/FieldInput.tsx @@ -8,13 +8,11 @@ import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; import { MenuItem } from "@mui/material"; -import { useEffect } from "react"; import { Control, Controller, UseFieldArrayRemove, UseFormSetValue, - useWatch, } from "react-hook-form"; import { IconButton } from "@/app-components/buttons/IconButton"; @@ -26,31 +24,17 @@ import { slugify } from "@/utils/string"; export const FieldInput = ({ setValue, index, - defaultLabel, - defaultName, + uuid, ...props }: { index: number; disabled?: boolean; remove: UseFieldArrayRemove; - control: Control>; - setValue: UseFormSetValue>; - defaultLabel?: string; - defaultName?: string; + control: Control; + setValue: UseFormSetValue; + uuid?: string; }) => { const { t } = useTranslate(); - const label = useWatch({ - control: props.control, - name: `fields.${index}.label`, - }); - - useEffect(() => { - if (defaultLabel && defaultName !== slugify(defaultLabel)) { - defaultName && setValue(`fields.${index}.name`, defaultName); - } else { - setValue(`fields.${index}.name`, label ? slugify(label) : ""); - } - }, [label, setValue, index]); return ( <> @@ -75,6 +59,24 @@ export const FieldInput = ({ label={t("label.label")} error={!!fieldState.error} helperText={fieldState.error?.message} + onChange={(e) => { + const currentValue = e.target.value; + const { label, name } = + props.control._defaultValues.fields?.find( + (field) => field?.uuid === uuid, + ) || {}; + + if (label && name !== slugify(label)) { + name && setValue(`fields.${index}.name`, name); + } else { + setValue( + `fields.${index}.name`, + currentValue ? slugify(currentValue) : "", + ); + } + + field.onChange(e); + }} /> )} /> diff --git a/frontend/src/types/content-type.types.ts b/frontend/src/types/content-type.types.ts index ee15b4c2..83d988b9 100644 --- a/frontend/src/types/content-type.types.ts +++ b/frontend/src/types/content-type.types.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. @@ -23,6 +23,7 @@ export type ContentField = { name: string; label: string; type: ContentFieldType; + uuid?: string; }; export interface IContentTypeAttributes { From da5200eb1b660682dfc6a4fb8a74e0eafbd8e9cc Mon Sep 17 00:00:00 2001 From: yassinedorbozgithub Date: Thu, 5 Jun 2025 16:53:27 +0100 Subject: [PATCH 26/52] fix(frontend): apply feedback --- .../content-types/ContentTypeForm.tsx | 9 +++++++-- .../content-types/components/FieldInput.tsx | 18 ++++++++---------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/content-types/ContentTypeForm.tsx b/frontend/src/components/content-types/ContentTypeForm.tsx index d91bbb8c..7bc201a9 100644 --- a/frontend/src/components/content-types/ContentTypeForm.tsx +++ b/frontend/src/components/content-types/ContentTypeForm.tsx @@ -131,9 +131,14 @@ export const ContentTypeForm: FC> = ({ gap={2} > uuid === f.uuid, + )} /> ))} diff --git a/frontend/src/components/content-types/components/FieldInput.tsx b/frontend/src/components/content-types/components/FieldInput.tsx index 4c07603c..8041b336 100644 --- a/frontend/src/components/content-types/components/FieldInput.tsx +++ b/frontend/src/components/content-types/components/FieldInput.tsx @@ -18,13 +18,17 @@ import { import { IconButton } from "@/app-components/buttons/IconButton"; import { Input } from "@/app-components/inputs/Input"; import { useTranslate } from "@/hooks/useTranslate"; -import { ContentFieldType, IContentType } from "@/types/content-type.types"; +import { + ContentField, + ContentFieldType, + IContentType, +} from "@/types/content-type.types"; import { slugify } from "@/utils/string"; export const FieldInput = ({ setValue, index, - uuid, + contentTypeField, ...props }: { index: number; @@ -32,7 +36,7 @@ export const FieldInput = ({ remove: UseFieldArrayRemove; control: Control; setValue: UseFormSetValue; - uuid?: string; + contentTypeField?: ContentField; }) => { const { t } = useTranslate(); @@ -61,14 +65,8 @@ export const FieldInput = ({ helperText={fieldState.error?.message} onChange={(e) => { const currentValue = e.target.value; - const { label, name } = - props.control._defaultValues.fields?.find( - (field) => field?.uuid === uuid, - ) || {}; - if (label && name !== slugify(label)) { - name && setValue(`fields.${index}.name`, name); - } else { + if (!contentTypeField?.label || !contentTypeField?.name) { setValue( `fields.${index}.name`, currentValue ? slugify(currentValue) : "", From 2c13f069f95574df017fa1555747a6c8e77c9a63 Mon Sep 17 00:00:00 2001 From: yassinedorbozgithub Date: Fri, 6 Jun 2025 06:54:51 +0100 Subject: [PATCH 27/52] fix(frontend): enhance contentType logic --- .../content-types/ContentTypeForm.tsx | 75 +++++++++++-------- .../content-types/components/FieldInput.tsx | 56 +++++--------- 2 files changed, 61 insertions(+), 70 deletions(-) diff --git a/frontend/src/components/content-types/ContentTypeForm.tsx b/frontend/src/components/content-types/ContentTypeForm.tsx index 7bc201a9..256ff2a9 100644 --- a/frontend/src/components/content-types/ContentTypeForm.tsx +++ b/frontend/src/components/content-types/ContentTypeForm.tsx @@ -25,26 +25,27 @@ import { IContentTypeAttributes, } from "@/types/content-type.types"; import { generateId } from "@/utils/generateId"; +import { slugify } from "@/utils/string"; import { FieldInput } from "./components/FieldInput"; -import { FIELDS_FORM_DEFAULT_VALUES, READ_ONLY_FIELDS } from "./constants"; +import { FIELDS_FORM_DEFAULT_VALUES } from "./constants"; export const ContentTypeForm: FC> = ({ - data: { defaultValues: contentTypeWithoutId }, + data: { defaultValues: contentTypeWithoutUuid }, Wrapper = Fragment, WrapperProps, ...rest }) => { const contentType = useMemo( () => - contentTypeWithoutId && { - ...contentTypeWithoutId, - fields: contentTypeWithoutId?.fields?.map((field) => ({ + contentTypeWithoutUuid && { + ...contentTypeWithoutUuid, + fields: contentTypeWithoutUuid?.fields?.map((field) => ({ ...field, uuid: generateId(), })), }, - [contentTypeWithoutId], + [contentTypeWithoutUuid], ); const { toast } = useToast(); const { t } = useTranslate(); @@ -62,6 +63,27 @@ export const ContentTypeForm: FC> = ({ }); const { append, fields, remove } = useFieldArray({ name: "fields", + rules: { + validate: (value) => { + const labelCounts = value.reduce((acc, field) => { + if (!field.label.trim()) return acc; + acc[field.label] = (acc[field.label] || 0) + 1; + + return acc; + }, {} as Record); + const hasDuplicatedLabels = Object.values(labelCounts).some( + (count: number) => count > 1, + ); + + if (hasDuplicatedLabels) { + toast.error(t("message.duplicate_labels_not_allowed")); + + return false; + } + + return true; + }, + }, control, }); const options = { @@ -83,22 +105,6 @@ export const ContentTypeForm: FC> = ({ options, ); const onSubmitForm = (params: IContentTypeAttributes) => { - const labelCounts = params.fields?.reduce((acc, field) => { - if (!field.label.trim()) return acc; - acc[field.label] = (acc[field.label] || 0) + 1; - - return acc; - }, {} as Record); - const hasDuplicates = Object.values(labelCounts || {}).some( - (count: number) => count > 1, - ); - - if (hasDuplicates) { - toast.error(t("message.duplicate_labels_not_allowed")); - - return; - } - if (contentType?.id) { updateContentType({ id: contentType.id, params }); } else { @@ -122,23 +128,28 @@ export const ContentTypeForm: FC> = ({ autoFocus /> - - {fields.map((f, index) => ( + {fields.map((field, idx) => ( uuid === f.uuid, - )} + onLabelChange={(value) => { + const fieldName = contentType?.fields?.find( + ({ uuid }) => uuid === field.uuid, + )?.name; + + if (!fieldName) { + setValue(`fields.${idx}.name`, value ? slugify(value) : ""); + } + }} + onRemove={() => { + remove(idx); + }} /> ))} diff --git a/frontend/src/components/content-types/components/FieldInput.tsx b/frontend/src/components/content-types/components/FieldInput.tsx index 8041b336..6d07c320 100644 --- a/frontend/src/components/content-types/components/FieldInput.tsx +++ b/frontend/src/components/content-types/components/FieldInput.tsx @@ -8,37 +8,27 @@ import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; import { MenuItem } from "@mui/material"; -import { - Control, - Controller, - UseFieldArrayRemove, - UseFormSetValue, -} from "react-hook-form"; +import { useMemo } from "react"; +import { Control, Controller } from "react-hook-form"; import { IconButton } from "@/app-components/buttons/IconButton"; import { Input } from "@/app-components/inputs/Input"; import { useTranslate } from "@/hooks/useTranslate"; -import { - ContentField, - ContentFieldType, - IContentType, -} from "@/types/content-type.types"; -import { slugify } from "@/utils/string"; +import { ContentFieldType, IContentType } from "@/types/content-type.types"; + +import { READ_ONLY_FIELDS } from "../constants"; export const FieldInput = ({ - setValue, - index, - contentTypeField, + idx, ...props }: { - index: number; - disabled?: boolean; - remove: UseFieldArrayRemove; + idx: number; control: Control; - setValue: UseFormSetValue; - contentTypeField?: ContentField; + onRemove?: () => void; + onLabelChange?: (value: string) => void; }) => { const { t } = useTranslate(); + const isDisabled = useMemo(() => idx < READ_ONLY_FIELDS.length, [idx]); return ( <> @@ -46,52 +36,42 @@ export const FieldInput = ({ variant="text" color="error" size="medium" - onClick={() => props.remove(index)} - disabled={props.disabled} + onClick={props.onRemove} + disabled={isDisabled} > - ( { - const currentValue = e.target.value; - - if (!contentTypeField?.label || !contentTypeField?.name) { - setValue( - `fields.${index}.name`, - currentValue ? slugify(currentValue) : "", - ); - } - + props?.onLabelChange?.(e.target.value); field.onChange(e); }} /> )} /> ( )} control={props.control} /> - ( Date: Fri, 6 Jun 2025 07:01:13 +0100 Subject: [PATCH 28/52] fix(frontend): apply feedback --- frontend/src/components/content-types/components/FieldInput.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/content-types/components/FieldInput.tsx b/frontend/src/components/content-types/components/FieldInput.tsx index 6d07c320..0294971a 100644 --- a/frontend/src/components/content-types/components/FieldInput.tsx +++ b/frontend/src/components/content-types/components/FieldInput.tsx @@ -36,7 +36,7 @@ export const FieldInput = ({ variant="text" color="error" size="medium" - onClick={props.onRemove} + onClick={() => props.onRemove?.()} disabled={isDisabled} > From ff85b92ba05dd0a9fa4c513224da6d1026cecb18 Mon Sep 17 00:00:00 2001 From: yassinedorbozgithub Date: Fri, 6 Jun 2025 12:27:45 +0100 Subject: [PATCH 29/52] refactor: prioritize props speading --- .../inputs/AutoCompleteEntitySelect.tsx | 12 ---------- .../inputs/AutoCompleteSelect.tsx | 6 ----- .../app-components/inputs/PasswordInput.tsx | 3 +-- .../visual-editor/form/ButtonsMessageForm.tsx | 16 ++++--------- .../visual-editor/form/ListMessageForm.tsx | 23 +++++++------------ 5 files changed, 13 insertions(+), 47 deletions(-) diff --git a/frontend/src/app-components/inputs/AutoCompleteEntitySelect.tsx b/frontend/src/app-components/inputs/AutoCompleteEntitySelect.tsx index 840a3d31..f0098fe8 100644 --- a/frontend/src/app-components/inputs/AutoCompleteEntitySelect.tsx +++ b/frontend/src/app-components/inputs/AutoCompleteEntitySelect.tsx @@ -52,15 +52,9 @@ const AutoCompleteEntitySelect = < Multiple extends boolean | undefined = true, >( { - label, - value, entity, format, searchFields, - multiple, - onChange, - error, - helperText, preprocess, idKey = "id", labelKey, @@ -106,17 +100,11 @@ const AutoCompleteEntitySelect = < return ( - value={value} - onChange={onChange} - label={label} - multiple={multiple} ref={ref} idKey={idKey} labelKey={labelKey} options={options || []} onSearch={onSearch} - error={error} - helperText={helperText} loading={isFetching} {...rest} /> diff --git a/frontend/src/app-components/inputs/AutoCompleteSelect.tsx b/frontend/src/app-components/inputs/AutoCompleteSelect.tsx index 439cc6d5..43bd384c 100644 --- a/frontend/src/app-components/inputs/AutoCompleteSelect.tsx +++ b/frontend/src/app-components/inputs/AutoCompleteSelect.tsx @@ -52,15 +52,12 @@ const AutoCompleteSelect = < FreeSolo extends boolean | undefined = false, >( { - label, value, options = [], idKey = "id", labelKey, multiple, onSearch, - error, - helperText, isOptionEqualToValue = (option, value) => option?.[idKey] === value?.[idKey], getOptionLabel = (option) => option?.[String(labelKey)] || option?.[idKey], @@ -157,10 +154,7 @@ const AutoCompleteSelect = < renderInput={(props) => ( handleSearch(e.target.value)} - error={error} - helperText={helperText} InputProps={{ ...props.InputProps, endAdornment: ( diff --git a/frontend/src/app-components/inputs/PasswordInput.tsx b/frontend/src/app-components/inputs/PasswordInput.tsx index 35206a5e..0b0c23c4 100644 --- a/frontend/src/app-components/inputs/PasswordInput.tsx +++ b/frontend/src/app-components/inputs/PasswordInput.tsx @@ -14,7 +14,7 @@ import { forwardRef, useState } from "react"; import { Input } from "./Input"; export const PasswordInput = forwardRef( - ({ onChange, InputProps, ...rest }, ref) => { + ({ InputProps, ...rest }, ref) => { const [showPassword, setShowPassword] = useState(false); const handleTogglePasswordVisibility = () => { setShowPassword(!showPassword); @@ -25,7 +25,6 @@ export const PasswordInput = forwardRef( ref={ref} type={showPassword ? "text" : "password"} {...rest} - onChange={onChange} InputProps={{ ...InputProps, endAdornment: ( diff --git a/frontend/src/components/visual-editor/form/ButtonsMessageForm.tsx b/frontend/src/components/visual-editor/form/ButtonsMessageForm.tsx index 0a362bec..daf8365d 100644 --- a/frontend/src/components/visual-editor/form/ButtonsMessageForm.tsx +++ b/frontend/src/components/visual-editor/form/ButtonsMessageForm.tsx @@ -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. @@ -63,17 +63,9 @@ const ButtonsMessageForm = () => { name="message.buttons" control={control} defaultValue={block?.message.buttons || []} - render={({ field }) => { - const { value, onChange } = field; - - return ( - - ); - }} + render={({ field }) => ( + + )} /> diff --git a/frontend/src/components/visual-editor/form/ListMessageForm.tsx b/frontend/src/components/visual-editor/form/ListMessageForm.tsx index e1328cec..f638a91f 100644 --- a/frontend/src/components/visual-editor/form/ListMessageForm.tsx +++ b/frontend/src/components/visual-editor/form/ListMessageForm.tsx @@ -295,21 +295,14 @@ const ListMessageForm = () => { name="options.content.buttons" control={control} defaultValue={content?.buttons || []} - render={({ field }) => { - const { value, onChange } = field; - - return ( - { - onChange(buttons); - }} - disablePayload={true} - maxInput={displayMode === "list" ? 1 : 2} - /> - ); - }} + render={({ field }) => ( + + )} /> From 911d1fb3dfafad56ff5669818c2fdc651d4da405 Mon Sep 17 00:00:00 2001 From: yassinedorbozgithub Date: Sat, 7 Jun 2025 14:12:41 +0100 Subject: [PATCH 30/52] fix(api): add missed botStats echo type --- api/src/analytics/schemas/bot-stats.schema.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/src/analytics/schemas/bot-stats.schema.ts b/api/src/analytics/schemas/bot-stats.schema.ts index a7dadcb2..a1c357db 100644 --- a/api/src/analytics/schemas/bot-stats.schema.ts +++ b/api/src/analytics/schemas/bot-stats.schema.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. @@ -22,6 +22,7 @@ export enum BotStatsType { new_conversations = 'new_conversations', returning_users = 'returning_users', retention = 'retention', + echo = 'echo', } export type ToLinesType = { From 22c4fcdfc8820bdcb709b0f46a648e6bfba64ab3 Mon Sep 17 00:00:00 2001 From: yassinedorbozgithub Date: Mon, 9 Jun 2025 09:55:17 +0100 Subject: [PATCH 31/52] fix(api): remove unused returns --- api/src/extensions/channels/web/base-web-channel.ts | 4 ++-- api/src/nlp/services/nlp.service.ts | 6 ++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/api/src/extensions/channels/web/base-web-channel.ts b/api/src/extensions/channels/web/base-web-channel.ts index 97ab1944..7dd143a8 100644 --- a/api/src/extensions/channels/web/base-web-channel.ts +++ b/api/src/extensions/channels/web/base-web-channel.ts @@ -126,10 +126,10 @@ export default abstract class BaseWebChannelHandler< try { const menu = await this.menuService.getTree(); - return client.emit('settings', { menu, ...settings }); + client.emit('settings', { menu, ...settings }); } catch (err) { this.logger.warn('Unable to retrieve menu ', err); - return client.emit('settings', settings); + client.emit('settings', settings); } } catch (err) { this.logger.error('Unable to initiate websocket connection', err); diff --git a/api/src/nlp/services/nlp.service.ts b/api/src/nlp/services/nlp.service.ts index 1c2c1f34..a8c05a18 100644 --- a/api/src/nlp/services/nlp.service.ts +++ b/api/src/nlp/services/nlp.service.ts @@ -74,7 +74,7 @@ export class NlpService { const helper = await this.helperService.getDefaultHelper(HelperType.NLU); const foreignId = await helper.addEntity(entity); this.logger.debug('New entity successfully synced!', foreignId); - return await this.nlpEntityService.updateOne( + await this.nlpEntityService.updateOne( { _id: entity._id }, { foreign_id: foreignId, @@ -82,7 +82,6 @@ export class NlpService { ); } catch (err) { this.logger.error('Unable to sync a new entity', err); - return entity; } } @@ -139,7 +138,7 @@ export class NlpService { const helper = await this.helperService.getDefaultNluHelper(); const foreignId = await helper.addValue(value); this.logger.debug('New value successfully synced!', foreignId); - return await this.nlpValueService.updateOne( + await this.nlpValueService.updateOne( { _id: value._id }, { foreign_id: foreignId, @@ -147,7 +146,6 @@ export class NlpService { ); } catch (err) { this.logger.error('Unable to sync a new value', err); - return value; } } From 3a643b0e4bccfae9d58b1e17cf14926fa7e83fb9 Mon Sep 17 00:00:00 2001 From: yassinedorbozgithub Date: Mon, 9 Jun 2025 10:59:34 +0100 Subject: [PATCH 32/52] fix(frontend): resolve label updates --- frontend/src/app-components/inputs/AutoCompleteSelect.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frontend/src/app-components/inputs/AutoCompleteSelect.tsx b/frontend/src/app-components/inputs/AutoCompleteSelect.tsx index 43bd384c..439cc6d5 100644 --- a/frontend/src/app-components/inputs/AutoCompleteSelect.tsx +++ b/frontend/src/app-components/inputs/AutoCompleteSelect.tsx @@ -52,12 +52,15 @@ const AutoCompleteSelect = < FreeSolo extends boolean | undefined = false, >( { + label, value, options = [], idKey = "id", labelKey, multiple, onSearch, + error, + helperText, isOptionEqualToValue = (option, value) => option?.[idKey] === value?.[idKey], getOptionLabel = (option) => option?.[String(labelKey)] || option?.[idKey], @@ -154,7 +157,10 @@ const AutoCompleteSelect = < renderInput={(props) => ( handleSearch(e.target.value)} + error={error} + helperText={helperText} InputProps={{ ...props.InputProps, endAdornment: ( From 4beeaae087bda13e52f2100a0c9e65f7c874abc9 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Mon, 9 Jun 2025 16:11:01 +0100 Subject: [PATCH 33/52] feat: disallow multiple matches when local fallback is enabled --- api/src/chat/services/block.service.spec.ts | 38 +++- api/src/chat/services/block.service.ts | 219 +++++++++++--------- api/src/chat/services/bot.service.spec.ts | 2 +- api/src/chat/services/bot.service.ts | 13 +- api/src/utils/test/mocks/block.ts | 8 + 5 files changed, 168 insertions(+), 112 deletions(-) diff --git a/api/src/chat/services/block.service.spec.ts b/api/src/chat/services/block.service.spec.ts index f4d45440..0988e6de 100644 --- a/api/src/chat/services/block.service.spec.ts +++ b/api/src/chat/services/block.service.spec.ts @@ -65,6 +65,7 @@ import { mockNlpGreetingNamePatterns, mockNlpGreetingPatterns, mockNlpGreetingWrongNamePatterns, + mockWebChannelData, } from '@/utils/test/mocks/block'; import { contextBlankInstance, @@ -288,11 +289,7 @@ describe('BlockService', () => { text: 'Hello', }, }, - { - isSocket: true, - ipAddress: '1.1.1.1', - agent: 'Chromium', - }, + mockWebChannelData, ); const webEventGetStarted = new WebEventWrapper( handlerMock, @@ -303,11 +300,18 @@ describe('BlockService', () => { payload: 'GET_STARTED', }, }, + mockWebChannelData, + ); + + const webEventAmbiguous = new WebEventWrapper( + handlerMock, { - isSocket: true, - ipAddress: '1.1.1.1', - agent: 'Chromium', + type: Web.IncomingMessageType.text, + data: { + text: "It's not a yes or no answer!", + }, }, + mockWebChannelData, ); it('should return undefined when no blocks are provided', async () => { @@ -332,6 +336,24 @@ describe('BlockService', () => { expect(result).toEqual(blockGetStarted); }); + it('should return undefined when multiple matches are not allowed', async () => { + const result = await blockService.match( + [ + { + ...blockEmpty, + patterns: ['/yes/'], + }, + { + ...blockEmpty, + patterns: ['/no/'], + }, + ], + webEventAmbiguous, + false, + ); + expect(result).toEqual(undefined); + }); + it('should match block with payload', async () => { webEventGetStarted.setSender(subscriberWithLabels); const result = await blockService.match(blocks, webEventGetStarted); diff --git a/api/src/chat/services/block.service.ts b/api/src/chat/services/block.service.ts index fe88a274..d85b7bc1 100644 --- a/api/src/chat/services/block.service.ts +++ b/api/src/chat/services/block.service.ts @@ -64,68 +64,66 @@ export class BlockService extends BaseService< } /** - * Filters an array of blocks based on the specified channel. + * Checks if block is supported on the specified channel. * - * This function ensures that only blocks that are either: - * - Not restricted to specific trigger channels (`trigger_channels` is undefined or empty), or - * - Explicitly allow the given channel - * - * are included in the returned array. - * - * @param blocks - The list of blocks to be filtered. + * @param block - The block * @param channel - The name of the channel to filter blocks by. * - * @returns The filtered array of blocks that are allowed for the given channel. + * @returns Whether the block is supported on the given channel. */ - filterBlocksByChannel( - blocks: B[], + isChannelSupported( + block: B, channel: ChannelName, ) { - return blocks.filter((b) => { - return ( - !b.trigger_channels || - b.trigger_channels.length === 0 || - b.trigger_channels.includes(channel) - ); - }); + return ( + !block.trigger_channels || + block.trigger_channels.length === 0 || + block.trigger_channels.includes(channel) + ); } /** - * Filters an array of blocks based on subscriber labels. + * Checks if the block matches the subscriber labels, allowing for two scenarios: + * - Has no trigger labels (making it applicable to all subscribers), or + * - Contains at least one trigger label that matches a label from the provided list. * - * This function selects blocks that either: - * - Have no trigger labels (making them applicable to all subscribers), or - * - Contain at least one trigger label that matches a label from the provided list. - * - * The filtered blocks are then **sorted** in descending order by the number of trigger labels, - * ensuring that blocks with more specific targeting (more trigger labels) are prioritized. - * - * @param blocks - The list of blocks to be filtered. + * @param block - The block to check. * @param labels - The list of subscriber labels to match against. - * @returns The filtered and sorted list of blocks. + * @returns True if the block matches the subscriber labels, false otherwise. */ - filterBlocksBySubscriberLabels( - blocks: B[], - profile?: Subscriber, + matchesSubscriberLabels( + block: B, + subscriber?: Subscriber, ) { - if (!profile) { - return blocks; + if (!subscriber) { + return block; } - return ( - blocks - .filter((b) => { - const triggerLabels = b.trigger_labels.map((l) => - typeof l === 'string' ? l : l.id, - ); - return ( - triggerLabels.length === 0 || - triggerLabels.some((l) => profile.labels.includes(l)) - ); - }) - // Priority goes to block who target users with labels - .sort((a, b) => b.trigger_labels.length - a.trigger_labels.length) + const triggerLabels = block.trigger_labels.map((l: string | Label) => + typeof l === 'string' ? l : l.id, ); + return ( + triggerLabels.length === 0 || + triggerLabels.some((l) => subscriber.labels.includes(l)) + ); + } + + /** + * Retrieves the configured NLU penalty factor from settings, or falls back to a default value. + * + * @returns The NLU penalty factor as a number. + */ + private async getPenaltyFactor(): Promise { + const settings = await this.settingService.getSettings(); + const configured = settings.chatbot_settings?.default_nlu_penalty_factor; + + if (configured == null) { + this.logger.warn( + 'Using fallback NLU penalty factor value: %s', + FALLBACK_DEFAULT_NLU_PENALTY_FACTOR, + ); + } + return configured ?? FALLBACK_DEFAULT_NLU_PENALTY_FACTOR; } /** @@ -133,75 +131,88 @@ export class BlockService extends BaseService< * * @param filteredBlocks blocks Starting/Next blocks in the conversation flow * @param event Received channel's message + * @param canHaveMultipleMatches Whether to allow multiple matches for the same event + * (eg. Yes/No question to which the answer is ambiguous "Sometimes yes, sometimes no") * * @returns The block that matches */ async match( blocks: BlockFull[], event: EventWrapper, + canHaveMultipleMatches = true, ): Promise { if (!blocks.length) { return undefined; } - // Search for block matching a given event - let block: BlockFull | undefined = undefined; - const payload = event.getPayload(); + // Narrow the search space + const channelName = event.getHandler().getName(); + const sender = event.getSender(); + const candidates = blocks.filter( + (b) => + this.isChannelSupported(b, channelName) && + this.matchesSubscriberLabels(b, sender), + ); - // Perform a filter to get the candidates blocks - const filteredBlocks = this.filterBlocksBySubscriberLabels( - this.filterBlocksByChannel(blocks, event.getHandler().getName()), - event.getSender(), + if (!candidates.length) { + return undefined; + } + + // Priority goes to block who target users with labels + const prioritizedCandidates = candidates.sort( + (a, b) => b.trigger_labels.length - a.trigger_labels.length, ); // Perform a payload match & pick last createdAt + const payload = event.getPayload(); if (payload) { - block = filteredBlocks - .filter((b) => { - return this.matchPayload(payload, b); - }) - .shift(); - } - - if (!block) { - // Perform a text match (Text or Quick reply) - const text = event.getText().trim(); - - // Perform a text pattern match - block = filteredBlocks - .filter((b) => { - return this.matchText(text, b); - }) - .shift(); - - // Perform an NLP Match - const nlp = event.getNLP(); - if (!block && nlp) { - const scoredEntities = - await this.nlpService.computePredictionScore(nlp); - - const settings = await this.settingService.getSettings(); - let penaltyFactor = - settings.chatbot_settings?.default_nlu_penalty_factor; - if (!penaltyFactor) { - this.logger.warn( - 'Using fallback NLU penalty factor value: %s', - FALLBACK_DEFAULT_NLU_PENALTY_FACTOR, - ); - penaltyFactor = FALLBACK_DEFAULT_NLU_PENALTY_FACTOR; - } - - if (scoredEntities.entities.length > 0) { - block = this.matchBestNLP( - filteredBlocks, - scoredEntities, - penaltyFactor, - ); - } + const payloadMatches = prioritizedCandidates.filter((b) => { + return this.matchPayload(payload, b); + }); + if (payloadMatches.length > 1 && !canHaveMultipleMatches) { + // If the payload matches multiple blocks , + // we return undefined so that we trigger the local fallback + return undefined; + } else if (payloadMatches.length > 0) { + // If we have a payload match, we return the first one + // (which is the most recent one due to the sort) + // and we don't check for text or NLP matches + return payloadMatches[0]; } } - return block; + // Perform a text match (Text or Quick reply) + const text = event.getText().trim(); + if (text) { + const textMatches = prioritizedCandidates.filter((b) => { + return this.matchText(text, b); + }); + + if (textMatches.length > 1 && !canHaveMultipleMatches) { + // If the text matches multiple blocks (especially regex), + // we return undefined so that we trigger the local fallback + return undefined; + } else if (textMatches.length > 0) { + return textMatches[0]; + } + } + + // Perform an NLP Match + const nlp = event.getNLP(); + if (nlp) { + const scoredEntities = await this.nlpService.computePredictionScore(nlp); + + if (scoredEntities.entities.length) { + const penaltyFactor = await this.getPenaltyFactor(); + return this.matchBestNLP( + prioritizedCandidates, + scoredEntities, + penaltyFactor, + ); + } + } + + return undefined; } /** @@ -500,11 +511,19 @@ export class BlockService extends BaseService< envelope: StdOutgoingSystemEnvelope, ) { // Perform a filter to get the candidates blocks - const filteredBlocks = this.filterBlocksBySubscriberLabels( - this.filterBlocksByChannel(blocks, event.getHandler().getName()), - event.getSender(), + const handlerName = event.getHandler().getName(); + const sender = event.getSender(); + const candidates = blocks.filter( + (b) => + this.isChannelSupported(b, handlerName) && + this.matchesSubscriberLabels(b, sender), ); - return filteredBlocks.find((b) => { + + if (!candidates.length) { + return undefined; + } + + return candidates.find((b) => { return b.patterns .filter( (p) => typeof p === 'object' && 'type' in p && p.type === 'outcome', diff --git a/api/src/chat/services/bot.service.spec.ts b/api/src/chat/services/bot.service.spec.ts index 93814633..d8b61596 100644 --- a/api/src/chat/services/bot.service.spec.ts +++ b/api/src/chat/services/bot.service.spec.ts @@ -293,7 +293,7 @@ describe('BotService', () => { event.setSender(webSubscriber); const clearMock = jest - .spyOn(botService, 'handleIncomingMessage') + .spyOn(botService, 'handleOngoingConversationMessage') .mockImplementation( async ( actualConversation: ConversationFull, diff --git a/api/src/chat/services/bot.service.ts b/api/src/chat/services/bot.service.ts index a5b9dbb3..ae3dac76 100644 --- a/api/src/chat/services/bot.service.ts +++ b/api/src/chat/services/bot.service.ts @@ -253,7 +253,7 @@ export class BotService { * * @returns A promise that resolves with a boolean indicating whether the conversation is active and a matching block was found. */ - async handleIncomingMessage( + async handleOngoingConversationMessage( convo: ConversationFull, event: EventWrapper, ) { @@ -272,8 +272,15 @@ export class BotService { max_attempts: 0, }; + // 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 matchedBlock = await this.blockService.match(nextBlocks, event); + 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 let fallbackBlock: BlockFull | undefined; @@ -376,7 +383,7 @@ export class BotService { 'Existing conversations', ); this.logger.debug('Conversation has been captured! Responding ...'); - return await this.handleIncomingMessage(conversation, event); + return await this.handleOngoingConversationMessage(conversation, event); } catch (err) { this.logger.error( 'An error occurred when searching for a conversation ', diff --git a/api/src/utils/test/mocks/block.ts b/api/src/utils/test/mocks/block.ts index 32a26f70..0de1196f 100644 --- a/api/src/utils/test/mocks/block.ts +++ b/api/src/utils/test/mocks/block.ts @@ -18,6 +18,7 @@ import { OutgoingMessageFormat } from '@/chat/schemas/types/message'; import { BlockOptions, ContentOptions } from '@/chat/schemas/types/options'; import { NlpPattern, Pattern } from '@/chat/schemas/types/pattern'; import { QuickReplyType } from '@/chat/schemas/types/quick-reply'; +import { WEB_CHANNEL_NAME } from '@/extensions/channels/web/settings'; import { modelInstance } from './misc'; @@ -391,3 +392,10 @@ export const blockCarouselMock = { } as unknown as BlockFull; export const blocks: BlockFull[] = [blockGetStarted, blockEmpty]; + +export const mockWebChannelData: SubscriberChannelDict[typeof WEB_CHANNEL_NAME] = + { + isSocket: true, + ipAddress: '1.1.1.1', + agent: 'Chromium', + }; From abade7a783d17c2bacfdae0afe09b40061309fe0 Mon Sep 17 00:00:00 2001 From: yassinedorbozgithub Date: Tue, 10 Jun 2025 06:39:49 +0100 Subject: [PATCH 34/52] fix(api): update JSDoc --- api/src/nlp/services/nlp.service.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/api/src/nlp/services/nlp.service.ts b/api/src/nlp/services/nlp.service.ts index a8c05a18..ff59ee9b 100644 --- a/api/src/nlp/services/nlp.service.ts +++ b/api/src/nlp/services/nlp.service.ts @@ -65,7 +65,6 @@ export class NlpService { * Handles the event triggered when a new NLP entity is created. Synchronizes the entity with the external NLP provider. * * @param entity - The NLP entity to be created. - * @returns The updated entity after synchronization. */ @OnEvent('hook:nlpEntity:create') async handleEntityCreate(entity: NlpEntityDocument) { @@ -128,8 +127,6 @@ export class NlpService { * Handles the event triggered when a new NLP value is created. Synchronizes the value with the external NLP provider. * * @param value - The NLP value to be created. - * - * @returns The updated value after synchronization. */ @OnEvent('hook:nlpValue:create') async handleValueCreate(value: NlpValueDocument) { From e528dcc2c21aff09ad8345941dc178ef5861767d Mon Sep 17 00:00:00 2001 From: yassinedorbozgithub Date: Tue, 10 Jun 2025 09:38:33 +0100 Subject: [PATCH 35/52] fix(api): apply feedback --- .../unique-field-names.decorator.ts | 23 +++++++++++++ api/src/cms/dto/contentType.dto.ts | 2 ++ api/src/cms/schemas/content-type.schema.ts | 22 ++++++++++-- .../validate-unique-names.validator.ts | 34 +++++++++++++++++++ 4 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 api/src/cms/decorators/unique-field-names.decorator.ts create mode 100644 api/src/cms/validators/validate-unique-names.validator.ts diff --git a/api/src/cms/decorators/unique-field-names.decorator.ts b/api/src/cms/decorators/unique-field-names.decorator.ts new file mode 100644 index 00000000..1cbe555d --- /dev/null +++ b/api/src/cms/decorators/unique-field-names.decorator.ts @@ -0,0 +1,23 @@ +/* + * 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. + * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). + */ + +import { registerDecorator, ValidationOptions } from 'class-validator'; + +import { UniqueFieldNamesConstraint } from '../validators/validate-unique-names.validator'; + +export function UniqueFieldNames(validationOptions?: ValidationOptions) { + return function (object: Record, propertyName: string) { + registerDecorator({ + target: object.constructor, + propertyName, + options: validationOptions, + constraints: [], + validator: UniqueFieldNamesConstraint, + }); + }; +} diff --git a/api/src/cms/dto/contentType.dto.ts b/api/src/cms/dto/contentType.dto.ts index 70fddc4c..09abdc70 100644 --- a/api/src/cms/dto/contentType.dto.ts +++ b/api/src/cms/dto/contentType.dto.ts @@ -21,6 +21,7 @@ import { import { FieldType } from '@/setting/schemas/types'; import { DtoConfig } from '@/utils/types/dto.types'; +import { UniqueFieldNames } from '../decorators/unique-field-names.decorator'; import { ValidateRequiredFields } from '../validators/validate-required-fields.validator'; export class ContentField { @@ -56,6 +57,7 @@ export class ContentTypeCreateDto { @ValidateNested({ each: true }) @Validate(ValidateRequiredFields) @Type(() => ContentField) + @UniqueFieldNames() fields?: ContentField[]; } diff --git a/api/src/cms/schemas/content-type.schema.ts b/api/src/cms/schemas/content-type.schema.ts index 6b6b3e43..fb1c1632 100644 --- a/api/src/cms/schemas/content-type.schema.ts +++ b/api/src/cms/schemas/content-type.schema.ts @@ -7,7 +7,6 @@ */ import { ModelDefinition, Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; -import mongoose from 'mongoose'; import { FieldType } from '@/setting/schemas/types'; import { BaseSchema } from '@/utils/generics/base-schema'; @@ -28,7 +27,7 @@ export class ContentType extends BaseSchema { */ @Prop({ - type: mongoose.Schema.Types.Mixed, + type: [ContentField], default: [ { name: 'title', @@ -41,6 +40,25 @@ export class ContentType extends BaseSchema { type: FieldType.checkbox, }, ], + required: true, + validate: { + /** + * Ensures every `name` in the fields array is unique. + * Runs on `save`, `create`, `insertMany`, and `findOneAndUpdate` + * when `runValidators: true` is set. + */ + validator(fields: ContentField[]): boolean { + if (!Array.isArray(fields)) return false; + const seen = new Set(); + return fields.every((f) => { + if (seen.has(f.name)) return false; + seen.add(f.name); + return true; + }); + }, + message: + 'Each element in "fields" must have a unique "name" (duplicate detected)', + }, }) fields: ContentField[]; } diff --git a/api/src/cms/validators/validate-unique-names.validator.ts b/api/src/cms/validators/validate-unique-names.validator.ts new file mode 100644 index 00000000..3cc1c5d3 --- /dev/null +++ b/api/src/cms/validators/validate-unique-names.validator.ts @@ -0,0 +1,34 @@ +/* + * 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. + * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). + */ + +import { + ValidationArguments, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; + +import { ContentField } from '../dto/contentType.dto'; + +@ValidatorConstraint({ async: false }) +export class UniqueFieldNamesConstraint + implements ValidatorConstraintInterface +{ + validate(fields: ContentField[], _args: ValidationArguments) { + if (!Array.isArray(fields)) return false; + const seen = new Set(); + return fields.every((f) => { + if (seen.has(f.name)) return false; + seen.add(f.name); + return true; + }); + } + + defaultMessage(args: ValidationArguments) { + return `${args.property} contains duplicate "name" values; each field.name must be unique`; + } +} From 8490d4f253e0196b12c2d80d462153bcb360996c Mon Sep 17 00:00:00 2001 From: yassinedorbozgithub Date: Tue, 10 Jun 2025 09:43:00 +0100 Subject: [PATCH 36/52] fix(frontend): apply feedback --- .../content-types/ContentTypeForm.tsx | 18 +++--------------- frontend/src/types/content-type.types.ts | 1 - 2 files changed, 3 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/content-types/ContentTypeForm.tsx b/frontend/src/components/content-types/ContentTypeForm.tsx index 256ff2a9..cf819f07 100644 --- a/frontend/src/components/content-types/ContentTypeForm.tsx +++ b/frontend/src/components/content-types/ContentTypeForm.tsx @@ -8,7 +8,7 @@ import AddIcon from "@mui/icons-material/Add"; import { Button } from "@mui/material"; -import { FC, Fragment, useMemo } from "react"; +import { FC, Fragment } from "react"; import { useFieldArray, useForm } from "react-hook-form"; import { ContentContainer, ContentItem } from "@/app-components/dialogs"; @@ -24,29 +24,17 @@ import { IContentType, IContentTypeAttributes, } from "@/types/content-type.types"; -import { generateId } from "@/utils/generateId"; import { slugify } from "@/utils/string"; import { FieldInput } from "./components/FieldInput"; import { FIELDS_FORM_DEFAULT_VALUES } from "./constants"; export const ContentTypeForm: FC> = ({ - data: { defaultValues: contentTypeWithoutUuid }, + data: { defaultValues: contentType }, Wrapper = Fragment, WrapperProps, ...rest }) => { - const contentType = useMemo( - () => - contentTypeWithoutUuid && { - ...contentTypeWithoutUuid, - fields: contentTypeWithoutUuid?.fields?.map((field) => ({ - ...field, - uuid: generateId(), - })), - }, - [contentTypeWithoutUuid], - ); const { toast } = useToast(); const { t } = useTranslate(); const { @@ -140,7 +128,7 @@ export const ContentTypeForm: FC> = ({ control={control} onLabelChange={(value) => { const fieldName = contentType?.fields?.find( - ({ uuid }) => uuid === field.uuid, + ({ name }) => name === field.name, )?.name; if (!fieldName) { diff --git a/frontend/src/types/content-type.types.ts b/frontend/src/types/content-type.types.ts index 83d988b9..251f6b16 100644 --- a/frontend/src/types/content-type.types.ts +++ b/frontend/src/types/content-type.types.ts @@ -23,7 +23,6 @@ export type ContentField = { name: string; label: string; type: ContentFieldType; - uuid?: string; }; export interface IContentTypeAttributes { From c71d750bdc58b686e7c5605420069c37a36d8618 Mon Sep 17 00:00:00 2001 From: yassinedorbozgithub Date: Tue, 10 Jun 2025 10:29:22 +0100 Subject: [PATCH 37/52] api: apply feedback --- api/src/cms/schemas/content-type.schema.ts | 9 ++------ .../cms/utilities/field-validation.utils.ts | 21 +++++++++++++++++++ .../validate-unique-names.validator.ts | 9 ++------ frontend/src/types/content-type.types.ts | 2 +- 4 files changed, 26 insertions(+), 15 deletions(-) create mode 100644 api/src/cms/utilities/field-validation.utils.ts diff --git a/api/src/cms/schemas/content-type.schema.ts b/api/src/cms/schemas/content-type.schema.ts index fb1c1632..2a684036 100644 --- a/api/src/cms/schemas/content-type.schema.ts +++ b/api/src/cms/schemas/content-type.schema.ts @@ -13,6 +13,7 @@ import { BaseSchema } from '@/utils/generics/base-schema'; import { LifecycleHookManager } from '@/utils/generics/lifecycle-hook-manager'; import { ContentField } from '../dto/contentType.dto'; +import { validateUniqueFields } from '../utilities/field-validation.utils'; @Schema({ timestamps: true }) export class ContentType extends BaseSchema { @@ -48,13 +49,7 @@ export class ContentType extends BaseSchema { * when `runValidators: true` is set. */ validator(fields: ContentField[]): boolean { - if (!Array.isArray(fields)) return false; - const seen = new Set(); - return fields.every((f) => { - if (seen.has(f.name)) return false; - seen.add(f.name); - return true; - }); + return validateUniqueFields(fields, 'name'); }, message: 'Each element in "fields" must have a unique "name" (duplicate detected)', diff --git a/api/src/cms/utilities/field-validation.utils.ts b/api/src/cms/utilities/field-validation.utils.ts new file mode 100644 index 00000000..2b5b0170 --- /dev/null +++ b/api/src/cms/utilities/field-validation.utils.ts @@ -0,0 +1,21 @@ +/* + * 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. + * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). + */ + +export const validateUniqueFields = ( + fields: T[], + fieldName: keyof T, +): boolean => { + if (!Array.isArray(fields)) return false; + const seen = new Set(); + return fields.every((f) => { + const fieldValue = f[fieldName] as string; + if (seen.has(fieldValue)) return false; + seen.add(fieldValue); + return true; + }); +}; diff --git a/api/src/cms/validators/validate-unique-names.validator.ts b/api/src/cms/validators/validate-unique-names.validator.ts index 3cc1c5d3..051411e0 100644 --- a/api/src/cms/validators/validate-unique-names.validator.ts +++ b/api/src/cms/validators/validate-unique-names.validator.ts @@ -13,19 +13,14 @@ import { } from 'class-validator'; import { ContentField } from '../dto/contentType.dto'; +import { validateUniqueFields } from '../utilities/field-validation.utils'; @ValidatorConstraint({ async: false }) export class UniqueFieldNamesConstraint implements ValidatorConstraintInterface { validate(fields: ContentField[], _args: ValidationArguments) { - if (!Array.isArray(fields)) return false; - const seen = new Set(); - return fields.every((f) => { - if (seen.has(f.name)) return false; - seen.add(f.name); - return true; - }); + return validateUniqueFields(fields, 'name'); } defaultMessage(args: ValidationArguments) { diff --git a/frontend/src/types/content-type.types.ts b/frontend/src/types/content-type.types.ts index 251f6b16..ee15b4c2 100644 --- a/frontend/src/types/content-type.types.ts +++ b/frontend/src/types/content-type.types.ts @@ -1,5 +1,5 @@ /* - * Copyright © 2025 Hexastack. All rights reserved. + * Copyright © 2024 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. From f4ca6613c766a0fb30b76df4bc1143e6f3ccb1fe Mon Sep 17 00:00:00 2001 From: yassinedorbozgithub Date: Tue, 10 Jun 2025 18:14:22 +0100 Subject: [PATCH 38/52] fix: apply feedback --- api/src/cms/schemas/content-type.schema.ts | 4 ++-- .../validators/validate-unique-names.validator.ts | 4 ++-- api/src/utils/test/fixtures/contenttype.ts | 2 +- .../components/content-types/ContentTypeForm.tsx | 14 ++++---------- 4 files changed, 9 insertions(+), 15 deletions(-) diff --git a/api/src/cms/schemas/content-type.schema.ts b/api/src/cms/schemas/content-type.schema.ts index 2a684036..f511d4ed 100644 --- a/api/src/cms/schemas/content-type.schema.ts +++ b/api/src/cms/schemas/content-type.schema.ts @@ -49,10 +49,10 @@ export class ContentType extends BaseSchema { * when `runValidators: true` is set. */ validator(fields: ContentField[]): boolean { - return validateUniqueFields(fields, 'name'); + return validateUniqueFields(fields, 'label'); }, message: - 'Each element in "fields" must have a unique "name" (duplicate detected)', + 'Each element in "fields" must have a unique "label" (duplicate detected)', }, }) fields: ContentField[]; diff --git a/api/src/cms/validators/validate-unique-names.validator.ts b/api/src/cms/validators/validate-unique-names.validator.ts index 051411e0..3ea81e5c 100644 --- a/api/src/cms/validators/validate-unique-names.validator.ts +++ b/api/src/cms/validators/validate-unique-names.validator.ts @@ -20,10 +20,10 @@ export class UniqueFieldNamesConstraint implements ValidatorConstraintInterface { validate(fields: ContentField[], _args: ValidationArguments) { - return validateUniqueFields(fields, 'name'); + return validateUniqueFields(fields, 'label'); } defaultMessage(args: ValidationArguments) { - return `${args.property} contains duplicate "name" values; each field.name must be unique`; + return `${args.property} contains duplicate "label" values; each field.name must be unique`; } } diff --git a/api/src/utils/test/fixtures/contenttype.ts b/api/src/utils/test/fixtures/contenttype.ts index 016bc323..0f751f8c 100644 --- a/api/src/utils/test/fixtures/contenttype.ts +++ b/api/src/utils/test/fixtures/contenttype.ts @@ -64,7 +64,7 @@ const contentTypes: TContentTypeFixtures['values'][] = [ }, { name: 'subtitle', - label: 'Image', + label: 'Subtitle', type: FieldType.file, }, ], diff --git a/frontend/src/components/content-types/ContentTypeForm.tsx b/frontend/src/components/content-types/ContentTypeForm.tsx index cf819f07..8276cd9c 100644 --- a/frontend/src/components/content-types/ContentTypeForm.tsx +++ b/frontend/src/components/content-types/ContentTypeForm.tsx @@ -52,16 +52,10 @@ export const ContentTypeForm: FC> = ({ const { append, fields, remove } = useFieldArray({ name: "fields", rules: { - validate: (value) => { - const labelCounts = value.reduce((acc, field) => { - if (!field.label.trim()) return acc; - acc[field.label] = (acc[field.label] || 0) + 1; - - return acc; - }, {} as Record); - const hasDuplicatedLabels = Object.values(labelCounts).some( - (count: number) => count > 1, - ); + validate: (fields) => { + const hasDuplicatedLabels = + new Set(fields.map((f) => f["label"] as string)).size === + fields.length; if (hasDuplicatedLabels) { toast.error(t("message.duplicate_labels_not_allowed")); From bac359761473d2cece209e237a7b0f93c143a72d Mon Sep 17 00:00:00 2001 From: yassinedorbozgithub Date: Tue, 10 Jun 2025 18:19:38 +0100 Subject: [PATCH 39/52] fix: apply feedback --- api/src/cms/utilities/field-validation.utils.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/api/src/cms/utilities/field-validation.utils.ts b/api/src/cms/utilities/field-validation.utils.ts index 2b5b0170..33dd212e 100644 --- a/api/src/cms/utilities/field-validation.utils.ts +++ b/api/src/cms/utilities/field-validation.utils.ts @@ -9,13 +9,5 @@ export const validateUniqueFields = ( fields: T[], fieldName: keyof T, -): boolean => { - if (!Array.isArray(fields)) return false; - const seen = new Set(); - return fields.every((f) => { - const fieldValue = f[fieldName] as string; - if (seen.has(fieldValue)) return false; - seen.add(fieldValue); - return true; - }); -}; +): boolean => + new Set(fields.map((f) => f[fieldName] as string)).size === fields.length; From 5b08d9d36f20480d40791f74b2f3a81426935960 Mon Sep 17 00:00:00 2001 From: yassinedorbozgithub Date: Tue, 10 Jun 2025 18:25:41 +0100 Subject: [PATCH 40/52] fix: resolve ui unique labels --- frontend/src/components/content-types/ContentTypeForm.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/content-types/ContentTypeForm.tsx b/frontend/src/components/content-types/ContentTypeForm.tsx index 8276cd9c..260438fd 100644 --- a/frontend/src/components/content-types/ContentTypeForm.tsx +++ b/frontend/src/components/content-types/ContentTypeForm.tsx @@ -53,11 +53,11 @@ export const ContentTypeForm: FC> = ({ name: "fields", rules: { validate: (fields) => { - const hasDuplicatedLabels = + const hasUniqueLabels = new Set(fields.map((f) => f["label"] as string)).size === fields.length; - if (hasDuplicatedLabels) { + if (!hasUniqueLabels) { toast.error(t("message.duplicate_labels_not_allowed")); return false; From af502e97dd6e0dd286ae5a9ea41f023e7ec4c162 Mon Sep 17 00:00:00 2001 From: yassinedorbozgithub Date: Tue, 10 Jun 2025 18:33:12 +0100 Subject: [PATCH 41/52] fix: update jsdoc --- api/src/cms/schemas/content-type.schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/cms/schemas/content-type.schema.ts b/api/src/cms/schemas/content-type.schema.ts index f511d4ed..9143b2aa 100644 --- a/api/src/cms/schemas/content-type.schema.ts +++ b/api/src/cms/schemas/content-type.schema.ts @@ -44,7 +44,7 @@ export class ContentType extends BaseSchema { required: true, validate: { /** - * Ensures every `name` in the fields array is unique. + * Ensures every `label` in the fields array is unique. * Runs on `save`, `create`, `insertMany`, and `findOneAndUpdate` * when `runValidators: true` is set. */ From 236c0bad19a33ad61ee35641a1972ec26fa16f1c Mon Sep 17 00:00:00 2001 From: yassinedorbozgithub Date: Wed, 11 Jun 2025 11:00:49 +0100 Subject: [PATCH 42/52] fix: apply feedback --- .../content-types/ContentTypeForm.tsx | 26 +++++++----------- .../content-types/components/FieldInput.tsx | 27 ++++++++++++------- 2 files changed, 27 insertions(+), 26 deletions(-) diff --git a/frontend/src/components/content-types/ContentTypeForm.tsx b/frontend/src/components/content-types/ContentTypeForm.tsx index 260438fd..ffee9df7 100644 --- a/frontend/src/components/content-types/ContentTypeForm.tsx +++ b/frontend/src/components/content-types/ContentTypeForm.tsx @@ -24,7 +24,6 @@ import { IContentType, IContentTypeAttributes, } from "@/types/content-type.types"; -import { slugify } from "@/utils/string"; import { FieldInput } from "./components/FieldInput"; import { FIELDS_FORM_DEFAULT_VALUES } from "./constants"; @@ -44,10 +43,12 @@ export const ContentTypeForm: FC> = ({ formState: { errors }, handleSubmit, } = useForm({ - defaultValues: contentType || { - name: "", - fields: FIELDS_FORM_DEFAULT_VALUES, - }, + defaultValues: contentType + ? { name: contentType.name, fields: contentType.fields } + : { + name: "", + fields: FIELDS_FORM_DEFAULT_VALUES, + }, }); const { append, fields, remove } = useFieldArray({ name: "fields", @@ -119,19 +120,10 @@ export const ContentTypeForm: FC> = ({ > { - const fieldName = contentType?.fields?.find( - ({ name }) => name === field.name, - )?.name; - - if (!fieldName) { - setValue(`fields.${idx}.name`, value ? slugify(value) : ""); - } - }} - onRemove={() => { - remove(idx); - }} + setValue={setValue} /> ))} diff --git a/frontend/src/components/content-types/components/FieldInput.tsx b/frontend/src/components/content-types/components/FieldInput.tsx index 0294971a..fb929add 100644 --- a/frontend/src/components/content-types/components/FieldInput.tsx +++ b/frontend/src/components/content-types/components/FieldInput.tsx @@ -9,23 +9,28 @@ import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; import { MenuItem } from "@mui/material"; import { useMemo } from "react"; -import { Control, Controller } from "react-hook-form"; +import { Control, Controller, UseFormSetValue } from "react-hook-form"; import { IconButton } from "@/app-components/buttons/IconButton"; import { Input } from "@/app-components/inputs/Input"; import { useTranslate } from "@/hooks/useTranslate"; import { ContentFieldType, IContentType } from "@/types/content-type.types"; +import { slugify } from "@/utils/string"; import { READ_ONLY_FIELDS } from "../constants"; export const FieldInput = ({ idx, - ...props + name, + remove, + control, + setValue, }: { idx: number; + name: string; + remove: (index?: number | number[]) => void; control: Control; - onRemove?: () => void; - onLabelChange?: (value: string) => void; + setValue: UseFormSetValue; }) => { const { t } = useTranslate(); const isDisabled = useMemo(() => idx < READ_ONLY_FIELDS.length, [idx]); @@ -36,13 +41,13 @@ export const FieldInput = ({ variant="text" color="error" size="medium" - onClick={() => props.onRemove?.()} + onClick={() => remove(idx)} disabled={isDisabled} > ( @@ -53,7 +58,11 @@ export const FieldInput = ({ error={!!fieldState.error} helperText={fieldState.error?.message} onChange={(e) => { - props?.onLabelChange?.(e.target.value); + const value = e.target.value; + + if (!name) { + setValue(`fields.${idx}.name`, value ? slugify(value) : ""); + } field.onChange(e); }} /> @@ -64,11 +73,11 @@ export const FieldInput = ({ render={({ field }) => ( )} - control={props.control} + control={control} /> ( Date: Wed, 11 Jun 2025 11:02:46 +0100 Subject: [PATCH 43/52] test: consolidate tests --- api/src/chat/services/block.service.ts | 4 +- .../controllers/nlp-sample.controller.spec.ts | 10 ++++ .../nlp/repositories/nlp-sample.repository.ts | 50 ++++++++++------- .../nlp/services/nlp-sample.service.spec.ts | 54 ++++++++++++++++++- api/src/nlp/services/nlp-sample.service.ts | 2 +- api/src/utils/generics/base-repository.ts | 4 +- 6 files changed, 99 insertions(+), 25 deletions(-) diff --git a/api/src/chat/services/block.service.ts b/api/src/chat/services/block.service.ts index d85b7bc1..af561a7e 100644 --- a/api/src/chat/services/block.service.ts +++ b/api/src/chat/services/block.service.ts @@ -95,8 +95,8 @@ export class BlockService extends BaseService< block: B, subscriber?: Subscriber, ) { - if (!subscriber) { - return block; + if (!subscriber || !subscriber.labels) { + return true; // No subscriber or labels to match against } const triggerLabels = block.trigger_labels.map((l: string | Label) => diff --git a/api/src/nlp/controllers/nlp-sample.controller.spec.ts b/api/src/nlp/controllers/nlp-sample.controller.spec.ts index 464f3276..54abd46d 100644 --- a/api/src/nlp/controllers/nlp-sample.controller.spec.ts +++ b/api/src/nlp/controllers/nlp-sample.controller.spec.ts @@ -207,12 +207,16 @@ describe('NlpSampleController', () => { const patterns: NlpValueMatchPattern[] = [ { entity: 'intent', match: 'value', value: 'nonexistent' }, ]; + jest.spyOn(nlpSampleService, 'findByPatternsAndPopulate'); const result = await nlpSampleController.findPage( pageQuery, ['language', 'entities'], {}, patterns, ); + expect(nlpSampleService.findByPatternsAndPopulate).toHaveBeenCalledTimes( + 1, + ); expect(Array.isArray(result)).toBe(true); expect(result).toHaveLength(0); }); @@ -220,7 +224,9 @@ describe('NlpSampleController', () => { describe('count', () => { it('should count the nlp samples', async () => { + jest.spyOn(nlpSampleService, 'count'); const result = await nlpSampleController.count({}); + expect(nlpSampleService.count).toHaveBeenCalledTimes(1); const count = nlpSampleFixtures.length; expect(result).toEqual({ count }); }); @@ -478,7 +484,9 @@ describe('NlpSampleController', () => { describe('filterCount', () => { it('should count the nlp samples without patterns', async () => { const filters = { text: 'Hello' }; + jest.spyOn(nlpSampleService, 'countByPatterns'); const result = await nlpSampleController.filterCount(filters, []); + expect(nlpSampleService.countByPatterns).toHaveBeenCalledTimes(1); expect(result).toEqual({ count: 1 }); }); @@ -487,7 +495,9 @@ describe('NlpSampleController', () => { const patterns: NlpValueMatchPattern[] = [ { entity: 'intent', match: 'value', value: 'greeting' }, ]; + jest.spyOn(nlpSampleService, 'countByPatterns'); const result = await nlpSampleController.filterCount(filters, patterns); + expect(nlpSampleService.countByPatterns).toHaveBeenCalledTimes(1); expect(result).toEqual({ count: 1 }); }); diff --git a/api/src/nlp/repositories/nlp-sample.repository.ts b/api/src/nlp/repositories/nlp-sample.repository.ts index 13c38544..37aa50db 100644 --- a/api/src/nlp/repositories/nlp-sample.repository.ts +++ b/api/src/nlp/repositories/nlp-sample.repository.ts @@ -52,6 +52,35 @@ export class NlpSampleRepository extends BaseRepository< super(model, NlpSample, NLP_SAMPLE_POPULATE, NlpSampleFull); } + /** + * Normalize the filter query. + * + * @param filters - The filters to normalize. + * @returns The normalized filters. + */ + private normalizeFilters( + filters: TFilterQuery, + ): TFilterQuery { + if (filters?.$and) { + return { + ...filters, + $and: filters.$and.map((condition) => { + // @todo: think of a better way to handle language to objectId conversion + // This is a workaround for the fact that language is stored as an ObjectId + // in the database, but we want to filter by its string representation. + if ('language' in condition && condition.language) { + return { + ...condition, + language: new Types.ObjectId(condition.language as string), + }; + } + return condition; + }), + }; + } + return filters; + } + /** * Build the aggregation stages that restrict a *nlpSampleEntities* collection * to links which: @@ -77,27 +106,12 @@ export class NlpSampleRepository extends BaseRepository< value: new Types.ObjectId(id), })); + const normalizedFilters = this.normalizeFilters(filters); + return [ { $match: { - // @todo: think of a better way to handle language to objectId conversion - // This is a workaround for the fact that language is stored as an ObjectId - // in the database, but we want to filter by its string representation. - ...filters, - ...(filters?.$and - ? { - $and: filters.$and?.map((condition) => { - if ('language' in condition && condition.language) { - return { - language: new Types.ObjectId( - condition.language as string, - ), - }; - } - return condition; - }), - } - : {}), + ...normalizedFilters, }, }, diff --git a/api/src/nlp/services/nlp-sample.service.spec.ts b/api/src/nlp/services/nlp-sample.service.spec.ts index 40e76b4e..e9ba31af 100644 --- a/api/src/nlp/services/nlp-sample.service.spec.ts +++ b/api/src/nlp/services/nlp-sample.service.spec.ts @@ -54,6 +54,7 @@ describe('NlpSampleService', () => { let nlpEntityService: NlpEntityService; let nlpSampleService: NlpSampleService; let nlpSampleEntityService: NlpSampleEntityService; + let nlpValueService: NlpValueService; let languageService: LanguageService; let nlpSampleEntityRepository: NlpSampleEntityRepository; let nlpSampleRepository: NlpSampleRepository; @@ -100,6 +101,7 @@ describe('NlpSampleService', () => { nlpEntityService, nlpSampleService, nlpSampleEntityService, + nlpValueService, nlpSampleRepository, nlpSampleEntityRepository, nlpSampleEntityRepository, @@ -109,6 +111,7 @@ describe('NlpSampleService', () => { NlpEntityService, NlpSampleService, NlpSampleEntityService, + NlpValueService, NlpSampleRepository, NlpSampleEntityRepository, NlpSampleEntityRepository, @@ -364,17 +367,29 @@ describe('NlpSampleService', () => { }); describe('findByPatterns', () => { + it('should return samples without providing patterns', async () => { + const result = await nlpSampleService.findByPatterns( + { filters: {}, patterns: [] }, + undefined, + ); + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + }); + it('should return samples matching the given patterns', async () => { // Assume pattern: entity 'intent', value 'greeting' const patterns: NlpValueMatchPattern[] = [ { entity: 'intent', match: 'value', value: 'greeting' }, ]; - + jest.spyOn(nlpSampleRepository, 'findByEntities'); + jest.spyOn(nlpValueService, 'findByPatterns'); const result = await nlpSampleService.findByPatterns( { filters: {}, patterns }, undefined, ); - + expect(nlpSampleRepository.findByEntities).toHaveBeenCalled(); + expect(nlpValueService.findByPatterns).toHaveBeenCalled(); expect(Array.isArray(result)).toBe(true); expect(result[0].text).toBe('Hello'); }); @@ -384,11 +399,15 @@ describe('NlpSampleService', () => { { entity: 'intent', match: 'value', value: 'nonexistent' }, ]; + jest.spyOn(nlpSampleRepository, 'findByEntities'); + jest.spyOn(nlpValueService, 'findByPatterns'); const result = await nlpSampleService.findByPatterns( { filters: {}, patterns }, undefined, ); + expect(nlpSampleRepository.findByEntities).not.toHaveBeenCalled(); + expect(nlpValueService.findByPatterns).toHaveBeenCalled(); expect(Array.isArray(result)).toBe(true); expect(result).toHaveLength(0); }); @@ -434,6 +453,19 @@ describe('NlpSampleService', () => { }); }); + it('should return populated NlpSampleFull without providing patterns', async () => { + const result = await nlpSampleService.findByPatternsAndPopulate( + { filters: { text: /Hello/gi }, patterns: [] }, + undefined, + ); + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(1); + expect(result[0]).toBeInstanceOf(NlpSampleFull); + expect(result[0].entities).toBeDefined(); + expect(Array.isArray(result[0].entities)).toBe(true); + }); + it('should return an empty array if no samples match the patterns', async () => { const patterns: NlpValueMatchPattern[] = [ { entity: 'intent', match: 'value', value: 'nonexistent' }, @@ -474,15 +506,33 @@ describe('NlpSampleService', () => { { entity: 'intent', match: 'value', value: 'greeting' }, ]; + jest.spyOn(nlpSampleRepository, 'countByEntities'); + jest.spyOn(nlpValueService, 'findByPatterns'); const count = await nlpSampleService.countByPatterns({ filters: {}, patterns, }); + expect(nlpSampleRepository.countByEntities).toHaveBeenCalled(); + expect(nlpValueService.findByPatterns).toHaveBeenCalled(); expect(typeof count).toBe('number'); expect(count).toBe(2); }); + it('should return the correct count without providing patterns', async () => { + jest.spyOn(nlpSampleRepository, 'findByEntities'); + jest.spyOn(nlpValueService, 'findByPatterns'); + const count = await nlpSampleService.countByPatterns({ + filters: {}, + patterns: [], + }); + + expect(nlpSampleRepository.findByEntities).not.toHaveBeenCalled(); + expect(nlpValueService.findByPatterns).not.toHaveBeenCalled(); + expect(typeof count).toBe('number'); + expect(count).toBeGreaterThan(2); + }); + it('should return 0 if no samples match the patterns', async () => { const patterns: NlpValueMatchPattern[] = [ { entity: 'intent', match: 'value', value: 'nonexistent' }, diff --git a/api/src/nlp/services/nlp-sample.service.ts b/api/src/nlp/services/nlp-sample.service.ts index 94de75fb..5f789643 100644 --- a/api/src/nlp/services/nlp-sample.service.ts +++ b/api/src/nlp/services/nlp-sample.service.ts @@ -104,7 +104,7 @@ export class NlpSampleService extends BaseService< * Same as `findByPatterns`, but also populates all relations declared * in the repository (`populatePaths`). * - * @param criteras `{ filters, patterns }` + * @param criteria `{ filters, patterns }` * @param page Optional paging / sorting descriptor. * @param projection Optional Mongo projection. * @returns Promise resolving to the populated samples. diff --git a/api/src/utils/generics/base-repository.ts b/api/src/utils/generics/base-repository.ts index 6258247e..18ef046f 100644 --- a/api/src/utils/generics/base-repository.ts +++ b/api/src/utils/generics/base-repository.ts @@ -378,7 +378,7 @@ export abstract class BaseRepository< criteria: string | TFilterQuery, options?: ClassTransformOptions, projection?: ProjectionType, - ) { + ): Promise { if (!criteria) { // @TODO : Issue a warning ? return null; @@ -768,7 +768,7 @@ export abstract class BaseRepository< * @param filter Mongo filter selecting the documents to update. * @param dto Update payload. * @param options `{ shouldFlatten?: boolean }`. - * @returns MongoDB `UpdateWriteOpResult` describing the operation outcome. + * @returns Promise that resolves a MongoDB `UpdateWriteOpResult` describing the operation outcome. */ async updateMany>( filter: TFilterQuery, From 5ba208c5e5fea7b6af3f5896e51b5df8a0a98030 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Wed, 11 Jun 2025 13:51:58 +0100 Subject: [PATCH 44/52] fix: remove unused dep injection --- api/src/nlp/repositories/nlp-sample.repository.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/api/src/nlp/repositories/nlp-sample.repository.ts b/api/src/nlp/repositories/nlp-sample.repository.ts index 37aa50db..1be94717 100644 --- a/api/src/nlp/repositories/nlp-sample.repository.ts +++ b/api/src/nlp/repositories/nlp-sample.repository.ts @@ -24,7 +24,6 @@ import { PageQueryDto } from '@/utils/pagination/pagination-query.dto'; import { TFilterQuery } from '@/utils/types/filter.types'; import { TNlpSampleDto } from '../dto/nlp-sample.dto'; -import { NlpSampleEntity } from '../schemas/nlp-sample-entity.schema'; import { NLP_SAMPLE_POPULATE, NlpSample, @@ -45,8 +44,6 @@ export class NlpSampleRepository extends BaseRepository< > { constructor( @InjectModel(NlpSample.name) readonly model: Model, - @InjectModel(NlpSampleEntity.name) - readonly sampleEntityModel: Model, private readonly nlpSampleEntityRepository: NlpSampleEntityRepository, ) { super(model, NlpSample, NLP_SAMPLE_POPULATE, NlpSampleFull); From d88959cea2bbf597486997ea72539700a9fed6f9 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Wed, 11 Jun 2025 14:29:34 +0100 Subject: [PATCH 45/52] refactor: handle ongoing conversation message --- api/src/chat/services/bot.service.spec.ts | 122 ++++++++++++++++++++-- api/src/chat/services/bot.service.ts | 77 +++++++++----- 2 files changed, 162 insertions(+), 37 deletions(-) diff --git a/api/src/chat/services/bot.service.spec.ts b/api/src/chat/services/bot.service.spec.ts index d8b61596..9fb7fa87 100644 --- a/api/src/chat/services/bot.service.spec.ts +++ b/api/src/chat/services/bot.service.spec.ts @@ -88,6 +88,7 @@ import { SubscriberService } from './subscriber.service'; describe('BotService', () => { let blockService: BlockService; let subscriberService: SubscriberService; + let conversationService: ConversationService; let botService: BotService; let handler: WebChannelHandler; let eventEmitter: EventEmitter2; @@ -192,14 +193,21 @@ describe('BotService', () => { }, ], }); - [subscriberService, botService, blockService, eventEmitter, handler] = - await getMocks([ - SubscriberService, - BotService, - BlockService, - EventEmitter2, - WebChannelHandler, - ]); + [ + subscriberService, + conversationService, + botService, + blockService, + eventEmitter, + handler, + ] = await getMocks([ + SubscriberService, + ConversationService, + BotService, + BlockService, + EventEmitter2, + WebChannelHandler, + ]); }); afterEach(jest.clearAllMocks); @@ -351,4 +359,102 @@ describe('BotService', () => { expect(captured).toBe(false); expect(triggeredEvents).toEqual([]); }); + + describe('proceedToNextBlock', () => { + it('should emit stats and call triggerBlock, returning true on success and reset attempt if not fallback', async () => { + const convo = { + id: 'convo1', + context: { attempt: 2 }, + next: [], + sender: 'user1', + active: true, + } as unknown as ConversationFull; + const next = { id: 'block1', name: 'Block 1' } as BlockFull; + const event = {} as any; + const fallback = false; + + jest + .spyOn(conversationService, 'storeContextData') + .mockImplementation((convo, _next, _event, _captureVars) => { + return Promise.resolve({ + ...convo, + } as Conversation); + }); + + jest.spyOn(botService, 'triggerBlock').mockResolvedValue(undefined); + const emitSpy = jest.spyOn(eventEmitter, 'emit'); + const result = await botService.proceedToNextBlock( + convo, + next, + event, + fallback, + ); + + expect(emitSpy).toHaveBeenCalledWith( + 'hook:stats:entry', + 'popular', + next.name, + ); + + expect(botService.triggerBlock).toHaveBeenCalledWith( + event, + expect.objectContaining({ id: 'convo1' }), + next, + fallback, + ); + expect(result).toBe(true); + expect(convo.context.attempt).toBe(0); + }); + + it('should increment attempt if fallback is true', async () => { + const convo = { + id: 'convo2', + context: { attempt: 1 }, + next: [], + sender: 'user2', + active: true, + } as any; + const next = { id: 'block2', name: 'Block 2' } as any; + const event = {} as any; + const fallback = true; + + const result = await botService.proceedToNextBlock( + convo, + next, + event, + fallback, + ); + + expect(convo.context.attempt).toBe(2); + expect(result).toBe(true); + }); + + it('should handle errors and emit conversation:end, returning false', async () => { + const convo = { + id: 'convo3', + context: { attempt: 1 }, + next: [], + sender: 'user3', + active: true, + } as any; + const next = { id: 'block3', name: 'Block 3' } as any; + const event = {} as any; + const fallback = false; + + jest + .spyOn(conversationService, 'storeContextData') + .mockRejectedValue(new Error('fail')); + + const emitSpy = jest.spyOn(eventEmitter, 'emit'); + const result = await botService.proceedToNextBlock( + convo, + next, + event, + fallback, + ); + + expect(emitSpy).toHaveBeenCalledWith('hook:conversation:end', convo); + expect(result).toBe(false); + }); + }); }); diff --git a/api/src/chat/services/bot.service.ts b/api/src/chat/services/bot.service.ts index ae3dac76..4007d70e 100644 --- a/api/src/chat/services/bot.service.ts +++ b/api/src/chat/services/bot.service.ts @@ -11,6 +11,7 @@ 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 { LoggerService } from '@/logger/logger.service'; import { SettingService } from '@/setting/services/setting.service'; @@ -41,6 +42,7 @@ export class BotService { private readonly conversationService: ConversationService, private readonly subscriberService: SubscriberService, private readonly settingService: SettingService, + private readonly helperService: HelperService, ) {} /** @@ -243,6 +245,49 @@ 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. + */ + async proceedToNextBlock( + convo: ConversationFull, + next: BlockFull, + event: EventWrapper, + fallback: boolean, + ): Promise { + // 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, + ); + + try { + convo.context.attempt = fallback ? convo.context.attempt + 1 : 0; + const updatedConversation = + await this.conversationService.storeContextData( + convo, + next, + event, + // If this is a local fallback then we don’t capture vars. + !fallback, + ); + + await this.triggerBlock(event, updatedConversation, next, fallback); + return true; + } catch (err) { + this.logger.error('Unable to proceed to the next block!', err); + this.eventEmitter.emit('hook:conversation:end', convo); + return false; + } + } + /** * Processes and responds to an incoming message within an ongoing conversation flow. * Determines the next block in the conversation, attempts to match the message with available blocks, @@ -283,7 +328,7 @@ export class BotService { ); // 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 - let fallbackBlock: BlockFull | undefined; + let fallbackBlock: BlockFull | undefined = undefined; if ( !matchedBlock && event.getMessageType() === IncomingMessageType.message && @@ -303,11 +348,7 @@ export class BotService { category: null, previousBlocks: [], }; - convo.context.attempt++; fallback = true; - } else { - convo.context.attempt = 0; - fallbackBlock = undefined; } const next = matchedBlock || fallbackBlock; @@ -315,30 +356,8 @@ export class BotService { this.logger.debug('Responding ...', convo.id); if (next) { - // Increment stats about popular blocks - this.eventEmitter.emit( - 'hook:stats:entry', - BotStatsType.popular, - next.name, - ); - // Go next! - this.logger.debug('Respond to nested conversion! Go next ', next.id); - try { - const updatedConversation = - await this.conversationService.storeContextData( - convo, - next, - event, - // If this is a local fallback then we don't capture vars - // Otherwise, old captured const value may be replaced by another const value - !fallback, - ); - await this.triggerBlock(event, updatedConversation, next, fallback); - } catch (err) { - this.logger.error('Unable to store context data!', err); - return this.eventEmitter.emit('hook:conversation:end', convo); - } - return true; + // Proceed to the execution of the next block + return await this.proceedToNextBlock(convo, next, event, fallback); } else { // Conversation is still active, but there's no matching block to call next // We'll end the conversation but this message is probably lost in time and space. From b973c466d741d561faaa917649283c5020a6dd8f Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Wed, 11 Jun 2025 16:44:14 +0100 Subject: [PATCH 46/52] test: consolidate unit tests --- api/src/chat/services/bot.service.spec.ts | 423 ++++++++++++++-------- api/src/chat/services/bot.service.ts | 41 ++- 2 files changed, 298 insertions(+), 166 deletions(-) diff --git a/api/src/chat/services/bot.service.spec.ts b/api/src/chat/services/bot.service.spec.ts index 9fb7fa87..0e4643f0 100644 --- a/api/src/chat/services/bot.service.spec.ts +++ b/api/src/chat/services/bot.service.spec.ts @@ -51,6 +51,8 @@ import { SettingService } from '@/setting/services/setting.service'; import { installBlockFixtures } from '@/utils/test/fixtures/block'; import { installContentFixtures } from '@/utils/test/fixtures/content'; import { installSubscriberFixtures } from '@/utils/test/fixtures/subscriber'; +import { mockWebChannelData, textBlock } from '@/utils/test/mocks/block'; +import { conversationGetStarted } from '@/utils/test/mocks/conversation'; import { closeInMongodConnection, rootMongooseTestModule, @@ -212,157 +214,149 @@ describe('BotService', () => { afterEach(jest.clearAllMocks); afterAll(closeInMongodConnection); - - it('should start a conversation', async () => { - const triggeredEvents: any[] = []; - - eventEmitter.on('hook:stats:entry', (...args) => { - triggeredEvents.push(args); + describe('startConversation', () => { + afterAll(() => { + jest.restoreAllMocks(); }); - const event = new WebEventWrapper(handler, webEventText, { - isSocket: false, - ipAddress: '1.1.1.1', - agent: 'Chromium', - }); + it('should start a conversation', async () => { + const triggeredEvents: any[] = []; - const [block] = await blockService.findAndPopulate({ patterns: ['Hi'] }); - const webSubscriber = (await subscriberService.findOne({ - foreign_id: 'foreign-id-web-1', - }))!; + eventEmitter.on('hook:stats:entry', (...args) => { + triggeredEvents.push(args); + }); - event.setSender(webSubscriber); - - let hasBotSpoken = false; - const clearMock = jest - .spyOn(botService, 'triggerBlock') - .mockImplementation( - ( - actualEvent: WebEventWrapper, - actualConversation: Conversation, - actualBlock: BlockFull, - isFallback: boolean, - ) => { - expect(actualConversation).toEqualPayload({ - sender: webSubscriber.id, - active: true, - next: [], - context: { - user: { - first_name: webSubscriber.first_name, - last_name: webSubscriber.last_name, - language: 'en', - id: webSubscriber.id, - }, - user_location: { - lat: 0, - lon: 0, - }, - skip: {}, - vars: {}, - nlp: null, - payload: null, - attempt: 0, - channel: 'web-channel', - text: webEventText.data.text, - }, - }); - expect(actualEvent).toEqual(event); - expect(actualBlock).toEqual(block); - expect(isFallback).toEqual(false); - hasBotSpoken = true; - }, + const event = new WebEventWrapper( + handler, + webEventText, + mockWebChannelData, ); - await botService.startConversation(event, block); - expect(hasBotSpoken).toEqual(true); - expect(triggeredEvents).toEqual([ - ['popular', 'hasNextBlocks'], - ['new_conversations', 'New conversations'], - ]); - clearMock.mockClear(); - }); + const [block] = await blockService.findAndPopulate({ patterns: ['Hi'] }); + const webSubscriber = (await subscriberService.findOne({ + foreign_id: 'foreign-id-web-1', + }))!; - it('should capture a conversation', async () => { - const triggeredEvents: any[] = []; + event.setSender(webSubscriber); - eventEmitter.on('hook:stats:entry', (...args) => { - triggeredEvents.push(args); - }); - - const event = new WebEventWrapper(handler, webEventText, { - isSocket: false, - ipAddress: '1.1.1.1', - agent: 'Chromium', - }); - const webSubscriber = (await subscriberService.findOne({ - foreign_id: 'foreign-id-web-1', - }))!; - event.setSender(webSubscriber); - - const clearMock = jest - .spyOn(botService, 'handleOngoingConversationMessage') - .mockImplementation( - async ( - actualConversation: ConversationFull, - event: WebEventWrapper, - ) => { - expect(actualConversation).toEqualPayload({ - next: [], - sender: webSubscriber, - active: true, - context: { - user: { - first_name: webSubscriber.first_name, - last_name: webSubscriber.last_name, - language: 'en', - id: webSubscriber.id, + let hasBotSpoken = false; + const clearMock = jest + .spyOn(botService, 'triggerBlock') + .mockImplementation( + ( + actualEvent: WebEventWrapper, + actualConversation: Conversation, + actualBlock: BlockFull, + isFallback: boolean, + ) => { + expect(actualConversation).toEqualPayload({ + sender: webSubscriber.id, + active: true, + next: [], + context: { + user: { + first_name: webSubscriber.first_name, + last_name: webSubscriber.last_name, + language: 'en', + id: webSubscriber.id, + }, + user_location: { + lat: 0, + lon: 0, + }, + skip: {}, + vars: {}, + nlp: null, + payload: null, + attempt: 0, + channel: 'web-channel', + text: webEventText.data.text, }, - user_location: { lat: 0, lon: 0 }, - vars: {}, - skip: {}, - nlp: null, - payload: null, - attempt: 0, - channel: 'web-channel', - text: webEventText.data.text, - }, - }); - expect(event).toEqual(event); - return true; - }, - ); - const captured = await botService.processConversationMessage(event); - expect(captured).toBe(true); - expect(triggeredEvents).toEqual([ - ['existing_conversations', 'Existing conversations'], - ]); - clearMock.mockClear(); + }); + expect(actualEvent).toEqual(event); + expect(actualBlock).toEqual(block); + expect(isFallback).toEqual(false); + hasBotSpoken = true; + }, + ); + + await botService.startConversation(event, block); + expect(hasBotSpoken).toEqual(true); + expect(triggeredEvents).toEqual([ + ['popular', 'hasNextBlocks'], + ['new_conversations', 'New conversations'], + ]); + clearMock.mockClear(); + }); }); - it('has no active conversation', async () => { - const triggeredEvents: any[] = []; - eventEmitter.on('hook:stats:entry', (...args) => { - triggeredEvents.push(args); + describe('processConversationMessage', () => { + afterAll(() => { + jest.restoreAllMocks(); }); - const event = new WebEventWrapper(handler, webEventText, { - isSocket: false, - ipAddress: '1.1.1.1', - agent: 'Chromium', - }); - const webSubscriber = (await subscriberService.findOne({ - foreign_id: 'foreign-id-web-2', - }))!; - event.setSender(webSubscriber); - const captured = await botService.processConversationMessage(event); - expect(captured).toBe(false); - expect(triggeredEvents).toEqual([]); + it('has no active conversation', async () => { + const triggeredEvents: any[] = []; + eventEmitter.on('hook:stats:entry', (...args) => { + triggeredEvents.push(args); + }); + const event = new WebEventWrapper( + handler, + webEventText, + mockWebChannelData, + ); + const webSubscriber = (await subscriberService.findOne({ + foreign_id: 'foreign-id-web-2', + }))!; + event.setSender(webSubscriber); + const captured = await botService.processConversationMessage(event); + + expect(captured).toBe(false); + expect(triggeredEvents).toEqual([]); + }); + + it('should capture a conversation', async () => { + const triggeredEvents: any[] = []; + + eventEmitter.on('hook:stats:entry', (...args) => { + triggeredEvents.push(args); + }); + + const event = new WebEventWrapper( + handler, + webEventText, + mockWebChannelData, + ); + const webSubscriber = (await subscriberService.findOne({ + foreign_id: 'foreign-id-web-1', + }))!; + event.setSender(webSubscriber); + + jest + .spyOn(botService, 'handleOngoingConversationMessage') + .mockImplementation(() => Promise.resolve(true)); + const captured = await botService.processConversationMessage(event); + expect(captured).toBe(true); + expect(triggeredEvents).toEqual([ + ['existing_conversations', 'Existing conversations'], + ]); + }); }); describe('proceedToNextBlock', () => { + const mockEvent = new WebEventWrapper( + handler, + webEventText, + mockWebChannelData, + ); + + afterAll(() => { + jest.restoreAllMocks(); + }); + it('should emit stats and call triggerBlock, returning true on success and reset attempt if not fallback', async () => { - const convo = { + const mockConvo = { + ...conversationGetStarted, id: 'convo1', context: { attempt: 2 }, next: [], @@ -370,23 +364,20 @@ describe('BotService', () => { active: true, } as unknown as ConversationFull; const next = { id: 'block1', name: 'Block 1' } as BlockFull; - const event = {} as any; const fallback = false; jest .spyOn(conversationService, 'storeContextData') - .mockImplementation((convo, _next, _event, _captureVars) => { - return Promise.resolve({ - ...convo, - } as Conversation); + .mockImplementation(() => { + return Promise.resolve(mockConvo as unknown as Conversation); }); jest.spyOn(botService, 'triggerBlock').mockResolvedValue(undefined); const emitSpy = jest.spyOn(eventEmitter, 'emit'); const result = await botService.proceedToNextBlock( - convo, + mockConvo, next, - event, + mockEvent, fallback, ); @@ -397,48 +388,48 @@ describe('BotService', () => { ); expect(botService.triggerBlock).toHaveBeenCalledWith( - event, + mockEvent, expect.objectContaining({ id: 'convo1' }), next, fallback, ); expect(result).toBe(true); - expect(convo.context.attempt).toBe(0); + expect(mockConvo.context.attempt).toBe(0); }); it('should increment attempt if fallback is true', async () => { - const convo = { + const mockConvo = { + ...conversationGetStarted, id: 'convo2', context: { attempt: 1 }, next: [], sender: 'user2', active: true, - } as any; + } as unknown as ConversationFull; const next = { id: 'block2', name: 'Block 2' } as any; - const event = {} as any; const fallback = true; const result = await botService.proceedToNextBlock( - convo, + mockConvo, next, - event, + mockEvent, fallback, ); - expect(convo.context.attempt).toBe(2); + expect(mockConvo.context.attempt).toBe(2); expect(result).toBe(true); }); it('should handle errors and emit conversation:end, returning false', async () => { - const convo = { + const mockConvo = { + ...conversationGetStarted, id: 'convo3', context: { attempt: 1 }, next: [], sender: 'user3', active: true, - } as any; + } as unknown as ConversationFull; const next = { id: 'block3', name: 'Block 3' } as any; - const event = {} as any; const fallback = false; jest @@ -447,14 +438,136 @@ describe('BotService', () => { const emitSpy = jest.spyOn(eventEmitter, 'emit'); const result = await botService.proceedToNextBlock( - convo, + mockConvo, next, - event, + mockEvent, fallback, ); - expect(emitSpy).toHaveBeenCalledWith('hook:conversation:end', convo); + expect(emitSpy).toHaveBeenCalledWith('hook:conversation:end', mockConvo); expect(result).toBe(false); }); }); + + describe('handleOngoingConversationMessage', () => { + const mockConvo = { + ...conversationGetStarted, + id: 'convo1', + context: { ...conversationGetStarted.context, attempt: 0 }, + next: [{ id: 'block1' }], + current: { + ...conversationGetStarted.current, + id: 'block0', + options: { + ...conversationGetStarted.current.options, + fallback: { + active: true, + max_attempts: 2, + message: [], + }, + }, + }, + } as unknown as ConversationFull; + + const mockEvent = new WebEventWrapper( + handler, + webEventText, + mockWebChannelData, + ); + + beforeAll(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should proceed to the matched next block', async () => { + const matchedBlock = { + ...textBlock, + id: 'block1', + name: 'Block 1', + } as BlockFull; + jest + .spyOn(blockService, 'findAndPopulate') + .mockResolvedValue([matchedBlock]); + jest.spyOn(blockService, 'match').mockResolvedValue(matchedBlock); + jest.spyOn(botService, 'proceedToNextBlock').mockResolvedValue(true); + + const result = await botService.handleOngoingConversationMessage( + mockConvo, + mockEvent, + ); + + expect(blockService.findAndPopulate).toHaveBeenCalled(); + expect(blockService.match).toHaveBeenCalled(); + expect(botService.proceedToNextBlock).toHaveBeenCalled(); + expect(result).toBe(true); + }); + + it('should proceed to fallback block if no match and fallback is allowed', async () => { + jest.spyOn(blockService, 'findAndPopulate').mockResolvedValue([]); + jest.spyOn(blockService, 'match').mockResolvedValue(undefined); + const proceedSpy = jest + .spyOn(botService, 'proceedToNextBlock') + .mockResolvedValue(true); + + const result = await botService.handleOngoingConversationMessage( + mockConvo, + mockEvent, + ); + + expect(proceedSpy).toHaveBeenCalledWith( + mockConvo, + expect.objectContaining({ id: 'block0', nextBlocks: mockConvo.next }), + mockEvent, + true, + ); + expect(result).toBe(true); + }); + + it('should end conversation and return false if no match and fallback not allowed', async () => { + const mockConvoWithoutFallback = { + ...mockConvo, + current: { + ...mockConvo.current, + options: { + ...mockConvo.current.options, + fallback: { + active: false, + max_attempts: 2, + message: [], + }, + }, + }, + } as unknown as ConversationFull; + jest.spyOn(blockService, 'findAndPopulate').mockResolvedValue([]); + jest.spyOn(blockService, 'match').mockResolvedValue(undefined); + const emitSpy = jest.spyOn(eventEmitter, 'emit'); + + const result = await botService.handleOngoingConversationMessage( + mockConvoWithoutFallback, + mockEvent, + ); + + expect(emitSpy).toHaveBeenCalledWith( + 'hook:conversation:end', + mockConvoWithoutFallback, + ); + expect(result).toBe(false); + }); + + it('should end conversation and throw if an error occurs', async () => { + jest + .spyOn(blockService, 'findAndPopulate') + .mockRejectedValue(new Error('fail')); + const emitSpy = jest.spyOn(eventEmitter, 'emit'); + + await expect( + botService.handleOngoingConversationMessage(mockConvo, mockEvent), + ).rejects.toThrow('fail'); + expect(emitSpy).toHaveBeenCalledWith('hook:conversation:end', mockConvo); + }); + }); }); diff --git a/api/src/chat/services/bot.service.ts b/api/src/chat/services/bot.service.ts index 4007d70e..304e2bc2 100644 --- a/api/src/chat/services/bot.service.ts +++ b/api/src/chat/services/bot.service.ts @@ -28,6 +28,7 @@ import { OutgoingMessageFormat, StdOutgoingMessageEnvelope, } from '../schemas/types/message'; +import { BlockOptions } from '../schemas/types/options'; import { BlockService } from './block.service'; import { ConversationService } from './conversation.service'; @@ -288,6 +289,27 @@ export class BotService { } } + /** + * Determines if a fallback should be attempted based on the event type, fallback options, and conversation context. + * + * @param event - The incoming event that triggered the conversation flow. + * @param fallbackOptions - The options for fallback behavior defined in the block. + * @param convo - The current conversation object containing context and state. + * + * @returns A boolean indicating whether a fallback should be attempted. + */ + private shouldAttemptLocalFallback( + event: EventWrapper, + fallbackOptions: BlockOptions['fallback'], + convo: ConversationFull, + ): boolean { + return ( + event.getMessageType() === IncomingMessageType.message && + !!fallbackOptions?.active && + convo.context.attempt < (fallbackOptions?.max_attempts ?? 0) + ); + } + /** * Processes and responds to an incoming message within an ongoing conversation flow. * Determines the next block in the conversation, attempts to match the message with available blocks, @@ -302,25 +324,25 @@ export class BotService { convo: ConversationFull, event: EventWrapper, ) { - const nextIds = convo.next.map(({ id }) => id); - // Reload blocks in order to populate his nextBlocks - // nextBlocks & trigger/assign _labels try { - const nextBlocks = await this.blockService.findAndPopulate({ - _id: { $in: nextIds }, - }); let fallback = false; - const fallbackOptions = convo.current?.options?.fallback + 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, @@ -331,13 +353,10 @@ export class BotService { let fallbackBlock: BlockFull | undefined = undefined; if ( !matchedBlock && - event.getMessageType() === IncomingMessageType.message && - fallbackOptions.active && - convo.context.attempt < fallbackOptions.max_attempts + this.shouldAttemptLocalFallback(event, fallbackOptions, convo) ) { // Trigger block fallback // NOTE : current is not populated, this may cause some anomaly - const currentBlock = convo.current; fallbackBlock = { ...currentBlock, nextBlocks: convo.next, From 39287c0fc9df04169c12ed4b6cca60f5b79737ce Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Wed, 11 Jun 2025 16:46:34 +0100 Subject: [PATCH 47/52] fix: remove unused dep injection --- api/src/chat/services/bot.service.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/api/src/chat/services/bot.service.ts b/api/src/chat/services/bot.service.ts index 304e2bc2..7052d961 100644 --- a/api/src/chat/services/bot.service.ts +++ b/api/src/chat/services/bot.service.ts @@ -11,7 +11,6 @@ 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 { LoggerService } from '@/logger/logger.service'; import { SettingService } from '@/setting/services/setting.service'; @@ -43,7 +42,6 @@ export class BotService { private readonly conversationService: ConversationService, private readonly subscriberService: SubscriberService, private readonly settingService: SettingService, - private readonly helperService: HelperService, ) {} /** From 6850bc4dfdad40909734cdeb9730c58515145e31 Mon Sep 17 00:00:00 2001 From: yassinedorbozgithub Date: Thu, 12 Jun 2025 06:09:17 +0100 Subject: [PATCH 48/52] fix(api): resolve camelCased classname --- api/src/utils/generics/base-repository.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/src/utils/generics/base-repository.ts b/api/src/utils/generics/base-repository.ts index 18ef046f..79a3d448 100644 --- a/api/src/utils/generics/base-repository.ts +++ b/api/src/utils/generics/base-repository.ts @@ -37,6 +37,7 @@ import { } from '@/utils/types/filter.types'; import { flatten } from '../helpers/flatten'; +import { camelCase } from '../helpers/misc'; import { PageQueryDto, QuerySortDto } from '../pagination/pagination-query.dto'; import { DtoAction, DtoConfig, DtoInfer } from '../types/dto.types'; @@ -128,7 +129,7 @@ export abstract class BaseRepository< * @returns A type-safe event name string. */ getEventName(suffix: EHook) { - const entity = this.cls.name.toLocaleLowerCase(); + const entity = camelCase(this.cls.name); return `hook:${entity}:${suffix}` as `hook:${IHookEntities}:${TNormalizedEvents}`; } From b9acf9d0c7ea4fddf509aaa9f0e9e2d0ce6823fb Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Thu, 12 Jun 2025 11:34:17 +0100 Subject: [PATCH 49/52] refactor: block fallback options --- api/src/chat/constants/block.ts | 17 +++++++++++++ api/src/chat/constants/conversation.ts | 28 +++++++++++++++++++++ api/src/chat/schemas/conversation.schema.ts | 19 ++------------ api/src/chat/schemas/types/options.ts | 16 ++++++------ api/src/chat/services/block.service.ts | 15 +++++++++++ api/src/chat/services/bot.service.ts | 7 ++---- 6 files changed, 73 insertions(+), 29 deletions(-) create mode 100644 api/src/chat/constants/block.ts create mode 100644 api/src/chat/constants/conversation.ts diff --git a/api/src/chat/constants/block.ts b/api/src/chat/constants/block.ts new file mode 100644 index 00000000..d8c36afe --- /dev/null +++ b/api/src/chat/constants/block.ts @@ -0,0 +1,17 @@ +/* + * 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. + * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). + */ + +import { FallbackOptions } from '../schemas/types/options'; + +export function getDefaultFallbackOptions(): FallbackOptions { + return { + active: false, + max_attempts: 0, + message: [], + }; +} diff --git a/api/src/chat/constants/conversation.ts b/api/src/chat/constants/conversation.ts new file mode 100644 index 00000000..2191e90d --- /dev/null +++ b/api/src/chat/constants/conversation.ts @@ -0,0 +1,28 @@ +/* + * 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. + * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). + */ + +import { Subscriber } from '../schemas/subscriber.schema'; +import { Context } from '../schemas/types/context'; + +export function getDefaultConversationContext(): Context { + return { + vars: {}, // Used for capturing vars from user entries + user: { + first_name: '', + last_name: '', + // @TODO: Typing is not correct + } as Subscriber, + user_location: { + // Used for capturing geolocation from QR + lat: 0.0, + lon: 0.0, + }, + skip: {}, // Used for list pagination + attempt: 0, // Used to track fallback max attempts + }; +} diff --git a/api/src/chat/schemas/conversation.schema.ts b/api/src/chat/schemas/conversation.schema.ts index 6d5a0aae..312745bf 100644 --- a/api/src/chat/schemas/conversation.schema.ts +++ b/api/src/chat/schemas/conversation.schema.ts @@ -17,27 +17,12 @@ import { THydratedDocument, } from '@/utils/types/filter.types'; +import { getDefaultConversationContext } from '../constants/conversation'; + import { Block } from './block.schema'; import { Subscriber } from './subscriber.schema'; import { Context } from './types/context'; -export function getDefaultConversationContext(): Context { - return { - vars: {}, // Used for capturing vars from user entries - user: { - first_name: '', - last_name: '', - } as Subscriber, - user_location: { - // Used for capturing geolocation from QR - lat: 0.0, - lon: 0.0, - }, - skip: {}, // Used for list pagination - attempt: 0, // Used to track fallback max attempts - }; -} - @Schema({ timestamps: true, minimize: false }) class ConversationStub extends BaseSchema { @Prop({ diff --git a/api/src/chat/schemas/types/options.ts b/api/src/chat/schemas/types/options.ts index f413a56d..b483a8f6 100644 --- a/api/src/chat/schemas/types/options.ts +++ b/api/src/chat/schemas/types/options.ts @@ -29,16 +29,18 @@ export const contentOptionsSchema = z.object({ export type ContentOptions = z.infer; +export const fallbackOptionsSchema = z.object({ + active: z.boolean(), + message: z.array(z.string()), + max_attempts: z.number().finite(), +}); + +export type FallbackOptions = z.infer; + export const BlockOptionsSchema = z.object({ typing: z.number().optional(), content: contentOptionsSchema.optional(), - fallback: z - .object({ - active: z.boolean(), - message: z.array(z.string()), - max_attempts: z.number().finite(), - }) - .optional(), + fallback: fallbackOptionsSchema.optional(), assignTo: z.string().optional(), effects: z.array(z.string()).optional(), }); diff --git a/api/src/chat/services/block.service.ts b/api/src/chat/services/block.service.ts index af561a7e..54a76eeb 100644 --- a/api/src/chat/services/block.service.ts +++ b/api/src/chat/services/block.service.ts @@ -23,6 +23,7 @@ import { FALLBACK_DEFAULT_NLU_PENALTY_FACTOR } from '@/utils/constants/nlp'; import { BaseService } from '@/utils/generics/base-service'; import { getRandomElement } from '@/utils/helpers/safeRandom'; +import { getDefaultFallbackOptions } from '../constants/block'; import { BlockDto } from '../dto/block.dto'; import { EnvelopeFactory } from '../helpers/envelope-factory'; import { BlockRepository } from '../repositories/block.repository'; @@ -40,6 +41,7 @@ import { StdOutgoingEnvelope, StdOutgoingSystemEnvelope, } from '../schemas/types/message'; +import { FallbackOptions } from '../schemas/types/options'; import { NlpPattern, PayloadPattern } from '../schemas/types/pattern'; import { Payload } from '../schemas/types/quick-reply'; import { SubscriberContext } from '../schemas/types/subscriberContext'; @@ -775,6 +777,19 @@ export class BlockService extends BaseService< throw new Error('Invalid message format.'); } + /** + * Retrieves the fallback options for a block. + * + * @param block - The block to retrieve fallback options from. + * @returns The fallback options for the block, or default options if not specified. + */ + getFallbackOptions(block: T): FallbackOptions { + const fallbackOptions: FallbackOptions = block.options?.fallback + ? block.options.fallback + : getDefaultFallbackOptions(); + return fallbackOptions; + } + /** * Updates the `trigger_labels` and `assign_labels` fields of a block when a label is deleted. * diff --git a/api/src/chat/services/bot.service.ts b/api/src/chat/services/bot.service.ts index 7052d961..0eeaf5ea 100644 --- a/api/src/chat/services/bot.service.ts +++ b/api/src/chat/services/bot.service.ts @@ -14,13 +14,10 @@ import EventWrapper from '@/channel/lib/EventWrapper'; import { LoggerService } from '@/logger/logger.service'; import { SettingService } from '@/setting/services/setting.service'; +import { getDefaultConversationContext } from '../constants/conversation'; import { MessageCreateDto } from '../dto/message.dto'; import { BlockFull } from '../schemas/block.schema'; -import { - Conversation, - ConversationFull, - getDefaultConversationContext, -} from '../schemas/conversation.schema'; +import { Conversation, ConversationFull } from '../schemas/conversation.schema'; import { Context } from '../schemas/types/context'; import { IncomingMessageType, From bcacdec4062e9af1f720435617a0876eebc5ba23 Mon Sep 17 00:00:00 2001 From: Med Marrouchi Date: Thu, 12 Jun 2025 11:59:02 +0100 Subject: [PATCH 50/52] Update api/src/chat/services/block.service.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- api/src/chat/services/block.service.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/api/src/chat/services/block.service.ts b/api/src/chat/services/block.service.ts index 54a76eeb..82918649 100644 --- a/api/src/chat/services/block.service.ts +++ b/api/src/chat/services/block.service.ts @@ -784,10 +784,7 @@ export class BlockService extends BaseService< * @returns The fallback options for the block, or default options if not specified. */ getFallbackOptions(block: T): FallbackOptions { - const fallbackOptions: FallbackOptions = block.options?.fallback - ? block.options.fallback - : getDefaultFallbackOptions(); - return fallbackOptions; + return block.options?.fallback ?? getDefaultFallbackOptions(); } /** From d77a02032a9583bcc92f94c14285e3d787ae315b Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Thu, 12 Jun 2025 12:10:29 +0100 Subject: [PATCH 51/52] refactor: minor refactor --- api/src/chat/services/bot.service.spec.ts | 171 +++++++++++++++++++++- api/src/chat/services/bot.service.ts | 44 ++++-- 2 files changed, 204 insertions(+), 11 deletions(-) diff --git a/api/src/chat/services/bot.service.spec.ts b/api/src/chat/services/bot.service.spec.ts index 0e4643f0..366ae7f3 100644 --- a/api/src/chat/services/bot.service.spec.ts +++ b/api/src/chat/services/bot.service.spec.ts @@ -51,7 +51,12 @@ import { SettingService } from '@/setting/services/setting.service'; import { installBlockFixtures } from '@/utils/test/fixtures/block'; import { installContentFixtures } from '@/utils/test/fixtures/content'; import { installSubscriberFixtures } from '@/utils/test/fixtures/subscriber'; -import { mockWebChannelData, textBlock } from '@/utils/test/mocks/block'; +import { + buttonsBlock, + mockWebChannelData, + quickRepliesBlock, + textBlock, +} from '@/utils/test/mocks/block'; import { conversationGetStarted } from '@/utils/test/mocks/conversation'; import { closeInMongodConnection, @@ -570,4 +575,168 @@ describe('BotService', () => { expect(emitSpy).toHaveBeenCalledWith('hook:conversation:end', mockConvo); }); }); + + describe('shouldAttemptLocalFallback', () => { + const mockEvent = new WebEventWrapper( + handler, + webEventText, + mockWebChannelData, + ); + + beforeAll(() => { + jest.resetAllMocks(); + }); + + afterAll(() => { + jest.resetAllMocks(); + }); + + it('should return true when fallback is active and max attempts not reached', () => { + const result = botService.shouldAttemptLocalFallback( + { + ...conversationGetStarted, + context: { ...conversationGetStarted.context, attempt: 1 }, + current: { + ...conversationGetStarted.current, + options: { + fallback: { + active: true, + max_attempts: 3, + message: ['Please pick an option.'], + }, + }, + }, + }, + mockEvent, + ); + expect(result).toBe(true); + }); + + it('should return false when fallback is not active', () => { + const result = botService.shouldAttemptLocalFallback( + { + ...conversationGetStarted, + context: { ...conversationGetStarted.context, attempt: 1 }, + current: { + ...conversationGetStarted.current, + options: { + fallback: { + active: false, + max_attempts: 0, + message: [], + }, + }, + }, + }, + mockEvent, + ); + expect(result).toBe(false); + }); + + it('should return false when max attempts reached', () => { + const result = botService.shouldAttemptLocalFallback( + { + ...conversationGetStarted, + context: { ...conversationGetStarted.context, attempt: 3 }, + current: { + ...conversationGetStarted.current, + options: { + fallback: { + active: true, + max_attempts: 3, + message: ['Please pick an option.'], + }, + }, + }, + }, + mockEvent, + ); + expect(result).toBe(false); + }); + + it('should return false when fallback options are missing', () => { + const result = botService.shouldAttemptLocalFallback( + { + ...conversationGetStarted, + current: { + ...conversationGetStarted.current, + options: {}, + }, + }, + mockEvent, + ); + + expect(result).toBe(false); + }); + }); + + describe('findNextMatchingBlock', () => { + const mockEvent = new WebEventWrapper( + handler, + webEventText, + mockWebChannelData, + ); + + beforeAll(() => { + jest.resetAllMocks(); + }); + + afterAll(() => { + jest.resetAllMocks(); + }); + + it('should return a matching block if one is found and fallback is not active', async () => { + jest.spyOn(blockService, 'match').mockResolvedValue(buttonsBlock); + + const result = await botService.findNextMatchingBlock( + { + ...conversationGetStarted, + current: { + ...conversationGetStarted.current, + options: { + fallback: { + active: false, + message: [], + max_attempts: 0, + }, + }, + }, + next: [quickRepliesBlock, buttonsBlock].map((b) => ({ + ...b, + trigger_labels: b.trigger_labels.map(({ id }) => id), + assign_labels: b.assign_labels.map(({ id }) => id), + nextBlocks: [], + attachedBlock: null, + category: null, + previousBlocks: undefined, + attachedToBlock: undefined, + })), + }, + mockEvent, + ); + expect(result).toBe(buttonsBlock); + }); + + it('should return undefined if no matching block is found', async () => { + jest.spyOn(blockService, 'match').mockResolvedValue(undefined); + + const result = await botService.findNextMatchingBlock( + { + ...conversationGetStarted, + current: { + ...conversationGetStarted.current, + options: { + fallback: { + active: true, + message: ['Please pick an option.'], + max_attempts: 1, + }, + }, + }, + }, + mockEvent, + ); + expect(result).toBeUndefined(); + }); + }); }); diff --git a/api/src/chat/services/bot.service.ts b/api/src/chat/services/bot.service.ts index 0eeaf5ea..835547e2 100644 --- a/api/src/chat/services/bot.service.ts +++ b/api/src/chat/services/bot.service.ts @@ -24,7 +24,7 @@ import { OutgoingMessageFormat, StdOutgoingMessageEnvelope, } from '../schemas/types/message'; -import { BlockOptions } from '../schemas/types/options'; +import { BlockOptions, FallbackOptions } from '../schemas/types/options'; import { BlockService } from './block.service'; import { ConversationService } from './conversation.service'; @@ -284,20 +284,47 @@ export class BotService { } } + /** + * Finds the next block that matches the event criteria within the conversation's next blocks. + * + * @param convo - The current conversation object containing context and state. + * @param event - The incoming event that triggered the conversation flow. + * + * @returns A promise that resolves with the matched block or undefined if no match is found. + */ + async findNextMatchingBlock( + convo: ConversationFull, + event: EventWrapper, + ): Promise { + const fallbackOptions: FallbackOptions = + this.blockService.getFallbackOptions(convo.current); + // 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) }, + }); + return await this.blockService.match( + nextBlocks, + event, + canHaveMultipleMatches, + ); + } + /** * Determines if a fallback should be attempted based on the event type, fallback options, and conversation context. * - * @param event - The incoming event that triggered the conversation flow. - * @param fallbackOptions - The options for fallback behavior defined in the block. * @param convo - The current conversation object containing context and state. + * @param event - The incoming event that triggered the conversation flow. * * @returns A boolean indicating whether a fallback should be attempted. */ - private shouldAttemptLocalFallback( - event: EventWrapper, - fallbackOptions: BlockOptions['fallback'], + shouldAttemptLocalFallback( convo: ConversationFull, + event: EventWrapper, ): boolean { + const fallbackOptions = this.blockService.getFallbackOptions(convo.current); return ( event.getMessageType() === IncomingMessageType.message && !!fallbackOptions?.active && @@ -346,10 +373,7 @@ export class BotService { // 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 let fallbackBlock: BlockFull | undefined = undefined; - if ( - !matchedBlock && - this.shouldAttemptLocalFallback(event, fallbackOptions, convo) - ) { + if (!matchedBlock && this.shouldAttemptLocalFallback(convo, event)) { // Trigger block fallback // NOTE : current is not populated, this may cause some anomaly fallbackBlock = { From c5e7bbcd1d836cd98267e517a815e41f6abce018 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Thu, 12 Jun 2025 11:23:40 +0100 Subject: [PATCH 52/52] feat: add the flow escape new type of helpers --- api/src/helper/helper.service.ts | 7 +- api/src/helper/lib/base-flow-escape-helper.ts | 52 +++++++++++++++ api/src/helper/types.ts | 24 +++++++ api/src/setting/seeds/setting.seed-model.ts | 14 ++++ .../public/locales/en/chatbot_settings.json | 6 +- .../public/locales/fr/chatbot_settings.json | 6 +- .../src/components/settings/SettingInput.tsx | 65 ++++++------------- frontend/src/services/api.class.ts | 1 + frontend/src/services/entities.ts | 9 +++ frontend/src/services/types.ts | 1 + frontend/src/types/base.types.ts | 2 + 11 files changed, 138 insertions(+), 49 deletions(-) create mode 100644 api/src/helper/lib/base-flow-escape-helper.ts diff --git a/api/src/helper/helper.service.ts b/api/src/helper/helper.service.ts index db0a8800..336815bb 100644 --- a/api/src/helper/helper.service.ts +++ b/api/src/helper/helper.service.ts @@ -161,8 +161,13 @@ export class HelperService { } const settings = await this.settingService.getSettings(); + const defaultHelperKey = `default_${type}_helper`; + if (!(defaultHelperKey in settings.chatbot_settings)) { + throw new Error(`Default ${type.toUpperCase()} helper setting not found`); + } + const defaultHelperName = settings.chatbot_settings[ - `default_${type}_helper` as any + defaultHelperKey ] as HelperName; const defaultHelper = this.get(type, defaultHelperName); diff --git a/api/src/helper/lib/base-flow-escape-helper.ts b/api/src/helper/lib/base-flow-escape-helper.ts new file mode 100644 index 00000000..5c93aea9 --- /dev/null +++ b/api/src/helper/lib/base-flow-escape-helper.ts @@ -0,0 +1,52 @@ +/* + * 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. + * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). + */ + +import EventWrapper from '@/channel/lib/EventWrapper'; +import { BlockStub } from '@/chat/schemas/block.schema'; +import { LoggerService } from '@/logger/logger.service'; +import { SettingService } from '@/setting/services/setting.service'; + +import { HelperService } from '../helper.service'; +import { FlowEscape, HelperName, HelperType } from '../types'; + +import BaseHelper from './base-helper'; + +export default abstract class BaseFlowEscapeHelper< + N extends HelperName = HelperName, +> extends BaseHelper { + protected readonly type: HelperType = HelperType.FLOW_ESCAPE; + + constructor( + name: N, + settingService: SettingService, + helperService: HelperService, + logger: LoggerService, + ) { + super(name, settingService, helperService, logger); + } + + /** + * Checks if the helper can handle the flow escape for the given block message. + * + * @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; + + /** + * Adjudicates the flow escape event. + * + * @param _event - The event wrapper containing the event data. + * @param _block - The block associated with the event. + * @returns - A promise that resolves to a FlowEscape.AdjudicationResult. + */ + abstract adjudicate( + _event: EventWrapper, + _block: T, + ): Promise; +} diff --git a/api/src/helper/types.ts b/api/src/helper/types.ts index 658c95c3..48652e50 100644 --- a/api/src/helper/types.ts +++ b/api/src/helper/types.ts @@ -9,6 +9,7 @@ import { ExtensionSetting } from '@/setting/schemas/types'; import { HyphenToUnderscore } from '@/utils/types/extension'; +import BaseFlowEscapeHelper from './lib/base-flow-escape-helper'; import BaseHelper from './lib/base-helper'; import BaseLlmHelper from './lib/base-llm-helper'; import BaseNlpHelper from './lib/base-nlp-helper'; @@ -93,9 +94,31 @@ export namespace LLM { } } +export namespace FlowEscape { + export enum Action { + REPROMPT = 're_prompt', + COERCE = 'coerce_to_option', + NEW_CTX = 'new_context', + } + + export type AdjudicationResult = + | { + action: Action.COERCE; + coercedOption: string; + } + | { + action: Action.REPROMPT; + repromptMessage?: string; + } + | { + action: Action.NEW_CTX; + }; +} + export enum HelperType { NLU = 'nlu', LLM = 'llm', + FLOW_ESCAPE = 'flow_escape', STORAGE = 'storage', UTIL = 'util', } @@ -105,6 +128,7 @@ export type HelperName = `${string}-helper`; interface HelperTypeMap { [HelperType.NLU]: BaseNlpHelper; [HelperType.LLM]: BaseLlmHelper; + [HelperType.FLOW_ESCAPE]: BaseFlowEscapeHelper; [HelperType.STORAGE]: BaseStorageHelper; [HelperType.UTIL]: BaseHelper; } diff --git a/api/src/setting/seeds/setting.seed-model.ts b/api/src/setting/seeds/setting.seed-model.ts index c65d9be3..f26b82aa 100644 --- a/api/src/setting/seeds/setting.seed-model.ts +++ b/api/src/setting/seeds/setting.seed-model.ts @@ -50,6 +50,20 @@ export const DEFAULT_SETTINGS = [ }, weight: 3, }, + { + group: 'chatbot_settings', + label: 'default_flow_escape_helper', + value: '', + type: SettingType.select, + config: { + multiple: false, + allowCreate: false, + entity: 'Helper', + idKey: 'name', + labelKey: 'name', + }, + weight: 3, + }, { group: 'chatbot_settings', label: 'default_storage_helper', diff --git a/frontend/public/locales/en/chatbot_settings.json b/frontend/public/locales/en/chatbot_settings.json index bde69e7b..b5acf678 100644 --- a/frontend/public/locales/en/chatbot_settings.json +++ b/frontend/public/locales/en/chatbot_settings.json @@ -9,7 +9,8 @@ "default_nlu_helper": "Default NLU Helper", "default_llm_helper": "Default LLM Helper", "default_storage_helper": "Default Storage Helper", - "default_nlu_penalty_factor": "NLU Penalty Factor" + "default_nlu_penalty_factor": "NLU Penalty Factor", + "default_flow_escape_helper": "Default Flow Escape Helper" }, "help": { "global_fallback": "Global fallback allows you to send custom messages when user entry does not match any of the block messages.", @@ -17,6 +18,7 @@ "default_nlu_helper": "The NLU helper is responsible for processing and understanding user inputs, including tasks like intent prediction, language detection, and entity recognition.", "default_llm_helper": "The LLM helper leverages advanced generative AI to perform tasks such as text generation, chat completion, and complex query responses.", "default_storage_helper": "The storage helper defines where to store attachment files. By default, the default local storage helper stores them locally, but you can choose to use Minio or any other storage solution.", - "default_nlu_penalty_factor": "The NLU penalty factor is a coefficient (between 0 and 1) applied exclusively to NLU-based entity matching. It reduces the score contribution of patterns that match broadly (e.g. using wildcard values like Any) rather than specific entity values. This helps the engine prioritize blocks triggered by more precise NLU matches, without affecting other matching strategies such as text, regex, or interaction triggers." + "default_nlu_penalty_factor": "The NLU penalty factor is a coefficient (between 0 and 1) applied exclusively to NLU-based entity matching. It reduces the score contribution of patterns that match broadly (e.g. using wildcard values like Any) rather than specific entity values. This helps the engine prioritize blocks triggered by more precise NLU matches, without affecting other matching strategies such as text, regex, or interaction triggers.", + "default_flow_escape_helper": "The Flow Escape helper is used when the user’s message does not match any option in a flow. It assists the chatbot in deciding whether to re-prompt, provide an explanation, or end the conversation." } } diff --git a/frontend/public/locales/fr/chatbot_settings.json b/frontend/public/locales/fr/chatbot_settings.json index 9d44b07c..f370ac4d 100644 --- a/frontend/public/locales/fr/chatbot_settings.json +++ b/frontend/public/locales/fr/chatbot_settings.json @@ -9,7 +9,8 @@ "default_nlu_helper": "Utilitaire NLU par défaut", "default_llm_helper": "Utilitaire LLM par défaut", "default_storage_helper": "Utilitaire de stockage par défaut", - "default_nlu_penalty_factor": "Facteur de pénalité NLU" + "default_nlu_penalty_factor": "Facteur de pénalité NLU", + "default_flow_escape_helper": "Utilitaire de secours de flux par défaut" }, "help": { "global_fallback": "La réponse de secours globale vous permet d'envoyer des messages personnalisés lorsque l'entrée de l'utilisateur ne correspond à aucun des messages des blocs.", @@ -17,6 +18,7 @@ "default_nlu_helper": "Utilitaire du traitement et de la compréhension des entrées des utilisateurs, incluant des tâches telles que la prédiction d'intention, la détection de langue et la reconnaissance d'entités.", "default_llm_helper": "Utilitaire responsable de l'intelligence artificielle générative avancée pour effectuer des tâches telles que la génération de texte, la complétion de chat et les réponses à des requêtes complexes.", "default_storage_helper": "Utilitaire de stockage définit l'emplacement où stocker les fichiers joints. Par défaut, le stockage local les conserve localement, mais vous pouvez choisir d'utiliser Minio ou toute autre solution de stockage.", - "default_nlu_penalty_factor": "Le facteur de pénalité NLU est un coefficient (entre 0 et 1) appliqué exclusivement aux correspondances d'entités basées sur NLU. Il réduit la contribution au score des motifs qui correspondent de manière générale (par exemple, en utilisant des valeurs génériques comme Any) plutôt que des valeurs d'entité spécifiques. Cela permet au chatbot de donner la priorité aux blocs déclenchés par des correspondances NLU plus précises, sans affecter d'autres stratégies de correspondance telles que le texte, les expressions regex ou les déclencheurs d'interaction." + "default_nlu_penalty_factor": "Le facteur de pénalité NLU est un coefficient (entre 0 et 1) appliqué exclusivement aux correspondances d'entités basées sur NLU. Il réduit la contribution au score des motifs qui correspondent de manière générale (par exemple, en utilisant des valeurs génériques comme Any) plutôt que des valeurs d'entité spécifiques. Cela permet au chatbot de donner la priorité aux blocs déclenchés par des correspondances NLU plus précises, sans affecter d'autres stratégies de correspondance telles que le texte, les expressions regex ou les déclencheurs d'interaction.", + "default_flow_escape_helper": "L’utilitaire de secours de flux est utilisé lorsque le message de l’utilisateur ne correspond à aucune option dans un scénario. Il aide le chatbot à décider s’il faut reformuler la question, fournir une explication ou mettre fin à la conversation." } } diff --git a/frontend/src/components/settings/SettingInput.tsx b/frontend/src/components/settings/SettingInput.tsx index 51fc181f..bd8c854d 100644 --- a/frontend/src/components/settings/SettingInput.tsx +++ b/frontend/src/components/settings/SettingInput.tsx @@ -20,8 +20,8 @@ import { PasswordInput } from "@/app-components/inputs/PasswordInput"; import { useTranslate } from "@/hooks/useTranslate"; import { EntityType, Format } from "@/services/types"; import { AttachmentResourceRef } from "@/types/attachment.types"; +import { IEntityMapTypes } from "@/types/base.types"; import { IBlock } from "@/types/block.types"; -import { IHelper } from "@/types/helper.types"; import { ISetting, SettingType } from "@/types/setting.types"; import { MIME_TYPES } from "@/utils/attachment"; @@ -32,6 +32,12 @@ interface RenderSettingInputProps { isDisabled?: (setting: ISetting) => boolean; } +const DEFAULT_HELPER_ENTITIES: Record = { + ["default_nlu_helper"]: EntityType.NLU_HELPER, + ["default_llm_helper"]: EntityType.LLM_HELPER, + ["default_flow_escape_helper"]: EntityType.FLOW_ESCAPE_HELPER, + ["default_storage_helper"]: EntityType.STORAGE_HELPER, +}; const SettingInput: React.FC = ({ setting, field, @@ -125,54 +131,25 @@ const SettingInput: React.FC = ({ {...rest} /> ); - } else if (setting.label === "default_nlu_helper") { + } else if ( + setting.label.startsWith("default_") && + setting.label.endsWith("_helper") + ) { const { onChange, ...rest } = field; return ( - + searchFields={["name"]} - entity={EntityType.NLU_HELPER} + entity={DEFAULT_HELPER_ENTITIES[setting.label]} format={Format.BASIC} - labelKey="name" - idKey="name" - label={t("label.default_nlu_helper")} - helperText={t("help.default_nlu_helper")} - multiple={false} - onChange={(_e, selected, ..._) => onChange(selected?.name)} - {...rest} - /> - ); - } else if (setting.label === "default_llm_helper") { - const { onChange, ...rest } = field; - - return ( - - searchFields={["name"]} - entity={EntityType.LLM_HELPER} - format={Format.BASIC} - labelKey="name" - idKey="name" - label={t("label.default_llm_helper")} - helperText={t("help.default_llm_helper")} - multiple={false} - onChange={(_e, selected, ..._) => onChange(selected?.name)} - {...rest} - /> - ); - } else if (setting.label === "default_storage_helper") { - const { onChange, ...rest } = field; - - return ( - - searchFields={["name"]} - entity={EntityType.STORAGE_HELPER} - format={Format.BASIC} - labelKey="name" - idKey="name" - label={t("label.default_storage_helper")} - helperText={t("help.default_storage_helper")} - multiple={false} - onChange={(_e, selected, ..._) => onChange(selected?.name)} + labelKey={setting.config?.labelKey || "name"} + idKey={setting.config?.idKey || "name"} + label={label} + helperText={helperText} + multiple={!!setting.config?.multiple} + onChange={(_e, selected, ..._) => + onChange(selected?.[setting.config?.idKey || "name"]) + } {...rest} /> ); diff --git a/frontend/src/services/api.class.ts b/frontend/src/services/api.class.ts index 509185ed..6e77e07e 100644 --- a/frontend/src/services/api.class.ts +++ b/frontend/src/services/api.class.ts @@ -74,6 +74,7 @@ export const ROUTES = { [EntityType.HELPER]: "/helper", [EntityType.NLU_HELPER]: "/helper/nlu", [EntityType.LLM_HELPER]: "/helper/llm", + [EntityType.FLOW_ESCAPE_HELPER]: "helper/flow_escape", [EntityType.STORAGE_HELPER]: "/helper/storage", } as const; diff --git a/frontend/src/services/entities.ts b/frontend/src/services/entities.ts index 7984adff..cfdbd2aa 100644 --- a/frontend/src/services/entities.ts +++ b/frontend/src/services/entities.ts @@ -304,6 +304,14 @@ export const LlmHelperEntity = new schema.Entity( }, ); +export const FlowEscapeHelperEntity = new schema.Entity( + EntityType.FLOW_ESCAPE_HELPER, + undefined, + { + idAttribute: ({ name }) => name, + }, +); + export const StorageHelperEntity = new schema.Entity( EntityType.STORAGE_HELPER, undefined, @@ -341,5 +349,6 @@ export const ENTITY_MAP = { [EntityType.HELPER]: HelperEntity, [EntityType.NLU_HELPER]: NluHelperEntity, [EntityType.LLM_HELPER]: LlmHelperEntity, + [EntityType.FLOW_ESCAPE_HELPER]: FlowEscapeHelperEntity, [EntityType.STORAGE_HELPER]: StorageHelperEntity, } as const; diff --git a/frontend/src/services/types.ts b/frontend/src/services/types.ts index af357e02..c3e6ed5e 100644 --- a/frontend/src/services/types.ts +++ b/frontend/src/services/types.ts @@ -38,6 +38,7 @@ export enum EntityType { HELPER = "Helper", NLU_HELPER = "NluHelper", LLM_HELPER = "LlmHelper", + FLOW_ESCAPE_HELPER = "FlowEscapeHelper", STORAGE_HELPER = "StorageHelper", } diff --git a/frontend/src/types/base.types.ts b/frontend/src/types/base.types.ts index 510189a3..895f3d61 100644 --- a/frontend/src/types/base.types.ts +++ b/frontend/src/types/base.types.ts @@ -116,6 +116,7 @@ export const POPULATE_BY_TYPE = { [EntityType.HELPER]: [], [EntityType.NLU_HELPER]: [], [EntityType.LLM_HELPER]: [], + [EntityType.FLOW_ESCAPE_HELPER]: [], [EntityType.STORAGE_HELPER]: [], } as const; @@ -208,6 +209,7 @@ export interface IEntityMapTypes { [EntityType.HELPER]: IEntityTypes; [EntityType.NLU_HELPER]: IEntityTypes; [EntityType.LLM_HELPER]: IEntityTypes; + [EntityType.FLOW_ESCAPE_HELPER]: IEntityTypes; [EntityType.STORAGE_HELPER]: IEntityTypes; }