diff --git a/api/src/attachment/controllers/attachment.controller.ts b/api/src/attachment/controllers/attachment.controller.ts index fbc3717a..3607903f 100644 --- a/api/src/attachment/controllers/attachment.controller.ts +++ b/api/src/attachment/controllers/attachment.controller.ts @@ -94,7 +94,7 @@ export class AttachmentController extends BaseController { ) filters: TFilterQuery, ) { - return await this.attachmentService.findPage(filters, pageQuery); + return await this.attachmentService.find(filters, pageQuery); } /** @@ -153,7 +153,7 @@ export class AttachmentController extends BaseController { throw new NotFoundException('Attachment not found'); } - return this.attachmentService.download(attachment); + return await this.attachmentService.download(attachment); } /** diff --git a/api/src/channel/lib/EventWrapper.ts b/api/src/channel/lib/EventWrapper.ts index af45c566..722be1e7 100644 --- a/api/src/channel/lib/EventWrapper.ts +++ b/api/src/channel/lib/EventWrapper.ts @@ -18,7 +18,7 @@ import { StdIncomingMessage, } from '@/chat/schemas/types/message'; import { Payload } from '@/chat/schemas/types/quick-reply'; -import { Nlp } from '@/helper/types'; +import { NLU } from '@/helper/types'; import ChannelHandler, { ChannelNameOf } from './Handler'; @@ -38,7 +38,7 @@ export default abstract class EventWrapper< _profile!: Subscriber; - _nlp!: Nlp.ParseEntities; + _nlp!: NLU.ParseEntities; /** * Constructor : Class used to wrap any channel's event in order @@ -148,7 +148,7 @@ export default abstract class EventWrapper< * * @returns The parsed NLP entities, or null if not available. */ - getNLP(): Nlp.ParseEntities | null { + getNLP(): NLU.ParseEntities | null { return this._nlp; } @@ -157,7 +157,7 @@ export default abstract class EventWrapper< * * @param nlp - NLP parse results */ - setNLP(nlp: Nlp.ParseEntities) { + setNLP(nlp: NLU.ParseEntities) { this._nlp = nlp; } diff --git a/api/src/chat/controllers/message.controller.ts b/api/src/chat/controllers/message.controller.ts index 61f7884c..ca34cd55 100644 --- a/api/src/chat/controllers/message.controller.ts +++ b/api/src/chat/controllers/message.controller.ts @@ -56,7 +56,7 @@ import { SubscriberService } from '../services/subscriber.service'; @UseInterceptors(CsrfInterceptor) @Controller('message') export class MessageController extends BaseController< - Message, + AnyMessage, MessageStub, MessagePopulate, MessageFull diff --git a/api/src/chat/repositories/message.repository.ts b/api/src/chat/repositories/message.repository.ts index 64bd99f4..72fbe300 100644 --- a/api/src/chat/repositories/message.repository.ts +++ b/api/src/chat/repositories/message.repository.ts @@ -72,7 +72,7 @@ export class MessageRepository extends BaseRepository< until = new Date(), limit: number = 30, ) { - return await this.findPage( + return await this.find( { $or: [{ recipient: subscriber.id }, { sender: subscriber.id }], createdAt: { $lt: until }, @@ -96,7 +96,7 @@ export class MessageRepository extends BaseRepository< since = new Date(), limit: number = 30, ) { - return await this.findPage( + return await this.find( { $or: [{ recipient: subscriber.id }, { sender: subscriber.id }], createdAt: { $gt: since }, diff --git a/api/src/chat/repositories/subscriber.repository.ts b/api/src/chat/repositories/subscriber.repository.ts index 9c81cfdd..a3ee6cd1 100644 --- a/api/src/chat/repositories/subscriber.repository.ts +++ b/api/src/chat/repositories/subscriber.repository.ts @@ -106,7 +106,7 @@ export class SubscriberRepository extends BaseRepository< * @returns The constructed query object. */ findByForeignIdQuery(id: string) { - return this.findPageQuery( + return this.findQuery( { foreign_id: id }, { skip: 0, limit: 1, sort: ['lastvisit', 'desc'] }, ); diff --git a/api/src/chat/schemas/types/context.ts b/api/src/chat/schemas/types/context.ts index cae1ccf6..73514097 100644 --- a/api/src/chat/schemas/types/context.ts +++ b/api/src/chat/schemas/types/context.ts @@ -7,7 +7,7 @@ */ import { ChannelName } from '@/channel/types'; -import { Nlp } from '@/helper/types'; +import { NLU } from '@/helper/types'; import { Subscriber } from '../subscriber.schema'; @@ -17,7 +17,7 @@ export interface Context { channel?: ChannelName; text?: string; payload?: Payload | string; - nlp?: Nlp.ParseEntities | null; + nlp?: NLU.ParseEntities | null; vars: { [key: string]: any }; user_location: { address?: Record; diff --git a/api/src/chat/services/block.service.ts b/api/src/chat/services/block.service.ts index 5251c932..e3dd3eaa 100644 --- a/api/src/chat/services/block.service.ts +++ b/api/src/chat/services/block.service.ts @@ -13,7 +13,7 @@ import { AttachmentService } from '@/attachment/services/attachment.service'; import EventWrapper from '@/channel/lib/EventWrapper'; import { ContentService } from '@/cms/services/content.service'; import { CONSOLE_CHANNEL_NAME } from '@/extensions/channels/console/settings'; -import { Nlp } from '@/helper/types'; +import { NLU } from '@/helper/types'; import { I18nService } from '@/i18n/services/i18n.service'; import { LanguageService } from '@/i18n/services/language.service'; import { LoggerService } from '@/logger/logger.service'; @@ -254,7 +254,7 @@ export class BlockService extends BaseService { * @returns The NLP patterns that matches */ matchNLP( - nlp: Nlp.ParseEntities, + nlp: NLU.ParseEntities, block: Block | BlockFull, ): NlpPattern[] | undefined { // No nlp entities to check against diff --git a/api/src/chat/services/message.service.ts b/api/src/chat/services/message.service.ts index c66d0903..67474daa 100644 --- a/api/src/chat/services/message.service.ts +++ b/api/src/chat/services/message.service.ts @@ -126,7 +126,7 @@ export class MessageService extends BaseService< * @returns The message history since the specified date. */ async findLastMessages(subscriber: Subscriber, limit: number = 5) { - const lastMessages = await this.findPage( + const lastMessages = await this.find( { $or: [{ sender: subscriber.id }, { recipient: subscriber.id }], }, diff --git a/api/src/cms/controllers/content.controller.ts b/api/src/cms/controllers/content.controller.ts index b807b051..5f4bf0c5 100644 --- a/api/src/cms/controllers/content.controller.ts +++ b/api/src/cms/controllers/content.controller.ts @@ -283,10 +283,7 @@ export class ContentController extends BaseController< ); throw new NotFoundException(`ContentType of id ${contentType} not found`); } - return await this.contentService.findPage( - { entity: contentType }, - pageQuery, - ); + return await this.contentService.find({ entity: contentType }, pageQuery); } /** diff --git a/api/src/cms/controllers/menu.controller.ts b/api/src/cms/controllers/menu.controller.ts index ab7b70a1..f2343d1d 100644 --- a/api/src/cms/controllers/menu.controller.ts +++ b/api/src/cms/controllers/menu.controller.ts @@ -164,8 +164,8 @@ export class MenuController extends BaseController< @CsrfCheck(true) @Patch(':id') async updateOne(@Body() body: MenuCreateDto, @Param('id') id: string) { - if (!id) return this.create(body); - return this.menuService.updateOne(id, body); + if (!id) return await this.create(body); + return await this.menuService.updateOne(id, body); } /** diff --git a/api/src/cms/repositories/content.repository.ts b/api/src/cms/repositories/content.repository.ts index 4dea95ce..77403b08 100644 --- a/api/src/cms/repositories/content.repository.ts +++ b/api/src/cms/repositories/content.repository.ts @@ -100,7 +100,7 @@ export class ContentRepository extends BaseRepository< * @returns A promise that resolves to the matching content documents. */ async textSearch(query: string) { - return this.find({ + return await this.find({ $text: { $search: query, $diacriticSensitive: false, diff --git a/api/src/cms/services/content.service.ts b/api/src/cms/services/content.service.ts index bdcac780..0ce0eace 100644 --- a/api/src/cms/services/content.service.ts +++ b/api/src/cms/services/content.service.ts @@ -49,7 +49,7 @@ export class ContentService extends BaseService< * @return A list of content matching the search query. */ async textSearch(query: string) { - return this.repository.textSearch(query); + return await this.repository.textSearch(query); } /** @@ -170,7 +170,7 @@ export class ContentService extends BaseService< } try { - const contents = await this.findPage(query, { + const contents = await this.find(query, { skip, limit, sort: ['createdAt', 'desc'], diff --git a/api/src/extensions/helpers/core-nlu/__test__/index.mock.ts b/api/src/extensions/helpers/core-nlu/__test__/index.mock.ts index 617710c3..c63e9cf3 100644 --- a/api/src/extensions/helpers/core-nlu/__test__/index.mock.ts +++ b/api/src/extensions/helpers/core-nlu/__test__/index.mock.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 { Nlp } from '@/helper/types'; +import { NLU } from '@/helper/types'; import { NlpParseResultType, RasaNlu } from '../types'; @@ -100,7 +100,7 @@ export const nlpParseResult: NlpParseResultType = { text: 'Hello Joe', }; -export const nlpBestGuess: Nlp.ParseEntities = { +export const nlpBestGuess: NLU.ParseEntities = { entities: [ { start: 5, diff --git a/api/src/extensions/helpers/core-nlu/index.helper.ts b/api/src/extensions/helpers/core-nlu/index.helper.ts index 6a2ebd39..2984aacb 100644 --- a/api/src/extensions/helpers/core-nlu/index.helper.ts +++ b/api/src/extensions/helpers/core-nlu/index.helper.ts @@ -11,7 +11,7 @@ import { Injectable } from '@nestjs/common'; import { HelperService } from '@/helper/helper.service'; import BaseNlpHelper from '@/helper/lib/base-nlp-helper'; -import { Nlp } from '@/helper/types'; +import { NLU } from '@/helper/types'; import { LanguageService } from '@/i18n/services/language.service'; import { LoggerService } from '@/logger/logger.service'; import { NlpEntity, NlpEntityFull } from '@/nlp/schemas/nlp-entity.schema'; @@ -191,10 +191,10 @@ export default class CoreNluHelper extends BaseNlpHelper< async filterEntitiesByConfidence( nlp: NlpParseResultType, threshold: boolean, - ): Promise { + ): Promise { try { let minConfidence = 0; - const guess: Nlp.ParseEntities = { + const guess: NLU.ParseEntities = { entities: nlp.entities.slice(), }; if (threshold) { @@ -255,7 +255,7 @@ export default class CoreNluHelper extends BaseNlpHelper< text: string, threshold: boolean, project: string = 'current', - ): Promise { + ): Promise { try { const settings = await this.getSettings(); const { data: nlp } = @@ -272,7 +272,7 @@ export default class CoreNluHelper extends BaseNlpHelper< }, ); - return this.filterEntitiesByConfidence(nlp, threshold); + return await this.filterEntitiesByConfidence(nlp, threshold); } catch (err) { this.logger.error('Core NLU Helper : Unable to parse nlp', err); throw err; diff --git a/api/src/extensions/helpers/llm-nlu/i18n/en/help.json b/api/src/extensions/helpers/llm-nlu/i18n/en/help.json new file mode 100644 index 00000000..f860ea94 --- /dev/null +++ b/api/src/extensions/helpers/llm-nlu/i18n/en/help.json @@ -0,0 +1,5 @@ +{ + "model": "Specify the name of the LLM (Large Language Model) you want to use. Leave this field empty if you prefer to use the default model specified in the LLM helper's settings.", + "language_classifier_prompt_template": "Provide the prompt template used for language detection. Use Handlebars syntax to dynamically insert variables or customize the prompt based on your requirements.", + "trait_classifier_prompt_template": "Define the prompt template for trait classification tasks, such as intent or sentiment detection. Use Handlebars syntax to structure and format the prompt appropriately." +} diff --git a/api/src/extensions/helpers/llm-nlu/i18n/en/label.json b/api/src/extensions/helpers/llm-nlu/i18n/en/label.json new file mode 100644 index 00000000..014066fa --- /dev/null +++ b/api/src/extensions/helpers/llm-nlu/i18n/en/label.json @@ -0,0 +1,5 @@ +{ + "model": "LLM Model", + "language_classifier_prompt_template": "Language Detection Prompt Template", + "trait_classifier_prompt_template": "Trait Classifier Prompt Template" +} diff --git a/api/src/extensions/helpers/llm-nlu/i18n/en/title.json b/api/src/extensions/helpers/llm-nlu/i18n/en/title.json new file mode 100644 index 00000000..47ffe14e --- /dev/null +++ b/api/src/extensions/helpers/llm-nlu/i18n/en/title.json @@ -0,0 +1,3 @@ +{ + "llm_nlu_helper": "LLM NLU Engine" +} diff --git a/api/src/extensions/helpers/llm-nlu/i18n/fr/help.json b/api/src/extensions/helpers/llm-nlu/i18n/fr/help.json new file mode 100644 index 00000000..63f54cdd --- /dev/null +++ b/api/src/extensions/helpers/llm-nlu/i18n/fr/help.json @@ -0,0 +1,5 @@ +{ + "model": "Spécifiez le nom du modèle LLM que vous souhaitez utiliser. Laissez ce champ vide si vous préférez utiliser le modèle par défaut spécifié dans les paramètres de l'assistant LLM.", + "language_classifier_prompt_template": "Fournissez le modèle de prompt utilisé pour la détection de langue. Utilisez la syntaxe Handlebars pour insérer dynamiquement des variables ou personnaliser le prompt en fonction de vos besoins.", + "trait_classifier_prompt_template": "Définissez le modèle de prompt pour les tâches de classification des traits, telles que la détection d'intention ou de sentiment. Utilisez la syntaxe Handlebars pour structurer et formater correctement le prompt." +} diff --git a/api/src/extensions/helpers/llm-nlu/i18n/fr/label.json b/api/src/extensions/helpers/llm-nlu/i18n/fr/label.json new file mode 100644 index 00000000..71f79359 --- /dev/null +++ b/api/src/extensions/helpers/llm-nlu/i18n/fr/label.json @@ -0,0 +1,5 @@ +{ + "model": "Modèle LLM", + "language_classifier_prompt_template": "Modèle de prompt de détection de langue", + "trait_classifier_prompt_template": "Modèle de prompt du classificateur de traits" +} diff --git a/api/src/extensions/helpers/llm-nlu/i18n/fr/title.json b/api/src/extensions/helpers/llm-nlu/i18n/fr/title.json new file mode 100644 index 00000000..9482e1ad --- /dev/null +++ b/api/src/extensions/helpers/llm-nlu/i18n/fr/title.json @@ -0,0 +1,3 @@ +{ + "llm_nlu_helper": "Moteur LLM NLU" +} diff --git a/api/src/extensions/helpers/llm-nlu/index.d.ts b/api/src/extensions/helpers/llm-nlu/index.d.ts new file mode 100644 index 00000000..67aefad7 --- /dev/null +++ b/api/src/extensions/helpers/llm-nlu/index.d.ts @@ -0,0 +1,22 @@ +/* + * 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. + * 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 LLM_NLU_HELPER_SETTINGS, { LLM_NLU_HELPER_NAMESPACE } from './settings'; + +declare global { + interface Settings extends SettingTree {} +} + +declare module '@nestjs/event-emitter' { + interface IHookExtensionsOperationMap { + [LLM_NLU_HELPER_NAMESPACE]: TDefinition< + object, + SettingMapByType + >; + } +} diff --git a/api/src/extensions/helpers/llm-nlu/index.helper.ts b/api/src/extensions/helpers/llm-nlu/index.helper.ts new file mode 100644 index 00000000..814f24cf --- /dev/null +++ b/api/src/extensions/helpers/llm-nlu/index.helper.ts @@ -0,0 +1,186 @@ +/* + * 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. + * 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 { Injectable, OnModuleInit } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import Handlebars from 'handlebars'; + +import { HelperService } from '@/helper/helper.service'; +import BaseNlpHelper from '@/helper/lib/base-nlp-helper'; +import { LLM, NLU } from '@/helper/types'; +import { LanguageService } from '@/i18n/services/language.service'; +import { LoggerService } from '@/logger/logger.service'; +import { NlpEntityFull } from '@/nlp/schemas/nlp-entity.schema'; +import { NlpEntityService } from '@/nlp/services/nlp-entity.service'; +import { SettingService } from '@/setting/services/setting.service'; + +import { LLM_NLU_HELPER_NAME } from './settings'; + +@Injectable() +export default class LlmNluHelper + extends BaseNlpHelper + implements OnModuleInit +{ + private languageClassifierPrompt: string; + + /** + * Trait prompts dictionary by id + */ + private traitClassifierPrompts: Array; + + constructor( + settingService: SettingService, + helperService: HelperService, + logger: LoggerService, + private readonly languageService: LanguageService, + private readonly nlpEntityService: NlpEntityService, + ) { + super(LLM_NLU_HELPER_NAME, settingService, helperService, logger); + } + + getPath() { + return __dirname; + } + + @OnEvent('hook:language:*') + @OnEvent('hook:llm_nlu_helper:language_classifier_prompt_template') + async buildLanguageClassifierPrompt() { + const settings = await this.getSettings(); + if (settings) { + const languages = await this.languageService.findAll(); + const delegate = Handlebars.compile( + settings.language_classifier_prompt_template, + ); + this.languageClassifierPrompt = delegate({ languages }); + } + } + + @OnEvent('hook:nlpEntity:*') + @OnEvent('hook:nlpValue:*') + @OnEvent('hook:llm_nlu_helper:trait_classifier_prompt_template') + async buildClassifiersPrompt() { + const settings = await this.getSettings(); + if (settings) { + const entities = await this.nlpEntityService.findAndPopulate({ + lookups: 'trait', + }); + const traitEntities = entities.filter(({ lookups }) => + lookups.includes('trait'), + ); + this.traitClassifierPrompts = traitEntities.map((entity) => ({ + ...entity, + prompt: Handlebars.compile(settings.trait_classifier_prompt_template)({ + entity, + }), + })); + } + } + + async onModuleInit() { + super.onModuleInit(); + + await this.buildLanguageClassifierPrompt(); + await this.buildClassifiersPrompt(); + } + + /** + * Finds entities in a given text based on their values and synonyms. + * + * This function takes a string of text and an array of entities, where each entity contains a value + * and a list of synonyms. It returns an array of objects, each representing an entity found in the text + * along with its start and end positions. + * + * @param text - The input text to search for entities. + * @param entities - An array of entities to search for, each containing a `value` and a list of `synonyms`. + * + * @returns An array of objects representing the found entities, with their `value`, `start`, and `end` positions. + */ + private findKeywordEntities( + text: string, + entity: NlpEntityFull, + ): NLU.ParseEntity[] { + return entity.values + .flatMap(({ value, expressions }) => { + const allValues = [value, ...expressions]; + + // Filter the terms that are found in the text + return allValues + .flatMap((term) => { + const regex = new RegExp(`\\b${term}\\b`, 'g'); + const matches = [...text.matchAll(regex)]; + + // Map matches to FoundEntity format + return matches.map((match) => ({ + entity: entity.name, + value: term, + start: match.index!, + end: match.index! + term.length, + confidence: 1, + })); + }) + .shift(); + }) + .filter((v) => !!v); + } + + async predict(text: string): Promise { + const settings = await this.getSettings(); + const helper = await this.helperService.getDefaultLlmHelper(); + const defaultLanguage = await this.languageService.getDefaultLanguage(); + // Detect language + const language = await helper.generateStructuredResponse( + `input text: ${text}`, + settings.model, + this.languageClassifierPrompt, + { + type: LLM.ResponseSchemaType.STRING, + description: 'Language of the input text', + }, + ); + + const traits: NLU.ParseEntity[] = [ + { + entity: 'language', + value: language || defaultLanguage.code, + confidence: undefined, + }, + ]; + for await (const { name, doc, prompt, values } of this + .traitClassifierPrompts) { + const allowedValues = values.map(({ value }) => value); + const result = await helper.generateStructuredResponse( + `input text: ${text}`, + settings.model, + prompt, + { + type: LLM.ResponseSchemaType.STRING, + description: `${name}${doc ? `: ${doc}` : ''}`, + enum: allowedValues.concat('unknown'), + }, + ); + const safeValue = result.toLowerCase().trim(); + const value = allowedValues.includes(safeValue) ? safeValue : ''; + traits.push({ + entity: name, + value, + confidence: undefined, + }); + } + + // Perform slot filling in a deterministic way since + // it's currently a challenging task for the LLMs. + const keywordEntities = await this.nlpEntityService.findAndPopulate({ + lookups: 'keywords', + }); + const entities = keywordEntities.flatMap((keywordEntity) => + this.findKeywordEntities(text, keywordEntity), + ); + + return { entities: traits.concat(entities) }; + } +} diff --git a/api/src/extensions/helpers/llm-nlu/package.json b/api/src/extensions/helpers/llm-nlu/package.json new file mode 100644 index 00000000..67ef402c --- /dev/null +++ b/api/src/extensions/helpers/llm-nlu/package.json @@ -0,0 +1,8 @@ +{ + "name": "hexabot-helper-llm-nlu", + "version": "2.0.0", + "description": "The LLM NLU Helper Extension for Hexabot to enable the Intent Classification and Language Detection", + "dependencies": {}, + "author": "Hexastack", + "license": "AGPL-3.0-only" +} diff --git a/api/src/extensions/helpers/llm-nlu/settings.ts b/api/src/extensions/helpers/llm-nlu/settings.ts new file mode 100644 index 00000000..9d7bb4b2 --- /dev/null +++ b/api/src/extensions/helpers/llm-nlu/settings.ts @@ -0,0 +1,47 @@ +/* + * 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. + * 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 { HelperSetting } from '@/helper/types'; +import { SettingType } from '@/setting/schemas/types'; + +export const LLM_NLU_HELPER_NAME = 'llm-nlu-helper'; + +export const LLM_NLU_HELPER_NAMESPACE = 'llm_nlu_helper'; + +export default [ + { + group: LLM_NLU_HELPER_NAMESPACE, + label: 'model', + value: '', + type: SettingType.text, + }, + { + group: LLM_NLU_HELPER_NAMESPACE, + label: 'language_classifier_prompt_template', + value: `You are an advanced language detection assistant. Your task is to identify the language of the given input text from the following supported languages: + +{{#each languages}} +- {{title}} (code={{code}}) +{{/each}} + +Provide a concise result by stating the language code only. If the language is not in the supported list, return an empty string.`, + type: SettingType.textarea, + }, + { + group: LLM_NLU_HELPER_NAMESPACE, + label: 'trait_classifier_prompt_template', + value: `You are an advanced text classification assistant. Your task is to classify the given input text provided in the following {{entity.name}} values: + +{{#each entity.values}} +- {{value}} +{{/each}} + +Provide a concise result by stating only the value of the {{entity.name}}. Return an empty string otherwise.`, + type: SettingType.textarea, + }, +] as const satisfies HelperSetting[]; diff --git a/api/src/helper/helper.module.ts b/api/src/helper/helper.module.ts index 24892974..365e6d78 100644 --- a/api/src/helper/helper.module.ts +++ b/api/src/helper/helper.module.ts @@ -10,6 +10,8 @@ import { HttpModule } from '@nestjs/axios'; import { Global, Module } from '@nestjs/common'; import { InjectDynamicProviders } from 'nestjs-dynamic-providers'; +import { NlpModule } from '@/nlp/nlp.module'; + import { HelperController } from './helper.controller'; import { HelperService } from './helper.service'; @@ -23,7 +25,7 @@ import { HelperService } from './helper.service'; 'dist/.hexabot/custom/extensions/helpers/**/*.helper.js', ) @Module({ - imports: [HttpModule], + imports: [HttpModule, NlpModule], controllers: [HelperController], providers: [HelperService], exports: [HelperService], diff --git a/api/src/helper/lib/base-llm-helper.ts b/api/src/helper/lib/base-llm-helper.ts index 541bb453..21757c29 100644 --- a/api/src/helper/lib/base-llm-helper.ts +++ b/api/src/helper/lib/base-llm-helper.ts @@ -11,7 +11,7 @@ import { LoggerService } from '@/logger/logger.service'; import { SettingService } from '@/setting/services/setting.service'; import { HelperService } from '../helper.service'; -import { HelperName, HelperType } from '../types'; +import { HelperName, HelperType, LLM } from '../types'; import BaseHelper from './base-helper'; @@ -30,7 +30,7 @@ export default abstract class BaseLlmHelper< } /** - * Generates a response using LLM + * Generates a text response using LLM * * @param prompt - The input text from the user * @param model - The model to be used @@ -45,6 +45,24 @@ export default abstract class BaseLlmHelper< extra?: any, ): Promise; + /** + * Generates a structured response using LLM + * + * @param prompt - The input text from the user + * @param model - The model to be used + * @param systemPrompt - The input text from the system + * @param schema - The OpenAPI data schema + * @param extra - Extra options + * @returns {Promise} - The generated response from the LLM + */ + generateStructuredResponse?( + prompt: string, + model: string, + systemPrompt: string, + schema: LLM.ResponseSchema, + extra?: any, + ): Promise; + /** * Send a chat completion request with the conversation history. * You can use this same approach to start the conversation diff --git a/api/src/helper/lib/base-nlp-helper.ts b/api/src/helper/lib/base-nlp-helper.ts index ae2775b3..57c2f6cd 100644 --- a/api/src/helper/lib/base-nlp-helper.ts +++ b/api/src/helper/lib/base-nlp-helper.ts @@ -23,7 +23,7 @@ import { import { SettingService } from '@/setting/services/setting.service'; import { HelperService } from '../helper.service'; -import { HelperName, HelperType, Nlp } from '../types'; +import { HelperName, HelperType, NLU } from '../types'; import BaseHelper from './base-helper'; @@ -119,7 +119,7 @@ export default abstract class BaseNlpHelper< * * @returns The formatted NLP training set */ - abstract format(samples: NlpSampleFull[], entities: NlpEntityFull[]): unknown; + format?(samples: NlpSampleFull[], entities: NlpEntityFull[]): unknown; /** * Perform training request @@ -129,10 +129,7 @@ export default abstract class BaseNlpHelper< * * @returns Training result */ - abstract train( - samples: NlpSampleFull[], - entities: NlpEntityFull[], - ): Promise; + train?(samples: NlpSampleFull[], entities: NlpEntityFull[]): Promise; /** * Perform evaluation request @@ -142,10 +139,7 @@ export default abstract class BaseNlpHelper< * * @returns NLP evaluation result */ - abstract evaluate( - samples: NlpSampleFull[], - entities: NlpEntityFull[], - ): Promise; + evaluate?(samples: NlpSampleFull[], entities: NlpEntityFull[]): Promise; /** * Delete/Forget a sample @@ -154,7 +148,7 @@ export default abstract class BaseNlpHelper< * * @returns The deleted sample otherwise an error */ - async forget(sample: NlpSample): Promise { + async forget?(sample: NlpSample): Promise { return sample; } @@ -166,10 +160,10 @@ export default abstract class BaseNlpHelper< * * @returns NLP Parsed entities */ - abstract filterEntitiesByConfidence( + filterEntitiesByConfidence?( nlp: any, threshold: boolean, - ): Promise; + ): Promise; /** * Returns only the entities that have strong confidence (> than the threshold), can return an empty result @@ -184,5 +178,5 @@ export default abstract class BaseNlpHelper< text: string, threshold?: boolean, project?: string, - ): Promise; + ): Promise; } diff --git a/api/src/helper/types.ts b/api/src/helper/types.ts index 2e6a7256..055d18e0 100644 --- a/api/src/helper/types.ts +++ b/api/src/helper/types.ts @@ -13,12 +13,7 @@ import BaseHelper from './lib/base-helper'; import BaseLlmHelper from './lib/base-llm-helper'; import BaseNlpHelper from './lib/base-nlp-helper'; -export namespace Nlp { - export interface Config { - endpoint?: string; - token: string; - } - +export namespace NLU { export interface ParseEntity { entity: string; // Entity name value: string; // Value name @@ -32,6 +27,60 @@ export namespace Nlp { } } +export namespace LLM { + /** + * Schema is used to define the format of input/output data. + * Represents a select subset of an OpenAPI 3.0 schema object. + * More fields may be added in the future as needed. + * @public + */ + export interface ResponseSchema { + /** + * Optional. The type of the property. {@link + * SchemaType}. + */ + type?: ResponseSchemaType; + /** Optional. The format of the property. */ + format?: string; + /** Optional. The description of the property. */ + description?: string; + /** Optional. Whether the property is nullable. */ + nullable?: boolean; + /** Optional. The items of the property. */ + items?: ResponseSchema; + /** Optional. The enum of the property. */ + enum?: string[]; + /** Optional. Map of {@link Schema}. */ + properties?: { + [k: string]: ResponseSchema; + }; + /** Optional. Array of required property. */ + required?: string[]; + /** Optional. The example of the property. */ + example?: unknown; + } + + /** + * Contains the list of OpenAPI data types + * as defined by https://swagger.io/docs/specification/data-models/data-types/ + * @public + */ + export enum ResponseSchemaType { + /** String type. */ + STRING = 'string', + /** Number type. */ + NUMBER = 'number', + /** Integer type. */ + INTEGER = 'integer', + /** Boolean type. */ + BOOLEAN = 'boolean', + /** Array type. */ + ARRAY = 'array', + /** Object type. */ + OBJECT = 'object', + } +} + export enum HelperType { NLU = 'nlu', LLM = 'llm', diff --git a/api/src/i18n/controllers/translation.controller.ts b/api/src/i18n/controllers/translation.controller.ts index f4ab4e7c..9e571103 100644 --- a/api/src/i18n/controllers/translation.controller.ts +++ b/api/src/i18n/controllers/translation.controller.ts @@ -137,7 +137,7 @@ export class TranslationController extends BaseController { ); await Promise.all(queue); // Purge non existing translations - return this.translationService.deleteMany({ + return await this.translationService.deleteMany({ str: { $nin: strings }, }); } diff --git a/api/src/nlp/services/nlp-sample-entity.service.ts b/api/src/nlp/services/nlp-sample-entity.service.ts index 3ebb8df2..088bbf00 100644 --- a/api/src/nlp/services/nlp-sample-entity.service.ts +++ b/api/src/nlp/services/nlp-sample-entity.service.ts @@ -74,6 +74,6 @@ export class NlpSampleEntityService extends BaseService< } as NlpSampleEntity; }); - return this.createMany(sampleEntities); + return await this.createMany(sampleEntities); } } diff --git a/api/src/setting/seeds/setting.seed-model.ts b/api/src/setting/seeds/setting.seed-model.ts index e5cfce7b..6f7d08c7 100644 --- a/api/src/setting/seeds/setting.seed-model.ts +++ b/api/src/setting/seeds/setting.seed-model.ts @@ -13,7 +13,7 @@ export const DEFAULT_SETTINGS = [ { group: 'chatbot_settings', label: 'default_nlu_helper', - value: 'core-nlu-helper', + value: 'llm-nlu-helper', type: SettingType.select, config: { multiple: false, diff --git a/api/src/user/controllers/user.controller.ts b/api/src/user/controllers/user.controller.ts index 2a4594b5..a07b257a 100644 --- a/api/src/user/controllers/user.controller.ts +++ b/api/src/user/controllers/user.controller.ts @@ -84,7 +84,7 @@ export class ReadOnlyUserController extends BaseController< @Roles('public') @Get('bot/profile_pic') async botProfilePic(@Query('color') color: string) { - return getBotAvatar(color); + return await getBotAvatar(color); } /** @@ -103,7 +103,7 @@ export class ReadOnlyUserController extends BaseController< } catch (e) { const user = await this.userService.findOne(id); if (user) { - return generateInitialsAvatar(user); + return await generateInitialsAvatar(user); } else { throw new NotFoundException(`user with ID ${id} not found`); } diff --git a/api/src/user/services/passwordReset.service.ts b/api/src/user/services/passwordReset.service.ts index 845ddf52..704e1a9b 100644 --- a/api/src/user/services/passwordReset.service.ts +++ b/api/src/user/services/passwordReset.service.ts @@ -127,7 +127,7 @@ export class PasswordResetService { * @returns The signed JWT token. */ async sign(dto: UserRequestResetDto) { - return this.jwtService.signAsync(dto, this.jwtSignOptions); + return await this.jwtService.signAsync(dto, this.jwtSignOptions); } /** @@ -138,6 +138,6 @@ export class PasswordResetService { * @returns The decoded payload of the token. */ async verify(token: string): Promise { - return this.jwtService.verifyAsync(token, this.jwtSignOptions); + return await this.jwtService.verifyAsync(token, this.jwtSignOptions); } } diff --git a/api/src/user/services/validate-account.service.ts b/api/src/user/services/validate-account.service.ts index 3516e827..c7a7d203 100644 --- a/api/src/user/services/validate-account.service.ts +++ b/api/src/user/services/validate-account.service.ts @@ -50,7 +50,7 @@ export class ValidateAccountService { * @returns A promise that resolves to the signed JWT token. */ async sign(dto: { email: string }) { - return this.jwtService.signAsync(dto, this.jwtSignOptions); + return await this.jwtService.signAsync(dto, this.jwtSignOptions); } /** @@ -61,7 +61,7 @@ export class ValidateAccountService { * @returns A promise that resolves to an object containing the user's email. */ async verify(token: string): Promise<{ email: string }> { - return this.jwtService.verifyAsync(token, this.jwtSignOptions); + return await this.jwtService.verifyAsync(token, this.jwtSignOptions); } /** diff --git a/api/src/utils/generics/base-repository.ts b/api/src/utils/generics/base-repository.ts index e2bc6f68..fdb55993 100644 --- a/api/src/utils/generics/base-repository.ts +++ b/api/src/utils/generics/base-repository.ts @@ -22,6 +22,7 @@ import { SortOrder, UpdateQuery, UpdateWithAggregationPipeline, + UpdateWriteOpResult, } from 'mongoose'; import { TFilterQuery } from '@/utils/types/filter.types'; @@ -70,7 +71,7 @@ export abstract class BaseRepository< this.registerLifeCycleHooks(); } - getPopulate() { + getPopulate(): P[] { return this.populate; } @@ -79,7 +80,7 @@ export abstract class BaseRepository< return `hook:${entity}:${suffix}` as `hook:${IHookEntities}:${TNormalizedEvents}`; } - private registerLifeCycleHooks() { + private registerLifeCycleHooks(): void { const repository = this; const hooks = LifecycleHookManager.getHooks(this.cls.name); @@ -202,7 +203,7 @@ export abstract class BaseRepository< protected async execute>( query: Query, cls: new () => R, - ) { + ): Promise { const resultSet = await query.lean(this.leanOpts).exec(); return resultSet.map((doc) => plainToClass(cls, doc, this.transformOpts)); } @@ -211,7 +212,7 @@ export abstract class BaseRepository< query: Query, cls: new () => R, options?: ClassTransformOptions, - ) { + ): Promise { const doc = await query.lean(this.leanOpts).exec(); return plainToClass(cls, doc, options ?? this.transformOpts); } @@ -219,7 +220,7 @@ export abstract class BaseRepository< protected findOneQuery( criteria: string | TFilterQuery, projection?: ProjectionType, - ) { + ): Query { if (!criteria) { // An empty criteria would return the first document that it finds throw new Error('findOneQuery() should not have an empty criteria'); @@ -247,7 +248,7 @@ export abstract class BaseRepository< async findOneAndPopulate( criteria: string | TFilterQuery, projection?: ProjectionType, - ) { + ): Promise { this.ensureCanPopulate(); const query = this.findOneQuery(criteria, projection).populate( this.populate, @@ -259,8 +260,32 @@ export abstract class BaseRepository< filter: TFilterQuery, pageQuery?: PageQueryDto, projection?: ProjectionType, - ) { - const { skip = 0, limit, sort = ['createdAt', 'asc'] } = pageQuery || {}; + ): Query; + + /** + * @deprecated + */ + protected findQuery( + filter: TFilterQuery, + pageQuery?: QuerySortDto, + projection?: ProjectionType, + ): Query; + + protected findQuery( + filter: TFilterQuery, + pageQuery?: QuerySortDto | PageQueryDto, + projection?: ProjectionType, + ): Query { + if (Array.isArray(pageQuery)) { + const query = this.model.find(filter, projection); + return query.sort([pageQuery] as [string, SortOrder][]); + } + + const { + skip = 0, + limit = 0, + sort = ['createdAt', 'asc'], + } = pageQuery || {}; const query = this.model.find(filter, projection); return query .skip(skip) @@ -272,12 +297,32 @@ export abstract class BaseRepository< filter: TFilterQuery, pageQuery?: PageQueryDto, projection?: ProjectionType, - ) { + ): Promise; + + /** + * @deprecated + */ + async find( + filter: TFilterQuery, + pageQuery?: QuerySortDto, + projection?: ProjectionType, + ): Promise; + + async find( + filter: TFilterQuery, + pageQuery?: QuerySortDto | PageQueryDto, + projection?: ProjectionType, + ): Promise { + if (Array.isArray(pageQuery)) { + const query = this.findQuery(filter, pageQuery, projection); + return await this.execute(query, this.cls); + } + const query = this.findQuery(filter, pageQuery, projection); return await this.execute(query, this.cls); } - private ensureCanPopulate() { + private ensureCanPopulate(): void { if (!this.populate || !this.clsPopulate) { throw new Error('Cannot populate query'); } @@ -287,23 +332,47 @@ export abstract class BaseRepository< filters: TFilterQuery, pageQuery?: PageQueryDto, projection?: ProjectionType, - ) { + ): Promise; + + /** + * @deprecated + */ + async findAndPopulate( + filters: TFilterQuery, + pageQuery?: QuerySortDto, + projection?: ProjectionType, + ): Promise; + + async findAndPopulate( + filters: TFilterQuery, + pageQuery?: QuerySortDto | PageQueryDto, + projection?: ProjectionType, + ): Promise { this.ensureCanPopulate(); + if (Array.isArray(pageQuery)) { + const query = this.findQuery(filters, pageQuery, projection).populate( + this.populate, + ); + return await this.execute(query, this.clsPopulate); + } + const query = this.findQuery(filters, pageQuery, projection).populate( this.populate, ); return await this.execute(query, this.clsPopulate); } - protected findAllQuery(sort?: QuerySortDto) { - return this.findQuery({}, { limit: undefined, skip: undefined, sort }); + protected findAllQuery( + sort?: QuerySortDto, + ): Query { + return this.findQuery({}, { limit: 0, skip: 0, sort }); } - async findAll(sort?: QuerySortDto) { - return await this.find({}, { limit: undefined, skip: undefined, sort }); + async findAll(sort?: QuerySortDto): Promise { + return await this.find({}, { limit: 0, skip: 0, sort }); } - async findAllAndPopulate(sort?: QuerySortDto) { + async findAllAndPopulate(sort?: QuerySortDto): Promise { this.ensureCanPopulate(); const query = this.findAllQuery(sort).populate(this.populate); return await this.execute(query, this.clsPopulate); @@ -315,7 +384,7 @@ export abstract class BaseRepository< protected findPageQuery( filters: TFilterQuery, { skip, limit, sort }: PageQueryDto, - ) { + ): Query { return this.findQuery(filters) .skip(skip) .limit(limit) @@ -339,7 +408,7 @@ export abstract class BaseRepository< async findPageAndPopulate( filters: TFilterQuery, pageQuery: PageQueryDto, - ) { + ): Promise { this.ensureCanPopulate(); const query = this.findPageQuery(filters, pageQuery).populate( this.populate, @@ -365,7 +434,7 @@ export abstract class BaseRepository< ); } - async createMany(dtoArray: U[]) { + async createMany(dtoArray: U[]): Promise { const docs = await this.model.create(dtoArray); return docs.map((doc) => @@ -394,7 +463,7 @@ export abstract class BaseRepository< async updateMany>( filter: TFilterQuery, dto: UpdateQuery, - ) { + ): Promise { return await this.model.updateMany(filter, { $set: dto, }); @@ -410,19 +479,19 @@ export abstract class BaseRepository< return await this.model.deleteMany(criteria); } - async preValidate(_doc: HydratedDocument) { + async preValidate(_doc: HydratedDocument): Promise { // Nothing ... } - async postValidate(_validated: HydratedDocument) { + async postValidate(_validated: HydratedDocument): Promise { // Nothing ... } - async preCreate(_doc: HydratedDocument) { + async preCreate(_doc: HydratedDocument): Promise { // Nothing ... } - async postCreate(_created: HydratedDocument) { + async postCreate(_created: HydratedDocument): Promise { // Nothing ... } @@ -430,7 +499,7 @@ export abstract class BaseRepository< _query: Query, _criteria: TFilterQuery, _updates: UpdateWithAggregationPipeline | UpdateQuery, - ) { + ): Promise { // Nothing ... } @@ -438,35 +507,35 @@ export abstract class BaseRepository< _query: Query, _criteria: TFilterQuery, _updates: UpdateWithAggregationPipeline | UpdateQuery, - ) { + ): Promise { // Nothing ... } async postUpdateMany( _query: Query, _updated: any, - ) { + ): Promise { // Nothing ... } async postUpdate( _query: Query, _updated: T, - ) { + ): Promise { // Nothing ... } async preDelete( _query: Query, _criteria: TFilterQuery, - ) { + ): Promise { // Nothing ... } async postDelete( _query: Query, _result: DeleteResult, - ) { + ): Promise { // Nothing ... } } diff --git a/api/src/utils/generics/base-service.ts b/api/src/utils/generics/base-service.ts index fede7015..5faeaf4c 100644 --- a/api/src/utils/generics/base-service.ts +++ b/api/src/utils/generics/base-service.ts @@ -48,7 +48,25 @@ export abstract class BaseService< filter: TFilterQuery, pageQuery?: PageQueryDto, projection?: ProjectionType, + ): Promise; + + /** + * @deprecated + */ + async find( + filter: TFilterQuery, + pageQuery?: QuerySortDto, + projection?: ProjectionType, + ): Promise; + + async find( + filter: TFilterQuery, + pageQuery?: QuerySortDto | PageQueryDto, + projection?: ProjectionType, ): Promise { + if (Array.isArray(pageQuery)) + return await this.repository.find(filter, pageQuery, projection); + return await this.repository.find(filter, pageQuery, projection); } @@ -56,7 +74,29 @@ export abstract class BaseService< filters: TFilterQuery, pageQuery?: PageQueryDto, projection?: ProjectionType, - ) { + ): Promise; + + /** + * @deprecated + */ + async findAndPopulate( + filters: TFilterQuery, + pageQuery?: QuerySortDto, + projection?: ProjectionType, + ): Promise; + + async findAndPopulate( + filters: TFilterQuery, + pageQuery?: QuerySortDto | PageQueryDto, + projection?: ProjectionType, + ): Promise { + if (Array.isArray(pageQuery)) + return await this.repository.findAndPopulate( + filters, + pageQuery, + projection, + ); + return await this.repository.findAndPopulate( filters, pageQuery, diff --git a/api/src/utils/test/mocks/nlp.ts b/api/src/utils/test/mocks/nlp.ts index 5fb31634..04a6e0bd 100644 --- a/api/src/utils/test/mocks/nlp.ts +++ b/api/src/utils/test/mocks/nlp.ts @@ -6,9 +6,9 @@ * 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 { Nlp } from '@/helper/types'; +import { NLU } from '@/helper/types'; -export const nlpEntitiesGreeting: Nlp.ParseEntities = { +export const nlpEntitiesGreeting: NLU.ParseEntities = { entities: [ { entity: 'intent', diff --git a/frontend/src/websocket/SocketIoClient.ts b/frontend/src/websocket/SocketIoClient.ts index 9c40194d..1829d4cb 100644 --- a/frontend/src/websocket/SocketIoClient.ts +++ b/frontend/src/websocket/SocketIoClient.ts @@ -149,7 +149,7 @@ export class SocketIoClient { url: string, options?: Partial>, ): Promise> { - return this.request({ + return await this.request({ method: "get", url, ...options, diff --git a/widget/src/utils/SocketIoClient.ts b/widget/src/utils/SocketIoClient.ts index 13b6d93a..a260f0ab 100644 --- a/widget/src/utils/SocketIoClient.ts +++ b/widget/src/utils/SocketIoClient.ts @@ -167,7 +167,7 @@ export class SocketIoClient { url: string, options?: Partial>, ): Promise> { - return this.request({ + return await this.request({ method: "get", url, ...options, @@ -178,7 +178,7 @@ export class SocketIoClient { url: string, options: Partial>, ): Promise> { - return this.request({ + return await this.request({ method: "post", url, ...options,